diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 43339372bcbc..98c8756f5856 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -29,7 +29,6 @@ pnpm dev Ordinary installs include native optional dependencies for the current OS and CPU only. Before a cross-architecture build (including `pnpm build:mac`, which produces both x64 and arm64 artifacts by default), run `pnpm install:release` to add the other CPU's variants. -See [the install policy](../docs/reference/pnpm-install-policy.md). ## Branch Naming diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7cfc408ebf21..b1dac35c0d4f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,6 +11,7 @@ ## Linked Issue + _If you do not have one and are an outside contributors, your PR **wiil** be ignored. Refs is not sufficient. Link an actual issue_ @@ -39,7 +40,7 @@ Fixes # ## Agent skill upstream boundary -- [ ] Not applicable, or this change follows `docs/reference/agent-skill-sharing-upstream-boundary.md` and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation. +- [ ] Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation. ## Notes diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index 39f1bb4b888b..49e85608d0e2 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -14,7 +14,7 @@ on: - '.github/actions/prepare-native-runtime/**' - '.github/actions/prepare-git-compatibility/**' - '.github/actions/prepare-headless-compiler/**' - - 'config/scripts/headless-detector-compiler-cache*' + - 'config/scripts/headless-detector-compiler-cache.mjs' - '.github/actions/prepare-linux-package-fixture/**' - 'config/docker/headless-serve-shutdown/**' - 'config/docker/cli-launch-contract/**' @@ -44,12 +44,11 @@ on: - '.github/workflows/ci-cache-warmup.yml' - '.github/actions/prepare-git-compatibility/**' - '.github/actions/prepare-headless-compiler/**' - - 'config/scripts/headless-detector-compiler-cache*' + - 'config/scripts/headless-detector-compiler-cache.mjs' - '.github/actions/prepare-linux-package-fixture/**' - 'config/docker/headless-serve-shutdown/**' - 'config/docker/cli-launch-contract/**' - 'config/docker/daemon-shutdown-descendants/**' - - 'config/scripts/ci-cache-warmup-workflow.test.mjs' permissions: contents: read diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml index ea21c4185f37..333c1baebe3d 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml @@ -30,7 +30,8 @@ jobs: if: ${{ github.ref == 'refs/heads/main' }} runs-on: blacksmith-2vcpu-ubuntu-2204 # The pre-drain sample adds 3-8 min (20 at most); a timeout cancels the job and skips the failsafe. - timeout-minutes: 90 + # A 1200000 drain step alone may take 50 min. + timeout-minutes: 120 environment: production env: GCP_PROJECT_ID: onorca-cloud @@ -53,8 +54,9 @@ jobs: # /v1/assign at hosts / window and is re-placed through the director's drain-return # lane. The wave validator admits only its closed set, and anything below the 300000 # default only on US general cells: Asia drains are bound by their targets' accept - # rate. The transition wait below is the 15-min migration lease plus this window. A - # cell on an older image drains at once. + # rate. The transition wait below is the 15-min migration lease plus this window, plus + # its excess over 300000, since restart-safe waits a window of quiet after the drain. A + # cell on an older image drains at once, or refuses a window above 300000. DRAIN_PACE_WINDOW_MS: ${{ inputs.drain-pace-window-ms }} steps: - name: Require exact reusable-workflow configuration @@ -499,8 +501,8 @@ jobs: if: ${{ inputs.mode == 'verify' }} run: echo 'Exact same-cap rollback point verified.' - # The sample can age the first one-hour token by up to 20 min; the drain's 20-min - # restart-safe wait gets a fresh one. + # The sample can age the first one-hour token by up to 20 min; the drain's restart-safe + # wait (20 min at the default pace, 50 at the slowest) gets a fresh one. - id: drain-auth if: ${{ inputs.mode != 'verify' && env.ROLLBACK_RESUME != 'true' }} uses: google-github-actions/auth@v2 @@ -517,6 +519,11 @@ jobs: env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.drain-auth.outputs.id_token }} run: | + # Before any mutation: a cell whose image caps the pace below this one stops untouched. + node dev/scripts/prepare-relay-production-capacity-canary.mjs \ + --director-origin "${DIRECTOR_ORIGIN}" --cell-origin "${CELL_ORIGIN}" \ + --cell-id "${TARGET_CELL_ID}" --approved-cells same-cap --mode pace-check \ + --pace-window-ms "${DRAIN_PACE_WINDOW_MS}" # Opens the window the report-only shadow health gate below judges this cell over. echo "drain-started-at=$(date -u +%FT%TZ)" >> "${GITHUB_OUTPUT}" echo "MUTATION_STARTED=true" >> "${GITHUB_ENV}" @@ -555,7 +562,7 @@ jobs: --heartbeat either --admission migration-only --draining required \ --activity restart-safe --expected-image-digests "${PREDECESSOR_IMAGE_DIGEST}" \ --pace-window-ms "${DRAIN_PACE_WINDOW_MS}" \ - --timeout-ms "$((900000 + DRAIN_PACE_WINDOW_MS))" + --timeout-ms "$((900000 + DRAIN_PACE_WINDOW_MS + (DRAIN_PACE_WINDOW_MS > 300000 ? DRAIN_PACE_WINDOW_MS - 300000 : 0)))" echo "drain-settled-at=$(date -u +%FT%TZ)" >> "${GITHUB_OUTPUT}" - id: capacity-auth @@ -709,19 +716,31 @@ jobs: env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.post-auth.outputs.id_token }} run: | - # A single transient 5xx (LB warm-up behind a fresh instance) must not - # fail a canary; 4xx (auth, generation mismatch) still fails fast. + # Rides out one DB stall: 5-7 s, then readiness grace and two 10 s LB + # health checks (c18, 10-07 18:49Z: 503 "no healthy upstream", healthy + # 16 s later). A cell still down after 60 s fails; other 4xx fail fast. + # Retried in this loop: the runner's 7.81 client (Ubuntu 22.04) never + # retries under --fail-with-body, so its --retry flags were inert. admin_post() { - local out="${RUNNER_TEMP}/$1.json" - if ! curl --fail-with-body --max-time 30 \ - --retry 3 --retry-delay 2 --retry-connrefused --output "${out}" \ - --request POST "$2" \ - --header "Authorization: Bearer ${ORCA_RELAY_ADMIN_ID_TOKEN}" \ - --header 'Content-Type: application/json' --data "$3"; then - cat "${out}" >&2 - return 1 - fi - cat "${out}" + local out="${RUNNER_TEMP}/$1.json" window=60 delay=5 status + local deadline=$((SECONDS + window)) + while true; do + # A refused or timed-out attempt writes no body; never report the last one's. + rm -f "${out}" + status="$(curl --silent --show-error --max-time 30 --output "${out}" \ + --write-out '%{http_code}' --request POST "$2" \ + --header "Authorization: Bearer ${ORCA_RELAY_ADMIN_ID_TOKEN}" \ + --header 'Content-Type: application/json' --data "$3")" || status=000 + case "${status}" in + 2??) cat "${out}"; return 0 ;; + 000|408|429|5??) ((SECONDS + delay < deadline)) || break ;; + *) break ;; + esac + sleep "${delay}" + done + echo "admin_post $1: HTTP ${status}" >&2 + cat "${out}" >&2 2>/dev/null || true + return 1 } node dev/scripts/verify-relay-capacity-transition.mjs \ --director-origin "${DIRECTOR_ORIGIN}" --cell-origin "${CELL_ORIGIN}" \ @@ -824,8 +843,8 @@ jobs: # read cost its full retry budget and can push the job past timeout-minutes, and a # cancelled job takes the whole wave with it. A healthy gate is already minutes of # serial reads, so both bounds sit above that: the script settles at seven minutes and - # reaching this eight is the pathological case. Eight on top of a ~14-minute cell still - # leaves the job's 90 minutes intact. + # reaching this eight is the pathological case. Eight on top of a cell of ~14 minutes + # (~45 at the slowest pace) still leaves the job's 120 minutes intact. timeout-minutes: 8 env: DRAIN_STARTED_AT: ${{ steps.drain.outputs.drain-started-at }} diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap.yml b/.github/workflows/cloud-deploy-relay-production-same-cap.yml index a4af4f2888d2..e30855b3a1ee 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap.yml @@ -54,11 +54,11 @@ on: required: true type: string drain-pace-window-ms: - description: Window the cell spreads its drain over; below 300000 only for US general cells, named in the confirmation + description: Window the cell spreads its drain over; below 300000 only for US general cells; any other value named in the confirmation required: true default: '300000' type: choice - options: ['300000', '60000', '30000'] + options: ['300000', '60000', '30000', '900000', '1200000'] canary-run-id: description: Successful same-code canary in this rehome control generation; reusable across batches required: false diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index be2386e24471..2ac4365dcfb6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -123,7 +123,8 @@ jobs: ORCA_E2E_APT_PACKAGES: build-essential python3 run: &install_e2e_tools | read -r -a packages <<< "$ORCA_E2E_APT_PACKAGES" - for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do + # Why apt-mirrors.txt: runner images resolve mirror+file: sources through it, so editing the lists alone leaves Azure in use. + for source in /etc/apt/apt-mirrors.txt /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do if [ -f "$source" ]; then sudo sed -i 's|https*://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source" fi @@ -134,7 +135,7 @@ jobs: Acquire::Retries "1"; APTCONF timeout 120 sudo apt-get update - timeout 300 sudo apt-get install -y "${packages[@]}" + timeout 300 sudo apt-get install -y "${packages[@]}" || timeout 300 sudo apt-get install -y --fix-missing "${packages[@]}" - uses: ./.github/actions/install-node-dependencies with: @@ -328,10 +329,12 @@ jobs: . != "tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts" and . != "tests/e2e/ssh-pi-compatible-agent-title.spec.ts" and . != "tests/e2e/ssh-port-forward-lifecycle.spec.ts" and + . != "tests/e2e/ssh-reattach-home-partition.spec.ts" and . != "tests/e2e/ssh-reconnect-tab-destruction.spec.ts" and . != "tests/e2e/ssh-restart-tab-accumulation.spec.ts" and . != "tests/e2e/ssh-skill-installation.spec.ts" and . != "tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts" and + . != "tests/e2e/ssh-startup-local-shadow.spec.ts" and . != "tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts" and . != "tests/e2e/terminal-inline-images-ssh.spec.ts" and . != "tests/e2e/ssh-docker-watcher-isolation.spec.ts" and diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9539701a0755..0ae029fa7231 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -432,6 +432,8 @@ jobs: PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }} PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} ORCA_UNIT_SELECTION_MODE: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} + # Set the repository variable to '5' to roll back full PR shards. + ORCA_UNIT_FULL_SHARD_COUNT: ${{ vars.ORCA_UNIT_FULL_SHARD_COUNT || '10' }} run: | if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi node config/scripts/ci-unit-plan.mjs @@ -1088,7 +1090,15 @@ jobs: - name: Install Linux package tooling id: linux-package-tools background: true - run: sudo apt-get update && sudo apt-get install -y cpio rpm + run: | + # Use the shell lane's bounded refresh; required tooling installation stays fatal. + sudo tee /etc/apt/apt.conf.d/99-orca-package-tools >/dev/null <<'APTCONF' + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + Acquire::Retries "1"; + APTCONF + timeout 120 sudo apt-get update || true + timeout 300 sudo apt-get install -y cpio rpm - name: Build package inputs run: | @@ -1265,6 +1275,7 @@ jobs: src/main/wsl/wsl-runner.test.ts src/main/wsl/wsl-guest-environment.test.ts src/main/wsl/wsl-executable-path.win32.test.ts + src/main/claude-accounts/claude-profile-history-windows.test.ts src/main/wsl/wsl-w1-w3-contract.test.ts src/shared/source-scan/source-tree-scan.test.ts src/main/cli/wsl-cli-powershell-boundary.test.ts diff --git a/.gitignore b/.gitignore index 0d98fa169f45..e31f15b56bad 100644 --- a/.gitignore +++ b/.gitignore @@ -210,3 +210,5 @@ tests/e2e/.cross-version-checkouts/ # Generated by the CI unit-test sequencer (and by reproducing a shard locally). ci-shards/ +# Local-only design/planning docs (not checked in). +# locations below (assets, readme, STYLEGUIDE, and mobile terminal shortcut bar). diff --git a/AGENTS.md b/AGENTS.md index 694b54bdb3a5..8bc061d97af4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,24 +90,24 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`. - **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms. - **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`. -- **Windows terminal shells**: `--shell` picks the shell a terminal _is_; `--command` is typed into whatever shell the host spawned, so a shell choice routed through `command` silently becomes a child process. See [`docs/reference/windows-terminal-shell-selection.md`](./docs/reference/windows-terminal-shell-selection.md). -- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md). -- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely; see [`docs/reference/windows-cmd-shim-resolution.md`](./docs/reference/windows-cmd-shim-resolution.md) before adding a shim shape or debugging one. +- **Windows terminal shells**: `--shell` picks the shell a terminal _is_; `--command` is typed into whatever shell the host spawned, so a shell choice routed through `command` silently becomes a child process. +- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). +- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely. - **Ripgrep**: Orca bundles `rg` for every platform, WSL, and SSH remotes. Spawn it through `spawnBundledRipgrep` (main) or `resolveRelayRipgrepCommand` (relay), never a bare `'rg'` — Windows resolves a bare name in the spawn cwd before PATH. Don't add git/readdir fallbacks locally; the relay's chain exists only for hosts an upload never reached. -- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md). -- **Windows MSYS/Git Bash panes**: their children break away from the per-PTY job unless it is created without `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, and a `conpty.node` built before that fix passes every existing gate. Before changing the per-PTY job or debugging `windows-msys-job.win32.test.ts`, read [`docs/reference/windows-msys-job-breakaway.md`](./docs/reference/windows-msys-job-breakaway.md). -- **Windows daemon-host relocation**: the terminal daemon runs from a copy of the app runtime under `%LOCALAPPDATA%`, which is what survives an auto-update. Before touching that copy, its exe name, or the NSIS uninstall macro, read [`docs/reference/windows-daemon-host-relocation.md`](./docs/reference/windows-daemon-host-relocation.md). -- **Windows EDR signal**: don't add `-ExecutionPolicy Bypass`, `-EncodedCommand`, `cmd.exe /c` with escaped free text, per-operation interpreter spawning, or runtime `Add-Type` compilation without reading [`docs/reference/windows-edr-posture.md`](./docs/reference/windows-edr-posture.md) first — behavioural EDR scores each of those, and being signed does not clear them. For file verdicts on the bytes we ship — antivirus false positives, and the vendor programs that clear a release before users meet the detection — see [`docs/reference/antivirus-prerelease-clearance.md`](./docs/reference/antivirus-prerelease-clearance.md). -- **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md). -- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc. +- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. +- **Windows MSYS/Git Bash panes**: their children break away from the per-PTY job unless it is created without `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, and a `conpty.node` built before that fix passes every existing gate. +- **Windows daemon-host relocation**: the terminal daemon runs from a copy of the app runtime under `%LOCALAPPDATA%`, which is what survives an auto-update. +- **Windows EDR signal**: don't add `-ExecutionPolicy Bypass`, `-EncodedCommand`, `cmd.exe /c` with escaped free text, per-operation interpreter spawning, or runtime `Add-Type` compilation without reviewing the EDR impact first — behavioural EDR scores each of those, and being signed does not clear them. +- **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. +- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. Packaging fails if a bundled native binary needs newer glibc. ## Native Dependency Installs -Ordinary `pnpm install` covers the host OS and CPU only. Before packaging for another architecture — including `pnpm build:mac`, which builds x64 and arm64 by default — run `pnpm install:release`. electron-builder only warns on a missing `extraResources` source, so the `beforePack` guard is what turns a thin install into a build failure instead of a silently broken artifact; see [`docs/reference/pnpm-install-policy.md`](./docs/reference/pnpm-install-policy.md). +Ordinary `pnpm install` covers the host OS and CPU only. Before packaging for another architecture — including `pnpm build:mac`, which builds x64 and arm64 by default — run `pnpm install:release`. electron-builder only warns on a missing `extraResources` source, so the `beforePack` guard is what turns a thin install into a build failure instead of a silently broken artifact. ## SSH Use Case -All changes must consider the SSH use case. Don't assume local-only execution. Before changing anything that reports on, stops, or lists remote work, follow [`docs/reference/ssh-execution-boundary.md`](./docs/reference/ssh-execution-boundary.md): the execution host owns everything that touches execution, and loss of contact is never evidence of process death — the verdict vocabulary is `live` / `unverifiable` / `exited`, with no synonyms. +All changes must consider the SSH use case. Don't assume local-only execution. Before changing anything that reports on, stops, or lists remote work, the execution host owns everything that touches execution, and loss of contact is never evidence of process death — the verdict vocabulary is `live` / `unverifiable` / `exited`, with no synonyms. ## Folder Workspace Use Case @@ -115,19 +115,19 @@ All changes must consider folder workspaces as well as git worktrees. Don't assu ## Agent Status -The execution host owns agent status in one store, the hook server's, and every reader (sidebar, `worktree ps`, mobile, dashboard) subscribes to it. Before adding a producer, a cache, or a reader-side precedence rule, read [`docs/reference/agent-status-store.md`](./docs/reference/agent-status-store.md): new producers write into that store, and readers keep only presentation policy. +The execution host owns agent status in one store, the hook server's, and every reader (sidebar, `worktree ps`, mobile, dashboard) subscribes to it. New producers write into that store, and readers keep only presentation policy. ## Agent Terminal Screens -A rule that reads what an agent CLI paints on a terminal — readiness, blocked prompts, idle — must be written against a captured transcript, not a remembered screen. Record one with [`docs/reference/agent-pty-transcript-capture.md`](./docs/reference/agent-pty-transcript-capture.md), which keeps escapes and wrapping intact and scrubs account identifiers before they reach git. Antigravity readiness has no transcript yet and five failed attempts without one; before touching it, read [`docs/reference/antigravity-readiness-evidence.md`](./docs/reference/antigravity-readiness-evidence.md). +A rule that reads what an agent CLI paints on a terminal — readiness, blocked prompts, idle — must be written against a captured transcript, not a remembered screen. Use `config/scripts/capture-agent-pty-transcript.mjs` to keep escapes and wrapping intact and scrub account identifiers before they reach git. ## Remote Wire Compatibility -Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — follow [`docs/reference/remote-wire-compatibility.md`](./docs/reference/remote-wire-compatibility.md). A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change. +Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — preserve compatibility with older peers. A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change. ## Git Binary Compatibility -Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md). +Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline. When adding or changing a Git command: diff --git a/README.md b/README.md index 5ed85162bb2d..b1b8f08b7bda 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,6 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. - **[Download from onOrca.dev](https://onorca.dev/download)** - Or grab a build directly: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [All builds](https://github.com/stablyai/orca/releases/latest) -- Running `orca serve` on a headless Linux server? See the [headless Linux server guide](docs/reference/headless-linux-server.md). _Or via a package manager:_ diff --git a/cloud/apps/relay-ops/src/incident-monitor-sources.ts b/cloud/apps/relay-ops/src/incident-monitor-sources.ts index 877b4bd59c56..9e041940af7f 100644 --- a/cloud/apps/relay-ops/src/incident-monitor-sources.ts +++ b/cloud/apps/relay-ops/src/incident-monitor-sources.ts @@ -144,6 +144,13 @@ export const GOOGLE_METRICS: GoogleMetricDefinition[] = [ 'resource.type="cloud_run_revision" AND metric.label."response_code_class"="5xx"', aggregation: 'window-sum', emptyIsZero: true + }, + { + signal: 'auth.requests', + type: 'run.googleapis.com/request_count', + resourceFilter: 'resource.type="cloud_run_revision"', + aggregation: 'window-sum', + emptyIsZero: true } ] diff --git a/cloud/apps/relay-ops/src/incident-monitor.test.ts b/cloud/apps/relay-ops/src/incident-monitor.test.ts index a4b3b3fbd0c5..db1d8797d1c3 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.test.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.test.ts @@ -66,7 +66,8 @@ function healthySample(at = startedAt): IncidentSample { 'director.memory': signal(0.3, at), 'director.concurrency': signal(5, at), 'director.errors': signal(0, at), - 'auth.errors': signal(0, at) + 'auth.errors': signal(0, at), + 'auth.requests': signal(58_000, at) } }, 'relay-logs': { @@ -287,15 +288,42 @@ describe('incident monitor evaluator', () => { expect(evaluateIncidentSample(excess, startedAt).failures).toContainEqual( expect.objectContaining({ signal: 'director.errors', observed: 16, threshold: 15 }) ) - const auth = healthySample() - auth.sources['cloud-monitoring']!.signals['auth.errors'] = signal(1) - expect(evaluateIncidentSample(auth, startedAt).status).toBe('freeze') const pressure = healthySample() pressure.sources['cloud-monitoring']!.signals['director.errors'] = signal(1) pressure.sources['cloud-monitoring']!.signals['cloud_sql.cpu'] = signal(0.81) expect(evaluateIncidentSample(pressure, startedAt).status).toBe('freeze') }) + it('tolerates isolated auth errors but freezes on a burst or a high error ratio', () => { + const isolated = healthySample() + isolated.sources['cloud-monitoring']!.signals['auth.errors'] = signal(1) + expect(evaluateIncidentSample(isolated, startedAt).status).toBe('green') + isolated.sources['cloud-monitoring']!.signals['auth.errors'] = signal(2) + expect(evaluateIncidentSample(isolated, startedAt).status).toBe('green') + + const burst = healthySample() + burst.sources['cloud-monitoring']!.signals['auth.errors'] = signal(3) + expect(evaluateIncidentSample(burst, startedAt).failures).toEqual([ + expect.objectContaining({ signal: 'auth.errors', observed: 3, threshold: 2 }) + ]) + + const ratio = healthySample() + ratio.sources['cloud-monitoring']!.signals['auth.errors'] = signal(2) + ratio.sources['cloud-monitoring']!.signals['auth.requests'] = signal(1_000) + expect(evaluateIncidentSample(ratio, startedAt).failures).toEqual([ + expect.objectContaining({ signal: 'auth.error_ratio', observed: 0.002, threshold: 0.001 }) + ]) + ratio.sources['cloud-monitoring']!.signals['auth.requests'] = signal(0) + expect(evaluateIncidentSample(ratio, startedAt).status).toBe('freeze') + + const unmeasured = healthySample() + unmeasured.sources['cloud-monitoring']!.signals['auth.errors'] = signal(1) + delete unmeasured.sources['cloud-monitoring']!.signals['auth.requests'] + expect(evaluateIncidentSample(unmeasured, startedAt).failures).toEqual([ + expect.objectContaining({ code: 'signal_missing', signal: 'auth.requests' }) + ]) + }) + it('freezes on SQL, director, relay pool, heartbeat, and migration breaches', () => { const sample = healthySample() sample.sources['cloud-monitoring']!.signals['cloud_sql.cpu'] = signal(0.81) diff --git a/cloud/apps/relay-ops/src/incident-monitor.ts b/cloud/apps/relay-ops/src/incident-monitor.ts index 22a2dc014fa9..94c7a4791498 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.ts @@ -121,7 +121,12 @@ export const INCIDENT_MONITOR_THRESHOLDS = { // real fault lands in the hundreds per window, an order of magnitude clear of // this bar. directorErrors: 15, - authErrors: 0, + // Why: 2, raised 2026-10-07 from 0. Auth answers ~58k requests per five minutes + // (24 h to 2026-10-07: min 44k) and logged two 5xx in that day, so a single 500 on + // the desktop sign-in callback froze the c13 and c28 waves with auth healthy. A real + // auth fault fails hundreds per window; the ratio bar keeps a near-idle auth strict. + authErrors: 2, + authErrorRatio: 0.001, // Why: 800 exceeded the 600 hard cap, so this could never trigger on a capped cell. 500 is // the ordinary admission limit a cell actually stops at (600 cap - 100 control-rebind reserve). cellConnections: 500, @@ -437,6 +442,27 @@ function checkRule( } } +// Why: a few errors are noise only against real traffic; with almost no requests they are the outage. +function checkAuthErrorRatio( + failures: IncidentFailure[], + signals: Record +): void { + const errors = signals['auth.errors']?.value ?? 0 + if (errors === 0) return + const requests = signals['auth.requests'] + if (!requests) return addMissingSignal(failures, 'cloud-monitoring', 'auth.requests') + const ratio = requests.value > 0 ? errors / requests.value : Number.POSITIVE_INFINITY + if (ratio > INCIDENT_MONITOR_THRESHOLDS.authErrorRatio) { + failures.push({ + code: 'threshold_max', + source: 'cloud-monitoring', + signal: 'auth.error_ratio', + observed: ratio, + threshold: INCIDENT_MONITOR_THRESHOLDS.authErrorRatio + }) + } +} + function checkCell( failures: IncidentFailure[], sample: IncidentSample, @@ -636,6 +662,8 @@ export function evaluateIncidentSample( const source = sample.sources[rule.source] if (source) checkRule(failures, rule.source, source.signals, rule) } + const monitoring = sample.sources['cloud-monitoring'] + if (monitoring) checkAuthErrorRatio(failures, monitoring.signals) for (const cell of sample.cells) { checkCell( failures, diff --git a/cloud/apps/relay/src/app.ts b/cloud/apps/relay/src/app.ts index e88162dfd88f..46e364eeb0d6 100644 --- a/cloud/apps/relay/src/app.ts +++ b/cloud/apps/relay/src/app.ts @@ -73,8 +73,9 @@ const ASSIGNMENT_REJECTION_LOG_WINDOW_MS = 10_000 // A release holds the row for about one lock timeout, so one second is enough. const ASSIGNMENT_ROW_BUSY_RETRY_AFTER_SECONDS = 1 const REGION_CATALOG_CACHE_MS = 30_000 -// A drain that outlives the roll step it belongs to is an outage, not a pacing win. -const DRAIN_PACE_WINDOW_MAX_MS = 5 * 60 * 1_000 +// A drain that outlives the roll step it belongs to is an outage, not a pacing win. The roll +// step's timeout scales with the window, up to the same-cap wave's slowest pace. +const DRAIN_PACE_WINDOW_MAX_MS = 20 * 60 * 1_000 type AdmissionRejectionLogEntry = { route: 'assign' | 'resolve' @@ -280,8 +281,14 @@ export function createRelayApp( }) // Not /healthz: Google Front End reserves that path before the container. + // The drain pace cap is read before a roll isolates a cell, so a slower pace than the cell + // accepts stops with the cell untouched. app.get('/health', (context) => - context.json({ ok: true, connectionCapacityProtocol: 2 }) + context.json({ + ok: true, + connectionCapacityProtocol: 2, + drainPaceWindowMaxMs: DRAIN_PACE_WINDOW_MAX_MS + }) ) app.get('/ready', async (context) => { if (!(await operations.ready())) return context.json({ error: 'dependency_unavailable' }, 503) diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index e082aaa72a56..007b71086157 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -71,6 +71,7 @@ import { } from './database.js' import type { RelayCellConfig } from './config.js' import type { CellLockHoldSite } from './cell-inventory-hold-samples.js' +import { isPostgresPoolConnectFailure } from './postgres-pool-pressure.js' import type { RelayDatabase, RelayLockOptions, @@ -80,10 +81,13 @@ import type { import type { RegionalRehomeSafetySnapshot } from './relay-observability.js' import { combineRegionalRehomeSafety, + nextRegionalRehomeSqlFailuresBreach, REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT, REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT, regionalRehomePoolPressure, - regionalRehomeSafetyFailure + regionalRehomeSafetyFailure, + regionalRehomeSqlFailuresSustained, + type RegionalRehomeSqlFailuresBreach } from './regional-rehome-safety.js' import { REGIONAL_REHOME_ARRIVAL_WINDOW_MS, @@ -3755,6 +3759,7 @@ export class RelayAssignmentStore { } private idleRegionalCandidateCursor: IdleRehomeHostCursor = null + private regionalRehomeSqlFailuresBreach: RegionalRehomeSqlFailuresBreach = null private readonly regionalRehomePollTelemetry = new RegionalRehomePollTelemetry() async selectIdleRegionalRehomeCandidates( @@ -3786,6 +3791,19 @@ export class RelayAssignmentStore { return gated('budget-closed') } const fleetSafety = await this.readRegionalRehomeFleetSafety(this.database, now) + const sqlBreach = nextRegionalRehomeSqlFailuresBreach( + this.regionalRehomeSqlFailuresBreach, + combineRegionalRehomeSafety(processSafety, fleetSafety).sqlFailures, + now + ) + this.regionalRehomeSqlFailuresBreach = sqlBreach + // Source cells only pause on sql failures; the poll is the regular observer that latches. + if (sqlBreach && regionalRehomeSqlFailuresSustained(sqlBreach)) { + const event = await this.disableRegionalRehomeForSafety(this.database, now, 'sql_failures', fleetSafety) + if (event) console.warn(JSON.stringify({ ...event, sustainedMs: now - sqlBreach.since })) + this.regionalRehomeSqlFailuresBreach = null + return gated('fleet-safety') + } if (regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now)) return gated('fleet-safety') const startedAt = performance.now() const selection = await selectIdleRegionalRehomes({ @@ -3821,8 +3839,8 @@ export class RelayAssignmentStore { return { outcome: 'deferred', reason: 'cohort-closed' } } let safetyDisable: Record | null = null - // Set on every fleet-safety failure, disable or not: the pause is durable - // and global either way, so no later candidate in this poll can get past it. + // Set on every fleet-safety failure, disable or not: the pause is global + // either way, so no later candidate in this poll can get past it. let safetyPaused = false let targetDeferral: RegionalRehomeTargetDeferral | null = null // Lock order: control, worker, host rows, then the target cell row alone, @@ -4044,7 +4062,7 @@ export class RelayAssignmentStore { rows: readonly ControlRenewalRequest[], now: number ): Promise { - if (rows.length === 1) return [await this.renewOneControlActivity(rows[0]!, now)] + if (rows.length === 1) return [await this.renewOneControlActivity(rows[0]!, now, 'priority')] if (this.database.dialect !== 'postgres') { // Correctness over throughput: the SQLite writer is serialized anyway, and // this is the dialect the unit suites run on. @@ -4054,7 +4072,7 @@ export class RelayAssignmentStore { const outcomes = new Array(rows.length) try { const parsed = readControlRenewalOutcomes( - await this.database.query( + await this.priorityQuery( CONTROL_RENEWAL_BATCH_SQL, controlRenewalBatchParams(ordered, now) ), @@ -4073,6 +4091,9 @@ export class RelayAssignmentStore { message: String((error as { message?: unknown }).message) }) ) + // No connection at all: per-host statements would only queue up behind the + // same saturated pool, multiplying the load that starved this flush. + if (isPostgresPoolConnectFailure(error)) return outcomes.fill('database_error') await Promise.all( ordered.map(async (row) => { try { @@ -4086,6 +4107,12 @@ export class RelayAssignmentStore { } } + private async priorityQuery(sql: string, params: unknown[]): Promise { + return this.database.queryPriority + ? await this.database.queryPriority(sql, params) + : await this.database.query(sql, params) + } + private async renewControlActivitiesInSeries( rows: readonly ControlRenewalRequest[], now: number @@ -4104,14 +4131,15 @@ export class RelayAssignmentStore { // Returns the outcome; a driver or pool failure reaches the caller unchanged. private async renewOneControlActivity( row: ControlRenewalRequest, - now: number + now: number, + lane: 'general' | 'priority' = 'general' ): Promise { if (this.database.dialect === 'postgres') { + const params = controlRenewalBatchParams([row], now) return readControlRenewalOutcomes( - await this.database.query( - CONTROL_RENEWAL_BATCH_SQL, - controlRenewalBatchParams([row], now) - ), + lane === 'priority' + ? await this.priorityQuery(CONTROL_RENEWAL_BATCH_SQL, params) + : await this.database.query(CONTROL_RENEWAL_BATCH_SQL, params), 1 )[0]! } @@ -5968,7 +5996,9 @@ export class RelayAssignmentStore { input.now ) if (safetyFailure) { - input.onSafetyDisabled(await this.pauseRegionalRehomeForSafety( + // A sql spike alone is usually one DB stall: pause this claim and leave + // the latch to the director's sustained-breach check. + input.onSafetyDisabled(safetyFailure === 'sql_failures' ? null : await this.pauseRegionalRehomeForSafety( transaction, input.worker, input.now, @@ -6343,7 +6373,18 @@ export class RelayAssignmentStore { reason: string, fleetSafety: RegionalRehomeFleetSafety ): Promise | null> { - const disabled = await transaction.query( + const event = await this.disableRegionalRehomeForSafety(transaction, now, reason, fleetSafety) + await this.incrementRegionalRehomeWorkerFailure(transaction, worker, now) + return event + } + + private async disableRegionalRehomeForSafety( + database: RelayDatabase, + now: number, + reason: string, + fleetSafety: RegionalRehomeFleetSafety + ): Promise | null> { + const disabled = await database.query( `UPDATE relay_region_rehome_control SET generation = generation + 1, enabled = 0, updated_at = ? WHERE control_id = 'global' AND enabled = 1 @@ -6353,27 +6394,23 @@ export class RelayAssignmentStore { // The durable disable is otherwise invisible: nothing else records why // claims stopped and inspection only shows enabled=false. Logged after // the transaction commits so a rollback cannot fabricate the record. - let event: Record | null = null - if (disabled.length > 0) { - event = { - event: 'orca_relay_regional_rehome_safety_disabled', - reason, - controlGeneration: integer(disabled[0]!, 'generation'), - now, - requiredCells: fleetSafety.requiredCells, - missingCells: fleetSafety.missingCells, - observedAt: fleetSafety.observedAt, - sqlFailures: fleetSafety.sqlFailures, - reconnects: fleetSafety.reconnects, - maxReconnects: fleetSafety.maxReconnects, - controlActivityRecoveryFailures: fleetSafety.controlActivityRecoveryFailures, - databasePoolWaiting: fleetSafety.databasePoolWaiting, - databasePoolWaitersMax: fleetSafety.databasePoolWaitersMax, - databasePoolWaitMsMax: fleetSafety.databasePoolWaitMsMax - } + if (disabled.length === 0) return null + return { + event: 'orca_relay_regional_rehome_safety_disabled', + reason, + controlGeneration: integer(disabled[0]!, 'generation'), + now, + requiredCells: fleetSafety.requiredCells, + missingCells: fleetSafety.missingCells, + observedAt: fleetSafety.observedAt, + sqlFailures: fleetSafety.sqlFailures, + reconnects: fleetSafety.reconnects, + maxReconnects: fleetSafety.maxReconnects, + controlActivityRecoveryFailures: fleetSafety.controlActivityRecoveryFailures, + databasePoolWaiting: fleetSafety.databasePoolWaiting, + databasePoolWaitersMax: fleetSafety.databasePoolWaitersMax, + databasePoolWaitMsMax: fleetSafety.databasePoolWaitMsMax } - await this.incrementRegionalRehomeWorkerFailure(transaction, worker, now) - return event } diff --git a/cloud/apps/relay/src/control-renewal-batch-store.test.ts b/cloud/apps/relay/src/control-renewal-batch-store.test.ts index e3798a19ae22..25c039700238 100644 --- a/cloud/apps/relay/src/control-renewal-batch-store.test.ts +++ b/cloud/apps/relay/src/control-renewal-batch-store.test.ts @@ -27,6 +27,7 @@ class RenewalStatementProbe implements RelayDatabase { readonly dialect = 'postgres' as const readonly statements: Array<{ sql: string; params: unknown[] }> = [] failuresRemaining = 0 + failureMessage = 'canceling statement due to statement timeout' constructor(private readonly outcomeFor: (userId: string) => ControlRenewalOutcome) {} @@ -34,7 +35,7 @@ class RenewalStatementProbe implements RelayDatabase { this.statements.push({ sql, params }) if (this.failuresRemaining > 0) { this.failuresRemaining -= 1 - throw new Error('canceling statement due to statement timeout') + throw new Error(this.failureMessage) } const userIds = params[0] as string[] return userIds.map((userId, index) => ({ @@ -149,6 +150,58 @@ describe('batched control renewals on PostgreSQL', () => { } }) + it('runs the batch on the priority lane and the per-host fallback on the general one', async () => { + const probe = new RenewalStatementProbe(() => 'renewed') + probe.failuresRemaining = 1 + const lanes: string[] = [] + const database: RelayDatabase = { + dialect: 'postgres', + query: async (sql, params) => { + lanes.push('general') + return await probe.query(sql, params) + }, + queryPriority: async (sql, params) => { + lanes.push('priority') + return await probe.query(sql, params) + }, + queryLocked: async () => await probe.queryLocked(), + transaction: async () => await probe.transaction(), + close: async () => undefined + } + const store = new RelayAssignmentStore(database, () => now) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + await store.renewControlActivities([ + renewal('user-a', 'host000000000001'), + renewal('user-a', 'host000000000002') + ]) + + // A 200-row fallback would otherwise jump the whole general queue. + expect(lanes).toEqual(['priority', 'general', 'general']) + } finally { + warn.mockRestore() + } + }) + + it('does not fan out per host when the batch never got a connection', async () => { + const probe = new RenewalStatementProbe(() => 'renewed') + probe.failuresRemaining = 1 + probe.failureMessage = 'timeout exceeded when trying to connect' + const store = new RelayAssignmentStore(probe, () => now) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + const outcomes = await store.renewControlActivities([ + renewal('user-a', 'host000000000001'), + renewal('user-a', 'host000000000002') + ]) + + expect(outcomes).toEqual(['database_error', 'database_error']) + expect(probe.statements).toHaveLength(1) + } finally { + warn.mockRestore() + } + }) + it('reports a host that fails its own fallback statement without touching the rest', async () => { const probe = new RenewalStatementProbe(() => 'renewed') probe.failuresRemaining = 2 diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index 122dd3fc1c0e..a50d617ac0c3 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -8,6 +8,7 @@ import { emptyPostgresPoolPressureCounts, isPostgresPoolConnectFailure, PostgresPoolPressure, + type PostgresPoolLane, type PostgresPoolPressureCounts } from './postgres-pool-pressure.js' import { applyPostgresSchema } from './postgres-schema-startup.js' @@ -71,6 +72,9 @@ export type RelayTransactionOptions = { reportRetries?: boolean } export interface RelayDatabase { readonly dialect?: 'sqlite' | 'postgres' query(sql: string, params?: unknown[]): Promise + // Ahead of every queued query() for the next free connection; see + // PostgresPoolPressure. Absent means plain query(). + queryPriority?(sql: string, params?: unknown[]): Promise queryLocked( sql: string, params?: unknown[], @@ -1058,11 +1062,23 @@ export class PostgresDatabase implements RelayDatabase { } async query(sql: string, params: unknown[] = []): Promise { + return await this.queryOnLane('general', sql, params) + } + + async queryPriority(sql: string, params: unknown[] = []): Promise { + return await this.queryOnLane('priority', sql, params) + } + + private async queryOnLane( + lane: PostgresPoolLane, + sql: string, + params: unknown[] + ): Promise { const startedAt = performance.now() let phase: 'acquire' | 'execute' = 'acquire' let client: pg.PoolClient | undefined try { - client = await this.pressure.connect() + client = await this.pressure.connect(lane) phase = 'execute' const result = await client.query(postgresSql(sql), params) return returnsRows(sql) ? (result.rows as SqlRow[]) : [{ changes: result.rowCount ?? 0 }] @@ -1173,6 +1189,10 @@ export class PostgresDatabase implements RelayDatabase { peekPoolPressure(): PostgresPoolPressureCounts { return this.pressure.peekCounts() } + + poolOldestWaitMs(): number { + return this.pressure.oldestWaitMs() + } } export function consumeRelayDatabasePoolPressure( @@ -1198,6 +1218,11 @@ export function readRelayDatabasePoolPressure( : emptyPostgresPoolPressureCounts() } +// How long the longest-waiting pooled query has queued so far; 0 when none waits. +export function readRelayDatabasePoolOldestWaitMs(database: RelayDatabase): number { + return database instanceof PostgresDatabase ? database.poolOldestWaitMs() : 0 +} + export function absorbPostgresIdleClientErrors(pool: Pick): void { pool.on('error', () => { // Why: node-postgres removes failed idle clients itself; leaving `error` diff --git a/cloud/apps/relay/src/host-session-registry.test.ts b/cloud/apps/relay/src/host-session-registry.test.ts index fcf73695b40a..912462620d03 100644 --- a/cloud/apps/relay/src/host-session-registry.test.ts +++ b/cloud/apps/relay/src/host-session-registry.test.ts @@ -18,7 +18,11 @@ import { type ControlRenewalOutcome, type ControlRenewalRequest } from './control-renewal-statement.js' -import { HostSessionRegistry, type HostSession } from './host-session-registry.js' +import { + HostSessionRegistry, + IDLE_REHOME_MIN_CONTROL_AGE_MS, + type HostSession +} from './host-session-registry.js' import { relayHostLogDigest } from './relay-host-log-digest.js' import type { RelayRuntimeObserver } from './relay-observability.js' import { @@ -1319,7 +1323,10 @@ describe('source-owned idle cutover', () => { sourceCellIncarnation: 'incarnation-1', targetCellId: 'target' } - async function source(store: Partial = {}) { + async function source( + store: Partial = {}, + controlAgeMs = IDLE_REHOME_MIN_CONTROL_AGE_MS + ) { const h = createRegistry(vi.fn().mockResolvedValue('control:1'), store) const socket = new FakeSocket() h.registry.acceptControl( @@ -1330,8 +1337,42 @@ describe('source-owned idle cutover', () => { ) socket.removeAllListeners('message') await h.activate(socket as unknown as WebSocket, identity, null, 1, false, 1) + // Moves the clock without firing the heartbeat timers that would close the session. + vi.setSystemTime(Date.now() + controlAgeMs) return { ...h, socket, session: h.registry.get(request)! } } + it.each([ + { controlAgeMs: 0, outcome: 'busy' }, + { controlAgeMs: IDLE_REHOME_MIN_CONTROL_AGE_MS - 1, outcome: 'busy' }, + { controlAgeMs: IDLE_REHOME_MIN_CONTROL_AGE_MS, outcome: 'committed' }, + { controlAgeMs: 60 * 60 * 1000, outcome: 'committed' } + ])('answers $outcome for a control socket $controlAgeMs ms old', async (row) => { + const { controlAgeMs, outcome } = row + const h = await source({}, controlAgeMs) + const commit = vi.fn().mockResolvedValue({ outcome: 'committed' }) + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome }) + expect(commit).toHaveBeenCalledTimes(outcome === 'busy' ? 0 : 1) + expect(h.socket.close).toHaveBeenCalledTimes(outcome === 'busy' ? 0 : 1) + }) + it('restarts the age when the host rebinds its control socket', async () => { + const h = await source() + const rebound = new FakeSocket() + h.registry.acceptControl( + rebound as unknown as WebSocket, + identity, + undefined, + new Set([RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME]) + ) + rebound.removeAllListeners('message') + await h.activate(rebound as unknown as WebSocket, identity, h.session, 1, true, 1) + const commit = vi.fn().mockResolvedValue({ outcome: 'committed' }) + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'busy' }) + expect(commit).not.toHaveBeenCalled() + }) it('keeps either established client busy until both actually leave', async () => { const h = await source() h.session.activeConnIds.add('phone') diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index 06cfb2d57ac3..5dfa4e27bdd1 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -76,6 +76,9 @@ const CONTROL_ACTIVITY_LEASE_MS = CONTROL_ACTIVITY_RENEWAL_INTERVAL_MS - RELAY_PROTOCOL_LIMITS.controlPingIntervalMs +// Past the 135-150 s liveness close, so a host that wakes briefly then sleeps never gets a move. +export const IDLE_REHOME_MIN_CONTROL_AGE_MS = 180_000 + export type HostSession = { identity: RelayTokenClaims readonly relayHostId: string @@ -88,6 +91,8 @@ export type HostSession = { appVersion: string state: HostState socket: WebSocket | null + // When the current control socket was wired; a rebind restarts it. + controlWiredAt: number leaseExpiresAt: number orphanTimer: ReturnType | null heartbeatTimer: ReturnType | null @@ -268,7 +273,8 @@ export class HostSessionRegistry { (this.idleWork.get(input.relayHostId) ?? 0) !== 0 || session.activeConnIds.size !== 0 || session.activeSplices.size !== 0 || - session.pendingConns.size !== 0 + session.pendingConns.size !== 0 || + this.now() - session.controlWiredAt < IDLE_REHOME_MIN_CONTROL_AGE_MS ) return { outcome: 'busy' } const revision = session.authorityRevision @@ -1260,6 +1266,7 @@ export class HostSessionRegistry { appVersion, state: 'active', socket, + controlWiredAt: this.now(), leaseExpiresAt: this.controlLeaseExpiresAt(), orphanTimer: null, heartbeatTimer: null, @@ -1290,6 +1297,7 @@ export class HostSessionRegistry { private wireActiveControl(session: HostSession): void { const socket = session.socket! const wiredAt = this.now() + session.controlWiredAt = wiredAt // Why: pin the build to THIS socket. A rebind refreshes session.appVersion and // only then closes the predecessor, whose close event always lands after that // write, so reading it at log time would stamp the successor's build. diff --git a/cloud/apps/relay/src/observed-relay-database.ts b/cloud/apps/relay/src/observed-relay-database.ts index 5149c1c2dd97..7cae39fd0626 100644 --- a/cloud/apps/relay/src/observed-relay-database.ts +++ b/cloud/apps/relay/src/observed-relay-database.ts @@ -29,11 +29,21 @@ export function observeRelayDatabase( error instanceof Error && error.message === 'database_lock_unavailable' ) + const queryPriority = database.queryPriority?.bind(database) const commitWithFinal = database.commitWithFinal?.bind(database) return { dialect: database.dialect, query, queryLocked, + ...(queryPriority + ? { + queryPriority: (sql: string, params?: unknown[]): Promise => + timedRelayOperation( + () => queryPriority(sql, params), + (durationMs, success) => observer.recordSql(durationMs, success) + ) + } + : {}), ...(commitWithFinal ? { commitWithFinal: (sql: string, params?: unknown[]): Promise => diff --git a/cloud/apps/relay/src/postgres-pool-pressure.test.ts b/cloud/apps/relay/src/postgres-pool-pressure.test.ts index 4a59dbfef975..ec3d90a40b01 100644 --- a/cloud/apps/relay/src/postgres-pool-pressure.test.ts +++ b/cloud/apps/relay/src/postgres-pool-pressure.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' -import { PostgresPoolPressure } from './postgres-pool-pressure.js' +import { isPostgresPoolConnectTimeout, PostgresPoolPressure } from './postgres-pool-pressure.js' describe('PostgreSQL pool pressure', () => { it('reports current waiters and interval high-water marks', async () => { @@ -47,4 +47,103 @@ describe('PostgreSQL pool pressure', () => { databasePoolWaitMsMax: 0 }) }) + + it('reads the oldest wait as it is now, not the interval maximum', async () => { + let now = 1_000 + const pool = { + totalCount: 1, + idleCount: 0, + waitingCount: 0, + connect: vi.fn(() => { + pool.waitingCount++ + return new Promise(() => {}) + }) + } + const pressure = new PostgresPoolPressure(pool as never, () => now) + expect(pressure.oldestWaitMs()).toBe(0) + void pressure.connect() + now = 2_600 + expect(pressure.oldestWaitMs()).toBe(1_600) + }) +}) + +describe('PostgreSQL pool priority lane', () => { + // pg-pool's acquire contract with a hand-driven supply of free connections. + function controlledPool(max: number, connectionTimeoutMillis = 0) { + const pending: Array<(client: unknown) => void> = [] + const pool = { + options: { max, connectionTimeoutMillis }, + totalCount: 0, + idleCount: 0, + waitingCount: 0, + connect: vi.fn(() => { + if (pool.totalCount < max) { + pool.totalCount++ + return Promise.resolve(client()) + } + pool.waitingCount++ + return new Promise((resolve) => pending.push(resolve)) + }) + } + const client = () => + Object.assign(new EventEmitter(), { + query: vi.fn(), + release: vi.fn(() => { + const next = pending.shift() + if (next) { + pool.waitingCount-- + next(client()) + } else pool.totalCount-- + }) + }) + return pool + } + + it('hands the next released connection to priority work ahead of queued general work', async () => { + const pool = controlledPool(2) + const pressure = new PostgresPoolPressure(pool as never) + const general = [await pressure.connect(), await pressure.connect()] + const order: string[] = [] + const queuedGeneral = pressure.connect().then((client) => { + order.push('general') + return client + }) + const priority = pressure.connect('priority').then((client) => { + order.push('priority') + return client + }) + await Promise.resolve() + expect(pressure.peekCounts().databasePoolWaiting).toBe(2) + + general[0]!.release() + const priorityClient = await priority + expect(order).toEqual(['priority']) + priorityClient.release() + const generalClient = await queuedGeneral + expect(order).toEqual(['priority', 'general']) + for (const client of [general[1]!, generalClient]) client.release() + expect(pool.totalCount).toBe(0) + expect(pressure.peekCounts().databasePoolWaiting).toBe(0) + }) + + it('fails a queued general caller with the pool timeout every classifier knows', async () => { + vi.useFakeTimers() + try { + const pressure = new PostgresPoolPressure(controlledPool(1, 2_000) as never) + const held = await pressure.connect() + const queued = pressure.connect().catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(2_000) + + const error = await queued + expect(isPostgresPoolConnectTimeout(error)).toBe(true) + expect(pressure.peekCounts().databasePoolWaiting).toBe(0) + // The timed-out caller left the queue, so the slot goes to the next one in line. + held.release() + const next = await pressure.connect() + next.release() + } finally { + vi.useRealTimers() + } + }) + }) diff --git a/cloud/apps/relay/src/postgres-pool-pressure.ts b/cloud/apps/relay/src/postgres-pool-pressure.ts index afeab79f5a12..190d02d27610 100644 --- a/cloud/apps/relay/src/postgres-pool-pressure.ts +++ b/cloud/apps/relay/src/postgres-pool-pressure.ts @@ -64,31 +64,100 @@ const emptyCounts = (): PostgresPoolPressureCounts => ({ databasePoolWaitMsMax: 0 }) +// `priority` work skips the general queue and takes the next connection any +// caller releases, so a burst of general work cannot make it wait its turn. +export type PostgresPoolLane = 'general' | 'priority' + +type GeneralLaneWaiter = { + grant: (releaseSlot: () => void) => void + timer: ReturnType | undefined +} + export class PostgresPoolPressure { private readonly waiters = new Map() private waitersMax = 0 private waitMsMax = 0 private lastConsumed = emptyCounts() + // General work queues here, not in pg-pool, so pg-pool's own queue holds only + // priority callers. Holding a connection back instead would cost general + // throughput: one of 16 is the margin an asia-east2 drain release runs on. + private readonly generalCapacity: number + private generalInUse = 0 + private readonly generalQueue: GeneralLaneWaiter[] = [] constructor( private readonly pool: pg.Pool, private readonly now: () => number = Date.now - ) {} - - async connect(): Promise { - const waitingBefore = this.pool.waitingCount - const connection = this.pool.connect() - if (this.pool.waitingCount <= waitingBefore) return await markedAcquire(connection) + ) { + this.generalCapacity = pool.options?.max ?? Infinity + } - const waiter = Symbol() + async connect(lane: PostgresPoolLane = 'general'): Promise { const startedAt = this.now() - this.waiters.set(waiter, startedAt) - this.waitersMax = Math.max(this.waitersMax, this.waiters.size) + let waiter: symbol | undefined + const markWaiting = (): void => { + if (waiter) return + waiter = Symbol() + this.waiters.set(waiter, startedAt) + this.waitersMax = Math.max(this.waitersMax, this.waiters.size) + } try { - return await markedAcquire(connection) + const slot = lane === 'priority' ? () => {} : this.takeGeneralSlot(markWaiting) + // Awaited only when queued: a free slot dials in this same turn. + const releaseSlot = typeof slot === 'function' ? slot : await slot + try { + const waitingBefore = this.pool.waitingCount + const connection = this.pool.connect() + if (this.pool.waitingCount > waitingBefore) markWaiting() + return await markedAcquire(connection, releaseSlot) + } catch (error) { + releaseSlot() + throw error + } } finally { - this.waitMsMax = Math.max(this.waitMsMax, this.now() - startedAt) - this.waiters.delete(waiter) + if (waiter) { + this.waitMsMax = Math.max(this.waitMsMax, this.now() - startedAt) + this.waiters.delete(waiter) + } + } + } + + // FIFO, bounded by the pool's own acquire timeout so a queued caller fails + // exactly as it would have inside pg-pool, and every classifier still matches. + private takeGeneralSlot(markWaiting: () => void): (() => void) | Promise<() => void> { + if (this.generalInUse < this.generalCapacity && this.generalQueue.length === 0) { + this.generalInUse++ + return this.generalSlotRelease() + } + markWaiting() + return new Promise((resolve, reject) => { + const queued: GeneralLaneWaiter = { grant: resolve, timer: undefined } + const timeoutMs = this.pool.options?.connectionTimeoutMillis ?? 0 + if (timeoutMs > 0) { + queued.timer = setTimeout(() => { + const index = this.generalQueue.indexOf(queued) + if (index >= 0) this.generalQueue.splice(index, 1) + reject(new Error(POOL_CONNECT_TIMEOUT_MESSAGES[0])) + }, timeoutMs) + queued.timer.unref?.() + } + this.generalQueue.push(queued) + }) + } + + private generalSlotRelease(): () => void { + let released = false + return () => { + if (released) return + released = true + const next = this.generalQueue.shift() + if (!next) { + this.generalInUse-- + return + } + // The slot passes straight to the next caller, so generalInUse is unchanged. + clearTimeout(next.timer) + next.grant(this.generalSlotRelease()) } } @@ -119,6 +188,12 @@ export class PostgresPoolPressure { } } + // Instantaneous, unlike peekCounts, which holds the last interval's maxima. + oldestWaitMs(): number { + if (this.waiters.size === 0) return 0 + return Math.max(0, this.now() - Math.min(...this.waiters.values())) + } + private readCounts(): PostgresPoolPressureCounts { const now = this.now() const oldestWaitMs = @@ -134,7 +209,10 @@ export class PostgresPoolPressure { } } -async function markedAcquire(connection: Promise): Promise { +async function markedAcquire( + connection: Promise, + onRelease: () => void +): Promise { let client: pg.PoolClient try { client = await connection @@ -142,7 +220,7 @@ async function markedAcquire(connection: Promise): Promise): Promise void): pg.PoolClient { let failure: Error | undefined const onError = (error: Error) => { failure ??= error @@ -171,7 +249,11 @@ function guardCheckedOutClient(client: pg.PoolClient): pg.PoolClient { client.removeListener('error', onError) // Passing the error makes pg-pool destroy the client instead of returning a // dead connection to the pool for the next caller to trip over. - release(releaseError ?? failure) + try { + release(releaseError ?? failure) + } finally { + onRelease() + } } return client } diff --git a/cloud/apps/relay/src/postgres-priority-lane-postgres.test.ts b/cloud/apps/relay/src/postgres-priority-lane-postgres.test.ts new file mode 100644 index 000000000000..7b0f55baf00c --- /dev/null +++ b/cloud/apps/relay/src/postgres-priority-lane-postgres.test.ts @@ -0,0 +1,48 @@ +import { afterAll, describe, expect, it } from 'vitest' +import type { PostgresDatabase } from './database.js' +import { isPostgresPoolConnectTimeout } from './postgres-pool-pressure.js' +import { openDelayedPostgresDatabase } from './test-fixtures/delayed-postgres-database.js' + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip + +// The 2026-10-06 asia-east2 shape at test scale: a reconnect herd far wider +// than the pool, every statement paying a cross-region round trip, and a +// control renewal arriving behind it. +describePostgres('PostgreSQL priority lane under a reconnect herd', () => { + const databases: PostgresDatabase[] = [] + const delay = { enabled: true, delayMs: 400 } + + afterAll(async () => { + delay.enabled = false + for (const database of databases) await database.close() + }) + + it('renews ahead of the herd while queued general work times out', async () => { + const database = openDelayedPostgresDatabase(databaseUrl!, delay, 4) + databases.push(database) + const herd = Array.from({ length: 40 }, () => + database.query('SELECT 1').then( + () => 'ok', + (error: unknown) => (isPostgresPoolConnectTimeout(error) ? 'timeout' : 'other') + ) + ) + await new Promise((resolve) => setTimeout(resolve, 50)) + const startedAt = performance.now() + const general = database + .query('SELECT 1 AS renewed') + .catch(() => undefined) + .then(() => performance.now() - startedAt) + const renewed = await database.queryPriority('SELECT 1 AS renewed') + const renewedMs = performance.now() - startedAt + + expect(renewed).toEqual([{ renewed: 1 }]) + // The next released connection plus one statement, never a turn in the herd's queue. + expect(renewedMs).toBeLessThan(1_500) + // The same statement on the general lane waits out the herd's acquire timeout. + expect(await general).toBeGreaterThan(1_900) + const outcomes = await Promise.all(herd) + expect(outcomes.filter((outcome) => outcome === 'timeout').length).toBeGreaterThan(20) + expect(outcomes).not.toContain('other') + }, 15_000) +}) diff --git a/cloud/apps/relay/src/regional-host-drain-app.test.ts b/cloud/apps/relay/src/regional-host-drain-app.test.ts index 38ebdd3f7096..06b19371bc81 100644 --- a/cloud/apps/relay/src/regional-host-drain-app.test.ts +++ b/cloud/apps/relay/src/regional-host-drain-app.test.ts @@ -734,7 +734,7 @@ describe('cell drain endpoint pacing', () => { }) it('refuses a window that is negative, fractional, or past the cap', async () => { - for (const paceWindowMs of [-1, 1.5, 300_001]) { + for (const paceWindowMs of [-1, 1.5, 1_200_001]) { const { app, drain } = appWithDrain() const response = await postPath(app, '/v1/admin/drain', 'deploy-token', { v: 1, @@ -751,10 +751,10 @@ describe('cell drain endpoint pacing', () => { const response = await postPath(app, '/v1/admin/drain', 'deploy-token', { v: 1, graceMs: 0, - paceWindowMs: 300_000 + paceWindowMs: 1_200_000 }) expect(response.status).toBe(200) - expect(drain).toHaveBeenCalledWith(0, { paceWindowMs: 300_000 }) + expect(drain).toHaveBeenCalledWith(0, { paceWindowMs: 1_200_000 }) }) it('still rejects an unauthenticated pacing request', async () => { diff --git a/cloud/apps/relay/src/regional-rehome-postgres.test.ts b/cloud/apps/relay/src/regional-rehome-postgres.test.ts index c1adbb912914..8cd1d28dbc18 100644 --- a/cloud/apps/relay/src/regional-rehome-postgres.test.ts +++ b/cloud/apps/relay/src/regional-rehome-postgres.test.ts @@ -415,9 +415,10 @@ describePostgres('PostgreSQL regional rehoming', () => { const claim = context.store.commitIdleRegionalRehome(request!, safety(context.now())) await expect(claim).resolves.toEqual({ outcome: 'deferred', reason: 'fleet-safety' }) + // A sql spike alone pauses the claim; only the director's sustained check latches. expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 2, - enabled: false + generation: 1, + enabled: true }) expect( await primary.query( diff --git a/cloud/apps/relay/src/regional-rehome-safety.test.ts b/cloud/apps/relay/src/regional-rehome-safety.test.ts index 4da072943d2b..38e6fa34d306 100644 --- a/cloud/apps/relay/src/regional-rehome-safety.test.ts +++ b/cloud/apps/relay/src/regional-rehome-safety.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest' import { REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT, REGIONAL_REHOME_SQL_FAILURES_LIMIT, - regionalRehomeSafetyFailure + REGIONAL_REHOME_SQL_FAILURES_OBSERVATION_GAP_MS, + REGIONAL_REHOME_SQL_FAILURES_SUSTAIN_MS, + nextRegionalRehomeSqlFailuresBreach, + regionalRehomeSafetyFailure, + regionalRehomeSqlFailuresSustained, + type RegionalRehomeSqlFailuresBreach } from './regional-rehome-safety.js' const NOW = 1_787_900_000_000 @@ -129,3 +134,44 @@ describe('regionalRehomeSafetyFailure', () => { ).toBe('monitoring_stale') }) }) + +describe('regional rehome sql failure breach', () => { + const STORM = REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1 + function observe(samples: [at: number, sqlFailures: number][]): RegionalRehomeSqlFailuresBreach { + let breach: RegionalRehomeSqlFailuresBreach = null + for (const [at, sqlFailures] of samples) { + breach = nextRegionalRehomeSqlFailuresBreach(breach, sqlFailures, NOW + at) + } + return breach + } + + it('does not sustain a single-stall spike', () => { + // 10-06 06:16Z: one 5-7 s stall published 396 for up to ~90 s. + const spike = observe([[0, 396], [30_000, 396], [60_000, 396], [90_000, 396]]) + expect(spike && regionalRehomeSqlFailuresSustained(spike)).toBe(false) + expect(observe([[0, 396], [90_000, 396], [96_000, 0]])).toBeNull() + }) + + it('sustains a breach watched for the whole window', () => { + const samples: [number, number][] = [] + for (let at = 0; at <= REGIONAL_REHOME_SQL_FAILURES_SUSTAIN_MS; at += 6_000) samples.push([at, STORM]) + const breach = observe(samples) + expect(breach && regionalRehomeSqlFailuresSustained(breach)).toBe(true) + }) + + it('restarts the window after an unwatched gap', () => { + const breach = observe([ + [0, STORM], + [REGIONAL_REHOME_SQL_FAILURES_OBSERVATION_GAP_MS + 1, STORM], + [REGIONAL_REHOME_SQL_FAILURES_OBSERVATION_GAP_MS + 30_000, STORM] + ]) + expect(breach?.since).toBe(NOW + REGIONAL_REHOME_SQL_FAILURES_OBSERVATION_GAP_MS + 1) + expect(breach && regionalRehomeSqlFailuresSustained(breach)).toBe(false) + }) + + it('reports a latching reason over a concurrent sql spike', () => { + expect( + regionalRehomeSafetyFailure(safety({ sqlFailures: STORM, controlActivityRecoveryFailures: 1 }), NOW, 19) + ).toBe('control_recovery_failures') + }) +}) diff --git a/cloud/apps/relay/src/regional-rehome-safety.ts b/cloud/apps/relay/src/regional-rehome-safety.ts index 072e82f76211..8caf2b26e054 100644 --- a/cloud/apps/relay/src/regional-rehome-safety.ts +++ b/cloud/apps/relay/src/regional-rehome-safety.ts @@ -54,16 +54,45 @@ export function regionalRehomeSafetyFailure( if (safety.observedAt === 0 || now - safety.observedAt > 60_000) { return 'monitoring_stale' } - if (safety.sqlFailures > REGIONAL_REHOME_SQL_FAILURES_LIMIT) return 'sql_failures' if (safety.controlActivityRecoveryFailures > 0) { return 'control_recovery_failures' } const reconnectLimit = Math.max(1, requiredCells) * REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT if (safety.reconnects > reconnectLimit) return 'elevated_reconnects' + // Last, so 'sql_failures' means no latching reason also failed. + if (safety.sqlFailures > REGIONAL_REHOME_SQL_FAILURES_LIMIT) return 'sql_failures' return null } +// The shared database stalls for 5-7 s about every 610 s, and one stall keeps +// the published sum over the sql bar for up to ~90 s (two 30 s windows plus +// heartbeat lag): 396 on 2026-10-06 latched rehome off fleet-wide. A breach +// therefore pauses claims, and only one that outlasts a stall latches. +export const REGIONAL_REHOME_SQL_FAILURES_SUSTAIN_MS = 150_000 +// The director polls every ~6 s; a longer silence means the breach was not watched. +export const REGIONAL_REHOME_SQL_FAILURES_OBSERVATION_GAP_MS = 45_000 + +export type RegionalRehomeSqlFailuresBreach = { since: number; lastSeenAt: number } | null + +export function nextRegionalRehomeSqlFailuresBreach( + breach: RegionalRehomeSqlFailuresBreach, + sqlFailures: number, + now: number +): RegionalRehomeSqlFailuresBreach { + if (sqlFailures <= REGIONAL_REHOME_SQL_FAILURES_LIMIT) return null + if (!breach || now - breach.lastSeenAt > REGIONAL_REHOME_SQL_FAILURES_OBSERVATION_GAP_MS) { + return { since: now, lastSeenAt: now } + } + return { since: breach.since, lastSeenAt: now } +} + +export function regionalRehomeSqlFailuresSustained( + breach: NonNullable +): boolean { + return breach.lastSeenAt - breach.since >= REGIONAL_REHOME_SQL_FAILURES_SUSTAIN_MS +} + export function combineRegionalRehomeSafety( processSafety: RegionalRehomeSafetySnapshot, fleetSafety: RegionalRehomeSafetySnapshot diff --git a/cloud/apps/relay/src/regional-rehome-store.test.ts b/cloud/apps/relay/src/regional-rehome-store.test.ts index f53c1c429f03..5a7c1072d81e 100644 --- a/cloud/apps/relay/src/regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/regional-rehome-store.test.ts @@ -16,7 +16,8 @@ import { } from './database.js' import { REGIONAL_REHOME_SQL_FAILURES_LIMIT, - REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT + REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT, + REGIONAL_REHOME_SQL_FAILURES_SUSTAIN_MS } from './regional-rehome-safety.js' import type { RegionalRehomeSafetySnapshot } from './relay-observability.js' @@ -760,7 +761,7 @@ describe('regional rehome assignment state', () => { await context.database.close() }) - it('atomically latches durable control off when candidate safety changes', async () => { + it('pauses the claim without latching when a sql spike lands between selection and commit', async () => { const context = await setup() await activatePreferredSource(context, { userId: 'user-1', @@ -786,11 +787,87 @@ describe('regional rehome assignment state', () => { outcome: 'deferred', reason: 'fleet-safety' }) + expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ + generation: 1, + enabled: true + }) + expect( + await context.database.query(`SELECT consecutive_failures FROM relay_region_rehome_worker_state`) + ).toEqual([{ consecutive_failures: 0 }]) + expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) + await context.database.close() + }) + + it('rides out a single-stall sql spike without latching the control off', async () => { + const context = await setup() + // 10-06 06:16Z: one stall put the fleet sum at 396 for about a minute. + for (let elapsed = 0; elapsed <= 90_000; elapsed += 6_000) { + await sqlFailuresHeartbeats(context, 396) + expect(await context.store.selectIdleRegionalRehomeCandidates(cleanSafety(context))).toEqual([]) + context.advance(6_000) + } + await sqlFailuresHeartbeats(context, 0) + await activatePreferredSource(context, { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + expect(await context.store.selectIdleRegionalRehomeCandidates(cleanSafety(context))).toHaveLength(1) + // The next stall, ~610 s later, starts its own breach rather than extending the last one. + context.advance(500_000) + for (let elapsed = 0; elapsed <= 90_000; elapsed += 6_000) { + await sqlFailuresHeartbeats(context, 396) + await context.store.selectIdleRegionalRehomeCandidates(cleanSafety(context)) + context.advance(6_000) + } + expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ + generation: 1, + enabled: true + }) + await context.database.close() + }) + + it('latches the control off when the sql breach outlasts a stall', async () => { + const context = await setup() + await activatePreferredSource(context, { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + const warnings = collectEventWarnings('orca_relay_regional_rehome_safety_disabled') + try { + for (let elapsed = 0; elapsed < REGIONAL_REHOME_SQL_FAILURES_SUSTAIN_MS; elapsed += 6_000) { + await sqlFailuresHeartbeats(context, REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1) + await context.store.selectIdleRegionalRehomeCandidates(cleanSafety(context)) + context.advance(6_000) + } + expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ enabled: true }) + await sqlFailuresHeartbeats(context, REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1) + expect(await context.store.selectIdleRegionalRehomeCandidates(cleanSafety(context))).toEqual([]) + } finally { + warnings.restore() + } + expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ + generation: 2, + enabled: false + }) + expect(warnings.entries).toMatchObject([ + { reason: 'sql_failures', controlGeneration: 2, sustainedMs: 150_000 } + ]) + await context.database.close() + }) + + it('still latches on a non-sql reason that fails alongside a sql spike', async () => { + const context = await setup() + await activatePreferredSource(context, { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + const [candidate] = await context.store.selectIdleRegionalRehomeCandidates(cleanSafety(context)) + expect(candidate).toBeDefined() + await context.database.query( + `UPDATE relay_cell_rehome_safety + SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1}, control_activity_recovery_failures = 1 + WHERE cell_id = ?`, + [target.id] + ) + expect(await context.store.commitIdleRegionalRehome(candidate!, cleanSafety(context))).toEqual({ + outcome: 'deferred', + reason: 'fleet-safety' + }) expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 2, enabled: false }) - expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await context.database.close() }) @@ -1828,6 +1905,29 @@ async function freshHeartbeats(context: Context): Promise { await heartbeat(context.store, target, targetIncarnation, 3, context.now(), safety) } +function cleanSafety(context: Context): RegionalRehomeSafetySnapshot { + return { + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } +} + +// Splits the fleet sum across both cells, each under the per-cell bar's scale. +async function sqlFailuresHeartbeats(context: Context, fleetSqlFailures: number): Promise { + const half = Math.floor(fleetSqlFailures / 2) + const safety = cleanSafety(context) + await heartbeat(context.store, source, sourceIncarnation, 3, context.now(), { ...safety, sqlFailures: half }) + await heartbeat(context.store, target, targetIncarnation, 3, context.now(), { + ...safety, + sqlFailures: fleetSqlFailures - half + }) +} + async function activatePreferredSource( context: Context, identity: { userId: string; relayHostId: string } diff --git a/cloud/apps/relay/src/relay-host-hello-shed.test.ts b/cloud/apps/relay/src/relay-host-hello-shed.test.ts new file mode 100644 index 000000000000..f5e116f5c9ce --- /dev/null +++ b/cloud/apps/relay/src/relay-host-hello-shed.test.ts @@ -0,0 +1,189 @@ +import { createHash } from 'node:crypto' +import { createServer, type Server } from 'node:http' +import { createServer as createNetServer } from 'node:net' +import { exportJWK, generateKeyPair, SignJWT } from 'jose' +import nacl from 'tweetnacl' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import type { RelayConfig } from './config.js' +import { PostgresDatabase } from './database.js' +import { HostSessionRegistry } from './host-session-registry.js' +import { createRelayServer, HOST_HELLO_SHED_OLDEST_WAIT_MS } from './relay-server.js' + +async function unusedPort(): Promise { + const server = createNetServer() + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('missing test port') + await new Promise((resolve) => server.close(() => resolve())) + return address.port +} + +// pg-pool's shape with every acquire stuck, so queued work only accumulates. +function stalledPool() { + const pool = { + options: { max: 2, connectionTimeoutMillis: 0 }, + totalCount: 2, + idleCount: 0, + waitingCount: 0, + connect: () => { + pool.waitingCount++ + return new Promise(() => {}) + }, + end: async () => undefined + } + return pool +} + +function cellConfig(port: number, issuer: string): RelayConfig { + const relayUrl = `http://127.0.0.1:${port}` + return { + port, + publicUrl: relayUrl, + cellUrl: relayUrl, + authIssuer: issuer, + authAudience: 'orca-relay', + jwksUrl: issuer, + assignmentSigningKey: new Uint8Array(32), + role: 'cell', + cellId: 'production-gce-c3', + cells: [{ id: 'production-gce-c3', url: relayUrl, capacityRequests: 4_000 }], + adminAudience: `${relayUrl}/admin`, + deployServiceAccount: 'deploy@example.com', + runtimeServiceAccount: 'runtime@example.com', + connectionHardCap: 600, + connectionUnobservedBound: 60, + adminJwksUrl: `${issuer}/admin-jwks`, + databasePoolMax: 2, + publicAssignmentsEnabled: true, + publicAssignmentConcurrency: 1, + publicAssignmentQueueMax: 128, + publicAssignmentWaitMs: 4_000, + publicResolveConcurrency: 1, + publicResolveWaitMs: 5_000, + publicAssignmentRetryAfterSeconds: 5, + dataDir: './test-data' + } +} + +describe('host hello shedding under database pool pressure', () => { + const cleanup: Array<() => Promise | void> = [] + + afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() + vi.restoreAllMocks() + }) + + // A cell whose pool holds `waiters` stuck queries, and a clock the test moves. + async function startCell(waiters: number) { + vi.spyOn(console, 'log').mockImplementation(() => undefined) + const keys = await generateKeyPair('ES256') + const publicJwk = await exportJWK(keys.publicKey) + const jwksServer: Server = createServer((_request, response) => { + response.setHeader('content-type', 'application/json') + response.end( + JSON.stringify({ keys: [{ ...publicJwk, kid: 'test-key', alg: 'ES256', use: 'sig' }] }) + ) + }) + await new Promise((resolve) => jwksServer.listen(0, '127.0.0.1', resolve)) + cleanup.push(() => new Promise((resolve) => jwksServer.close(() => resolve()))) + const jwksAddress = jwksServer.address() + if (!jwksAddress || typeof jwksAddress === 'string') throw new Error('missing JWKS address') + const issuer = `http://127.0.0.1:${jwksAddress.port}` + + const realNow = Date.now() + let elapsedMs = 0 + // Before the database exists, so the pool's wait clock reads it too. + vi.spyOn(Date, 'now').mockImplementation(() => realNow + elapsedMs) + const database = new PostgresDatabase(stalledPool() as never) + for (let index = 0; index < waiters; index++) void database.query('SELECT 1').catch(() => {}) + const port = await unusedPort() + const relay = createRelayServer(cellConfig(port, issuer), database) + relay.server.listen(port, '127.0.0.1') + await new Promise((resolve) => relay.server.once('listening', resolve)) + cleanup.push(() => new Promise((resolve) => relay.server.close(() => resolve()))) + + const hostId = createHash('sha256') + .update(nacl.box.keyPair().publicKey) + .digest('base64url') + .slice(0, 16) + const token = await new SignJWT({ + prof: 'profile-1', + org: 'org-1', + purpose: 'host-control', + relayHostId: hostId + }) + .setProtectedHeader({ alg: 'ES256', kid: 'test-key' }) + .setIssuer(issuer) + .setAudience('orca-relay') + .setSubject('user-1') + .setIssuedAt() + .setExpirationTime('5m') + .sign(keys.privateKey) + return { + url: `ws://127.0.0.1:${port}/v1/host/control`, + token, + advance: (ms: number) => { + elapsedMs += ms + } + } + } + + // Resolves with the refusal's status, or 101 once the upgrade is accepted. + async function dial( + cell: Awaited> + ): Promise<{ status: number; retryAfter?: string }> { + const socket = new WebSocket(cell.url, { + headers: { authorization: `Bearer ${cell.token}` }, + perMessageDeflate: false + }) + return await new Promise((resolve, reject) => { + socket.once('unexpected-response', (request, response) => { + resolve({ status: response.statusCode ?? 0, retryAfter: response.headers['retry-after'] }) + request.destroy() + }) + socket.once('open', () => { + resolve({ status: 101 }) + socket.terminate() + }) + socket.once('error', reject) + }) + } + + it('admits hellos behind a deep queue that is still moving', async () => { + const cell = await startCell(120) + cell.advance(HOST_HELLO_SHED_OLDEST_WAIT_MS - 300) + + expect(await dial(cell)).toEqual({ status: 101 }) + }) + + it('admits hellos behind one slow waiter', async () => { + // The test pool holds 2 connections, so a lone aged waiter is under the count floor. + const cell = await startCell(1) + cell.advance(HOST_HELLO_SHED_OLDEST_WAIT_MS * 2) + + expect(await dial(cell)).toEqual({ status: 101 }) + }) + + it('refuses a hello with a retryable 503 once a full queue has aged', async () => { + const cell = await startCell(2) + cell.advance(HOST_HELLO_SHED_OLDEST_WAIT_MS) + + expect(await dial(cell)).toEqual({ status: 503, retryAfter: '2' }) + // Shipped desktops dial with no unexpected-response listener, so the refusal + // reaches them as the ordinary connect error their retry backoff handles. + const desktop = new WebSocket(cell.url, { + headers: { authorization: `Bearer ${cell.token}` } + }) + const error = await new Promise((resolve) => desktop.once('error', resolve)) + expect(error.message).toBe('Unexpected server response: 503') + }) + + it('never refuses a rebind over a live control', async () => { + vi.spyOn(HostSessionRegistry.prototype, 'hasActiveControl').mockReturnValue(true) + const cell = await startCell(2) + cell.advance(HOST_HELLO_SHED_OLDEST_WAIT_MS * 2) + + expect(await dial(cell)).toEqual({ status: 101 }) + }) +}) diff --git a/cloud/apps/relay/src/relay-observability.ts b/cloud/apps/relay/src/relay-observability.ts index 3638de915495..612beb0fa1ed 100644 --- a/cloud/apps/relay/src/relay-observability.ts +++ b/cloud/apps/relay/src/relay-observability.ts @@ -165,6 +165,7 @@ type RelayMetricDeltas = { controlRenewalFlushRowsMax: number controlActivityRecoveries: number controlActivityRecoveryFailures: number + hostHellosShed: number } // A host chooses how often it answers a ping, so the process-wide window is a @@ -221,7 +222,8 @@ const emptyDeltas = (): RelayMetricDeltas => ({ controlRenewalFlushLatenciesMs: [], controlRenewalFlushRowsMax: 0, controlActivityRecoveries: 0, - controlActivityRecoveryFailures: 0 + controlActivityRecoveryFailures: 0, + hostHellosShed: 0 }) function ascending(values: number[]): number[] { @@ -289,6 +291,10 @@ export class RelayObservability implements RelayRuntimeObserver { this.deltas.reconnects++ } + recordHostHelloShed(): void { + this.deltas.hostHellosShed++ + } + recordAssignmentAdmission(outcome: AssignmentAdmissionOutcome): void { if (outcome === 'sticky') this.deltas.stickyAssignments++ else if (outcome === 'sticky-rejected') this.deltas.stickyAssignmentRejections++ @@ -571,6 +577,7 @@ export class RelayObservability implements RelayRuntimeObserver { deltas.controlRenewalsByOutcome.control_activity_not_found ?? 0, controlActivityRecoveriesDelta: deltas.controlActivityRecoveries, controlActivityRecoveryFailuresDelta: deltas.controlActivityRecoveryFailures, + hostHellosShedDelta: deltas.hostHellosShed, // Meaning changed when renewals began batching: for a batched row this is // the flush's duration, not that row's own statement latency. The // per-flush fields below are the ones to read for statement cost. diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index de7f635ba708..70873e334c06 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -20,7 +20,11 @@ import { createRelayApp } from './app.js' import { RelayAssignmentStore } from './assignment-store.js' import type { RelayConfig } from './config.js' import { RelayCredentialStore } from './credential-store.js' -import { readRelayDatabasePoolPressure, type RelayDatabase } from './database.js' +import { + readRelayDatabasePoolOldestWaitMs, + readRelayDatabasePoolPressure, + type RelayDatabase +} from './database.js' import { HostSessionRegistry } from './host-session-registry.js' import { observeRelayDatabase } from './observed-relay-database.js' import { RelayObservability } from './relay-observability.js' @@ -41,8 +45,23 @@ function decodePathSegment(value: string): string | null { } } -function rejectUpgrade(socket: NodeJS.WritableStream, status: number, message: string): void { - socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`) +// Healthy cells peak at 2 waiters (p99, 2026-10-07). A head that has waited half +// the 2 s acquire timeout with a whole pool's worth queued behind it means +// hellos are already timing out; the count floor keeps one slow waiter from +// tripping it. On 10-07 every window that met both also logged SQL failures. +export const HOST_HELLO_SHED_OLDEST_WAIT_MS = 1_000 +const HOST_HELLO_SHED_RETRY_AFTER_SECONDS = 2 + +function rejectUpgrade( + socket: NodeJS.WritableStream, + status: number, + message: string, + retryAfterSeconds?: number +): void { + const retryAfter = retryAfterSeconds === undefined ? '' : `Retry-After: ${retryAfterSeconds}\r\n` + socket.write( + `HTTP/1.1 ${status} ${message}\r\n${retryAfter}Connection: close\r\nContent-Length: 0\r\n\r\n` + ) if ('destroy' in socket && typeof socket.destroy === 'function') socket.destroy() } @@ -508,6 +527,20 @@ export function createRelayServer( userId: identity.sub, relayHostId: identity.relayHostId }) + // A hello costs several pooled queries, each failing after a 2 s wait. Shed + // only while the pool is already timing them out: the refused desktop gets + // the same connect error and backoff a timed-out hello gives it today, 2 s + // sooner (shipped desktops ignore Retry-After). A rebind over a live control + // is a lease rotation, not a reconnect, so it is never refused here. + if ( + !isRebind && + readRelayDatabasePoolPressure(database).databasePoolWaiting >= config.databasePoolMax && + readRelayDatabasePoolOldestWaitMs(database) >= HOST_HELLO_SHED_OLDEST_WAIT_MS + ) { + observability.recordHostHelloShed() + rejectUpgrade(socket, 503, 'Service Unavailable', HOST_HELLO_SHED_RETRY_AFTER_SECONDS) + return + } const controlUpgrade = connectionLedger?.tryReserveControl(isRebind) ?? null if ( (connectionLedger && !controlUpgrade) || diff --git a/cloud/apps/relay/src/relay.blackbox.test.ts b/cloud/apps/relay/src/relay.blackbox.test.ts index 0202d964ee73..d2687dbcb472 100644 --- a/cloud/apps/relay/src/relay.blackbox.test.ts +++ b/cloud/apps/relay/src/relay.blackbox.test.ts @@ -408,7 +408,8 @@ describe('served relay URL', () => { it('exposes only /health, never /healthz', async () => { expect(await (await fetch(`${relayUrl}/health`)).json()).toEqual({ ok: true, - connectionCapacityProtocol: 2 + connectionCapacityProtocol: 2, + drainPaceWindowMaxMs: 1_200_000 }) expect(await (await fetch(`${relayUrl}/ready`)).json()).toEqual({ ok: true }) expect((await fetch(`${relayUrl}/healthz`)).status).toBe(404) diff --git a/cloud/dev/fixtures/terraform-root-partition/families.json b/cloud/dev/fixtures/terraform-root-partition/families.json index 11e9db811a30..2d8ff0e8cd30 100644 --- a/cloud/dev/fixtures/terraform-root-partition/families.json +++ b/cloud/dev/fixtures/terraform-root-partition/families.json @@ -135,13 +135,16 @@ "google_iam_workload_identity_pool_provider.github_staging_relay_capacity", "google_iam_workload_identity_pool_provider.github_staging_relay_deploy", "google_logging_metric.relay_cell_fix_level", + "google_logging_metric.relay_cell_pool_herd", "google_logging_metric.relay_incident", "google_logging_metric.relay_long_cell_inventory_hold", "google_logging_metric.relay_snapshot", "google_monitoring_alert_policy.relay_assignment_5xx", "google_monitoring_alert_policy.relay_assignment_edge_429", + "google_monitoring_alert_policy.relay_cell_control_close_burst", "google_monitoring_alert_policy.relay_cell_control_rtt", "google_monitoring_alert_policy.relay_cell_outdated_fix_level", + "google_monitoring_alert_policy.relay_cell_pool_herd", "google_monitoring_alert_policy.relay_cell_process_exit", "google_monitoring_alert_policy.relay_cloud_nat_port_drops", "google_monitoring_alert_policy.relay_cloud_sql_backends", diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs index 5c3452e542cd..fb13cecc59fc 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs @@ -36,7 +36,10 @@ function cellOrigin(cellId) { const APPROVED_CELL_LISTS = { 'same-cap': SAME_CAP_CELLS } // Matches the cell's own cap on /v1/admin/drain. -const MAX_PACE_WINDOW_MS = 5 * 60 * 1_000 +const MAX_PACE_WINDOW_MS = 20 * 60 * 1_000 +// Every paced image before the cap rose rejects a longer window with the same 400 an unpaced +// image gives, and an unpaced drain is the opposite of what a slower window asked for. +const LEGACY_MAX_PACE_WINDOW_MS = 5 * 60 * 1_000 export function parseProductionCapacityCellArguments(argv) { const values = {} @@ -46,8 +49,8 @@ export function parseProductionCapacityCellArguments(argv) { if (!key?.startsWith('--') || value === undefined) throw new Error('invalid arguments') values[key.slice(2)] = value } - if (!['isolate', 'drain', 'activate'].includes(values.mode)) { - throw new Error('--mode must be isolate, drain, or activate') + if (!['pace-check', 'isolate', 'drain', 'activate'].includes(values.mode)) { + throw new Error('--mode must be pace-check, isolate, drain, or activate') } const approvedList = values['approved-cells'] if (approvedList !== undefined && !APPROVED_CELL_LISTS[approvedList]) { @@ -75,7 +78,7 @@ export function parseProductionCapacityCellArguments(argv) { paceWindowMs < 0 || paceWindowMs > MAX_PACE_WINDOW_MS ) { - throw new Error('--pace-window-ms must be an integer between 0 and 300000') + throw new Error('--pace-window-ms must be an integer between 0 and 1200000') } return { directorOrigin: DIRECTOR_ORIGIN, @@ -92,8 +95,29 @@ async function responseJson(response, label) { return body } +// Read-only, and run before the isolate: an image that does not advertise its cap has the +// legacy one, so a slower pace stops here with the cell untouched instead of isolated. +async function checkDrainPace(fetchImpl, config, wait) { + const health = await responseJson( + await fetchAdminOnceMore(fetchImpl, `${config.cellOrigin}/health`, {}, { wait }), + 'cell health' + ) + if (health.ok !== true) throw new Error('cell health is not ok') + const maxPaceWindowMs = Number.isSafeInteger(health.drainPaceWindowMaxMs) + ? health.drainPaceWindowMaxMs + : LEGACY_MAX_PACE_WINDOW_MS + if (config.paceWindowMs > maxPaceWindowMs) { + throw new Error( + `cell accepts drain paces up to ${maxPaceWindowMs} ms, not ${config.paceWindowMs} ms; ` + + 'it was not isolated' + ) + } + return { paceWindowMs: config.paceWindowMs, maxPaceWindowMs } +} + export async function prepareProductionCapacityCell(config, overrides = {}) { const fetchImpl = overrides.fetch ?? fetch + if (config.mode === 'pace-check') return await checkDrainPace(fetchImpl, config, overrides.wait) const token = overrides.token ?? process.env.ORCA_RELAY_ADMIN_ID_TOKEN if (!token || token.length > 8_192) throw new Error('admin identity token is unavailable') const postRaw = async (origin, path, body) => @@ -126,6 +150,11 @@ export async function prepareProductionCapacityCell(config, overrides = {}) { // An unpaced drain is the behaviour that cell already has, so fall back to it. if (paced.status !== 400) throw new Error(`/v1/admin/drain returned ${paced.status}`) await paced.json().catch(() => ({})) + if (paceWindowMs > LEGACY_MAX_PACE_WINDOW_MS) { + throw new Error( + `cell rejected a ${paceWindowMs} ms drain pace; refusing to drain faster than asked` + ) + } } await postAt(config.cellOrigin, '/v1/admin/drain', { v: 1, graceMs: 0 }) return { changed: false, drained: true, paceWindowMs: 0 } diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs index 020cd6ae553a..fb2ad53895be 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs @@ -231,6 +231,59 @@ describe('production Relay capacity cell admission', () => { ]) }) + it('refuses to fall back to an unpaced drain when the cell rejects a slower window', async () => { + for (const paceWindowMs of [900_000, 1_200_000]) { + const bodies = [] + await assert.rejects( + prepareProductionCapacityCell( + { ...config, mode: 'drain', paceWindowMs }, + { + token: 'token', + wait: async () => {}, + fetch: async (_url, init) => { + bodies.push(JSON.parse(init.body)) + return response({ error: 'invalid_request' }, 400) + } + } + ), + /refusing to drain faster than asked/ + ) + assert.deepEqual(bodies, [{ v: 1, graceMs: 0, paceWindowMs }]) + } + }) + + it('stops a slower pace than an old-cap cell accepts before anything is isolated', async () => { + const check = async (health, paceWindowMs) => { + const requests = [] + const result = prepareProductionCapacityCell( + { ...config, mode: 'pace-check', paceWindowMs }, + { + wait: async () => {}, + fetch: async (url, init) => { + requests.push({ path: new URL(url).pathname, method: init.method ?? 'GET' }) + return response(health) + } + } + ) + return { result, requests } + } + // Every image before the cap rose answers /health without it. + const old = { ok: true, connectionCapacityProtocol: 2 } + for (const pace of [900_000, 1_200_000]) { + const { result, requests } = await check(old, pace) + await assert.rejects(result, /up to 300000 ms, not \d+ ms; it was not isolated/) + assert.deepEqual(requests, [{ path: '/health', method: 'GET' }]) + } + assert.deepEqual(await (await check(old, 300_000)).result, { + paceWindowMs: 300_000, maxPaceWindowMs: 300_000 + }) + const current = { ...old, drainPaceWindowMaxMs: 1_200_000 } + assert.deepEqual(await (await check(current, 1_200_000)).result, { + paceWindowMs: 1_200_000, maxPaceWindowMs: 1_200_000 + }) + await assert.rejects((await check({ ok: false }, 300_000)).result, /cell health is not ok/) + }) + it('fails a paced drain that the cell rejects for any other reason', async () => { await assert.rejects( prepareProductionCapacityCell( @@ -253,10 +306,12 @@ describe('production Relay capacity cell admission', () => { '--mode', 'drain', '--pace-window-ms', value ] - for (const value of ['-1', '300001', '1.5', 'soon']) { + for (const value of ['-1', '1200001', '1.5', 'soon']) { assert.throws(() => parseProductionCapacityCellArguments(argv(value)), /pace-window-ms/) } - assert.equal(parseProductionCapacityCellArguments(argv('300000')).paceWindowMs, 300_000) + for (const value of [300_000, 900_000, 1_200_000]) { + assert.equal(parseProductionCapacityCellArguments(argv(String(value))).paceWindowMs, value) + } }) it('restores only the selected cell to general admission', async () => { diff --git a/cloud/dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs b/cloud/dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs index 196fff9edf35..0f5845a732c3 100644 --- a/cloud/dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs +++ b/cloud/dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs @@ -1,5 +1,9 @@ import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' +import { spawn } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { test } from 'node:test' import { fileURLToPath } from 'node:url' import { relayWorkflowUrl } from './relay-repository.mjs' @@ -13,13 +17,28 @@ function workflow(name) { return readFileSync(fileURLToPath(relayWorkflowUrl(name)), 'utf8') } -// A single transient 5xx from a warming instance behind the global load balancer -// must not fail a canary, so no admin endpoint may be read by a bare curl. -test('no admin endpoint is reached by a curl without a bounded retry', () => { +const VERIFY_STEP = 'name: Verify new incarnation, exact image, protocol, and durable safety' +const STALL_WINDOW = 'local out="${RUNNER_TEMP}/$1.json" window=60 delay=5 status' + +function verifyStep() { + return workflow('deploy-relay-production-same-cap-job.yml') + .split(VERIFY_STEP)[1] + .split('\n - name:')[0] +} + +function verifyAdminPost() { + const step = verifyStep() + const start = step.indexOf('admin_post() {') + return step.slice(start, step.indexOf('\n }', start) + '\n }'.length) +} + +// curl 7.81 (the Ubuntu 22.04 runners) never retries under --fail-with-body, so the +// remaining --retry flags are inert and only the verify loop below really retries. +// What holds for every admin curl: it is bounded and never retries a final 4xx. +test('every admin curl is bounded and never retries a final 4xx', () => { for (const name of WORKFLOWS) { for (const invocation of workflow(name).split(/\bcurl\b/).slice(1)) { const flags = invocation.split('\n }')[0] - assert.match(flags, /--retry 3 --retry-delay 2 --retry-connrefused/, name) assert.match(flags, /--max-time 30/, name) // --retry-all-errors would also retry 401, 403, and 409, which are final. assert.doesNotMatch(flags, /--retry-all-errors/, name) @@ -39,5 +58,80 @@ test('every retried admin request captures only the final attempt body', () => { /TARGET_RUNTIME="\$\(admin_post target-runtime/, /TARGET_DIRECTOR_STATUS="\$\(admin_post target-cell-status/ ]) assert.match(job, call) - assert.doesNotMatch(job, /\$\(curl /) + // The verify loop captures only the status code; its body goes to the file. + assert.doesNotMatch(job.replace(verifyAdminPost(), ''), /\$\(curl /) +}) + +// c18, 2026-10-07 18:49Z: a DB stall made the fresh cell answer 503 "no healthy upstream"; +// three 2 s retries gave up and the cell was healthy 16 s later. +// 60 s covers a 7 s stall, 16 s readiness grace and two 10 s LB health checks. +test('the post-roll verify outlasts one DB stall and its health-check recovery', () => { + assert.ok(verifyAdminPost().includes(STALL_WINDOW)) + assert.match(verifyAdminPost(), /--max-time 30/) +}) + +// Runs the step's own admin_post, with its retry clock scaled down twelve-fold. +async function runVerifyAdminPost(respond) { + const scaled = verifyAdminPost().replace('window=60 delay=5', 'window=5 delay=1') + assert.notEqual(scaled, verifyAdminPost()) + let calls = 0 + const startedAt = Date.now() + const server = createServer((request, response) => { + calls += 1 + const { status, body, drop } = respond(Date.now() - startedAt) + if (drop) return void request.socket.destroy() + response.writeHead(status, { 'content-type': 'application/json' }) + response.end(body) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const runnerTemp = mkdtempSync(join(tmpdir(), 'relay-verify-admin-post-')) + try { + const url = `http://127.0.0.1:${server.address().port}/v1/admin/runtime-status` + const child = spawn( + 'bash', + ['-euo', 'pipefail', '-c', `${scaled}\nadmin_post target-runtime "${url}" '{"v":1}'`], + { env: { ...process.env, RUNNER_TEMP: runnerTemp, ORCA_RELAY_ADMIN_ID_TOKEN: 't' } } + ) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => { stdout += chunk }) + child.stderr.on('data', (chunk) => { stderr += chunk }) + const code = await new Promise((resolve) => child.on('close', resolve)) + return { code, stdout, stderr, calls, elapsedMs: Date.now() - startedAt } + } finally { + server.close() + rmSync(runnerTemp, { recursive: true, force: true }) + } +} + +const UNHEALTHY = { status: 503, body: 'no healthy upstream' } + +test('the verify read recovers when the cell comes back inside the retry window', async () => { + const result = await runVerifyAdminPost((elapsedMs) => + elapsedMs < 2_500 ? UNHEALTHY : { status: 200, body: '{"ok":true}' } + ) + assert.equal(result.code, 0, result.stderr) + assert.equal(result.stdout, '{"ok":true}') + // The old three-retry budget would have given up here. + assert.ok(result.calls > 3, `calls ${result.calls}`) +}) + +test('the verify read still fails a cell that stays down past the window', async () => { + const result = await runVerifyAdminPost(() => UNHEALTHY) + assert.notEqual(result.code, 0) + assert.ok(result.elapsedMs >= 3_000, `gave up after ${result.elapsedMs} ms: ${result.stderr}`) + assert.ok(result.elapsedMs < 8_000, `bounded at ${result.elapsedMs} ms`) +}) + +test('the verify read fails a final 4xx without waiting out the window', async () => { + const result = await runVerifyAdminPost(() => ({ status: 409, body: '{"error":"generation"}' })) + assert.notEqual(result.code, 0) + assert.equal(result.calls, 1) +}) + +test('a final connection failure does not report the previous attempt body', async () => { + const result = await runVerifyAdminPost((elapsedMs) => (elapsedMs < 1_000 ? UNHEALTHY : { drop: true })) + assert.notEqual(result.code, 0) + assert.match(result.stderr, /admin_post target-runtime: HTTP 000/) + assert.doesNotMatch(result.stderr, /no healthy upstream/) }) diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.mjs index 0b1a756615f6..cf666c7238d3 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.mjs @@ -25,7 +25,8 @@ export function selectorWaveDelta(cellId) { // The cell spreads its drain sends evenly over this window (host-session-registry.ts drain), so // it sets the re-placement arrival rate: hosts / window. A closed set, stepped down one rung at a // time per cloud/docs/relay-workflows.md; the first entry is the default every wave used before. -export const SAME_CAP_DRAIN_PACE_WINDOWS_MS = [300_000, 60_000, 30_000] +// The slower windows are for cells whose 300 s drain browned out the database (c28, 10-07). +export const SAME_CAP_DRAIN_PACE_WINDOWS_MS = [300_000, 60_000, 30_000, 900_000, 1_200_000] export const DEFAULT_SAME_CAP_DRAIN_PACE_WINDOW_MS = SAME_CAP_DRAIN_PACE_WINDOWS_MS[0] // Only US general cells may drain faster than the default. An Asia drain is bounded by its @@ -46,7 +47,7 @@ export function drainPaceWindowMs(value, cellIds) { `drain pace window must be one of ${SAME_CAP_DRAIN_PACE_WINDOWS_MS.join(', ')} ms` ) } - if (parsed !== DEFAULT_SAME_CAP_DRAIN_PACE_WINDOW_MS) { + if (parsed < DEFAULT_SAME_CAP_DRAIN_PACE_WINDOW_MS) { const slow = cellIds.filter((cell) => !SAME_CAP_FAST_DRAIN_PACE_CELLS.includes(cell)) if (slow.length > 0) { throw new Error( @@ -205,9 +206,9 @@ export function verifyCanaryAuthority(authority, expected, repositoryRoot) { ) } // A batch rolls up to ten cells back to back, so a faster one needs a canary whose own drain - // passed; the default is what every wave ran before, and stays available to any canary. + // passed; the default and anything slower stay available to any canary. if ( - batchPaceWindowMs !== DEFAULT_SAME_CAP_DRAIN_PACE_WINDOW_MS && + batchPaceWindowMs < DEFAULT_SAME_CAP_DRAIN_PACE_WINDOW_MS && authority.paceVerdict !== 'PASS' ) { throw new Error( diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs index f292bc17a680..a9210812a3a9 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs @@ -634,16 +634,22 @@ function fastWave(overrides = {}) { } test('admits only the reviewed drain pace windows', () => { - assert.deepEqual(SAME_CAP_DRAIN_PACE_WINDOWS_MS, [300_000, 60_000, 30_000]) + assert.deepEqual( + SAME_CAP_DRAIN_PACE_WINDOWS_MS, + [300_000, 60_000, 30_000, 900_000, 1_200_000] + ) assert.equal(validateSameCapWave(fastWave()).drainPaceWindowMs, 60_000) assert.equal(validateSameCapWave(fastWave({ drainPaceWindowMs: '30000', confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${usGeneral} drain-pace-window-ms=30000` })).drainPaceWindowMs, 30_000) - for (const pace of [undefined, '', '0', '120000', '299999', '600000', '60000.0', '060000', ' 60000']) { + for (const pace of [ + undefined, '', '0', '120000', '299999', '600000', '60000.0', '060000', ' 60000', + '1200001', '1800000' + ]) { assert.throws( () => validateSameCapWave(fastWave({ drainPaceWindowMs: pace })), - /drain pace window must be one of 300000, 60000, 30000 ms/, + /drain pace window must be one of 300000, 60000, 30000, 900000, 1200000 ms/, String(pace) ) } @@ -684,6 +690,33 @@ test('keeps Asia and migration-only cells on the default pace', () => { })), /not production-gce-c17/) }) +test('admits the slower paces on every cell class, Asia and migration-only included', () => { + for (const pace of ['900000', '1200000']) { + for (const cellIds of [ + usGeneral, + 'production-gce-c28,production-gce-c29', + 'production-gce-c8,production-gce-c29', + 'production-gce-c17' + ]) { + const single = !cellIds.includes(',') + const wave = validateSameCapWave(fastWave({ + cellIds, + ...(single ? { mode: 'canary-apply', canaryRunId: '' } : {}), + drainPaceWindowMs: pace, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${cellIds} drain-pace-window-ms=${pace}` + })) + assert.equal(wave.drainPaceWindowMs, Number(pace), `${cellIds} ${pace}`) + } + assert.throws(() => validateSameCapWave(fastWave({ + mode: 'canary-apply', + canaryRunId: '', + cellIds: 'production-gce-c29', + drainPaceWindowMs: pace, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c29` + })), /confirmation does not match/) + } +}) + test('binds a non-default pace into the confirmation, in both directions', () => { // A confirmation that does not name the pace is a confirmation of the default. assert.throws(() => validateSameCapWave(fastWave({ @@ -746,6 +779,17 @@ test('a canary authorizes batches at its own pace or slower, never faster', () = for (const pace of ['60000', '300000']) assert.equal(verify(seal('60000'), pace).cellId, 'production-gce-c7') assert.throws(() => verify(seal('60000'), '30000'), /drained over 60000 ms/) assert.throws(() => verify(seal('300000'), '60000'), /drained over 300000 ms/) + // Slower than the default needs no PASS, and a slow canary authorizes only slower-or-equal. + for (const pace of ['900000', '1200000']) { + assert.equal(verify(seal('300000'), pace).cellId, 'production-gce-c7') + assert.equal(verify(seal('300000', shadowReport('300000', 'WARN')), pace).cellId, 'production-gce-c7') + assert.equal(verify(seal('60000'), pace).cellId, 'production-gce-c7') + } + assert.equal(seal('1200000').drainPaceWindowMs, 1_200_000) + assert.equal(verify(seal('900000'), '1200000').cellId, 'production-gce-c7') + assert.equal(verify(seal('1200000'), '1200000').cellId, 'production-gce-c7') + assert.throws(() => verify(seal('900000'), '300000'), /drained over 900000 ms/) + assert.throws(() => verify(seal('1200000'), '900000'), /drained over 1200000 ms/) // An authority sealed before the pace was recorded proves nothing about it. const { drainPaceWindowMs: _dropped, ...unpaced } = seal('300000') assert.throws(() => verify({ ...unpaced, v: 1 }, '300000'), /does not match/) @@ -865,8 +909,29 @@ test('the workflows offer exactly the closed set and scale the drain wait with i assert.match(job, /\n DRAIN_PACE_WINDOW_MS: \$\{\{ inputs\.drain-pace-window-ms \}\}\n/) assert.doesNotMatch(job, /DRAIN_PACE_WINDOW_MS: '/) assert.match(job, /cell-class \\\n\s+--cell-id "\$\{TARGET_CELL_ID\}" --drain-pace-window-ms "\$\{DRAIN_PACE_WINDOW_MS\}"/) - // The 15-min migration lease plus the window: exactly today's 20 min at the default. - assert.match(job, /--timeout-ms "\$\(\(900000 \+ DRAIN_PACE_WINDOW_MS\)\)"/) - assert.equal(900_000 + SAME_CAP_DRAIN_PACE_WINDOWS_MS[0], 1_200_000) - assert.ok(Math.max(...SAME_CAP_DRAIN_PACE_WINDOWS_MS) === DEFAULT_SAME_CAP_DRAIN_PACE_WINDOW_MS) + // The 15-min migration lease plus the window, plus its excess over the default: restart-safe + // waits a whole window of quiet after the last send, so a slow drain needs about twice it. + const timeout = /--timeout-ms "\$\(\((.+)\)\)"\n/.exec(job)[1] + const timeoutMs = (pace) => Number(execFileSync( + 'bash', ['-c', `DRAIN_PACE_WINDOW_MS=${pace}; echo $((${timeout}))`], { encoding: 'utf8' } + )) + // Exactly the timeouts every pace had before the slower ones existed. + for (const pace of [300_000, 60_000, 30_000]) assert.equal(timeoutMs(pace), 900_000 + pace) + for (const pace of SAME_CAP_DRAIN_PACE_WINDOWS_MS) { + assert.ok(timeoutMs(pace) >= 2 * pace + 600_000, String(pace)) + } + assert.equal(timeoutMs(1_200_000), 3_000_000) + const slowest = timeoutMs(Math.max(...SAME_CAP_DRAIN_PACE_WINDOWS_MS)) + // The rest of the job keeps the 70 min it had beside the old 20-min drain step in 90. + const jobMinutes = Number(/\n rollout:\n(?: [^\n]*\n)*? timeout-minutes: (\d+)\n/.exec(job)[1]) + assert.ok(jobMinutes * 60_000 >= slowest + 70 * 60_000) + // The drain step's fresh one-hour ID token must outlive its wait, with room for the drain call. + assert.ok(slowest + 5 * 60_000 <= 60 * 60_000) + // The pace check runs before the isolate and before the failsafe treats the cell as touched. + const drainStep = job.slice(job.indexOf('- name: Reversibly isolate and drain only the selected cell')) + const paceCheck = drainStep.indexOf('--mode pace-check') + assert.notEqual(paceCheck, -1) + assert.match(drainStep.slice(paceCheck, drainStep.indexOf('\n', paceCheck) + 80), /--pace-window-ms "\$\{DRAIN_PACE_WINDOW_MS\}"/) + assert.ok(paceCheck < drainStep.indexOf('MUTATION_STARTED=true')) + assert.ok(paceCheck < drainStep.indexOf('--mode isolate')) }) diff --git a/cloud/dev/scripts/relay-reconnect-herd-alerts.test.mjs b/cloud/dev/scripts/relay-reconnect-herd-alerts.test.mjs new file mode 100644 index 000000000000..5cd3e5733bfa --- /dev/null +++ b/cloud/dev/scripts/relay-reconnect-herd-alerts.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +// Why: both herd alerts key on text the relay writes (a log line and a runtime field). A rename on +// either side silences the alert without failing anything. + +const read = (relative) => readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8') +const terraform = read('../../infra/terraform/relay-observability.tf') + +const block = (kind, name) => { + const body = new RegExp(`resource "${kind}" "${name}" \\{([\\s\\S]*?)\\n\\}`).exec(terraform)?.[1] + assert.ok(body, `${kind}.${name} not found in relay-observability.tf`) + return body +} + +const incidentMetric = (key) => { + const body = new RegExp(`\\n ${key} = \\{([\\s\\S]*?)\\n \\}`).exec(terraform)?.[1] + assert.ok(body, `relay_incident_metrics.${key} not found`) + return body +} + +test('the disconnect-burst metric matches the line a cell writes per control close', () => { + const emitter = read('../../apps/relay/src/host-session-registry.ts') + assert.match(emitter, /`\[orca-relay\] control closed host=\$\{/) + assert.match(emitter, /` code=\$\{code\} reason=\$\{/) + const metric = incidentMetric('cell_control_abnormal_closes') + assert.match(metric, /jsonPayload\.message:\\"\[orca-relay\] control closed \\"/) + assert.match(metric, /jsonPayload\.message:\\" code=1006 \\"/) + assert.match(metric, /logs\/cos_containers/) + const policy = block('google_monitoring_alert_policy', 'relay_cell_control_close_burst') + assert.match(policy, /orca_relay_cell_control_abnormal_closes/) + assert.match(policy, /group_by_fields\s*=\s*\["resource\.label\.\\"instance_id\\""\]/) +}) + +test('the pool-herd metric thresholds the interval maximum the relay emits', () => { + const emitter = read('../../apps/relay/src/postgres-pool-pressure.ts') + assert.match(emitter, /\bdatabasePoolWaitersMax: number\b/) + const metric = block('google_logging_metric', 'relay_cell_pool_herd') + // 200, not lower: single-cell stalls of 50-196 waiters recur daily; herds go past 200. + assert.match(metric, /jsonPayload\.databasePoolWaitersMax>200"/) + const policy = block('google_monitoring_alert_policy', 'relay_cell_pool_herd') + assert.match(policy, /threshold_value\s*=\s*0\n/) +}) diff --git a/cloud/docs/relay-workflows.md b/cloud/docs/relay-workflows.md index c66785a254af..faf404adf2a1 100644 --- a/cloud/docs/relay-workflows.md +++ b/cloud/docs/relay-workflows.md @@ -530,22 +530,34 @@ drained host re-dials the director as soon as it reads `drain`, so the window se arrival rate: hosts / window, about 1.8-2.7 hosts/s for a US cell at the default. The window also sets two waits: restart-safe needs an empty runtime for (ceil(window / 5 s) + 1) consecutive 5-second samples, and the drain step's overall timeout is the 15-minute migration lease plus the -window (20 minutes at the default). +window, plus the window's excess over `300000` (20 minutes at the default, 50 at `1200000`). +Because the quiet starts only once the last host has left, a drain step takes about twice the +window: about 30 minutes at `900000` and 40 at `1200000`. -- Allowed values are `300000` (the default, and every wave before this input), `60000`, and - `30000`. The wave validator refuses anything else, and each cell job checks again. +- Allowed values are `300000` (the default, and every wave before this input), `60000`, + `30000`, `900000`, and `1200000`. The wave validator refuses anything else, and each cell job + checks again. - Below `300000` is for US general cells only (C7-C10, C13-C16, C19-C26, C32, C33). Asia drains are bound by the target cells' own accept rate (about 4-6 hosts/s per Asia cell), not by the - window. Migration-only cells hold no hosts. Both stay at `300000`. + window. Migration-only cells hold no hosts. Both stay at `300000` or slower. +- `900000` and `1200000` are for any cell. They exist because c28's 1,901-host drain at `300000` + (about 6.3 hosts/s, 2026-10-07) held database lock time above its bar for about 10 minutes. + At `1200000` a 2,000-host Asia cell re-places about 1.7 hosts/s. - A non-default window must be named at the end of the confirmation, for example `ROLL_RELAY_SAME_CAP drain-pace-window-ms=60000`. A confirmation that names no window confirms `300000`, so a form left at another value fails closed. - A canary's authority records its window and its pace verdict (below). A batch may use that window or a slower one, never a faster one. A batch below `300000` also needs the canary's pace - verdict to be PASS. Stepping back to `300000` mid-ladder needs no new canary. + verdict to be PASS. Stepping back to `300000` or slower mid-ladder needs no new canary. - A cell on an image without paced drains rejects the window, and the job falls back to an unpaced drain. The job records what the cell accepted, and a canary that did not drain at its own window seals `UNVERIFIED`. +- A window above `300000` needs the cell being drained to already run an image that accepts it + (the cap rose with this ladder's slow rungs). Before the isolate, the job reads the cap the cell + advertises on `/health` (`drainPaceWindowMaxMs`; an image without it has `300000`) and stops + with the cell untouched if the window is above it. Re-dispatch at `300000`, or after the cell's + next roll. If a cell still rejects the window at the drain, the job fails rather than drain + unpaced, and the cell stays isolated for an operator, as any failed cell does. **What judges a paced drain.** The shadow health gate (report only, after each cell) judges two checks the pace can move. Together they are the report's `paceVerdict`, which the canary seals: diff --git a/cloud/infra/terraform/relay-observability.tf b/cloud/infra/terraform/relay-observability.tf index a0a91a64addc..2e69003e3535 100644 --- a/cloud/infra/terraform/relay-observability.tf +++ b/cloud/infra/terraform/relay-observability.tf @@ -57,6 +57,14 @@ locals { description = "Cells whose reserved_requests disagreed with their lease units when reconciliation corrected them; should trend to zero." filter = "((resource.type=\"cloud_run_revision\" AND (${local.relay_service_log_filter})) OR resource.type=\"gce_instance\") AND jsonPayload.event=\"orca_relay_reservation_drift\"" } + # Why the cell's line and not the load balancer's: the LB writes a WebSocket's log entry at + # close but stamps it with the stream's start, and a log-based metric counts by that stamp. Of + # the 1,046 `internal_error` streams on 2026-10-06 18:34Z, 42 were stamped within 10 minutes of + # the drop; the median was 2.4 h earlier, so an LB-log rate never shows the pulse. + cell_control_abnormal_closes = { + description = "Desktop control sockets a GCE cell saw end with no close frame (1006), counted when they closed. Hundreds on one cell in a minute is the load balancer ending streams." + filter = "resource.type=\"gce_instance\" AND logName=\"projects/${var.project_id}/logs/cos_containers\" AND jsonPayload.message:\"[orca-relay] control closed \" AND jsonPayload.message:\" code=1006 \"" + } } relay_runtime_metrics = { @@ -107,6 +115,7 @@ locals { db_waiters_max = { field = "databasePoolWaitersMax", description = "Maximum requests queued for a PostgreSQL connection during the interval." } db_oldest_wait_ms = { field = "databasePoolOldestWaitMs", description = "Current oldest PostgreSQL pool waiter age." } db_wait_ms_max = { field = "databasePoolWaitMsMax", description = "Maximum PostgreSQL pool wait during the interval." } + host_hellos_shed = { field = "hostHellosShedDelta", description = "Desktop control connections refused with a retryable 503 because the PostgreSQL pool queue was full." } cell_inventory_hold_ms_max = { field = "cellInventoryHoldMsMax", description = "Longest cell-inventory lock hold in the interval." } cell_inventory_hold_ms_p95 = { field = "cellInventoryHoldMsP95", description = "Cell-inventory lock hold p95 in the interval; the bound is tuned against this." } cell_inventory_holds = { field = "cellInventoryHolds", description = "Cell-inventory locks acquired in the interval; the percentiles above summarise these." } @@ -928,6 +937,98 @@ resource "google_monitoring_alert_policy" "relay_director_cell_inventory_hold" { } } +# The bar lives in the filter, as for the long-hold metric: an exponential bucket's percentile +# interpolation would put any value from 128 to 255 above 200. Why 200: a healthy cell peaks at +# 2 waiters (p99), but single-cell stalls of 50-196 recur several times a day and already time +# out. 200 keeps those for the SQL-failure alerts and pages on herds. Since 2026-10-03 a bar of 50 +# was crossed in 84 episodes, 150 in 29, and 200 in 10, among them every known herd (10-03 17:50, +# 10-05 06:26 and 18:22, 10-06 01:33 and 18:34, 10-07 20:00). +resource "google_logging_metric" "relay_cell_pool_herd" { + project = var.project_id + name = "orca_relay_cell_pool_herd" + description = "Cell runtime samples in which more than 200 requests queued at once for a PostgreSQL connection." + filter = "${local.relay_runtime_log_filter} AND jsonPayload.role=\"cell\" AND jsonPayload.databasePoolWaitersMax>200" + + metric_descriptor { + metric_kind = "DELTA" + value_type = "INT64" + unit = "1" + } +} + +resource "google_monitoring_alert_policy" "relay_cell_control_close_burst" { + project = var.project_id + display_name = "Orca Relay: cell desktop disconnect burst" + combiner = "OR" + enabled = true + notification_channels = var.relay_alert_notification_channels + + conditions { + display_name = "Over 120 abnormal desktop control closes on one cell in a minute" + + condition_threshold { + filter = "resource.type=\"gce_instance\" AND metric.type=\"logging.googleapis.com/user/orca_relay_cell_control_abnormal_closes\"" + comparison = "COMPARISON_GT" + threshold_value = 120 + duration = "0s" + + aggregations { + alignment_period = "60s" + per_series_aligner = "ALIGN_SUM" + cross_series_reducer = "REDUCE_SUM" + group_by_fields = ["resource.label.\"instance_id\""] + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "Over 120 desktop control sockets on one relay cell ended without a close frame (code 1006) in one minute. On 2026-10-06 at 18:34 UTC the asia-east2 cells logged 213-247 each in the minute Google's load balancer ended 1,046 of their streams, against a baseline of 6-49 per minute (US cells: 17 or fewer, all codes). Smaller pulses of about 60 per cell (01:33 and 20:40 UTC that day) stay under the bar. First response: read the load balancer logs for the same minute by `receiveTimestamp`, not `timestamp` (the LB stamps a WebSocket with its start), and bucket `jsonPayload.statusDetails`. If they show `internal_error` across several cells at once, the proxy ended the streams: record the timestamps for the Google support case, and do not drain, restart or resize the cell. If `Orca Relay: cell database pool herd` fired in the same minute, the reconnecting desktops overran the cell's database pool. Otherwise look at the cell itself: a crash or restart, the event loop, or memory." + mime_type = "text/markdown" + } + + depends_on = [google_logging_metric.relay_incident] +} + +resource "google_monitoring_alert_policy" "relay_cell_pool_herd" { + project = var.project_id + display_name = "Orca Relay: cell database pool herd" + combiner = "OR" + enabled = true + notification_channels = var.relay_alert_notification_channels + + conditions { + display_name = "Over 200 requests queued for a PostgreSQL connection on one cell" + + condition_threshold { + # The metric filter already drops samples at or under 200, so any value present is a breach. + filter = "resource.type=\"gce_instance\" AND metric.type=\"logging.googleapis.com/user/${google_logging_metric.relay_cell_pool_herd.name}\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "60s" + per_series_aligner = "ALIGN_SUM" + cross_series_reducer = "REDUCE_SUM" + group_by_fields = ["resource.label.\"instance_id\""] + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "More than 200 requests on one relay cell queued at once for a PostgreSQL connection, and requests that wait 2 s fail. A healthy cell peaks at 2 waiters (p99 on 2026-10-07). Single-cell stalls of 50-196 waiters recur several times a day, mostly on the asia-east2 cells, whose every query crosses the Pacific. Each is about 30 seconds long and already timing requests out (96-367 SQL failures per 30-second sample at 184-196 on 2026-10-07). This bar leaves those to the SQL-failure alerts. Reconnect herds go well past 200 on several cells at once (2026-10-06 18:34 UTC: 233-527 on all five asia-east2 cells). Since 2026-10-03 this bar was crossed in 10 episodes. Six were herds across several cells, and four were a single cell at 201-228. This uses the interval maximum (`databasePoolWaitersMax`), so a herd shorter than the 30-second sample still shows. First response: check `Orca Relay: cell desktop disconnect burst` for the same minute. If it fired too, a mass disconnect caused this, so follow that policy and leave the database alone. If it did not, check Cloud SQL health and lock contention (`Orca Relay: lock timeout burst`) before touching the cell." + mime_type = "text/markdown" + } +} + resource "google_monitoring_alert_policy" "relay_lock_timeout_burst" { project = var.project_id display_name = "Orca Relay: lock timeout burst" diff --git a/cloud/package.json b/cloud/package.json index 272c532a10e7..e3a585ff858d 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -21,7 +21,7 @@ "load:relay:model": "node dev/scripts/run-relay-load-model.mjs", "load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs", "ops:relay": "pnpm --filter @orca-cloud/relay-ops dev", - "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", + "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-reconnect-herd-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", "test": "pnpm -r test && node --test dev/scripts/check-relay-same-cap-headroom.test.mjs dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/drive-relay-director-deploy.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/measure-relay-drain-disconnect-gap.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-job-mode-conditions.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", "typecheck": "pnpm -r typecheck" }, diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index c9429d6262e2..dfb04a2ee1d4 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -120,6 +120,12 @@ const skillFreshnessResources = { from: 'resources/skills', to: 'skills' } +// Why a real directory: native-chat agents load this skill plugin by path (Claude --plugin-dir, +// Codex skill roots), and neither can read inside app.asar. +const nativeChatVisualsResource = { + from: 'resources/native-chat-visuals', + to: 'native-chat-visuals' +} // Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged // apps. Keeping relay assets as extraResources makes them real directories // instead of paths hidden inside app.asar. @@ -151,6 +157,7 @@ const commonExtraResources = [ ...bundledRipgrepExtraResources, bundledPluginResources, skillFreshnessResources, + nativeChatVisualsResource, emojiShortcodeDatasetResource ] // Why: native speech addons must be real files outside app.asar; copy only the @@ -284,6 +291,7 @@ module.exports = { // it from process.resourcesPath; exclude the source copy from app.asar. '!resources/onboarding/feature-wall/**', '!resources/skills/**', + '!resources/native-chat-visuals/**', // Why: bundled plugins ship via extraResources to resources/plugins/launch; // packing the source tree into app.asar would duplicate those exact bytes. '!resources/plugins/launch/**', diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 346fe9ee7d1b..a01d07a35659 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -325,24 +325,10 @@ "terminal automation completion", "local and relayed cancellation" ], - "platforms": [ - "macos", - "linux", - "windows" - ], - "providers": [ - "local", - "ssh", - "wsl", - "remote-runtime" - ], - "coveredPlatforms": [ - "macos" - ], - "coveredProviders": [ - "local", - "remote-runtime" - ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "remote-runtime"], "coverageNotes": "Real HTTP hook ingress and relay forwarding, production runtime automation observer, and captured Claude native ready bytes run locally on macOS. SSH/WSL transport ownership is simulated; physical remote hosts and native Windows/Linux are not verified.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/24878", @@ -1456,7 +1442,7 @@ }, { "id": "agent-session.structured-send-at-most-once", - "title": "Ambiguous structured sends never become a second provider delivery", + "title": "One structured-send attempt dispatches once; a later Send is a new message", "maturity": "experimental", "protection": "partial", "owner": "agent-session-runtime", @@ -1466,37 +1452,45 @@ "providers": ["local", "ssh", "remote-runtime", "mobile"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "mobile"], - "coverageNotes": "Deterministic host, renderer, direct/Relay transport, and mobile hook tests cover durable identity, caller changes, journal loss, acknowledgement loss, expiry, and remount. The host code is execution-location neutral, but live SSH/remote runtimes and physical iOS/Android lifecycle are not exercised.", + "coverageNotes": "Deterministic host and direct/Relay transport tests cover per-operation duplicate protection, caller changes, missing host journal rows, acknowledgement loss, and expiry. Mobile tests cover new-action identity, remount, storage refusal, current attachment paths, and capability changes; renderer tests cover one request per send and the bounded hand-back. Live SSH/remote runtimes and physical iOS/Android lifecycle are not exercised.", "motivatingLinks": ["https://github.com/stablyai/orca/pull/20133"], - "invariant": "One structured-send operation id causes at most one provider dispatch. A recorded or transport-ambiguous send reuses that id across retry, caller reconnect, client remount, and journal recovery; only a terminal rejection may rotate to a first delivery.", - "oracle": "Inject adapter acknowledgement loss, RPC response loss, caller replacement, logical-client close after response, auth recovery with a written request, missing journal submissions, legacy pending rows, stale fences, operation expiry, mobile remount, and durable-journal capacity. Assert one provider dispatch or one operation id for every ambiguous retry, fresh identity only after rejection, and no eviction of ambiguous mobile ids.", + "invariant": "One structured-send operation id causes at most one provider dispatch. Each user Send on phone or desktop is a new action with a fresh id and one request; neither automatically resends an ambiguous action. The host retains the original action's outcome, and a later phone Send remains possible with equal text, after remount, or when phone storage fails. Repeated transmission of the same operation remains protected by host deduplication.", + "oracle": "Inject adapter/RPC acknowledgement loss, caller replacement, logical-client close after response, auth recovery with a written request, missing host submissions, legacy pending rows, stale fences, expiry, mobile remount, and phone storage refusal. Assert at most one dispatch for repeated transmission of one operation. For a later phone Send, assert a fresh id and second delivery while the original host entry remains unknown; equal images use current uploaded paths and delivery fields follow current host capabilities.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", - "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts" + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-structured-send-new-action.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-background-tasks.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts ../mobile/src/mobile-web-shell/page-storage-keys.test.ts ../mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts ../mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx ../mobile/src/mobile-web-shell/bridge-host-init.test.ts ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts --maxWorkers=2" ], "testFiles": [ "src/shared/agent-session-operation-ledger.test.ts", - "src/shared/structured-agent-session-send-disposition.test.ts", + "src/shared/structured-agent-session-send-evidence.test.ts", "src/main/runtime/agent-session-operation-admission.test.ts", "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts", "src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts", "src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts", "src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts", "src/main/runtime/orchestration/structured-pointer-operation-id.test.ts", - "src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx", + "src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts", "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx", - "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx", - "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx", "src/renderer/src/lib/launch-structured-agent-session.test.ts", "mobile/src/session/mobile-native-chat-image-attachment.test.ts", "mobile/src/session/use-mobile-native-chat-image-attachments.test.ts", - "mobile/src/session/mobile-structured-send-operation-journal.test.ts", + "mobile/src/session/mobile-structured-send-new-action.test.ts", "mobile/src/session/mobile-structured-send-delivery.test.ts", "mobile/src/session/use-mobile-structured-agent-session-send.test.tsx", "mobile/src/session/use-mobile-structured-agent-session.test.tsx", "mobile/src/transport/mobile-relay-rpc-session.test.ts", "mobile/src/transport/rpc-client-delivery-ambiguity.test.ts", - "mobile/src/transport/stable-logical-rpc-client.test.ts" + "mobile/src/transport/stable-logical-rpc-client.test.ts", + "mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx", + "mobile/src/session/use-mobile-structured-agent-session-background-tasks.test.tsx", + "mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx", + "mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx", + "mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx", + "mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts", + "mobile/src/mobile-web-shell/page-storage-keys.test.ts", + "mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts", + "mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx", + "mobile/src/mobile-web-shell/bridge-host-init.test.ts" ], "assertionRefs": [ { @@ -1516,12 +1510,11 @@ { "file": "mobile/src/session/use-mobile-structured-agent-session-send.test.tsx", "assertions": [ - "keeps one id across acknowledgement loss and host unknown replays", - "reuses an ambiguous id after the session hook remounts", - "keeps an ambiguous id after the host replay window expires", - "reuses the original uploaded attachment identity after acknowledgement loss", - "keeps the send id after a pending-admission refusal", - "rotates after a %s pre-handler RPC refusal that proves the send did not run" + "a later Send owns a new id even when the earlier host answer was %s", + "keeps the original unknown host row while allowing another Send", + "does not replay an old action after remount", + "uses newly uploaded attachment paths for a new action with the same image", + "never reads an old phone journal, even when storage is full or unreadable" ] }, { @@ -1551,28 +1544,30 @@ "assertions": ["keeps a synchronous pre-write relay failure definite"] }, { - "file": "src/shared/structured-agent-session-send-disposition.test.ts", - "assertions": ["never rotates $state operation after its host tombstone expires"] + "file": "src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts", + "assertions": [ + "gives back at once, as unconfirmed, a send whose request threw, and never sends it again" + ] } ], "evidenceRuns": [ { - "date": "2026-10-04", + "date": "2026-10-07", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-structured-send-new-action.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-background-tasks.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts ../mobile/src/mobile-web-shell/page-storage-keys.test.ts ../mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts ../mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx ../mobile/src/mobile-web-shell/bridge-host-init.test.ts ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts --maxWorkers=2", "result": "passed", - "durationSeconds": 42.3, - "summary": "Thirteen focused host, shared, renderer, and orchestration files passed 197 tests after the delivery probe tests moved to their own file." + "durationSeconds": 5.42, + "summary": "Nineteen focused mobile files passed 241 tests for fresh Send identity, remount, attachment paths, storage refusal, mixed host capabilities, queued cards, and written-request ambiguity." }, { - "date": "2026-09-12", + "date": "2026-10-06", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "result": "passed", - "durationSeconds": 2.37, - "summary": "Ten focused mobile session, attachment, and transport files passed 113 tests." + "durationSeconds": 14, + "summary": "Eleven focused host, shared, renderer, and orchestration files passed 173 tests after the desktop outbox gave way to the in-memory sender, which makes one request per send and never resends." } ], "runtimeBudget": { @@ -1585,27 +1580,24 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Four targeted failures were observed: spending the mobile id after acknowledgement loss made three retries use two ids; settling cross-caller recovery under the new caller left the original ledger row pending; returning no synthetic result for a legacy or crash-left pending row dispatched it as a new accepted send; and a8d3de8d20's blanket settlement rotated a stale-fence option id and failed the renderer invariant. The focused tests pass with the production fixes restored; a byte-identical old-production run is not recorded." + "evidence": "The exact phone acknowledgement-loss/later-equal-Send test fails twice with the original sender and classifier restored byte-for-byte: the second Send returns unknown under the earlier id. It passes with per-action identity. Existing host tests also pin cross-caller recovery, missing submissions, and legacy pending rows against repeat dispatch; those host paths were not changed." }, "performanceBudget": { "required": true, - "evidence": "No polling, timers, subprocesses, or provider fanout were added. Each mobile journal mutation parses the bounded journal and performs at most one full AsyncStorage rewrite; entries retain hashes, ids, and original attachment paths, cap at 4,096, and fail closed at capacity. A first host send performs three serialized whole-store durable transactions: admission, the pre-effect unknown tombstone, and final settlement. Lease and fence admission now share one transaction with the operation row, so a transient pre-effect refusal leaves no row and, absent pruning, performs no store write. No capacity-scale host fsync or mobile AsyncStorage latency benchmark is recorded." + "evidence": "No polling, timers, subprocesses, or provider fanout were added. The phone send path no longer reads or writes AsyncStorage or hashes image contents. The existing host admission, pre-effect unknown record, and settlement transactions are unchanged. No capacity-scale host fsync benchmark is recorded." }, "promotionCriteria": [ "Soak both commands in CI with zero unexplained flakes.", "Add live physical-mobile and remote-runtime interruption evidence before claiming full provider coverage." ], "knownGaps": [ - "A payload-keyed mobile ambiguity cannot distinguish retrying the original send from an intentional later send with identical content. It fails closed until authoritative settlement, so the claim that retention costs no liveness is false without a durable composer-action identity.", - "Clearing or externally corrupting desktop localStorage or mobile AsyncStorage can erase a client-owned ambiguous identity.", - "An ambiguous id retained past host tombstone expiry remains safely blocked rather than becoming live again.", - "A host crash after premarking the operation unknown but before journal append or provider dispatch can conservatively suppress a message that never reached the provider.", - "A replay synthesized from an operation row whose journal submission is missing is not republished into the journal; desktop stops automatic polling but remains safely blocked on that FIFO head.", - "The changed send guarantee is not capability-negotiated. New mobile clients fail closed across caller-identity change, but old clients can rotate an ambiguous id against a new host, and an old mobile client can interpret an ok response carrying unknown as accepted.", - "An ambiguous attachment retry depends on the original host temp path remaining usable when the first request never reached the host; if it is gone, the retry rejects rather than rotating.", + "Phone composer drafts remain in memory: killing the app between Send and host recording loses that text, as before.", + "A later intentional Send can deliver another copy of text from an earlier unknown action; the original action stays unresolved until the host can establish its outcome.", + "A host crash after recording the operation unknown but before journal append or provider dispatch can conservatively suppress an attempt that never reached the provider.", + "Older phone bundles retain their payload journal behavior until upgraded; legacy embedded-page storage access remains permitted.", "No live SSH, remote-runtime, physical iOS/Android, Linux, or Windows interruption run is recorded." ], - "demotionRule": "Keep experimental or demote if any ambiguous retry changes operation id, provider dispatch count exceeds one, durable identity is evicted by age or capacity, or the focused commands flake without a diagnosed harness defect." + "demotionRule": "Keep experimental or demote if one operation dispatches twice, uncertainty is reported as definite rejection, a later phone Send reuses the earlier action or is blocked by phone storage, or the focused commands flake without a diagnosed harness defect." }, { "id": "agent-session.completed-turn-duration", @@ -3788,9 +3780,7 @@ }, { "file": "src/main/system-resume-broadcast.test.ts", - "assertions": [ - "publishes suspend and resume to main-process lifecycle consumers" - ] + "assertions": ["publishes suspend and resume to main-process lifecycle consumers"] }, { "file": "src/main/system-power-lifecycle.test.ts", @@ -5067,9 +5057,7 @@ }, { "file": "src/cli/runtime/serve-signal-exit-diagnostic.test.ts", - "assertions": [ - "late update handoff failure cannot rearm termination after child exit" - ] + "assertions": ["late update handoff failure cannot rearm termination after child exit"] }, { "file": "src/main/serve-update-handoff.test.ts", @@ -5485,10 +5473,7 @@ "coveredPlatforms": ["macos"], "coveredProviders": ["remote-runtime"], "coverageNotes": "Deterministic main-IPC contract tests cover disconnect-driven close delivery, exactly-once close, per-subscription teardown isolation against a failing socket close and a throwing liveness probe, containment of a throwing renderer send on the unguarded host-close path, and continued suppression of stale payloads from a retired transport. A headed paired-server journey (real Orca host plus a separate paired Orca desktop client) covers hidden-but-mounted reveal, cold-parked reveal, and cold-parked reveal across a disconnect/reconnect. Live Linux and Windows paired-server evidence and real sleep/wake transport loss remain uncollected.", - "motivatingLinks": [ - "tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts", - "docs/reference/headless-linux-server.md" - ], + "motivatingLinks": ["tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts"], "invariant": "Every renderer-held runtime subscription receives exactly one terminal close event when its transport is retired, including when the retirement advanced the transport generation first, and a single failing teardown never abandons that environment's remaining subscriptions nor escapes into the transport that reported the close. Payload frames from a retired transport stay suppressed. A revealed remote terminal therefore reattaches over a live multiplex connection: its buffer restores, typed input reaches the host PTY, the echo paints without a tab flip, and the PTY converges on the revealed pane grid.", "oracle": "The main IPC contract test subscribes terminal.multiplex through the real handler, disconnects the environment, and asserts the renderer received exactly one {type: close} subscription event. Two isolation tests subscribe a second stream to the same environment and make the first one fail -- in its socket close, and in the liveness probe inside notifyClosed -- then assert the disconnect does not throw, both transports closed, and every close the renderer could still receive was delivered. A third drives a host-initiated close through the transport callback, which is the one notifyClosed call site with no surrounding guard, with a renderer send that throws, and asserts it cannot escape into the WebSocket close handler. A fourth test asserts that after retirement a late response frame is not forwarded and a late transport close does not re-send. The paired-server journey runs three reveal scenarios against one real host and one real paired desktop client, and for each records buffer restore, host-side receipt of the typed marker through an out-of-band host sink file, live paint without a tab flip, and PTY-versus-pane grid convergence.", "commands": [ @@ -12626,9 +12611,7 @@ }, { "file": "src/main/ipc/pty-startup-barrier-and-listing.test.ts", - "assertions": [ - "global inventory starts local and SSH provider listings concurrently" - ] + "assertions": ["global inventory starts local and SSH provider listings concurrently"] }, { "file": "src/renderer/src/components/status-bar/resource-session-inventory.test.ts", @@ -21095,9 +21078,7 @@ }, { "file": "src/main/browser/agent-browser-bridge-tab-routing.test.ts", - "assertions": [ - "closing a tab retires the exact named agent-browser session" - ] + "assertions": ["closing a tab retires the exact named agent-browser session"] }, { "file": "src/main/startup/serve-signal-handlers.test.ts", @@ -21536,9 +21517,7 @@ }, { "file": "config/scripts/verify-packaged-browser-participation.test.mjs", - "assertions": [ - "reject missing, substituted, skipped and retried scenarios" - ] + "assertions": ["reject missing, substituted, skipped and retried scenarios"] } ], "evidenceRuns": [ @@ -21600,15 +21579,11 @@ "gh run view 34074017928 --log", "pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/terminal-hangul-terminating-digit-native.spec.ts --project=electron-headful --workers=1 --repeat-each=3 --retries=0 --reporter=list,json" ], - "testFiles": [ - "tests/e2e/terminal-hangul-terminating-digit-native.spec.ts" - ], + "testFiles": ["tests/e2e/terminal-hangul-terminating-digit-native.spec.ts"], "assertionRefs": [ { "file": "tests/e2e/terminal-hangul-terminating-digit-native.spec.ts", - "assertions": [ - "a digit typed right after a Hangul syllable reaches the pty" - ] + "assertions": ["a digit typed right after a Hangul syllable reaches the pty"] } ], "evidenceRuns": [ diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index cf1c744d3b4b..96a46677626f 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node -// Package orcad for the pinned Node; keep module loading compatible with legacy Node launchers. +// Package the server separately from its compatibility launcher. import { fork, spawnSync } from 'node:child_process' import { build } from 'esbuild' import { buildOrcadEntry, + buildOrcadLauncher, externalNativeAddons, ORCAD_EXTERNAL_MODULES, ORCAD_CHILD_ENTRY_POINTS @@ -30,6 +31,8 @@ import { ORCAD_EMOJI_SHORTCODE_DATASET, ORCAD_FOREIGN_SQLITE_READER_ENTRY, ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY, + ORCAD_LAUNCHER_FILENAME, + ORCAD_SERVER_ENTRY_FILENAME, ORCAD_NODE_PTY_DIR, ORCAD_NODE_PTY_JS_ARTIFACTS, ORCAD_NODE_RUNTIME_MARKER_FILENAME, @@ -67,7 +70,8 @@ const FOREIGN_SQLITE_READER_OUT_FILE = join(OUT_DIR, ORCAD_FOREIGN_SQLITE_READER // and `resolveWorkerEntryPath` looks for it next to the running bundle. const PORT_SCAN_WORKER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.portScanCommandWorker) const PORT_SCAN_WORKER_OUT_FILE = join(OUT_DIR, ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY) -const OUT_FILE = join(OUT_DIR, 'orcad.js') +const OUT_FILE = join(OUT_DIR, ORCAD_LAUNCHER_FILENAME) +const SERVER_OUT_FILE = join(OUT_DIR, ORCAD_SERVER_ENTRY_FILENAME) const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET if (!BUILD_TARGET) { throw new Error('ORCAD_BUILD_TARGET is required; run `pnpm build:orcad`') @@ -189,6 +193,9 @@ if (existsSync(AGENT_BROWSER_SOURCE) && process.env.ORCAD_OMIT_AGENT_BROWSER !== cpSync(join(ROOT, 'resources', 'licenses', 'ripgrep'), join(OUT_DIR, 'ripgrep', 'licenses'), { recursive: true }) +cpSync(join(ROOT, 'resources', 'native-chat-visuals'), join(OUT_DIR, 'native-chat-visuals'), { + recursive: true +}) /** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source * directory under `outdir`, and both children must land flat beside orcad.js — that is where @@ -226,7 +233,8 @@ const childResults = await Promise.all([ ) ]) -const result = await buildOrcadEntry(OUT_FILE) +const result = await buildOrcadEntry(SERVER_OUT_FILE) +const launcherResult = await buildOrcadLauncher(OUT_FILE) const output = Object.values(result.metafile.outputs).find( (o) => o.entryPoint === 'src/main/orcad/main.ts' @@ -251,7 +259,11 @@ function collectImporters(metafiles, matches) { return importers } -const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)] +const metafiles = [ + launcherResult.metafile, + result.metafile, + ...childResults.map((child) => child.metafile) +] const electronImporters = collectImporters( metafiles, (specifier) => specifier === 'electron' || specifier.startsWith('electron/') @@ -291,7 +303,7 @@ if (graphErrors.length > 0) { // Node's uncaught-exception report echoes that whole line — which contains every string // literal in the bundle. A crash therefore "matches" any expected message, and a textual // assertion passes against a bundle that never loaded. - const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], { + const smoke = spawnSync(process.execPath, [SERVER_OUT_FILE, '--orcad-smoke-load-check'], { encoding: 'utf8', timeout: 60_000 }) diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index 8c20cb8e8c28..d3a86cbb210e 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -49,7 +49,9 @@ export const STRUCTURED_CHAT_LANES = [ { directory: ['src', 'shared'] }, { directory: ['src', 'main', 'runtime'], basename: /^(?:structured-|agent-session-)/ }, { directory: ['src', 'main', 'provider-process'] }, - { directory: ['src', 'main', 'acp'] } + { directory: ['src', 'main', 'acp'] }, + { directory: ['src', 'main', 'jsonl-rpc'] }, + { directory: ['src', 'main', 'pi'], basename: /^rpc-/ } ] export function collectStructuredChatEntryPoints(root = ROOT) { diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index 9f9e81f0f40b..82941bab2905 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -39,6 +39,8 @@ describe('structured chat coverage', () => { 'src/main/runtime/structured-agent-session-host.ts': 'export {}', 'src/main/provider-process/provider-process-teardown.ts': 'export {}', 'src/main/acp/acp-structured-session-adapter.ts': 'export {}', + 'src/main/jsonl-rpc/peer.ts': 'export {}', + 'src/main/pi/rpc-launch.ts': 'export {}', 'src/shared/agent-session-record.ts': 'export {}' } @@ -55,7 +57,8 @@ describe('structured chat coverage', () => { 'src/main/runtime/structured-agent-runtime-registrations.ts', 'src/main/runtime/rpc/methods/structured-agent-session-agents.ts', 'src/main/acp/adapter.ts', - 'src/main/provider-process/worker.ts' + 'src/main/provider-process/worker.ts', + 'src/main/jsonl-rpc/nested/dialect.ts' ] const excluded = [ 'src/main/native-chat/reader.test.ts', @@ -73,7 +76,10 @@ describe('structured chat coverage', () => { 'src/main/runtime/orca-runtime-tests/structured-agent-session-host.ts', 'src/shared/types.d.ts', 'src/main/runtime/other.ts', - 'src/main/runtime/rpc/methods/browser.ts' + 'src/main/runtime/rpc/methods/browser.ts', + 'src/main/jsonl-rpc/peer.test.ts', + 'src/main/pi/rpc-launch.test.ts', + 'src/main/pi/titlebar-extension-service.ts' ] const root = fixture( Object.fromEntries([...sources, ...excluded].map((file) => [file, 'export {}'])) @@ -90,7 +96,9 @@ describe('structured chat coverage', () => { Object.entries(requiredLanes).filter(([file]) => !file.startsWith(`${lane}/`)) ) expect(() => collectStructuredChatEntryPoints(fixture(without))).toThrow(`${lane} is missing`) - expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(7) + expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength( + Object.keys(requiredLanes).length + ) } ) @@ -101,6 +109,8 @@ describe('structured chat coverage', () => { ...requiredLanes, 'src/main/acp/adapter.ts': "import 'acp-desktop-package'", 'src/main/provider-process/worker.ts': "import 'provider-desktop-package'", + 'src/main/jsonl-rpc/peer.ts': "import 'rpc-desktop-package'", + 'src/main/pi/rpc-launch.ts': "import 'pi-desktop-package'", 'node_modules/acp-desktop-package/package.json': JSON.stringify({ main: 'index.js', sideEffects @@ -110,12 +120,24 @@ describe('structured chat coverage', () => { main: 'index.js', sideEffects }), - 'node_modules/provider-desktop-package/index.js': "require('electron')" + 'node_modules/provider-desktop-package/index.js': "require('electron')", + 'node_modules/rpc-desktop-package/package.json': JSON.stringify({ + main: 'index.js', + sideEffects + }), + 'node_modules/rpc-desktop-package/index.js': "require('electron')", + 'node_modules/pi-desktop-package/package.json': JSON.stringify({ + main: 'index.js', + sideEffects + }), + 'node_modules/pi-desktop-package/index.js': "require('electron')" }) const current = await collectElectronImporters(collectStructuredChatEntryPoints(root)) expect(current.map((file) => file.split('/node_modules/').pop())).toEqual([ 'acp-desktop-package/index.js', - 'provider-desktop-package/index.js' + 'pi-desktop-package/index.js', + 'provider-desktop-package/index.js', + 'rpc-desktop-package/index.js' ]) } ) diff --git a/config/scripts/check-runtime-launcher-protocol-ratchet.mjs b/config/scripts/check-runtime-launcher-protocol-ratchet.mjs index a26a5d87aa31..d289819fe67b 100644 --- a/config/scripts/check-runtime-launcher-protocol-ratchet.mjs +++ b/config/scripts/check-runtime-launcher-protocol-ratchet.mjs @@ -12,6 +12,8 @@ import { DAEMON_PROTOCOL_SOURCE_PATH, parseDaemonProtocolFacts } from './daemon- export const RUNTIME_LAUNCHER_PATHS = [ // orcad handoff to its bundled runtime, and the pinned runtimes it can hand off to. 'src/main/orcad/orcad-bundled-runtime.ts', + 'src/main/orcad/launcher.ts', + 'config/scripts/orcad-entry-build.mjs', 'src/shared/node-runtime-pin.ts', 'src/main/ssh/pinned-runtime-materializer.ts', 'src/main/ssh/runtime-archive-download.ts', diff --git a/config/scripts/ci-cache-warmup-workflow.test.mjs b/config/scripts/ci-cache-warmup-workflow.test.mjs index 01b6c214e5d2..5c626caa703e 100644 --- a/config/scripts/ci-cache-warmup-workflow.test.mjs +++ b/config/scripts/ci-cache-warmup-workflow.test.mjs @@ -85,6 +85,18 @@ it('lets scheduled warmers wait while pushes, PR updates, and manual runs can re }) }) +it('keeps compiler changes validated without starting warmers for test-only edits', () => { + for (const event of ['push', 'pull_request']) { + const paths = workflow.on[event].paths + expect(paths).toContain('config/scripts/headless-detector-compiler-cache.mjs') + expect(paths).not.toContain('config/scripts/headless-detector-compiler-cache*') + expect(paths).not.toContain('config/scripts/headless-detector-compiler-cache.test.mjs') + expect(paths).not.toContain('config/scripts/ci-cache-warmup-workflow.test.mjs') + expect(paths).toContain('.github/workflows/ci-cache-warmup.yml') + expect(paths).toContain('.github/actions/prepare-headless-compiler/**') + } +}) + it('warms and probes both Windows images with the persistence job runtime', () => { const job = workflow.jobs['warm-windows'] expect(job.strategy.matrix.os).toEqual( diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index e2e207ce58bb..53747f9d496d 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -21,10 +21,12 @@ export const DOCKER_SSH_E2E_SPECS = [ 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts', 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts', 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', + 'tests/e2e/ssh-reattach-home-partition.spec.ts', 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', 'tests/e2e/ssh-restart-tab-accumulation.spec.ts', 'tests/e2e/ssh-skill-installation.spec.ts', 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts', + 'tests/e2e/ssh-startup-local-shadow.spec.ts', 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts', 'tests/e2e/terminal-inline-images-ssh.spec.ts', 'tests/e2e/ssh-docker-watcher-isolation.spec.ts', diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 0badaf6a7583..41039fa66f34 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -7407,7 +7407,6 @@ "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx": 587, "src/renderer/src/components/terminal-pane/use-system-prefers-dark.test.ts": 45, "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-active-pty-reporting.test.ts": 1301, - "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-file-drop.test.ts": 1219, "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-paste-events.test.ts": 1149, "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-sync-fit-registration.test.ts": 1211, "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts": 1904, @@ -7632,7 +7631,6 @@ "src/renderer/src/hooks/useEditorExternalWatch.test.ts": 492, "src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx": 420, "src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx": 556, - "src/renderer/src/hooks/useGlobalFileDrop.test.ts": 1582, "src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx": 6914, "src/renderer/src/hooks/useInstalledAgentSkills.test.ts": 1496, "src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts": 50, diff --git a/config/scripts/ci-unit-plan.mjs b/config/scripts/ci-unit-plan.mjs index 117231d99bc1..8b6ffebc8746 100644 --- a/config/scripts/ci-unit-plan.mjs +++ b/config/scripts/ci-unit-plan.mjs @@ -7,6 +7,11 @@ import { FULL_SHARD_COUNT, planUnitSelection } from './ci-unit-selection.mjs' import { readTimingBaseline, writeAssignment } from './ci-shard-assignment.mjs' export function prepareUnitPlan(env = process.env) { + const requestedCount = env.ORCA_UNIT_FULL_SHARD_COUNT ?? String(FULL_SHARD_COUNT) + if (!['5', '10'].includes(requestedCount)) { + throw new Error('ORCA_UNIT_FULL_SHARD_COUNT must be 5 or 10') + } + const fullShardCount = Number(requestedCount) const files = discoverUnitFiles() let plan try { @@ -28,7 +33,8 @@ export function prepareUnitPlan(env = process.env) { graph: collectUnitDependencyGraph, timings: readTimingBaseline('unit').timings, event, - mode: env.ORCA_UNIT_SELECTION_MODE + mode: env.ORCA_UNIT_SELECTION_MODE, + fullShardCount }) } catch (error) { plan = { @@ -39,9 +45,9 @@ export function prepareUnitPlan(env = process.env) { files, candidateFiles: files, executionFiles: files, - shards: Array.from({ length: FULL_SHARD_COUNT }, (_, index) => ({ + shards: Array.from({ length: fullShardCount }, (_, index) => ({ index: index + 1, - count: FULL_SHARD_COUNT + count: fullShardCount })) } } diff --git a/config/scripts/ci-unit-plan.test.mjs b/config/scripts/ci-unit-plan.test.mjs index eda7903ae1de..b5022628927f 100644 --- a/config/scripts/ci-unit-plan.test.mjs +++ b/config/scripts/ci-unit-plan.test.mjs @@ -77,3 +77,46 @@ it.each([true, false])( } } ) + +it.each(['5', '10', 'invalid'])( + 'validates the requested full shard count and keeps fallback coverage: %s', + (requestedCount) => { + const root = mkdtempSync(join(tmpdir(), 'orca-unit-count-')) + try { + mkdirSync(join(root, 'src')) + writeFileSync(join(root, 'src/retained.test.ts'), 'export const retained = true') + const eventPath = join(root, 'event.json') + writeFileSync(eventPath, '{}') + const result = runProcessSync({ + program: process.execPath, + args: [fileURLToPath(new URL('./ci-unit-plan.mjs', import.meta.url))], + cwd: root, + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_UNIT_FULL_SHARD_COUNT: requestedCount, + GITHUB_EVENT_NAME: 'workflow_dispatch', + GITHUB_EVENT_PATH: eventPath, + ORCA_SHARD_SOURCE_SHA: 'full-count-fixture' + } + }) + if (requestedCount === 'invalid') { + expect(result.code).not.toBe(0) + expect(result.stderr).toContain('ORCA_UNIT_FULL_SHARD_COUNT must be 5 or 10') + return + } + expect(result.code, result.stderr).toBe(0) + const plan = JSON.parse(readFileSync(join(root, 'ci-shards/unit-selection.json'), 'utf8')) + expect(plan.mode).toBe('shadow') + expect(plan.executionFiles).toEqual(['src/retained.test.ts']) + expect(plan.shards).toEqual( + Array.from({ length: Number(requestedCount) }, (_, index) => ({ + index: index + 1, + count: Number(requestedCount) + })) + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +) diff --git a/config/scripts/ci-unit-selection-review.test.mjs b/config/scripts/ci-unit-selection-review.test.mjs index ff8f4c385687..0e7536e0578b 100644 --- a/config/scripts/ci-unit-selection-review.test.mjs +++ b/config/scripts/ci-unit-selection-review.test.mjs @@ -53,3 +53,19 @@ it('does not call incomplete, duplicate, interrupted or selected evidence a full expect(reviewUnitSelection(rows).every((row) => !row.completeFullRun)).toBe(true) } }) + +it('requires all ten full assignments exactly once for complete evidence', () => { + const files = Array.from({ length: 10 }, (_, index) => `retained-${index}.test.ts`) + const tenPlan = { ...plan, files, candidateFiles: files } + const records = files.map((file, index) => { + const row = record(index + 1, file, 'passed') + return { + ...row, + plan: tenPlan, + timing: { ...row.timing, shard: { index: index + 1, count: 10 } } + } + }) + expect(reviewUnitSelection(records)[0].completeFullRun).toBe(true) + expect(reviewUnitSelection(records.slice(0, -1))[0].completeFullRun).toBe(false) + expect(reviewUnitSelection([...records.slice(0, -1), records[0]])[0].completeFullRun).toBe(false) +}) diff --git a/config/scripts/ci-unit-selection.mjs b/config/scripts/ci-unit-selection.mjs index b531ec069caa..0d2bbe68c29b 100644 --- a/config/scripts/ci-unit-selection.mjs +++ b/config/scripts/ci-unit-selection.mjs @@ -35,16 +35,25 @@ export function selectUnitFiles(files, changed, graphInput) { // the account's slots and made the unit matrix 68% of daily slot demand. Against the checked-in // baseline, five shards each carry 24.7 test-minutes over four workers plus ~2.6 minutes of fixed // setup, so ~8.8 minutes -- less than the 10.5-minute p95 queue the oversharding was causing. +// Full PR workflows request ten; daily callers and selected drafts retain five. export const FULL_SHARD_COUNT = 5 -export function planUnitSelection({ files, changed, graph, timings, event, mode = 'shadow' }) { +export function planUnitSelection({ + files, + changed, + graph, + timings, + event, + mode = 'shadow', + fullShardCount = FULL_SHARD_COUNT +}) { const candidate = selectUnitFiles(files, changed, graph) const selected = mode === 'selected' && event?.pull_request?.draft === true && !candidate.full const executionFiles = selected ? candidate.files : files const totalMs = balanceFiles(executionFiles, 1, timings).shards[0].durationMs const count = selected ? Math.max(1, Math.min(FULL_SHARD_COUNT, Math.ceil(totalMs / 900_000), executionFiles.length)) - : FULL_SHARD_COUNT + : fullShardCount return { version: 1, mode: selected ? 'selected' : 'shadow', diff --git a/config/scripts/ci-unit-selection.test.mjs b/config/scripts/ci-unit-selection.test.mjs index 2f2b7726111c..f3764a54c1bf 100644 --- a/config/scripts/ci-unit-selection.test.mjs +++ b/config/scripts/ci-unit-selection.test.mjs @@ -120,6 +120,42 @@ describe('conservative unit selection', () => { ) }) + it('uses ten complete PR shards while selected drafts retain the five-shard cap', () => { + const expandedSources = new Map(sources) + for (let index = 0; index < 5; index++) { + expandedSources.set(`src/additional-${index}.test.ts`, `import './leaf'`) + } + const expandedGraph = { + ...buildUnitDependencyGraph(expandedSources), + files: new Set(expandedSources.keys()) + } + const expandedFiles = [...expandedSources.keys()] + .filter((file) => file.endsWith('.test.ts')) + .sort() + const base = { + files: expandedFiles, + changed: ['src/leaf.ts'], + graph: expandedGraph, + fullShardCount: 10 + } + const full = planUnitSelection({ ...base, timings: {} }) + expect(full.executionFiles).toEqual(expandedFiles) + expect(full.shards).toEqual( + Array.from({ length: 10 }, (_, index) => ({ index: index + 1, count: 10 })) + ) + const selected = planUnitSelection({ + ...base, + timings: Object.fromEntries(expandedFiles.map((file) => [file, 1_800_000])), + mode: 'selected', + event: { pull_request: { draft: true } } + }) + expect(selected.shards).toHaveLength(5) + expect(selected.executionFiles).toEqual( + selectUnitFiles(expandedFiles, ['src/leaf.ts'], expandedGraph).files + ) + expect(selected.executionFiles.length).toBeGreaterThan(5) + }) + it('records failures that would have been missed while shadow runs remain full', () => { const plan = planUnitSelection({ files, changed: ['src/leaf.ts'], graph, timings: {} }) expect( diff --git a/config/scripts/claude-account-windows-spawn-repro.mjs b/config/scripts/claude-account-windows-spawn-repro.mjs deleted file mode 100644 index eaf04762e5ba..000000000000 --- a/config/scripts/claude-account-windows-spawn-repro.mjs +++ /dev/null @@ -1,342 +0,0 @@ -import { spawn } from 'node:child_process' -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { buildWindowsCommandInvocation } from '../../src/main/claude-accounts/windows-command-invocation.ts' - -const strategy = process.argv[2] -if (!['baseline', 'candidate', 'explicit-cmd'].includes(strategy)) { - throw new Error( - 'Usage: node config/scripts/claude-account-windows-spawn-repro.mjs ' - ) -} -if (process.platform !== 'win32') { - throw new Error('This reproduction requires a physical Windows host.') -} - -const expectedArgs = [ - '', - 'two words', - 'amp&ersand', - 'pipe|value', - 'lessvalue', - 'caret^value', - 'trailing\\', - 'two-trailing\\\\', - '(parentheses)', - '100%', - '%ORCA_ARG_TRAP%', - 'bang!value', - '한글-λ' -] -const tempRoot = await mkdtemp(join(tmpdir(), 'orca-claude-spawn-')) -const reportedDir = join(tempRoot, 'Profile with spaces 한글') -const reportedCapturePath = join(reportedDir, 'capture.json') -const reportedPidPath = join(reportedDir, 'pids.json') -const reportedShimPath = join(reportedDir, 'claude fixture.cmd') -const reportedFixturePath = join(reportedDir, 'capture-child.cjs') -const fixtureDir = join(tempRoot, 'Profile space & ^ (paren) %ORCA_PATH_TRAP% !bang! 한글') -const capturePath = join(fixtureDir, 'capture.json') -const pidPath = join(fixtureDir, 'pids.json') -const shimPath = join(fixtureDir, 'claude fixture.cmd') -const fixturePath = join(fixtureDir, 'capture-child.cjs') -const fixtureEnv = { - ...process.env, - CLAUDE_CONFIG_DIR: join(fixtureDir, 'config space & ^ (paren) %ORCA_ENV_LITERAL% !bang! 한글'), - ORCA_ARG_TRAP: 'EXPANDED_ARG', - ORCA_PATH_TRAP: 'EXPANDED_PATH', - ORCA_FIXTURE_CAPTURE: capturePath, - ORCA_FIXTURE_PIDS: pidPath, - ORCA_FIXTURE_NODE: process.execPath -} -const reportedEnv = { - ...fixtureEnv, - CLAUDE_CONFIG_DIR: join(reportedDir, 'config with spaces 한글'), - ORCA_FIXTURE_CAPTURE: reportedCapturePath, - ORCA_FIXTURE_PIDS: reportedPidPath -} - -function quoteForCandidate(value) { - return `"${value.replace(/"/g, '""')}"` -} - -function launch(args, command = shimPath, env = fixtureEnv) { - if (strategy === 'baseline') { - return spawn(command, args, { cwd: tempRoot, env, shell: true, windowsHide: true }) - } - if (strategy === 'candidate') { - return spawn(quoteForCandidate(command), args, { - cwd: tempRoot, - env, - shell: true, - windowsHide: true - }) - } - const invocation = buildWindowsCommandInvocation(command, args) - return spawn(invocation.command, invocation.args, { - cwd: tempRoot, - env, - shell: false, - windowsVerbatimArguments: invocation.windowsVerbatimArguments, - windowsHide: true - }) -} - -function collect(child) { - return new Promise((resolve) => { - let stdout = '' - let stderr = '' - child.stdout?.on('data', (chunk) => (stdout += chunk.toString())) - child.stderr?.on('data', (chunk) => (stderr += chunk.toString())) - child.on('error', (error) => resolve({ code: null, stdout, stderr, error: error.message })) - child.on('close', (code) => resolve({ code, stdout, stderr, error: null })) - }) -} - -async function waitForFile(path, timeoutMs = 5_000) { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - try { - return JSON.parse(await readFile(path, 'utf8')) - } catch { - await new Promise((resolve) => setTimeout(resolve, 25)) - } - } - throw new Error(`Timed out waiting for fixture output: ${path}`) -} - -async function taskExists(pid) { - const result = await collect( - spawn('tasklist.exe', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], { - windowsHide: true - }) - ) - if (result.error || result.code !== 0) { - throw new Error(`tasklist failed for PID ${pid}: ${result.error ?? result.stderr}`) - } - return result.stdout.includes(`"${pid}"`) -} - -async function killTree(pid) { - const result = await collect( - spawn('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { windowsHide: true }) - ) - if (result.error || result.code !== 0) { - throw new Error(`taskkill failed for PID ${pid}: ${result.error ?? result.stderr}`) - } -} - -async function waitForTreeExit(pids, timeoutMs = 5_000) { - const deadline = Date.now() + timeoutMs - let alive = {} - do { - alive = Object.fromEntries( - await Promise.all( - Object.entries(pids).map(async ([name, pid]) => [name, await taskExists(pid)]) - ) - ) - if (!Object.values(alive).some(Boolean)) { - return alive - } - await new Promise((resolve) => setTimeout(resolve, 50)) - } while (Date.now() < deadline) - return alive -} - -const results = { - strategy, - reportedPath: null, - pathMatrix: {}, - argvMatrix: {}, - hostilePathAndArgv: null, - error: null, - cancellation: null -} -const fixtureSource = - `const { spawn } = require('node:child_process')\n` + - `const { writeFileSync } = require('node:fs')\n` + - `if (process.argv[2] === '--exit-error') { process.stderr.write('fixture error: 한글 & ^ % !\\n'); process.exit(23) }\n` + - `if (process.argv[2] === '--linger') {\n` + - ` const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { windowsHide: true })\n` + - ` writeFileSync(process.env.ORCA_FIXTURE_PIDS, JSON.stringify({ child: process.pid, grandchild: grandchild.pid }))\n` + - ` setInterval(() => {}, 1000)\n` + - `} else {\n` + - ` writeFileSync(process.env.ORCA_FIXTURE_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), configDir: process.env.CLAUDE_CONFIG_DIR }))\n` + - `}\n` -const shimSource = '@echo off\r\n"%ORCA_FIXTURE_NODE%" "%~dp0capture-child.cjs" %*\r\n' -let lingeringShellPid = null -try { - await mkdir(fixtureDir, { recursive: true }) - await mkdir(reportedDir, { recursive: true }) - await writeFile(fixturePath, fixtureSource, 'utf8') - await writeFile(shimPath, shimSource, 'utf8') - await writeFile(reportedFixturePath, fixtureSource, 'utf8') - await writeFile(reportedShimPath, shimSource, 'utf8') - - const reportedArgs = ['auth', 'status', '--json'] - const reportedRun = await collect(launch(reportedArgs, reportedShimPath, reportedEnv)) - let reportedCapture = null - try { - reportedCapture = await waitForFile(reportedCapturePath, 1_000) - } catch {} - results.reportedPath = { - ...reportedRun, - actual: reportedCapture, - expected: { argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR }, - pass: - reportedRun.code === 0 && - JSON.stringify(reportedCapture) === - JSON.stringify({ argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR }) - } - - for (const [name, segment] of Object.entries({ - spaces: 'profile space', - ampersand: 'profile&name', - caret: 'profile^name', - parentheses: 'profile(name)', - percent: 'profile%ORCA_PATH_TRAP%', - bang: 'profile!name', - unicode: 'profile-한글-λ' - })) { - const directory = join(tempRoot, segment) - const captureFile = join(directory, 'capture.json') - const command = join(directory, 'claude fixture.cmd') - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'capture-child.cjs'), fixtureSource, 'utf8') - await writeFile(command, shimSource, 'utf8') - const env = { - ...fixtureEnv, - CLAUDE_CONFIG_DIR: join(directory, 'config'), - ORCA_FIXTURE_CAPTURE: captureFile - } - const run = await collect(launch(reportedArgs, command, env)) - let actual = null - try { - actual = await waitForFile(captureFile, 500) - } catch {} - results.pathMatrix[name] = { - code: run.code, - stderr: run.stderr.trim(), - actual: actual?.argv ?? null, - pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(reportedArgs) - } - } - - for (const [name, value] of Object.entries({ - empty: '', - spaces: 'two words', - ampersand: 'amp&ersand', - pipe: 'pipe|value', - lessThan: 'lessvalue', - caret: 'caret^value', - trailingBackslash: 'trailing\\', - twoTrailingBackslashes: 'two-trailing\\\\', - parentheses: '(parentheses)', - percent: '%ORCA_ARG_TRAP%', - bang: 'bang!value', - unicode: '한글-λ' - })) { - const args = ['prefix', value, 'suffix'] - const captureFile = join(reportedDir, `capture-${name}.json`) - const env = { ...reportedEnv, ORCA_FIXTURE_CAPTURE: captureFile } - const run = await collect(launch(args, reportedShimPath, env)) - let actual = null - try { - actual = await waitForFile(captureFile, 500) - } catch {} - results.argvMatrix[name] = { - code: run.code, - stderr: run.stderr.trim(), - actual: actual?.argv ?? null, - pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(args) - } - } - - const argvRun = await collect(launch(expectedArgs)) - let capture = null - try { - capture = await waitForFile(capturePath, 1_000) - } catch {} - results.hostilePathAndArgv = { - ...argvRun, - actual: capture, - expected: { argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR }, - pass: - argvRun.code === 0 && - JSON.stringify(capture) === - JSON.stringify({ argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR }) - } - - results.error = await collect(launch(['--exit-error'], reportedShimPath, reportedEnv)) - results.error.pass = - results.error.code === 23 && results.error.stderr.includes('fixture error: 한글 & ^ % !') - - const lingering = launch(['--linger'], reportedShimPath, reportedEnv) - lingeringShellPid = lingering.pid - const lingeringResult = collect(lingering) - try { - const pids = await waitForFile(reportedPidPath) - await killTree(lingering.pid) - const alive = await waitForTreeExit({ - shell: lingering.pid, - child: pids.child, - grandchild: pids.grandchild - }) - results.cancellation = { - shell: lingering.pid, - ...pids, - alive, - pass: !Object.values(alive).some(Boolean) - } - } catch (error) { - if (await taskExists(lingering.pid)) { - await killTree(lingering.pid) - } - const launchResult = await Promise.race([ - lingeringResult, - new Promise((resolve) => - setTimeout( - () => resolve({ code: null, error: 'fixture did not exit after cleanup' }), - 5_000 - ) - ) - ]) - results.cancellation = { - shell: lingering.pid, - launchResult, - pass: false, - error: error instanceof Error ? error.message : String(error) - } - } -} finally { - if (lingeringShellPid && (await taskExists(lingeringShellPid))) { - await killTree(lingeringShellPid) - } - try { - let pids - try { - pids = JSON.parse(await readFile(reportedPidPath, 'utf8')) - } catch { - pids = JSON.parse(await readFile(pidPath, 'utf8')) - } - for (const pid of [pids.child, pids.grandchild]) { - if (await taskExists(pid)) { - await killTree(pid) - } - } - } catch {} - await rm(tempRoot, { recursive: true, force: true }) -} - -console.log(JSON.stringify(results, null, 2)) -process.exitCode = - results.reportedPath?.pass && - Object.values(results.pathMatrix).every((result) => result.pass) && - Object.values(results.argvMatrix).every((result) => result.pass) && - results.hostilePathAndArgv?.pass && - results.error?.pass && - results.cancellation?.pass - ? 0 - : 1 diff --git a/config/scripts/headless-detector-compiler-cache.test.mjs b/config/scripts/headless-detector-compiler-cache.test.mjs index 484807159a75..be7f7d2ba8ae 100644 --- a/config/scripts/headless-detector-compiler-cache.test.mjs +++ b/config/scripts/headless-detector-compiler-cache.test.mjs @@ -82,6 +82,11 @@ it('activates only the actual compiler packages and preserves import graph behav join(root, 'config', 'scripts', `${name}.mjs`) ) } + mkdirSync(join(root, 'src', 'shared'), { recursive: true }) + cpSync( + new URL('../../src/shared/orcad-artifacts.ts', import.meta.url), + join(root, 'src', 'shared', 'orcad-artifacts.ts') + ) writeFileSync( join(root, 'entry.ts'), "import './first'; export * from './exports'; import('./dynamic'); require('./required'); import 'external-package'; import './native.node'" diff --git a/config/scripts/mobile-mirrored-storage-write-path.test.mjs b/config/scripts/mobile-mirrored-storage-write-path.test.mjs index d626677cbec9..b67c82ceea77 100644 --- a/config/scripts/mobile-mirrored-storage-write-path.test.mjs +++ b/config/scripts/mobile-mirrored-storage-write-path.test.mjs @@ -31,7 +31,6 @@ const MIRRORED_WRITERS = [ keys: ['TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY'] }, { file: 'src/components/CustomKeyModal.tsx', keys: ['CUSTOM_ACCESSORY_KEYS_STORAGE_KEY'] }, - { file: 'src/session/mobile-structured-send-operation-journal.ts', keys: ['STORAGE_KEY'] }, { file: 'src/worktree/last-visited-worktree-repo.ts', keys: ['LAST_VISITED_WORKTREE_STORAGE_KEY'] diff --git a/config/scripts/mobile-web-app-session-render.test.mjs b/config/scripts/mobile-web-app-session-render.test.mjs index 9bf1560141f4..a18ccf93c031 100644 --- a/config/scripts/mobile-web-app-session-render.test.mjs +++ b/config/scripts/mobile-web-app-session-render.test.mjs @@ -374,7 +374,6 @@ describeRender( * `mobile-web-app-screencast-lane-grant.test.mjs` derives from this closure. * * **The storage refusals a control makes.** The case above covers the writes a mount makes on its - * own; a refusal a user's own write earns still needs the control. That chain is - * `mobile/src/session/mobile-structured-send-page-storage-refusal.test.ts` end to end over the - * real `page-async-storage`. + * own; store-level write refusals are covered by + * `mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts`. */ diff --git a/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs b/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs index dd8541d6425d..120e0c49e7d5 100644 --- a/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs +++ b/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs @@ -99,6 +99,7 @@ const EXPECTED_PACKAGING_JOBS = [ 'release-cut.yml build', 'release-mac-build.yml build-mac', 'win-crash-survival-e2e.yml crash-survival', + 'win-orcad-serve-switch-e2e.yml serve-switch', 'win-update-survival-e2e.yml survival', 'windows-signing-rehearsal.yml rehearse' ] diff --git a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs index d9ee133a70e3..fc0894d58f6b 100644 --- a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs +++ b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs @@ -10,8 +10,8 @@ // Those numbers are the `!useConptyDll` branch, which is the branch a RELAY runs. Every desktop // site that opens a terminal pane sets `useConptyDll: true` and takes the other branch, where // upstream already destroys the input socket. The hidden rate-limit probe -// (`src/main/rate-limits/claude-pty.ts`) does omit the option and so does run this hunk, but no -// user-visible pane does. The divergence pinned below is about which +// (`src/main/rate-limits/codex-pty-rate-limit-probe.ts`) does omit the option and so does run this +// hunk, but no user-visible pane does. The divergence pinned below is about which // branch each host runs for terminals -- not about a regression in the panes users open. import { createRequire } from 'node:module' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 71e788867e1b..0499796eab17 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -65,10 +65,18 @@ export function discoverNodeServerTests(root = ROOT) { } export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) { - const [{ build }, { externalNativeAddons, ORCAD_CHILD_ENTRY_POINTS, ORCAD_ENTRY_POINT }] = - await Promise.all([import('esbuild'), import('./orcad-entry-build.mjs')]) + const [ + { build }, + { + externalNativeAddons, + ORCAD_CHILD_ENTRY_POINTS, + ORCAD_ENTRY_POINT, + ORCAD_LAUNCHER_ENTRY_POINT + } + ] = await Promise.all([import('esbuild'), import('./orcad-entry-build.mjs')]) const entries = entryPoints ?? [ ORCAD_ENTRY_POINT, + ORCAD_LAUNCHER_ENTRY_POINT, ...Object.values(ORCAD_CHILD_ENTRY_POINTS), ...BUILD_SCRIPTS, ...discoverNodeServerTests(root) diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs index 8d9e8eb67102..9e51b23af9ce 100644 --- a/config/scripts/node-server-test-paths.mjs +++ b/config/scripts/node-server-test-paths.mjs @@ -31,6 +31,7 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } = 'src/main/orcad/orcad-packaged-node-pty.integration.test.ts', 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', 'src/main/orcad/orcad-node-launcher.integration.test.ts', + 'src/main/orcad/orcad-launcher-isolation.integration.test.ts', 'src/main/orcad/orcad-stop-request-shutdown.integration.test.ts', 'src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts', 'src/main/orcad/orcad-serve-parity.integration.test.ts', diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index 90767764d21c..a746ac4bb5b2 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -1,9 +1,13 @@ import { build } from 'esbuild' -import { join } from 'node:path' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { ORCAD_SERVER_ENTRY_FILENAME } from '../../src/shared/orcad-artifacts.ts' const root = join(import.meta.dirname, '..', '..') export const ORCAD_ENTRY_POINT = 'src/main/orcad/main.ts' +export const ORCAD_LAUNCHER_ENTRY_POINT = 'src/main/orcad/launcher.ts' export const ORCAD_CHILD_ENTRY_POINTS = { watcher: 'src/main/ipc/parcel-watcher-process-entry.ts', daemon: 'src/main/daemon/daemon-entry.ts', @@ -50,3 +54,22 @@ export function buildOrcadEntry(outfile) { logLevel: 'error' }) } + +export function buildOrcadLauncher(outfile) { + const serverSha256 = createHash('sha256') + .update(readFileSync(join(dirname(outfile), ORCAD_SERVER_ENTRY_FILENAME))) + .digest('hex') + return build({ + entryPoints: [join(root, ORCAD_LAUNCHER_ENTRY_POINT)], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile, + metafile: true, + minify: true, + sourcemap: false, + define: { ORCAD_SERVER_SHA256: JSON.stringify(serverSha256) }, + logLevel: 'error' + }) +} diff --git a/config/scripts/orcad-prebuild-slot-contents.mjs b/config/scripts/orcad-prebuild-slot-contents.mjs index 4084d9fcc20e..8a6482e6e854 100644 --- a/config/scripts/orcad-prebuild-slot-contents.mjs +++ b/config/scripts/orcad-prebuild-slot-contents.mjs @@ -17,8 +17,8 @@ const require = createRequire(import.meta.url) export const MANIFEST_SCHEMA_VERSION = 2 /** - * Node 18, the rung C host floor, supports N-API 8 (9 only from 18.17). Pinned here rather - * than left to the headers' default so a header bump cannot silently raise it. + * Keep N-API 8 stable across bundled and host runtimes rather than using the headers' + * default, so a header bump cannot silently change the shipped addon ABI. */ export const SLOT_NAPI_VERSION = 8 diff --git a/config/scripts/orcad-prebuild-slot-contents.test.mjs b/config/scripts/orcad-prebuild-slot-contents.test.mjs index 720a0436c3ef..af6f3b45f5e4 100644 --- a/config/scripts/orcad-prebuild-slot-contents.test.mjs +++ b/config/scripts/orcad-prebuild-slot-contents.test.mjs @@ -57,7 +57,7 @@ const next = (slot, overrides = {}) => ({ }) describe('N-API pinning', () => { - it('pins N-API 8 so a host Node 18 (rung C) can load every slot', () => { + it('keeps the N-API 8 addon ABI stable across runtime upgrades', () => { expect(SLOT_NAPI_VERSION).toBe(8) // The client's rung C host-Node gate must ask for exactly what the slots are built against. expect(ORCAD_ADDON_NAPI_VERSION).toBe(SLOT_NAPI_VERSION) diff --git a/config/scripts/orcad-terminal-smoke-change-scope.mjs b/config/scripts/orcad-terminal-smoke-change-scope.mjs index 3ece0aa5b3a9..45df389a372e 100644 --- a/config/scripts/orcad-terminal-smoke-change-scope.mjs +++ b/config/scripts/orcad-terminal-smoke-change-scope.mjs @@ -1,10 +1,15 @@ import { readFileSync } from 'node:fs' import { pathToFileURL } from 'node:url' import { classifyNodeServerChanges, collectNodeServerInputs } from './node-server-change-scope.mjs' -import { ORCAD_CHILD_ENTRY_POINTS, ORCAD_ENTRY_POINT } from './orcad-entry-build.mjs' +import { + ORCAD_CHILD_ENTRY_POINTS, + ORCAD_ENTRY_POINT, + ORCAD_LAUNCHER_ENTRY_POINT +} from './orcad-entry-build.mjs' const ENTRY_POINTS = [ ORCAD_ENTRY_POINT, + ORCAD_LAUNCHER_ENTRY_POINT, ...Object.values(ORCAD_CHILD_ENTRY_POINTS), 'src/cli/index.ts', 'config/scripts/build-orcad-node.mjs', diff --git a/config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs b/config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs index b263b86f7899..2e0cd5444675 100644 --- a/config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs +++ b/config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs @@ -8,7 +8,6 @@ describe('cross-version wire routing for the send path', () => { 'src/shared/agent-session-wire-refusals.ts', 'src/shared/structured-agent-session-mutation.ts', 'src/shared/structured-agent-session-send-mutation.ts', - 'src/shared/structured-agent-session-outbox.ts', 'src/main/runtime/rpc/core.ts', 'src/main/runtime/rpc/errors.ts', 'src/main/runtime/rpc/rpc-streaming-dispatcher.ts', @@ -23,9 +22,7 @@ describe('cross-version wire routing for the send path', () => { }) it.each([ - 'src/shared/structured-agent-session-outbox-admission.ts', - 'src/shared/structured-agent-session-outbox-delivery.ts', - 'src/shared/structured-agent-session-outbox-stop-withdrawal.ts', + 'src/shared/structured-agent-session-send-evidence.ts', 'src/shared/structured-agent-session-composer.ts', 'src/shared/structured-agent-session-reducer.ts', 'src/main/runtime/orchestration/send-agent-turn.ts', diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 8b7446c6be40..2688928f5266 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -193,7 +193,6 @@ const CROSS_VERSION_WIRE_PREFIXES = [ // fingerprint the host's ledger and journal re-derive. 'src/shared/structured-agent-session-mutation.ts', 'src/shared/structured-agent-session-send-mutation.ts', - 'src/shared/structured-agent-session-outbox.ts', 'src/shared/agent-session-record', 'src/shared/agent-session-provider-handle', 'src/shared/agent-session-journal-', diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 37b2b9230810..443457a426a7 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -32,6 +32,7 @@ it('shares one setup and runs the unchanged compiler after static checks finish' 'node config/scripts/ci-unit-plan.mjs' ]) expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') + expect(plan.env.ORCA_UNIT_FULL_SHARD_COUNT).toBe("${{ vars.ORCA_UNIT_FULL_SHARD_COUNT || '10' }}") expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) expect(steps.indexOf(compiler)).toBeGreaterThan( steps.findIndex((step) => step.wait?.includes('localization-extraction')) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 7bc063b1d421..6c5ae84429c5 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -224,6 +224,27 @@ describe('PR workflow parallelism', () => { expect(installStep.run).toMatch(/timeout \d+ sudo apt-get install/) }) + it('bounds package tooling refresh and keeps required installation fatal', () => { + const steps = workflow.jobs.package.steps + const installStep = steps.find((step) => step.id === 'linux-package-tools') + const commands = installStep.run + .split('\n') + .filter((line) => !line.trim().startsWith('#')) + .join('\n') + + expect(installStep.background).toBe(true) + expect(commands).toContain('Acquire::http::Timeout "15";') + expect(commands).toContain('Acquire::https::Timeout "15";') + expect(commands).toContain('Acquire::Retries "1";') + expect(commands).toMatch(/^timeout 120 sudo apt-get update \|\| true$/m) + expect(commands).toMatch(/^timeout 300 sudo apt-get install -y cpio rpm$/m) + expect(commands.trim().split('\n').at(-1)).toBe('timeout 300 sudo apt-get install -y cpio rpm') + expect(steps.find((step) => step.wait?.includes('linux-package-tools')).wait).toEqual([ + 'linux-package-tools', + 'web-client' + ]) + }) + it('keeps every real-zsh test in the dedicated shell lane', () => { const discoveredFiles = globSync(testFilePatterns) // Why this file is excluded: it carries the detector pattern as a literal diff --git a/config/scripts/profile-state-worker-smoke.mjs b/config/scripts/profile-state-worker-smoke.mjs index 9071417145dd..18ac442ddca6 100644 --- a/config/scripts/profile-state-worker-smoke.mjs +++ b/config/scripts/profile-state-worker-smoke.mjs @@ -11,6 +11,7 @@ import { ORCAD_PROFILE_PREFLIGHT_FLAG, parseOrcadProfilePreflight } from '../../src/shared/orcad-profile-preflight.ts' +import { ORCAD_SERVER_ENTRY_FILENAME } from '../../src/shared/orcad-artifacts.ts' import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' async function initializeFixture(directory, databasePath, profileId) { @@ -88,7 +89,7 @@ export async function smokeProfileStateWorkers(outDir, { timeoutMs = 30_000, run const nonce = randomUUID() const result = runProcessSync({ program: runtimePath, - args: [join(outDir, 'orcad.js'), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce], + args: [join(outDir, ORCAD_SERVER_ENTRY_FILENAME), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce], env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, timeoutMs, maxOutputBytes: 64 * 1024 diff --git a/config/scripts/run-node-server-tests.mjs b/config/scripts/run-node-server-tests.mjs index 2183b9665503..948635f1c187 100644 --- a/config/scripts/run-node-server-tests.mjs +++ b/config/scripts/run-node-server-tests.mjs @@ -2,7 +2,10 @@ import { join, resolve } from 'node:path' import { randomUUID } from 'node:crypto' import { globSync, readFileSync } from 'node:fs' -import { ORCAD_VERSION_FILENAME } from '../../src/shared/orcad-artifacts.ts' +import { + ORCAD_SERVER_ENTRY_FILENAME, + ORCAD_VERSION_FILENAME +} from '../../src/shared/orcad-artifacts.ts' import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' import { ORCAD_PROFILE_PREFLIGHT_FLAG, @@ -60,7 +63,7 @@ if (artifact) { const nonce = randomUUID() const result = runProcessSync({ program: runtimePath, - args: [join(packageDir, 'orcad.js'), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce], + args: [join(packageDir, ORCAD_SERVER_ENTRY_FILENAME), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce], cwd: root, env, timeoutMs: 90_000 diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs index 72fbbb9d44f2..502ddf8acad0 100644 --- a/config/scripts/run-ssh-docker-e2e.mjs +++ b/config/scripts/run-ssh-docker-e2e.mjs @@ -71,12 +71,15 @@ const result = spawnSync( 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts', 'tests/e2e/ssh-external-image-preview.spec.ts', 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts', + 'tests/e2e/ssh-native-chat-phone-history.spec.ts', 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts', 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', + 'tests/e2e/ssh-reattach-home-partition.spec.ts', 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', 'tests/e2e/ssh-restart-tab-accumulation.spec.ts', 'tests/e2e/ssh-skill-installation.spec.ts', 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts', + 'tests/e2e/ssh-startup-local-shadow.spec.ts', 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts', 'tests/e2e/terminal-inline-images-ssh.spec.ts', '--config', diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs index 2b23115859ca..0c43f7d60c1e 100644 --- a/config/scripts/runtime-serve-terminal-smoke.mjs +++ b/config/scripts/runtime-serve-terminal-smoke.mjs @@ -28,10 +28,14 @@ import { dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { randomBytes } from 'node:crypto' import process from 'node:process' +import { ORCAD_SERVER_ENTRY_FILENAME } from '../../src/shared/orcad-artifacts.ts' +import { packagedNodeRuntimePath } from './build-orcad-node.mjs' +import { currentTarget } from './server-build-target.mjs' const projectDir = resolve(import.meta.dirname, '../..') const serveEntry = join(projectDir, 'out', 'main', 'index.js') -const ORCAD_ENTRY = join(projectDir, 'out', 'orcad', 'orcad.js') +const ORCAD_DIR = join(projectDir, 'out', 'orcad') +const ORCAD_ENTRY = join(ORCAD_DIR, ORCAD_SERVER_ENTRY_FILENAME) const READY_TIMEOUT_MS = 120_000 const OUTPUT_TIMEOUT_MS = 30_000 const SHUTDOWN_TIMEOUT_MS = 15_000 @@ -175,7 +179,7 @@ function resolveLaunch(userDataDir) { if (target === 'orcad') { return { label: `orcad (${ORCAD_ENTRY})`, - command: process.execPath, + command: packagedNodeRuntimePath(ORCAD_DIR, currentTarget()), args: [ORCAD_ENTRY, '--port', String(PORT), '--json'], env: { ORCA_USER_DATA: userDataDir } } diff --git a/config/scripts/terminal-partial-escape-native-continuation.test.mjs b/config/scripts/terminal-partial-escape-native-continuation.test.mjs new file mode 100644 index 000000000000..d42b0115c578 --- /dev/null +++ b/config/scripts/terminal-partial-escape-native-continuation.test.mjs @@ -0,0 +1,113 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' +import { advancePartialEscapeTail } from '../../src/shared/terminal-partial-escape-tail' + +const require = createRequire(import.meta.url) +const { Terminal } = require(process.env.ORCA_PARTIAL_ESCAPE_HEADLESS ?? '@xterm/headless') +const { SerializeAddon } = require('@xterm/addon-serialize') + +function model() { + const terminal = new Terminal({ cols: 40, rows: 6, allowProposedApi: true, logLevel: 'off' }) + const serializer = new SerializeAddon() + terminal.loadAddon(serializer) + const effects = [] + for (const id of [2, 7, 8, 52, 1337]) { + terminal.parser.registerOscHandler(id, (data) => { + effects.push(['osc', id, data]) + return true + }) + } + terminal.parser.registerDcsHandler({ final: 'q' }, (data) => { + effects.push(['dcs', data]) + return true + }) + terminal.onData((data) => effects.push(['reply', data])) + return { terminal, serializer, effects } +} + +const write = (h, data) => h.terminal._core.writeSync(data) +const state = (h) => h.terminal._core._inputHandler._parser.currentState +const screen = (h) => { + const buffer = h.terminal.buffer.active + return { + cursor: [buffer.cursorX, buffer.cursorY], + rows: Array.from({ length: buffer.length }, (_, row) => { + const line = buffer.getLine(row) + return Array.from({ length: line.length }, (_, column) => { + const cell = line.getCell(column) + return [cell.getChars(), cell.getWidth(), cell.getFgColor(), cell.getBgColor()] + }) + }) + } +} + +function compareContinuation(source, restored, prefix, continuation) { + source.terminal.reset() + restored.terminal.reset() + source.effects.length = 0 + restored.effects.length = 0 + write(source, `BEFORE${prefix}\x1b`) + const serialized = source.serializer.serialize() + const pending = advancePartialEscapeTail('', `BEFORE${prefix}\x1b`) + source.effects.length = 0 + write(restored, serialized + pending) + expect(restored.effects, JSON.stringify({ prefix, continuation })).toEqual([]) + expect(state(restored)).toBe(state(source)) + write(source, continuation) + write(restored, continuation) + expect(screen(restored), JSON.stringify({ prefix, continuation })).toEqual(screen(source)) + expect(state(restored)).toBe(state(source)) + expect(restored.effects).toEqual(source.effects) +} + +const prefixes = [ + '\x1b]2;title', + '\x1b]7;file:///original', + '\x1b]8;;https://example.com', + '\x1b]52;c;aGVsbG8=', + '\x1b]1337;File=inline=1:AAAA', + '\x1bPqpayload', + '\x1b_Gpayload', + '\x1bXpayload', + '\x1b^payload' +] + +describe('completed string continuation against the native terminal parser', () => { + it.each(prefixes)('matches uninterrupted output for every following byte after %j', (prefix) => { + const source = model() + const restored = model() + try { + for (let byte = 0; byte < 256; byte++) { + compareContinuation(source, restored, prefix, `${String.fromCharCode(byte)}AFTER`) + } + } finally { + source.terminal.dispose() + restored.terminal.dispose() + } + }) + + it.each([0, 32, 4095, 4096, 4097, 8192])( + 'does not repeat completed side effects for a %i-character payload', + (length) => { + const source = model() + const restored = model() + try { + for (const introducer of ['\x1b]2;', '\x1b]7;', '\x1b]52;', '\x1bPq']) { + for (const continuation of [ + '\\AFTER', + '[32mAFTER', + '\x18AFTER', + '\x1aAFTER', + '\x1b]7;file:///next\x07AFTER', + '\x1bPqnext\x1b\\AFTER' + ]) { + compareContinuation(source, restored, introducer + 'x'.repeat(length), continuation) + } + } + } finally { + source.terminal.dispose() + restored.terminal.dispose() + } + } + ) +}) diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 69e473c91d22..09029f3c6d21 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -6,6 +6,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'config/scripts/cloud-security-history-workflow.test.mjs', 'config/scripts/skill-recipe-shell.test.mjs', 'src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts', + 'src/main/ssh/ssh-remote-commands.test.ts', 'src/shared/child-process/run-process.test.ts', 'tests/e2e/cursor-quota-transport.unit.test.ts', 'src/main/ai-vault-search/session-search-index-writer.test.ts', @@ -83,6 +84,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-startup-tab-restore.test.ts', 'src/shared/remote-runtime-shared-control-connection.test.ts', 'src/main/runtime/relay/relay-control-client.test.ts', + 'src/main/ipc/filesystem-watcher-ignore-real.test.ts', 'src/main/ipc/runtime-environment-preference.test.ts', 'src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts', 'src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx', diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 0edefd1c8e2e..b48eed345b42 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -13,6 +13,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/automations/refused-manual-run.test.ts', 'src/main/automations/retained-run-reconciliation.test.ts', 'src/main/automations/run-completion-watcher.test.ts', + 'src/main/automations/service-late-agent-result.test.ts', 'src/main/automations/service-precheck.test.ts', 'src/main/automations/service.test.ts', 'src/main/claude/claude-api-retry-idle-sweep.test.ts', @@ -32,9 +33,12 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts', 'src/main/codex/codex-structured-question-order.test.ts', 'src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts', + 'src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts', 'src/main/ipc/ssh-host-partition-session-export.test.ts', 'src/main/ipc/ssh-host-server-on-connect-wiring.test.ts', 'src/main/ipc/ssh-managed-server-move-conversion.test.ts', + 'src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts', + 'src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts', 'src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts', 'src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts', 'src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts', @@ -42,10 +46,12 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts', @@ -118,6 +124,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts', @@ -144,7 +151,9 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts', 'src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts', + 'src/main/opencode/opencode-acp-stored-messages.test.ts', 'src/main/orcad/orcad-automations.test.ts', + 'src/main/pi/rpc-launch-resolution.test.ts', 'src/main/runtime/agent-session-conversation-clear-commit.test.ts', 'src/main/runtime/agent-session-conversation-name-store.test.ts', 'src/main/runtime/agent-session-death-evidence-persistence.test.ts', @@ -155,6 +164,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/agent-session-record-store-tab-table.test.ts', 'src/main/runtime/agent-session-record-store.test.ts', 'src/main/runtime/agent-session-released-reservation-replay.test.ts', + 'src/main/runtime/agent-session-runtime-attribution.test.ts', 'src/main/runtime/agent-session-store-transactions.test.ts', 'src/main/runtime/agent-session-unsaved-creation-supersession.test.ts', 'src/main/runtime/automation-change-publication.test.ts', @@ -187,6 +197,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-rollback-compatibility.test.ts', 'src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts', 'src/main/runtime/structured-agent-session-startup-reconcile.test.ts', + 'src/main/runtime/structured-claude-attachment-grant.test.ts', 'src/main/runtime/structured-claude-pending-rewind.test.ts', 'src/main/ssh-expired-lease-pane-readoption.test.ts', 'src/main/ssh-reattach-pane-cardinality.test.ts', diff --git a/config/update-translation.txt b/config/update-translation.txt index 11097556bf0a..3f574c6de8d7 100644 --- a/config/update-translation.txt +++ b/config/update-translation.txt @@ -121,3 +121,39 @@ Method (reproducible): - Gates: catalog verify, coverage --check, extraction, runtime-catalog pass; 296 locale tests pass (policy, zh-round5, generic-terms, etc.). +## Scan 2026-10-07 + +- HEAD scanned: de4ee59cba (origin/main; branch rebased onto it). Base: d3943c6d81 + (the 2026-10-05 scan's HEAD). Window: d3943c6d81 .. HEAD (244 commits). Note: the last + 5 commits that landed during the scan touch no locale catalog and add no en key. +- Gates before fix: catalog verify PASS, coverage --check PASS (13 allowlisted), extraction + PASS, runtime-catalog PASS. No unkeyed strings and en.json in sync; the gap was + target-locale coverage only. +- Delta since base: 331 new en keys (native-chat incl. rewind/composer/notices/visuals, + managed servers + SSH move, settings appearance chat, project board/views, markdown + callouts, media preview), 33 removed, 15 changed en values. All five locales were + missing the SAME 187 keys; es one extra (TerminalErrorToast.heldByPreviousRelay) — + 177 vs 176. 11 of the 187 are intentionally untranslated provider credit-balance keys + pinned by provider-credit-balance-locales.test.ts (#25408). Translated the remaining + 176 new keys x5 (es 177). +- Changed-value review: the 15 new English values were already reflected in the locales, + except the two `Settings.*` unsaved-AI-settings keys, which still read "AI changes" / + "移動" from the retired "Git AI Author changes" wording — fixed in es/fr/ja (and the + ko/zh "them" pronoun re-pointed at the settings). +- Applied 176 new keys x5 (+1 es) via apply-translation-delta.mjs (the pipeline repaired + ja 新しい->新規, エージェント->Agent, retry/待っている; ko/es/zh needed none). Strictly + additive: +881 keys, 0 removed. +- Gates after fix: catalog verify PASS (all five 15566/15577; the 11 omissions are the + pinned balance keys), coverage --check PASS (13 allowlisted), extraction PASS, + runtime-catalog PASS, locale vitest suites PASS (43 files, 386 tests). +- Reviews (3 of max 4 rounds; round 0 = 5 parallel translators, then 5 reviewers/round): + R1 applied 14 (es {{sources}} agreement via a parse-safe rephrase + informal-tú register + + idiomatic port-forward copy; fr rewind pronoun/coordination; ja MT artifacts the + pipeline's 新しい/待っている rewrites introduced, "Host locked" phase label, hostNewer + comparison sense, deltaMove missing object; ko old-chat wording + comma-splice; zh + sourceChanged until-clause + older-chat wording). + R2 applied 13 (all-locale unsaved-AI-settings staleness; es structuredCopy usted->tú; + fr {{sources}} agreement, pane->volet, comma-splice; ja outcome.deferred referent). + R3 sign-off clean on all five locales (0 errors, 0 warns; placeholders byte-identical, + 0 untranslated leftovers, agent/proxy terminology verified). + diff --git a/config/vitest.performance.config.ts b/config/vitest.performance.config.ts index 9d739cbd52bd..0a219a6a2573 100644 --- a/config/vitest.performance.config.ts +++ b/config/vitest.performance.config.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs' import { resolve } from 'node:path' import { defineConfig } from 'vitest/config' import baseConfig from './vitest.config' +import { nodeRuntimePool } from './scripts/vitest-node-runtime-pool' const contracts = [ 'src/main/sqlite/sync-database.test.ts', @@ -27,6 +28,9 @@ export default defineConfig({ ...baseConfig, test: { ...baseConfig.test, + // Project-level includes otherwise override this contract-only selection. + projects: undefined, + ...(process.versions.bun ? { pool: 'node-runtime', poolRunner: nodeRuntimePool } : {}), include: contracts, fileParallelism: false, retry: 0 diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index f895d9dc38a5..73c3c5db4caa 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 96m + + downloads: 98m @@ -15,7 +15,7 @@ downloads downloads - 96m - 96m + 98m + 98m diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index a3edc79aa0c2..51b66b41cc85 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -222,7 +222,6 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i - **[Télécharger depuis onOrca.dev](https://onorca.dev/download)** - Ou récupérez un build directement : [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/download/v1.4.147-rc.3/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Tous les builds](https://github.com/stablyai/orca/releases/latest) - **Sous Windows :** utilisez la [dernière RC (`v1.4.147-rc.3`)](https://github.com/stablyai/orca/releases#release-v1.4.147-rc.3) — elle inclut des correctifs Windows absents de la stable. -- Vous lancez `orca serve` sur un serveur Linux headless ? Consultez le [guide serveur Linux headless](../reference/headless-linux-server.md). _Ou via un gestionnaire de paquets :_ diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index e3bda3709e0a..345c5a70f604 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -217,7 +217,6 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 - **[onOrca.dev에서 다운로드](https://onorca.dev/download)** - 또는 빌드를 직접 받기: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [전체 빌드](https://github.com/stablyai/orca/releases/latest) -- headless Linux 서버에서 `orca serve`를 실행하시나요? [Headless Linux 서버 가이드](../reference/headless-linux-server.md)를 확인하세요. _또는 패키지 매니저로 설치:_ diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 5c53089cb0a5..387faf13f1bc 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -217,7 +217,6 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca. - **[Baixe em onOrca.dev](https://onorca.dev/download)** - Ou baixe um build diretamente: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Todos os builds](https://github.com/stablyai/orca/releases/latest) -- Rodando `orca serve` em um servidor Linux headless? Veja o [guia de servidor Linux headless](../reference/headless-linux-server.md). _Ou por um gerenciador de pacotes:_ diff --git a/docs/reference/admin-agent-skill-sharing.md b/docs/reference/admin-agent-skill-sharing.md deleted file mode 100644 index 1ea9a65d8ef5..000000000000 --- a/docs/reference/admin-agent-skill-sharing.md +++ /dev/null @@ -1,97 +0,0 @@ -# Administer agent skill sharing - -This guide describes the first-release access, lifecycle, retention, and recovery contract for -Orca skill sharing. The operator runbook remains the source of truth for incident commands and -environment-specific procedures. - -## Access model - -- Shared bundles are unlisted bearer resources. Orca provides no public browse, search, recipient - inventory, or package index. -- Anyone with an active, unexpired link can inspect the package and request a short-lived download - grant without signing in. -- Publishing, package/version management, owned-link inventory, revocation, and deletion require - an authenticated package owner with current organization access. -- Missing, expired, revoked, deleted, and unauthorized resources return the same non-disclosing - response. -- Desktop and remote runtimes receive no GCP identity or long-lived storage credential. - -The durable share ID is a credential. Do not put it in tickets, logs, analytics, or support -bundles. Use revocation if a link may have reached an unintended recipient. - -## Revocation and deletion - -Revoking a share immediately blocks new resolution and download grants. A generation-bound grant -issued before revocation can work until its five-minute expiry. Already installed skills remain on -recipient machines. - -Package deletion follows this order: - -1. Mark the package deleted and revoke its active shares. -2. Dereference retained versions transactionally. -3. Delete only an object generation that no retained version references. -4. Reconcile bounded pending deletions after partial database or GCS failures. - -A version cannot be deleted while an active pinned share references it. Deletion uses the exact -recorded GCS generation and never overwrites an immutable published key. - -## User and organization departure - -Packages belong to an owner tenant and record the publishing user. In an organization tenant, -another current member can manage the package after its publisher leaves; Orca does not rewrite -the recorded creator. Removing a user does not automatically revoke the organization's links, -delete packages, or remove installed copies. - -Before deleting an organization tenant: - -1. Disable new grants for the tenant. -2. Have an authorized operator inventory and revoke active shares. -3. Decide whether packages transfer to another authorized owner, remain retained, or are deleted. -4. Resolve legal hold, erasure, and audit-retention requirements. -5. Apply the coordinated metadata and object lifecycle; do not bypass reference checks. - -The product does not yet encode a universal ownership-transfer or legal-retention policy. Privacy, -security, and the organization owner must approve the applicable policy before external rollout. -Until that decision is recorded, preserve metadata and soft-deleted generations rather than -guessing. - -## Retention contract - -| Data | Default behavior | -| -------------------------------------- | --------------------------------------------------------- | -| Upload policy and pending upload row | Expires after 15 minutes | -| Abandoned `uploads/` quarantine object | Deleted by GCS after one day | -| Published immutable package object | No age-based deletion; retained while referenced | -| Issued download grant | Expires after five minutes | -| Deleted package object | Recoverable through GCS soft delete for seven days | -| PostgreSQL metadata | Covered by backups and seven-day point-in-time recovery | -| Installed recipient copy | Independent local data; Cloud deletion does not remove it | -| Audit event | Follows the approved audit-retention policy | - -Organization retention, legal hold, and erasure requirements take precedence over product rollback -retention. Product deletion is not a legal-hold mechanism. - -## Audit and privacy - -Audit records may include package/version IDs, actor IDs, event category, outcome, and timestamp. -They must not include skill contents, filenames, manifests, organization membership lists, local -paths, durable share URLs, upload policies, download grants, or credentials. Anonymous abuse -controls must not persist raw requester IP addresses. - -Normal Cloud logs are limited to route, method, status, duration, and bounded failure categories. -Use seeded privacy canaries when validating staging logs and diagnostic exports. - -## Recovery and incident controls - -Upload, download, and remote-install operations have independent kill switches. Disable the -narrowest affected operation; existing local discovery and installs continue to work. - -Coordinate PostgreSQL point-in-time recovery with GCS generation recovery. Restore metadata into -an isolated database, identify exact referenced generations, restore only matching soft-deleted -objects, verify archive and package identities, then transactionally repoint metadata. Keep grants -disabled until bearer preview and a generation-bound download pass. - -See the Orca Cloud `docs/skill-sharing-runbook.md` for deployment controls, reconciliation, -saturation, signing failures, database outages, and the guarded restore workflow. Security -invariants and unresolved release gates are recorded in -[Agent skill sharing threat model](./agent-skill-sharing-threat-model.md). diff --git a/docs/reference/agent-pty-transcript-capture.md b/docs/reference/agent-pty-transcript-capture.md deleted file mode 100644 index 4b0f41c46d30..000000000000 --- a/docs/reference/agent-pty-transcript-capture.md +++ /dev/null @@ -1,131 +0,0 @@ -# Capturing an agent PTY transcript - -Orca's readiness and blocked-prompt rules are text rules over what an agent CLI paints on a -terminal. They are only as good as the screens they were written against. This is how to record -one, byte for byte, so a rule can be pinned to evidence instead of to a remembered screen. - -Related: [`antigravity-readiness-evidence.md`](./antigravity-readiness-evidence.md) names the -specific Antigravity transcripts that are still missing and what each one decides. - -## The recorder - -``` -node config/scripts/capture-agent-pty-transcript.mjs --name [options] -- [args...] -``` - -It allocates a real PTY, spawns the agent inside it, mirrors the session to your terminal so you -can drive it by hand, and appends every byte it receives to -`src/main/runtime/__fixtures__/.txt`. It does not strip escapes, fold `\r`, rewrap -lines, or normalise anything — the file is what the terminal received. - -- **Ending a capture:** press Ctrl+]. The recorder consumes that key and - never forwards it, which is the only way to end a capture _while a dialog still owns the - screen_. Quitting the agent instead would first dismiss the dialog you came to record. -- `--cols N --rows M` pin the PTY size (default: your terminal's). Wrapping is part of the - evidence, so record the size — the sidecar does it for you. -- `--duration S` stops unattended after S seconds, for a screen that needs no interaction. -- `--send ":"` types into the PTY at a fixed offset, repeatable, with `\r` `\n` `\t` `\e` - escapes. A dialog capture has to be driven, and an unattended run (CI, or an agent) has no TTY to - type into; the keystrokes ride the same PTY a human's would. For example, the committed - `antigravity-dialog-model-picker.txt` was recorded with - `--duration 24 --send "14000:/model" --send "16000:\r"`, which leaves the picker owning the - screen when the capture stops. -- `--note ""` records the account type, plan, model and CLI version in the sidecar. -- `--out ` writes outside the fixture directory (use it for a first dry run). - -Each capture also writes `.meta.json` with the timestamp, platform, command, -PTY size, note and exit code. Commit it with the transcript; the version and account type behind -a screen are not recoverable from the bytes. - -**Prerequisite:** `node-pty` must be built for plain Node: - -``` -node config/scripts/ensure-native-runtime.mjs --runtime=node -``` - -Orca itself does not need to be running, and the recorder never touches Orca state. - -### Platform notes - -- **macOS / Linux:** nothing special. `TERM=xterm-256color` is set for the child. -- **Windows:** run it from Windows Terminal / PowerShell, not a Git Bash (MSYS) pane — MSYS - rewrites arguments that start with `/`, which mangles the `cmd.exe /c` hand-off. A `.cmd` or - `.bat` agent shim cannot be spawned by node-pty directly, so the recorder routes those through - `cmd.exe` for you. -- **WSL:** capture _inside_ the distro (run the recorder from the distro's checkout). Recording - `wsl.exe` from the Windows side adds the login-shell banner to the transcript. -- **SSH:** record on the execution host. A transcript recorded locally is not evidence about what - a remote agent prints. - -## Privacy: scrub before committing - -A live agent screen routinely contains things that must not enter git history: - -| Scrub | Why | -| ---------------------------------------------------------------------- | ---------------------------------------------------- | -| Account email / sign-in identifier | The account row on a ready screen prints it verbatim | -| Org, tenant or team name | Identifies a customer | -| Machine hostname and OS username | Appear in prompts, paths and the OSC title | -| Absolute home paths (`/Users/`, `C:\Users\`) | Contain the username | -| JWTs, `AIza…` keys, `1//…` refresh tokens, `Bearer …`, `sk-…`, `ghp_…` | Live credentials; a sign-in screen can echo one | -| Private repo, branch and ticket names | Leak roadmap detail | -| Anything you pasted into the agent during the capture | You typed it; it is in the transcript | - -The recorder scans the file as soon as the capture ends and prints every hit with a line and -column. To scrub: - -``` -node config/scripts/capture-agent-pty-transcript.mjs --scan src/main/runtime/__fixtures__/.txt --redact -``` - -Redaction replaces each finding with a **same-length** placeholder (`u…u@example.com`, `XXXX…`). -Length matters: a transcript's value is its exact wrapping and column alignment, and a shorter -replacement reflows the screen and destroys the evidence. - -### Verify it is gone - -1. `node config/scripts/capture-agent-pty-transcript.mjs --scan src/main/runtime/__fixtures__/.txt` - must print `clean` and exit `0`. It recognises its own placeholders, so a scrubbed file passes. -2. Grep for the specifics the scanner cannot know: - `rg -n -i -- "$(whoami)|||" src/main/runtime/__fixtures__/.txt` -3. Read it once with escapes visible: `LC_ALL=C cat -v src/main/runtime/__fixtures__/.txt`. - The scanner matches shapes; only a human catches a project name. -4. Check the sidecar too — `--note` text is free-form and is committed. - -`config/scripts/pty-transcript-secret-scan.test.mjs` re-scans every committed -`__fixtures__/*.txt`, so a transcript that skips step 1 fails the suite. - -## Consuming a transcript in a test - -Feed the raw bytes through the runtime rather than into a matcher directly: escape handling, -tail retention and title tracking all live in `onPtyData`, and a rule tested on pre-normalised -text is tested on something no pane ever sees. - -`src/main/runtime/agent-transcript-pane-test-harness.ts` builds the pane; -`src/main/runtime/terminal-interactive-wait-visibility.test.ts` (cursor-agent) and -`src/main/runtime/antigravity-readiness-transcripts.test.ts` (Antigravity) are examples. An agent -whose readiness is read off the live screen gets its suite from -`src/main/runtime/screen-ruled-agent-transcript-suite.ts`. - -## Worked example: the Antigravity captures - -The six committed `antigravity-*.txt` fixtures were recorded this way on macOS against -`agy` 1.1.25. Two points generalise: - -- **Reach a state without mutating the operator's config.** The ready-screen captures ran in a - directory the CLI already trusted, so no trust answer was written. Where a dialog could only be - reached by signing the operator out or deleting their settings, it was left uncaptured and - recorded as such rather than forced. -- **An environment variable is a legitimate capture knob** where a setting is not. - `AGY_CLI_HIDE_ACCOUNT_INFO=1` produced a second ready screen with no account row, which is - evidence no amount of reasoning about the first screen could have supplied. It changes nothing - on disk. - -## Known gap in the existing captures - -The three `cursor-agent-*.txt` fixtures contain **no escape bytes and no carriage returns**. -Whatever produced them went through a renderer and a clipboard, so they preserve wording and -box-drawing glyphs but not the caret, the cursor moves, the repaints, or whether the CLI uses the -alternate screen buffer. They are good enough for the wording-based rules built on them and are -not evidence for anything else. New captures made with this recorder keep those bytes; the -Antigravity scaffold asserts their presence so a pasted screen cannot pass as a capture. diff --git a/docs/reference/agent-session-search-contract.md b/docs/reference/agent-session-search-contract.md deleted file mode 100644 index daaef0fb618c..000000000000 --- a/docs/reference/agent-session-search-contract.md +++ /dev/null @@ -1,172 +0,0 @@ -# Agent session search contract - -`AiVaultSearchRequest`, `AiVaultSearchResponse`, `AiVaultSearchHit`, and -`AiVaultSearchStatus` are defined in `src/shared/ai-vault-search-types.ts` and -validated by `src/shared/ai-vault-search-contract.ts`. - -## Search and pagination - -- Tool output beyond 3,072 characters per row is not indexed and not searchable; user and assistant text is indexed in full. -- A page cursor outstanding during a retention purge is refused once as `stale-cursor`; the client re-issues page 1. -- A phrase match across a chunk boundary of a long message is not supported. - -`aiVault.searchSessions(request)` accepts `query`, optional `scope` -(`conversation` or `all`, default `all`), `freshness` (`indexed` or -`wait-until-current`, default `indexed`), `limit`, opaque `cursor`, `filters`, -and `debug` (default false). Conversation scope searches user and assistant text. -Filters accept `agents`, `scopePaths`, ISO `since`, and `sort` (`relevance` or -`newest`). Paths refer to the execution host and work for folders without Git. -Legacy `tier` and `refresh` fields are accepted and discarded; they do not change -the defaults. Limits use the engine's resolver: default 20, integers clamped to -1–100, fractional numbers use the default. Long queries reach the engine so it -can report truncation rather than fail validation. - -Results contain `kind: 'results'`, `hits`, `page: { cursor, hasMore }`, -`generation`, `truncated: { candidates, snippets, query, freshness }`, and -`durationMs`. `snippets` is a count; the other truncation fields are booleans. -`durationMs` measures the engine search, excluding any reconciliation wait. -`debug: true` adds `debug: { route, repairedTerms?, plannerReport }`; the report -contains `route`, optional `repairedTerms`, and `scope`. Diagnostics never appear -at the top level. Status is never attached to search results. - -A cursor belongs to one query, one host's index generation, and an opaque persisted -index incarnation. Query, scope, filters, and sorting must remain the same; page -size may change. Writes that advance the generation can invalidate it, including -retention purges. Clearing or rebuilding the database invalidates it even when the -new generation counter matches. A refused cursor yields -`{ kind: 'stale-cursor', generation, expectedGeneration? }` and the -client discards it and issues page 1 without a cursor. Reusing that refused cursor -continues to fail; there is no server-side cursor acknowledgement state. -Malformed cursors and cursors for a different query yield -`{ kind: 'malformed-cursor' }`. Generation checks also reject a first page if the -index changes during retrieval. Generation is a fence, not a retained snapshot: -a client cannot ask the host to recreate a previous generation. - -Pages are per host only. Ordering is local to that host's query. Clients must -discard cursors when changing hosts. All-computers search, merged ordering, -per-host aggregate outcomes, and merged cursors are deferred to a separate PR. -That follow-up must define generation fencing, page-size changes, unavailable -hosts, and bounded parallel retrieval before exposing a combined result list. - -## Execution host routing - -Search and status address one execution host: `local`, `ssh:`, or -`runtime:`. An omitted host means this desktop's local index. -Invalid IDs and `all` are refused; neither can widen a request to other hosts. - -- `local` searches this machine's index over desktop IPC. -- `ssh:` asks that relay session and nothing else. -- `runtime:` asks that paired runtime over its RPC. A paired - runtime answers for itself and never forwards through another desktop. - -Each hit may carry `executionHostId`. The desktop stamps remote answers with -the host it addressed rather than trusting an ID returned by that host. -Local answers and older hosts may omit attribution. - -## Evidence and exposure - -Each hit carries agent, session ID, title, cwd, branch, updated time, message -count, score, source, and evidence. Evidence contains snippet, role, and timestamp; -it is null for operator-only matches that have no text evidence. Snippet matches -use `[[` and `]]` markers. Source presence is `present`, `unverifiable`, or -`missing`; the current engine emits the first two. Loss of contact does not prove -a source missing. - -`redactForTransport(hit, transport)` is the exposure policy: - -| Transport | filePath / codexHome | resumeCommand | Status `degradedRoots[].root` | -| ----------------------------------------- | ------------------------------------ | --------------------------------- | ----------------------------- | -| Desktop IPC on the same machine | Included when known, under source | Included only for present sources | Included | -| Runtime RPC on the same machine | Included when known, under source | Included only for present sources | Included | -| Relay or paired runtime/web/mobile client | Withheld; source keeps presence only | Withheld | Withheld | - -`cwd`, titles, snippets, and other hit metadata remain visible to paired clients. -Snippets cross the authenticated transport as indexed; this contract does not -apply an observability redactor to transcript content. A missing Codex home is -omitted. Resume commands reuse the command stored by the transcript reader, -constructed by the sidebar's `buildAiVaultResumeCommand`; this layer does not -construct commands or execute them. The runtime uses its authenticated -`clientKind` context to distinguish paired clients from same-machine RPC, never -a request-supplied locality flag. The receiving remote client also applies the -same exposure function. - -## Status, freshness, and availability - -`aiVault.searchStatus()` returns `enabled`, `phase` (`idle`, `indexing`, `current`, -`degraded`, or `closed`), `filesIndexed`, `filesDue`, `filesFailed`, `degradedRoots` -(`root` and `reason`), `lastReconcileAt`, `lastSweepCompletedAt`, and `generation`. -Times are milliseconds since epoch or null. These are the indexer's observations; -an indexed row is not a new filesystem verification. A degraded root's `root` and -raw `reason` can both contain host filesystem paths. Desktop IPC and same-machine runtime RPC receive the full diagnostic; -relay and paired clients receive only the fixed reason "Source root could not -be verified." for each degraded root. The array length retains the count. - -`wait-until-current` calls `service.reconcile()` before searching. The adapter -uses `indexer.reconcile({ full: false })`. After five seconds the endpoint searches -anyway and sets `truncated.freshness: true` on results. It does not cancel the -host's reconciliation. Completion before the deadline leaves the flag false; -a reconciliation error before the deadline propagates. The indexer's bounded -recent pass is not a promise that the entire historical corpus was swept. - -Search unavailability is a value: -`{ kind: 'unavailable', reason: 'disabled' | 'not-ready' | 'no-service' }`. -No registered service returns `no-service`. Status without a service has -`enabled: false`, `phase: 'idle'`, zero counts and generation, empty degraded roots, -and null timestamps. This is a sentinel for an absent service, not a claim of an -empty, current index. A registered service may report disabled or not-ready. - -## Boundaries and compatibility - -- Desktop: `aiVault:searchSessions` and `aiVault:searchStatus`, via preload. -- Runtime and relay: `aiVault.searchSessions` and `aiVault.searchStatus`. -- CLI: `orca search` calls both over the runtime RPC, against the host that - `--environment` / `--pairing-code` selects and no other. It reuses - `createSessionSearchClient`, so an old host's refusal reaches the caller as - `unavailable/no-service` rather than an error, and needs no new capability. - In an Orca SSH terminal, the forwarded CLI defaults to the controlling Orca - runtime's index. `--path` filters that index; it does not select the SSH host. - `--environment` / `--pairing-code` can explicitly select a paired runtime. -- Desktop preload optionally accepts an execution host scope as a separate - routing argument. It addresses exactly that host; missing connections never - fall back to the local index. The web preload addresses its own paired runtime, - answers `all` and any other host with - `unavailable/no-service` rather than an error. - -Requests and responses are parsed where received from another process. Existing -relay JSON-RPC request/response framing needs no new stream opcode. Following the -existing relay method probe pattern, an old host's explicit `-32601` refusal (or -runtime `method_not_found`) maps to `unavailable/no-service` on the client; status -uses the absent-service sentinel above. Transport failures, authentication errors, -and invalid payloads remain errors. Unknown request fields are stripped for wire -compatibility. - -The process-local `setSessionSearchService(service | null)` registry connects -these endpoints to the production service installed by PR 3b. Desktop indexing -runs in the scanner child; orcad and the SSH relay register their own in-process -services. Registration alone does not grant consent. - -## Desktop index controls (PR8) - -Settings → Agent Session History controls this desktop's persisted -`aiVaultSearch { enabled, historyDays }` policy. It stays local even when another -execution host is selected. Paired clients cannot grant consent or clear an index -through this surface; SSH relay registration remains disabled without a separate -host consent mechanism. - -`aiVault.clearSearchIndex()` is a no-argument desktop-only preload operation over -`aiVault:clearSearchIndex`. It addresses the local scanner child, not the selected -remote host. The child's existing interactive request lane executes `searchClear` -through `SessionSearchInstance.clear()`: close the indexer and database handles, -remove the SQLite database and sidecars, then reconstruct only if consent remains -enabled. Errors propagate to the settings pane. This operation never deletes -original transcripts. There is no new runtime or relay method. Opaque cursors also -carry a persistent database identity: clearing creates a new identity, so a -pre-clear or legacy cursor returns `stale-cursor` even if the rebuilt numeric -generation happens to match. Reopening the same database preserves its identity. - -Disabling closes the indexer and keeps the index copy; clearing deletes the copy. -Changing retention reuses the existing close-and-construct policy application. -The settings pane reads status only while enabled and visible, polls only an -observed indexing phase, and stops on completion or error. Opening the pane, -changing policy, or pressing Refresh obtains a new observation. The indexer's own -schedule does not depend on the pane. diff --git a/docs/reference/agent-session-search-query-tuning.md b/docs/reference/agent-session-search-query-tuning.md deleted file mode 100644 index e7895e0382d6..000000000000 --- a/docs/reference/agent-session-search-query-tuning.md +++ /dev/null @@ -1,229 +0,0 @@ -# Agent session search: query tuning - -What a search costs, and what the knobs in `src/main/ai-vault-search/session-search-engine.ts` -buy. Every number here comes from `config/scripts/session-search-query-benchmark.ts` -over the synthetic corpus in `session-search-synthetic-corpus.ts`, except the -`conversation_fts` shoot-out, which writes its own corpus because the answer -turns on how much of a transcript is tool output. Nothing in this file was -measured against a real transcript, and neither benchmark must ever be pointed -at one. - -## Running it - -The benchmark is a top-level-await module that imports the main-process tree by -extensionless path, so it needs a bundler-backed runner rather than bare `node`: - -```sh -cat > src/main/ai-vault-search/zz-bench.test.ts <<'EOF' -import { it } from 'vitest' -it('runs', { timeout: 1_800_000 }, async () => { - await import('../../../config/scripts/session-search-query-benchmark') -}) -EOF -BENCH_OUT=/tmp/ss-query-bench.json pnpm test src/main/ai-vault-search/zz-bench.test.ts -rm src/main/ai-vault-search/zz-bench.test.ts -``` - -The `conversation_fts` shoot-out below runs the same way, importing -`config/scripts/session-search-conversation-fts-benchmark` instead, with -`CORPUS_MB` and `TOOL_SHARE` to size and shape its corpus. `config/scripts` is -not inside any typecheck project, so while that throwaway test exists `tsc` -reports TS6307 for each script it pulls in; delete it and the run is clean -again. - -`BENCH_OUT` exists because vitest intercepts `console.log`; the report is written -to that path as well as printed. - -## Scope: what the second FTS table buys a reader - -Corpus: 40 synthetic Claude transcripts, 10.5 MB, 9,600 messages, indexed through -the real store. Eight queries, one per rung of the route ladder plus the two -shapes that skip it; 5 warm-up runs and 25 samples each. Apple silicon, warm page -cache, machine otherwise idle. Milliseconds, and p95 over 25 samples moves -several milliseconds run to run if anything else is competing for the disk. - -| Scope | p50 | p95 | -| -------------- | ---- | ---- | -| `all` | 7.22 | 8.94 | -| `conversation` | 5.33 | 7.86 | - -Per query, `all` then `conversation` (p50 / p95): - -| Query | `all` | `conversation` | -| ------------------------------------------------ | ------------ | -------------- | -| `"terminal reattach"` (phrase) | 5.24 / 8.42 | 2.97 / 3.24 | -| `resolveTerminalPath` (identifier) | 7.55 / 8.94 | 6.47 / 6.72 | -| `src/main/…/session-transcript-reader.ts` (path) | 8.69 / 10.12 | 7.78 / 8.04 | -| `why is the daemon snapshot stale` (prose) | 7.84 / 8.57 | 5.90 / 7.01 | -| `reattahc worktre` (typo repair) | 7.30 / 7.39 | 5.53 / 5.89 | -| `index` (common term) | 5.45 / 5.66 | 3.81 / 4.02 | -| `repo:app-3` (operator only) | 0.12 / 0.16 | 0.10 / 0.10 | -| `worktree` scoped to one cwd | 1.47 / 1.63 | 1.25 / 1.49 | - -Reading it: - -- `conversation` is about 1.4x faster at p50 and 1.1x at p95, and it is a column - filter over the same table rather than a table of its own. Narrowing to the - two prose columns is what buys the gap: fewer postings to score. It is also - the scope where a match is something a person wrote rather than something a - tool printed. -- A `scopePaths` query is the cheapest real search on the page. It is the one - narrowing SQL can express exactly, so it seeks `sessions_cwd_key` and hands - ranking a small candidate set. -- The operator-only figure is a floor, not a typical cost. `repo:` and `path:` - are applied in JS over retrieved rows (see `session-search-row-filter` for why - they cannot be pushed into SQL), so their cost tracks how many sessions the - walk has to read before it fills a candidate set. This corpus has 40 sessions, - which is one page of that walk; an index where few sessions match the operator - will read up to the ceiling in `session-search-retrieval` instead. - -## What the conversation scope costs at real corpus size - -`conversation` was a second FTS table holding a copy of the two prose columns. -It is a column filter now — `{user_text assistant_text}: (…)` with bm25 weights -that zero the other two — and PR 2 deleted the table on the strength of the -shoot-out this section used to hold: the filter came in at 1.16-1.36x the p95 of -the dedicated table, under the 2x bar, while the table cost a tenth of the index -to maintain. What follows is what the shipped schema actually does, measured -again on the same corpus after the table went and tool rows were capped. - -Corpus: Claude transcripts from `config/scripts/session-search-tool-heavy-corpus.ts`, -105 MB, indexed through the real store, at two points in the 80-97% band a real -transcript tree sits in. Half the tokens in tool output are words the -conversation also uses, so a conversation term really does have postings the -filter must discard. Twenty queries per rung, both scopes interleaved query by -query, warm cache; `config/scripts/session-search-scope-benchmark.ts`, run twice. - -| Tool share | Rung | `all` p50 / p95 | `conversation` p50 / p95 | -| ---------- | ------ | --------------- | ------------------------ | -| 86% | phrase | 16.69 / 17.48 | 13.08 / 13.52 | -| 86% | or | 31.91 / 35.74 | 22.25 / 23.87 | -| 86% | and | 70.04 / 74.00 | 53.47 / 59.39 | -| 93% | phrase | 9.14 / 13.36 | 7.23 / 8.51 | -| 93% | or | 16.46 / 18.70 | 12.34 / 14.88 | -| 93% | and | 39.65 / 43.44 | 31.05 / 32.92 | - -Three things to read out of it. - -**The filter is a win, not a cost.** Every rung is faster narrow than wide, by -1.2x to 1.4x at p50. The shoot-out compared the filter against a table built for -exactly this query; against the wide table it replaces, it does what the second -table did, which is read fewer postings. - -**The `and` rung is where the corpus size shows.** Those queries are eight terms, -chosen so no ordered run that long occurs and the phrase rung has to miss; a -real two-term AND sits nearer the phrase row. It is also the noisiest: the -second run's p95 reached 140 ms on one bucket, which is what twenty samples of a -70 ms query buys. Read the p50 column. - -**The index is far smaller than the shoot-out's was.** 57 MB at 93% tool output -and 103 MB at 86%, against roughly 150 MB for `messages_fts` alone before PR 2 -capped an indexed tool row at 3,072 characters. Most of a tool-heavy transcript -is now not in the index at all, which moves every number above and is the larger -effect of the two. - -What is **not** measured here is relevance, and the column filter does carry one -ranking difference the deleted table did not. FTS5's bm25 normalises by the -whole row's length and has no per-column length, so two rows with identical -prose score differently when one also holds tool output. The rowid set is -unchanged, which is what the deletion was decided on; the order within it can -move. `session-search-engine.test.ts` pins the direction. - -## `sessionCandidateLimit` - -The reviewer's F13: this is a tunable default, not a constant. It bounds how many -sessions the SQL hands ranking, so it bounds both retrieval cost and how deep a -caller can page before the answer simply stops. - -The limit only costs anything once more sessions match than the limit allows, so -this is measured over a second corpus: 2,500 one-turn transcripts, 10.9 MB, every -one of them matching the query. Limits are interleaved sample by sample, because -run back to back the first configuration pays for every page the OS cache had not -seen and the ordering alone moves p95 further than the limit does. - -| Limit | p50 | p95 | Pages of 20 a caller can reach | -| ----- | ----- | ----- | ------------------------------ | -| 200 | 6.85 | 7.21 | 10 | -| 600 | 7.93 | 8.36 | 30 | -| 1200 | 9.55 | 10.53 | 60 | -| 2400 | 12.32 | 13.45 | 120 | - -600 is the default: it costs about 16% over 200 at p50 and buys three times the -reachable depth, and the curve only turns steep past 1200. A host with a much -larger index can raise it; the result's `truncated.candidates` says when the limit -was the thing that cut the answer, so a caller never has to guess. - -What is **not** measured here is relevance. These numbers say what a limit costs, -not what it retrieves. The MRR figures quoted in the BM25 weights -(`session-search-retrieval.ts`) and in the identifier shadow column -(`session-search-identifier-split.ts`) come from the original retrieval shoot-out -on real transcripts and are not reproducible from this repository. Any change to -the limit justified on relevance grounds needs an eval set, not this benchmark. - -## What typo repair costs - -The repair is the one rung whose cost tracks the size of the vocabulary rather -than the size of a result. It only runs for a term the scope has no posting for, -so an ordinary query never pays it; a query of nonsense pays it once per term. - -Measured over a synthetic vocabulary of 1.6 M distinct terms, every term in two -rows so none is filtered out: - -| Query | p50 | -| -------------------------------------- | ------ | -| one known term (no repair) | 11 ms | -| one unknown term | 10 ms | -| 39 unknown 12-character terms (480 ch) | 387 ms | -| 12 unknown 40-character terms | 99 ms | - -Two things follow. The cost is linear in unknown terms and in vocabulary size, -and `search` is synchronous, so a 512-character query of nonsense holds the -thread for a third of a second on an index that large. And the scoped-count fix -made this cheaper rather than dearer — it was 737 ms before — because ordering -the vocabulary scan by term drops the sort that ordering by `doc` required, and -the counts it added are at most eight bounded probes per prefix. A cap on -unknown terms per query is recorded as a follow-up in the split plan. - -## Page warmup, dropped - -PR 2 deferred `warm()` — a sliced read of `messages` that pulls its pages into -the OS cache before the first query — to whoever knew which pages a read -touches. It is not re-added here, for two reasons. The measurement that -justified it (first query 1.3 s to 0.45 s) was on a 4 GB index, and neither -corpus in this file is within an order of magnitude of that, so PR 4 cannot -show a win: removing the call moved the 10.5 MB corpus's p50 by less than the -run-to-run spread. And it is a cancellable background pass, which needs an owner -with a lifecycle; a query library that holds no timers has nothing to hang the -`stopped()` on, and a fire-and-forget async read from a synchronous `search` is -a rejection nothing can supervise. It belongs with the indexer in PR 3b, which -already owns starting and stopping work. - -## Not settled here - -Which process may open, unlink and rebuild the index is PR 3b's decision. A -second handle that finds an older schema version replaces the file while a live -store keeps answering from the unlinked inode, and this PR is what first makes -that reachable, because it is the first thing that reads. What PR 4 does is -refuse to make it worse. The engine restores its derived vocabulary and generation -triggers before a search. A missing `messages_fts` fails clearly; the connection -owner must rebuild the source index. There is no degraded-search capability state -or query logging. Logging can be added by a caller when an evaluation consumer exists. - -Each search checks the generation before retrieval and after its final content -read. A concurrent commit rejects the page with `stale-generation`, including a -first page without a cursor. The caller can retry from page one. No long-lived -read transaction is needed, and a mixed page is never returned as a valid snapshot. - -Repository/path operators are applied before a phrase or AND route is accepted. -Candidate truncation is reported by the rung that answered, not by every rung -tried. Each rung of the ladder matches a superset of the one before it, so a -rung that reached its cap with no eligible sessions is always followed by one -that reaches it too: a full candidate set stays explicit either way. - -The phrase and AND rungs run for prose as well as for literal-looking input, -over the query's tokens as typed rather than the stop-word-stripped OR body. A -sentence pasted out of a transcript is ordinary words in order; over OR its -common words fill the candidate limit with recent sessions and the old session -holding the sentence never reaches ranking. The cost is two FTS queries that -usually miss, which on the corpus above sits inside this harness's run-to-run -noise. A one-token query still takes the rung only when it looked literal. diff --git a/docs/reference/agent-skill-provider-paths.md b/docs/reference/agent-skill-provider-paths.md deleted file mode 100644 index 293229ad70e5..000000000000 --- a/docs/reference/agent-skill-provider-paths.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent skill provider paths - -Last verified: 2026-08-11. - -V1 supports only providers whose paths are independently established by official documentation. -The registry is deliberately small; it is not copied or synchronized from a community path table. - -| Provider | Detection | Global canonical support | Workspace support | Orca placement | -| --- | --- | --- | --- | --- | -| Codex | `codex` CLI found through Orca's host-owned PATH detection | Reads `$HOME/.agents/skills` directly | Reads `.agents/skills` from the current directory through the repository root | Canonical copy only | -| Claude Code | `claude` CLI found through Orca's host-owned PATH detection | Reads `$HOME/.claude/skills` | Reads `.claude/skills` from the launch directory through the repository root, plus nested directories as files are accessed | Relative directory symlink on POSIX, directory junction on Windows, or verified independent-copy fallback | - -Codex locations and symlink behavior are documented in the official OpenAI documentation: -[Build skills](https://learn.chatgpt.com/docs/build-skills#where-codex-loads-local-skills). - -Claude Code locations, precedence, parent traversal, live detection, and symlink behavior are -documented in the official Anthropic documentation: -[Extend Claude with skills](https://code.claude.com/docs/en/skills#where-skills-live). - -Codex therefore needs no provider-specific placement. Claude Code does not document -`.agents/skills` as a discovery root, so Orca reconciles its documented `.claude/skills` path back -to the canonical copy. Orca never replaces a path it does not own. If alias creation is unavailable, -the verified copy fallback is tracked in the install receipt so update and removal can detect drift. - -## Registry change process - -Every registry change requires normal code review and all of the following evidence: - -1. Link current official provider documentation for global and workspace paths. -2. Record whether the provider reads `.agents/skills` directly and its documented link behavior. -3. Verify global and folder-workspace discovery on macOS, Linux, native Windows, and WSL where the - provider supports those platforms. -4. Exercise local, paired-runtime, and SSH host-owned path resolution. -5. Test alias denial, broken owned aliases, independent-copy drift, update, rollback, and removal. -6. Update mixed-version capability evidence if the placement contract changes. - -Do not add automated upstream path-table synchronization. A provider release that changes discovery -semantics must enter through this review process. diff --git a/docs/reference/agent-skill-sharing-threat-model.md b/docs/reference/agent-skill-sharing-threat-model.md deleted file mode 100644 index efccfd7119b3..000000000000 --- a/docs/reference/agent-skill-sharing-threat-model.md +++ /dev/null @@ -1,101 +0,0 @@ -# Agent skill sharing threat model - -Status: implementation baseline for security and privacy review. This document does not constitute -security approval. - -## Scope and trust model - -This model covers private skill packaging, Orca Cloud publication and authorization, durable share -resolution, local and remote installation, provider placement, update, rollback, removal, and -operator recovery. It applies to macOS, Linux, native Windows, WSL, paired Orca runtimes, and SSH -targets. - -Private means unlisted: an unpredictable active share ID is a bearer credential, while publication -and management remain access-controlled to authenticated Orca users and organizations. V1 is not -end-to-end encrypted from Orca Cloud operators. A skill is code from its author: `SKILL.md` can -change agent behavior and packaged scripts may be executed later by a user or agent, although the -installer itself never executes package content. - -## Protected assets - -- Skill contents, filenames, manifests, package metadata, release notes, and author identity. -- Organization membership, selected-user ACLs, durable share identifiers, and package existence. -- Signed upload policies, download grants, authentication tokens, database credentials, and GCP - service identities. -- Existing local skills, provider configuration, user modifications, provenance, and transaction - recovery state. -- Cloud package metadata, immutable object generations, audit records, and deletion state. -- Availability and cost of Cloud Run, Cloud SQL, GCS, connected runtimes, and local filesystems. - -## Actors and boundaries - -Actors include an authorized publisher, an authorized recipient, an authenticated but unauthorized -Orca user, a malicious skill author, a compromised renderer, an untrusted remote RPC caller, an -attacker controlling a network endpoint, and an operator with GCP or database access. - -Trust boundaries are: - -1. Source skill directory to the owner-private package staging directory. -2. Desktop renderer to the main process and its authenticated Cloud client. -3. Orca client to a paired runtime or SSH host across independently versioned protocols. -4. Orca Cloud API authorization to short-lived GCS access. -5. GCS quarantine to validated immutable publication and PostgreSQL metadata. -6. Extracted staging to canonical destination and provider placements. -7. Local diagnostic records to a user-reviewed support-bundle upload. -8. Terraform and deployment identities to staging and production resources. - -## Security invariants - -- Package identity is deterministic and binds normalized paths, exact bytes, executable state, and - immutable package/version IDs. -- Publication and installation accept only the `manifest.json` plus `skill/` envelope and never - execute archive content. -- Archive validation completes before destination mutation. -- Destination paths are resolved by the runtime that owns the host or workspace. -- Unowned or modified local content is never silently replaced or deleted. -- Final GCS objects are immutable, generation-fenced, and reachable only through fresh ACL checks - followed by short-lived grants. -- No remote caller can turn a desktop-local path into remote filesystem authority. -- Interrupted transactions converge to a verified old or requested version. -- Logs and support bundles exclude package contents and private authorization or filesystem data. -- Cloud sharing, downloading, and remote installation have independent kill switches. - -## Threat register - -| ID | Threat | Required controls and current evidence | Residual release gate | -| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| TM-01 | Source changes after preview or a link swaps bytes during packaging. | Observe the specific source directory, copy without following links, re-observe staged bytes, compare every identity, and bind preview to the final digest. Source-drift and link/special-file tests cover rejection and cleanup. | Repeat race and permission tests on every supported filesystem. | -| TM-02 | Archive traversal, drive paths, links, devices, duplicate paths, Unicode/case collisions, or decompression/resource exhaustion escape staging. | Streaming parser rejects unsafe entry classes and normalized collisions; compressed, extracted, entry, file, depth, and per-file limits are enforced during parsing and extraction. Boundary, malformed, checksum, and fuzz tests run before destination mutation. | Keep package safety suites required in release CI. | -| TM-03 | A forged manifest lies about names, bytes, executable state, or package identity. | Parse the manifest before trusting entries, require `skill/SKILL.md`, hash extracted bytes independently, recompute package identity, and compare package/version IDs and digest. | Cross-platform identical-byte digest evidence remains required. | -| TM-04 | A client chooses another home, workspace, WSL distro, SSH path, or escapes a destination root. | The executing runtime resolves home and workspace identities, realpath-checks directories, uses platform-native joins, and rejects client-supplied remote paths. `local-file` ingress is trusted in-process only. | Real SSH and mixed-version topology tests remain required. | -| TM-05 | Installation overwrites or deletion removes unowned or locally modified content. | Planner distinguishes missing, unchanged, clean, modified, unowned, external-link, broken-link, and collision states. Provenance lives outside installed content. Replacement requires an explicit conflict decision; removal revalidates ownership and digest. | Complete the remaining real junction, external-link, copy-drift, and permission matrix. | -| TM-06 | A crash between renames or receipt publication loses both old and new versions. | Same-filesystem staging, durable journals, backups, flushed receipt replacement, bounded recovery, and ownership tokens protect every commit boundary. Failure injection covers every journal transition. | Real process termination and disk/antivirus contention tests remain required. | -| TM-07 | A grant leaks through redirects, userinfo, an insecure scheme, DNS/host confusion, or an oversized stream. | Cloud requests reject redirects. Package downloads require configured origins and HTTPS, reject URL credentials and cross-origin redirects, cap redirect count, recheck expiry, stream exact expected bytes, and verify archive/package digests. | Validate approved production origins and exercise malicious network cases in staging. | -| TM-08 | Reuse of an upload ID, wrong tenant, wrong key, stale generation, or quarantine object publishes attacker-selected bytes. | Random tenant-bound upload rows, signed POST conditions, expiry, exact metadata/key/type/size validation, generation-fenced reads, streamed validation, and idempotent finalization fail closed. | Staging GCS integration must cover stale generations and lifecycle deletion. | -| TM-09 | IDOR, stale organization membership, or a guessed identifier exposes package management data or bearer-protected content. | Management requests authenticate and re-evaluate current ownership. Recipient preview and grants require the exact unpredictable, active bearer share ID and ignore legacy ACL rows. Not-found responses hide unauthorized existence. | Human authorization review and organization-departure policy approval remain required. | -| TM-10 | Content-addressed deduplication discloses another tenant's package through response shape or timing. | Tenant-scoped APIs never expose GCS keys, generations, or whether an object already existed; existing-object reuse verifies both archive and logical package identity. Cross-tenant tests prove identical archives use separate tenant-hashed objects and response shapes disclose no reuse. | Keep tenant-isolation and response-disclosure coverage required in release CI. | -| TM-11 | A compromised renderer, old client, or arbitrary RPC caller sends credentials, local paths, unknown opcodes, or unsupported operations to a host. | Main owns auth tokens and grants; remote requests use strict schemas and capabilities; package transfer is separate from install; no stream opcode was added; mixed versions fail with update-required results. | Real client-newer/server-newer and SSH parity gates remain required. | -| TM-12 | Transfer replay, overlap, disconnect, or abandoned staging consumes disk or commits different bytes. | Session count, idle time, total bytes, and chunk size are bounded. Offsets are monotonic, identical retry is idempotent, changed replay fails, commit hashes the exact staged file, and cancellation/disconnect cleanup is bounded. | Exercise offline-GCS and real disconnects at every transfer boundary. | -| TM-13 | Provider aliases or junctions escape canonical storage or point at external content. | POSIX aliases are relative from real parents, Windows junctions use absolute canonical targets, targets are revalidated, and unowned/broken/external links are conflicts. Copy fallback is independently hashed and receipt-owned. Real Windows and WSL coverage includes existing, broken, external, denied, and drifted placement behavior. | Preserve the physical placement matrix in release coverage. | -| TM-14 | Skill instructions or scripts are mistaken for trusted Orca code or executed during install. | Share/install previews identify author, organization, scripts, executable files, digest, and version. Installation never runs scripts. Trust copy says the package is code from its author. | Security and design must approve the final trust wording and accessibility behavior. | -| TM-15 | Telemetry, logs, or support bundles leak instructions, filenames, paths, ACLs, grants, policies, or credentials. | Desktop install diagnostics map values to bounded categories before tracing. Deployed staging logs contain route templates and bounded request metadata only, and the logging exclusion removes bearer URLs. Support-bundle tests inject private canaries and prove they are absent from collected output. | Preserve deployed-log and support-bundle privacy checks for future changes. | -| TM-16 | Permissive staging permissions expose package bytes to another local user. | Package archives, downloads, extraction, relayed uploads, locks, journals, and receipts use owner-private modes on POSIX; Windows uses owner-profile paths and inherited ACLs. Existing POSIX download roots are tightened before use. Real Windows, WSL, Ubuntu-floor, and SSH validation passed. | Preserve owner-private staging checks across supported hosts. | -| TM-17 | Deletion, revocation, retention, or user departure leaves unauthorized grants or unrecoverable metadata/blob divergence. | Revocation blocks new grants immediately; existing grants expire within five minutes. Database references govern final deletion, quarantine lifecycle cleans abandonment, and GCS soft delete supplies recovery. Local installs remain independent. | Approve departure/legal-retention policy and exercise coordinated database/GCS recovery. | -| TM-18 | Broad IAM, public bucket access, service-account keys, or a compromised deployment identity bypasses application authorization. | Uniform bucket access, public-access prevention, bucket-scoped object access, service-account-scoped signing, skill-secret-only access, Cloud SQL client role, no desktop IAM, and no long-lived keys are Terraform-defined. | Review the staging and production plans and verify live IAM before rollout. | -| TM-19 | Unbounded validation or request concurrency causes memory, CPU, database, storage, or egress denial of service. | Fixed streaming buffers, package limits, per-instance finalization semaphore, rate/quota limits, bounded transfer sessions, Cloud Run instance limits, lifecycle cleanup, and independent kill switches constrain work. | Complete load testing, dashboards, alerts, and budget thresholds. | -| TM-20 | Operator recovery, diagnostics, or legal workflows bypass tenant isolation or leak content. | Runbooks require generation-specific recovery, coordinated PostgreSQL/GCS restoration, audited lifecycle actions, and no package contents in normal logs. | Security/privacy approval and restricted break-glass procedure remain required. | - -## Required review evidence - -Security and privacy approval must not rely on this document alone. Reviewers need: - -- Package/admission schemas and stable failure categories. -- Archive parser, extraction containment, transaction, provenance, and removal tests. -- Cloud authorization, object-generation, tenant-isolation, deletion, and recovery tests. -- Terraform plans plus live staging IAM, bucket, Cloud Run, Secret Manager, and Cloud SQL evidence. -- Real macOS, Linux-floor, Windows, WSL, paired-runtime, mixed-version, and SSH results. -- Captured staging logs, metrics, traces, and support bundles with seeded private canaries absent. -- Load-test results and independently tested upload, download, and remote-install kill switches. - -Approval owners record findings and accepted residual risks outside this implementation document. -The external rollout gate remains closed until those findings are resolved or explicitly accepted. diff --git a/docs/reference/agent-skill-sharing-upstream-boundary.md b/docs/reference/agent-skill-sharing-upstream-boundary.md deleted file mode 100644 index bf2323d2895d..000000000000 --- a/docs/reference/agent-skill-sharing-upstream-boundary.md +++ /dev/null @@ -1,43 +0,0 @@ -# Agent skill sharing upstream boundary - -Status: proposed for formal engineering and legal review. - -Date: 2026-08-11. - -## Context - -Orca needs private, durable, cross-machine skill sharing with bounded archive ingestion, -host-owned destination resolution, crash-safe transactions, provenance, and mixed-version remote -support. `vercel-labs/skills` exposes a CLI and does not provide the Cloud authorization or local -transaction contract Orca requires. - -The behavioral assessment used upstream commit -`c6f69c631292444cc541ac6d91e2226b0ff247da`. - -## Decision - -Orca implements its package, Cloud, installation, provider-placement, and recovery behavior -independently. The upstream project is a behavioral reference only. - -Do not copy or mechanically translate upstream source, tests, fixtures, registry entries, provider -path tables, comments, or documentation. Derive provider paths from official provider -documentation and verify them with real installations. Orca does not depend on the upstream CLI, -npm package, or an unsupported programmatic API. - -If a future change proposes incorporating upstream material, stop and review the exact material, -license, attribution, notices, and maintenance implications before implementation or merge. - -## Consequences - -- Orca owns stability, security, compatibility, and maintenance of this narrower installer. -- There is no automatic upstream synchronization job. -- Similar behavior is acceptable when independently derived from requirements and official - provider contracts; textual or structural copying is not. -- Provider registry changes require normal code review plus official-documentation and real-host - evidence. -- The pull request template requires reviewers to confirm this boundary for relevant changes. - -## Review record - -Product chose the reference-only approach during planning. Formal engineering and legal reviewers -remain to be named before external rollout. diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md deleted file mode 100644 index 912b14d51fe9..000000000000 --- a/docs/reference/agent-status-store.md +++ /dev/null @@ -1,577 +0,0 @@ -# Agent status store - -## Status - -The current boundary is PR 2A: structured sessions use the hook server's fully -scoped canonical store; unbound PTY/relay evidence remains in an isolated legacy -adapter. Do not remove the renderer bridge or its publication filters in this -slice: they still carry native-chat child rows. - -The sections below record the original 2026-09-09 rollout. Its PR 1a and PR 1b -have landed; its proposed PR 2/3 sequence is superseded by that boundary: - -1. main-only: every producer writes into one store and `worktree ps` reads it, - split into 1a (structured sessions join the store) and 1b (the runtime's - duplicate retained store is deleted); -2. renderer: the sidebar becomes a subscriber and stops re-deriving rows; -3. shared: one worktree-status rollup and one freshness rule for every reader. - -## The problem this solves - -Orca shows "what is this agent doing" in four places: the desktop sidebar, the -`orca worktree ps` command, the mobile app, and the agent dashboard. Before -#19217 those readers did not even share their inputs. After #19217 they share -the structured-session mapping and nothing else. - -An audit on 2026-09-09 found six producers and three consumers, and three -separate copies of the same row inside the main process alone: - -| Main-process copy | Keyed by | Owned by | Persisted | Evicted | -| --------------------------------- | --------- | --------------------------------------------------------------------------------- | ------------------ | ---------------------------------------------- | -| hook server `lastStatusByPaneKey` | paneKey | `src/main/agent-hooks/server.ts` | `last-status.json` | tab close, pty exit, hydrate, worktree removal | -| runtime `RuntimeAgentRowStore` | paneKey | `runtime-agent-row-store.ts` (deleted in PR 1b) | no | pty exit only | -| structured feed `published` | sessionId | `src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts` | no | never (a broadcast cache) | - -The second copy is a duplicate write: the OSC status parsed in main is -forwarded to the hook server _and_ retained in the runtime store from the same -call (`orca-runtime-create-terminal-side-effect-command-code-detector.ts`). -The third copy is keyed differently and never reaches the hook server at all, -which is why `worktree ps` grew its own adapter for it in #19217. - -Each reader then applies its own precedence and freshness rules, so the same -pane can legitimately read differently on the desktop, on the phone, and in -the CLI. - -## The rule - -**The execution host owns agent status, in one store, and every reader -subscribes to it.** This follows the boundary in -[`ssh-execution-boundary.md`](./ssh-execution-boundary.md): the host that runs -the process is the only party that can observe it, and the client is never -authoritative for execution state. - -Three consequences: - -- One store per execution host. A remote host keeps its own store and the - client mirrors it down, as the web-session mirror already does. Mirroring is - not merging: a client never writes its observations back to a host. -- Precedence is decided once, at write time, with provenance recorded on the - row. Readers never re-adjudicate hook versus terminal versus structured. -- Readers keep only presentation policy and user facts: the 30-minute display - decay, acknowledgements, dismissals, unread. Those stay reader-side but - become one shared implementation (PR 3). - -## The store already exists - -The hook server's state is that store today for every PTY-based agent. The -audit established: - -- hook HTTP posts, the WSL and SSH relay receivers, and main's own OSC parse - all converge on the same `applyNormalizedStatus` path, stamped with the - authority id `main-agent-hooks`; -- it alone holds pane authority: launch tokens and their hashed commitments, - retired-pane fences, pane-key aliases, per-connection ordering watermarks, - and the evidence-age map that must outlive a transport clear; -- it alone persists, with a seven-day hydrate window and the - `restoredUnconfirmed` stamp that keeps a hydrated row from ever reading as - live truth; -- it already fans out to both renderer windows over `agentStatus:set` and - `agentStatus:clear`, and serves `agentStatus:getSnapshot`. - -Nothing else in main carries those guarantees, and building a second store -with them would be the wrong direction. So the design is not "add a store". It -is: **route the two producers that bypass the hook server through it, then -delete the copies.** - -## PR 1a: structured sessions publish into the store - -No renderer behavior changes. The sidebar keeps receiving the same IPC events -it receives today, plus structured-session rows it currently derives itself. - -### Structured sessions publish into the hook server - -The structured feed keeps its job of projecting a session's journal into a -summary and streaming it to subscribers. On every publish it additionally -ingests the summary into the hook server as a status row: - -| Row field | From | -| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it | -| `tabId` | `structuredAgentSessionTabId(sessionId)` | -| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) | -| `state` | `structuredAgentSessionAgentStatus(summary).state`: the lead's own status folded with the live child records the store holds for the session (not the summary's task list), so a settled lead whose subagent still runs reads `working` | -| `workingMode` | `'monitoring'` from the same fold when watch loops are the only live child work; omitted otherwise, which clears it on the row | -| `mainAgent` | the main agent's own state before the fold, its last-turn verdict (`summary.turnOutcome`, present only while idle) and its own clock; see "The main agent fact" below | -| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it | -| prompt, tool, last message, model, provider session | the summary's fields | - -Sessions with no request (`status === null`) produce no row. A request is a -turn record, an assistant message, a user message the provider journaled itself -(history, an older host), an accepted or unanswered send, or a send the agent or -its start refused; a send that was withdrawn, or left undelivered by a -restart or a close, fails nobody and makes nothing listable. -`summary.turnOutcome` is the latest request's verdict: its turn's outcome, or -`failure` for a send the agent or its start refused (a send that joined a running -turn is answered by that turn). A turn the provider gave no outcome reads as its -host-observed end through `agentTurnVerdict`: `interruption` for an `interrupted` -lifecycle, `unconfirmed` for an `unverifiable` one. It is derived on each read, -never journaled. The row also publishes `interrupted` from -`mainAgent.outcome`, exactly as the hook lanes do. When the host revokes live ownership the row is re-set -without the flag; when the host closes or evicts the session the row is -dropped. Both already exist as feed events (`revokeLive` and the roster -filter in `liveSessionSummaries`); PR 1 turns them into store writes. - -Dropping the session from the host's map and dropping its row are one -operation, `forgetStructuredAgentSession`. The store keeps a row until told, -and a host-owned row bypasses the staleness check, so a deletion path that -forgot the row would strand a permanently working-looking agent. - -Two rules the ingest must keep: - -- **Never persist a structured row.** The journal is the durable truth for a - structured session and the host republishes on restore. A structured row in - `last-status.json` would hydrate as `restoredUnconfirmed` and then fight the - live republish. The serializer skips rows carrying `structuredHost`, and - hydrate drops any such row found on disk. Applying one therefore also skips - the persist schedule: the walk and stringify could only reproduce the file - that is already on disk, once per debounce window for every streaming chat. -- **Never let it fight a hook row.** A structured session has no PTY, so no - hook or OSC event carries its pane key. The ingest still goes through the - disposition gate so a retired pane key is refused like any other. - -Applying one does still run both status fan-outs, and that is intended rather -than incidental. `notifyStatusChangeListeners` is what feeds -`agentAwakeService`'s power-save blocker, and `subscribeEnrichedStatus` is what -feeds `AgentSessionTransitionRecorder`'s stats, so joining the store enrolls -native chats in both. A working native chat is real work and should hold the -machine awake exactly like a PTY agent does. - -The drop side routes through `dropStatusEntry`, not `clearPaneState`: a -pane-status-clear reaches the renderer, and until PR 2 the renderer's own feed -bridge is that pane key's writer. It also passes `preserveResumeIdentity: -false` — the `providerSessionOnly` remnant a dismissed pane keeps exists so the -agent can be resumed in that pane, and a structured session has no pane and -keeps its resume identity in the record store. Like every other -`dropStatusEntry` caller, it emits no pane clear, so a session dropped -mid-`working` leaves `AgentSessionTransitionRecorder` holding an open stats -session until its LRU evicts it; that gap is shared with the user-dismissal -path and is not specific to structured rows. - -The ingest lives in the feed, not in `structured-agent-session-host.ts`, which -sits at the file-length cap. - -### `worktree ps` becomes a reader - -The structured adapter added in #19217 is deleted, and structured rows reach -`worktree ps` through the same snapshot as every other row. The -retained-versus-hook reconciliation in `collectRuntimeWorktreePtyAgentSources` -stayed until PR 1b removed the store that fed it. What this step settles is -the admission gate that decides which rows a worktree listing may show: - -- a hook or OSC row needs its tab mirrored or a connected pty, as today, and - SSH rows stay exempt because their tabs may exist only remotely; -- a row carrying `structuredHost` is admitted while the host holds the session, and - the host's drop on close is what removes it. No tab-mirror requirement: a - structured session's tab lives in the renderer's own tab state, and a - headless host has no renderer to mirror it from. That argument only holds if - the headless host is itself wired to the store, which is a separate - obligation per entry point: the Electron hosts (desktop and `orca serve`) - share `main-process-runtime-service.ts`, and `orcad` constructs its own - runtime in `src/main/orcad/orcad-entry.ts`. A host missing that wiring lists - no agents at all, not just no structured ones, because `worktree ps` reads - the same snapshot for every row. - -The freshness bypass for host-owned structured rows already exists in -`isFreshNonDoneAgentStatus`; with the flag now on the row it becomes the only -path, and the hand-rolled check in `runtime-worktree-agent-rows.ts` goes. - -### Wire compatibility - -`AgentStatusIpcPayload` gains one optional field, `structuredHost`, and the -`worktree ps` row gains `structuredHostOwned`. Under rule 1 of -[`remote-wire-compatibility.md`](./remote-wire-compatibility.md) both are safe: -an old client ignores them. `worktree ps` rows keep their shape and vocabulary, -so the mobile app sees no change. - -Until PR 2 the main process does not forward structured rows to the renderer -over `agentStatus:set` or `agentStatus:getSnapshot`. The renderer's feed -bridge still writes those rows itself, and forwarding them too would give one -pane key two writers. Removing that filter is the first step of PR 2. - -### The main agent fact - -Claude, Codex and Grok hook rows and structured-session rows publish the combined -`state` and, beside it, the main agent's own state as `payload.mainAgent`. Other agents' -rows and terminal-title-only rows carry none, and readers fall back to `state`: - -```ts -mainAgent?: { state: AgentStatusState; outcome?: AgentTurnOutcome; stateStartedAt: number } -``` - -`state` still answers "what should the user see" and folds live child work in, -so a settled main agent whose subagent still runs reads `working`. `mainAgent` answers -"what is the main agent itself doing", which the fold used to destroy at publish -time; every guard that reconstructed a fragment of it (`fromChildWork`, the -persisted `claudeLeadBoundaryChildOnly` flag) now reads `mainAgent` instead of a -stored copy. A Claude row whose `mainAgent` is `done` while a child agent still -works (including a child's permission wait) refuses OSC, which carries no child -identity; the children's own lifecycle hooks settle it. `outcome` is the recorded verdict on -the main agent's most recent finished turn, present only while `mainAgent.state` is -`done`. It is reported by the provider, or is a `cancellation` Orca inferred -from the user's own interrupt keystroke, or a `superseded` the host recorded when -a newer request replaced a structured Claude turn before it ended (it names no -sender, and sets no legacy flag), or, on a structured row whose turn the -provider gave no verdict, is what the host observed of its end: `interruption` -(a proven death nobody asked for) or `unconfirmed` (an end it cannot prove, -never success). The journal's turn outcome, by contrast, stores only recorded verdicts: the provider's, a `cancellation`, or the host's `superseded`; `interruption` and `unconfirmed` are derived from the turn's lifecycle state and never stored. A plain end of turn carries none, because absent -means unknown and a provider that omits its interrupt flag must not turn a -cancel into a success. -In the Claude hook lane the cancellation comes primarily from Orca's own -inferred interrupt (`markClaudeLeadTurnInterrupted`), because current Claude -sends no hook at all on a cancel and no `is_interrupt` on Stop; that flag on a -turn boundary remains a secondary source for builds that send it, and -`StopFailure` maps to `failure`. - -Readers decode the verdict through one accessor, `agentMainAgentVerdict`, which -reads the main agent's own state, not the combined row's: `mainAgent.outcome` -while `mainAgent.state` is `done`, then the legacy `interrupted` flag as a -cancellation, which alone needs the combined `done`. So a main agent that -failed while its subagents still run has a verdict on a `working` row. Every -copy of a row (state-history entries, sleep records, `worktree ps` rows) takes -the verdict through `agentVerdictFields`, which carries `interrupted` and the -whole `mainAgent` (state, outcome and its own clock) together, so a copy agrees -with the row and can date a failure by `mainAgent.stateStartedAt`. - -Display reads the verdict through `agentVerdictDisplayMark`. A fault marks the -agent failed whatever the combined state, because it is news the user must see -even while subagents run: a `failure`, and an `interruption`, a turn cut short -by anything other than the user or a newer request. A user's stop (`cancellation`) -marks it interrupted, drawn in the muted tone with the row text "Interrupted by user"; -a turn a newer request replaced (`superseded`) marks it interrupted in the same muted -tone with the row text "Interrupted"; and `unconfirmed` marks it unconfirmed, all only -on a `done` row, so a stopped -or finished main agent with live child work still reads working. The folded -turn header follows the same mark: "Failed after N", "Interrupted after N", or -"Worked for N". -Each subagent keeps its own row and state. Container rollups (worktree card, -terminal tab, Cmd+J) rank a pending question first, then a failure, then live -work, then an unconfirmed end, then a user's stop, then done. On the worktree -card, a failure retained after its agent's pane went away has no expiry, so it -ranks below live work and above an unconfirmed end. Lifecycle waiters keep -reading the combined `state`. - -Policy splits the verdict two ways. Clean-finish policy (hibernation, pane -ownership, the star-nag value moment) treats a failure, an interruption and an -unconfirmed end like a cancellation (`agentTurnEndedUncleanly`). Attention -(completion time, Smart Sort, sticky retention, Cmd+J Recent) demotes only a -turn ended on purpose, the user's stop or a newer request that replaced it -(`agentTurnEndedOnPurpose`); a failure, an interruption or -an unconfirmed end ranks like a completion. - -Admission is one function, `normalizeAgentStatusPayload`, on the relay wire, -IPC and disk. A malformed `mainAgent` drops the field and keeps the row. Old hosts -send none and readers fall back to `state`. Hook rows persist it inside the -payload; hydration maps an older row's `claudeLeadBoundaryChildOnly: true` -onto `mainAgent: { state: 'done' }` when the row has no `mainAgent`, and never writes the -flag again. Hydration seeds the Claude main agent record straight from a saved -`mainAgent` that is `done`, so the children's drain can still settle the row after -a restart. `claudeRunningNonAgentTask` is persisted alongside because it is the one -child-work fact `mainAgent` cannot express: a shell running beside the main agent, -whose liveness hydration does not restore. Hydration seeds only a row that says -`false`; a row silent about it stays unseeded. The row builder pairs the two facts in -one place: a listener event restates the shell fact, and any other write (an OSC -repaint, an inferred answer) keeps it only while `mainAgent` is unchanged. A child's -sticky permission prompt still records the main agent's own progress and background -evidence in the held row, and pushes the held row to subscribers when `mainAgent` changes. - -Every lane, Codex included, combines through the fold. A child waiting on a -human is a fold input (`childWorkLiveness: 'waiting'`, derived from the child's -own `waiting` state; a child's `blocked` means it failed and stays live work) -and makes the row wait whatever the main agent is doing, unless the main agent -is itself asking. The Codex hook lane feeds it from its child transcripts, and -the structured lanes from child records, which read `waiting` for a Codex child -thread's approval or input flag and for a Claude subagent's open permission -request. Known -divergences, pinned by name in the parity table -(`src/shared/main-agent-status-parity.test.ts`) where they are reachable, so a -reader does not mistake them for drift: - -- The Claude hook lane holds a child's permission wait in one slot on the - displaced main agent record (`waitingAgentId`, `stateBeforeWait`), not on - the child. It publishes the displaced state as `mainAgent`, but the next - main agent event overwrites the slot, so the row stops reading `waiting` - while the child is still asking, and a second asking child replaces the - first. -- In the structured lane a child's pending prompt also makes the session - `attention`, which reads as the main agent's own `blocked`: one needs-input - state whoever asked. A Claude subagent reads `waiting` only while the - journal holds its card pending: from after the card's row is written until - just before anyone closes it, so every publish that shows the child waiting - also shows the session's `attention`, and the row never reads `waiting` for - a Claude subagent's request. -- The Codex hook lane drops its roster on a root `Stop` when it tracks no - child transcripts, so a still-running or still-asking child stops holding - the row. - -How the main agent's turn ended is not a fold input. A cancel is a verdict on -the main agent, carried as `mainAgent.outcome: 'cancellation'` (and, for -readers that predate `mainAgent`, as the row's `interrupted` flag on a `done` -row); it never retires a shell, scheduled check or subagent the turn left -running. That work leaves the row only when its own inventory omits it or the -session ends, so a cancelled turn with a still-running shell reads -`monitoring` in every lane, and the parity table in -`src/shared/main-agent-status-parity.test.ts` drives that story through all of -them. The same rule governs the cancel Orca infers from Ctrl+C: for any row -that publishes `mainAgent`, the inference is admitted only when -`mainAgent.state` is `working`, so Orca does not treat a Ctrl+C at the idle -prompt of a row held open by child work as a turn cancel (Codex also keeps the -child-evidence guard, and a row without `mainAgent` keeps only that guard). -The keypress itself is not inert, though: measured live, Claude 2.1.280 stops -its background subagents on a single idle-prompt Ctrl+C (shells survive) and -Codex 0.156.1 quits outright, so refusing the inference can leave the row -showing a subagent its CLI already stopped. The synthesized row is the fold -of the cancelled main agent with the child work the pane's owner can see: the -local listener's roster for a local pane, the row's own subagents and shell fact -for a relayed one, whose provider records live on the relay. - -The store holds that verdict against restatements that predate it -(`server-cancel-verdict-latch.ts`), because a relay never learns of a cancel -the desktop infers and some TUIs emit late same-turn hooks. The hold is read -off the row (`mainAgent.outcome: 'cancellation'`), never stored beside it, and -dies on a new turn (a main agent prompt submission, a changed or explicit -prompt, a session start) or the provider's own settled `mainAgent`. Child and -replayed events under the hold keep the cancelled main agent and are re-folded -with their own child evidence. - -## PR 1b: the runtime's retained row store is deleted - -Landed. `RuntimeAgentRowStore` is gone, and with it the retained-versus-hook -reconciliation in `collectRuntimeWorktreePtyAgentSources`. The hook server's -store is now the only main-process copy of a PTY agent's row. - -### The five call sites - -| Call site | Before | After | -| ------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `orca-runtime-create-terminal-side-effect-command-code-detector.ts` `retain()` | second write of the OSC payload already sent to the hook server | deleted; the event now carries the pane's `terminalHandle` and the hook ingest keeps the only copy | -| `...command-code-detector.ts` `clearPty()` | drops rows on pty exit | deleted; pane teardown already clears the hook row | -| `orca-runtime-get-worktree-ps.ts` `values()` | fed `retainedSnapshots` | deleted; the reader keeps only `hookSnapshots` | -| `orca-runtime-serialize-agent-prompt-submission.ts` `getFreshExplicit()` | retained row first, hook rows second | `selectFreshExplicitAgentStatus`, hook rows only | -| `orca-runtime-prune-mobile-session-tab-group-layout.ts` `getFreshForMobile()` | pane key, then pty id | `selectFreshAgentRowForMobileTab`: pane key, then `terminalHandle` | - -Both readers moved into `runtime-hook-agent-row-selection.ts`, which also owns -`RuntimeAgentRowSnapshot` now that nothing retains one. - -### `terminalHandle` is the row's join back to its terminal - -The retained store's only real extra was the pty id, and two readers used it. -The plan said to stamp the event's `ptyId` into `terminalHandle`; that was -wrong. A terminal handle (`term_`) and a pty id are different -identifiers, and `getFreshExplicit` was already comparing hook rows against a -real handle. What landed instead: - -- `AgentHookEventPayload` and the runtime's terminal-status event gained an - optional `terminalHandle`. The detector resolves it once per chunk through - `getAgentStatusTerminalHandleForPaneKey` — the same lookup the renderer-facing - IPC boundary already runs for every row, so the two surfaces cannot disagree - about which terminal a pane is. -- `applyNormalizedStatus` carries the handle forward when an incoming event - resolves none. Only main's OSC parse can resolve one, so an HTTP hook post for - the same pane would otherwise erase it. -- It is never persisted. A handle belongs to the runtime that issued it, and a - hydrated one could only rejoin a row to somebody else's terminal. -- `toAgentStatusIpcPayload` publishes it, which also makes `getFreshExplicit`'s - long-dead handle comparison live: the runtime reads raw snapshot rows, and - before this nothing ever stamped the field on them. - -`worktree ps` uses it too. `ConnectedPtyEvidence` traded its flat `ptyIds` set -for `ptyIdByTerminalHandle`, so a row still resolves the connected PTY behind -it — which is both the working-terminal rollup's match key and the last rescue -for a row whose pane binding was nulled by a controller incarnation change. - -### The change detector had to move with the store - -`retain()` was not only a store: its boolean return was the signal that -republished `session.tabs` for a status-only transition, which no title change -covers (#7970). `hook-status-session-tabs-invalidation.ts` already mirrors that -projection change set, including restore provenance and terminal-handle joins, -so the replacement was to route the signal off the store rather than build a -second comparator. -`installHookStatusSessionTabsRepublish` now owns all three arms — enriched -status, pane clear, and the status-drop tap a dismissal emits — and both hosts -install it. - -### Both hosts, not just the desktop one - -`orcad` constructed its runtime with no `onTerminalAgentStatus`, so main's OSC -parse never reached the store there and the retained copy was the only carrier. -Deleting it without wiring orcad would have made a headless host list no PTY -agents at all. `orcad-entry.ts` now binds the producer and installs the -republish signal, alongside the snapshot and structured sink it already had. - -### The intended behavior change - -A row the user dismisses on the desktop leaves `worktree ps` and the phone at -once, instead of lingering until the pty exits. One store means one dismissal. - -Legacy numeric pane keys remain a bounded compatibility case. Persisted layouts -register aliases to their stable leaf owners; an in-process OSC observation may -also retain a numeric key only when the runtime supplies the matching tab, PTY, -and terminal handle. HTTP and relay ingress still require a stable key or a -registered alias, and numeric rows are never persisted. - -## PR 2: the renderer subscribes - -With structured rows arriving over `agentStatus:set`, the renderer's -`StructuredAgentSessionStatusBridge` no longer needs to write status; its -unmount cleanup becomes a tab-close signal to the host. The IPC applicator is -the single writer for observed status. The 2026-09-09 audit sorted the other -writers: - -| Writer | Disposition | -| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| Command Code output seeds, parked-pane seeds, pty-exit removal | delete; main already emits the same facts | -| structured bridge status writes | delete; main now publishes the row | -| structured bridge failed-start row (the host refused the create) | keep; a refused create leaves the host no session, so the bridge writes it from the launch record | -| launch placeholder seeds (a user launched an agent with a prompt) | keep for now; main holds the launch config and can seed later | -| dismissal, acknowledgement, unmount | keep; user facts and component lifecycle | -| remote-runtime OSC parse (bytes never transit local main) | keep, fenced behind the host's published row once the host is new enough; rule 3 of the wire doc applies | -| web-session mirror receipt clock | keep; the decay rule needs both clocks from one machine | - -The Command Code done-settle window is renderer policy with no main -equivalent. PR 2 either moves it into main's detector or leaves it, and says -which. - -## PR 3: one rollup, one clock - -The worktree card status is derived three times: `lib/worktree-status.ts` in -the renderer, `runtime-worktree-status-projection.ts` in main, and -`agent-row-display.ts` in mobile, which hand-copies the 30-minute constant. -PR 3 moves the rollup and the decay into `src/shared` and makes all three -call it. - -## Readiness reads the store - -`terminal wait --for tui-idle` is a reader too. Before STA-9100 hook state -reached it only through the ` ready` titles the window writes, so a -headless `orca serve` never saw it (#16095). Now an agent whose rule file says -`profile.hooks: "authoritative"` (OpenCode, OpenCode 2, Pi, OMP) or -`"turn-end"` (Codex) has its fresh row read straight from the store, through the same -`selectFreshExplicitAgentStatusRow` join prompt-receipt verification uses -(`src/main/runtime/tui-idle-hook-lane.ts`): - -- the main agent's turn, not the combined row, decides: `mainAgent.state` when - published, so a subagent's Stop does not end the lead turn. `done` settles - the wait, `working` holds it, and a permission wait never settles. The tail's - blocked text goes through the existing permission arbiter with the turn as - its explicit status, so a denied prompt's dialog left in the tail no longer - blocks a turn the hook says ended; -- the row joins on any pane key or terminal handle the PTY owns; a pane neither - reaches, a stale or restored row, a session-start `done`, a row from before - the PTY respawned, and a `done` received before the pane's latest input all - leave the decision to the screen and text rules, which is also how startup - readiness works before an agent's first hook. The input is the PTY run's - `lastInputAt` (`terminal-run-facts.ts`), which both write funnels record, so - a key the user typed counts like a prompt Orca sent: the next turn's first - hook may still be in flight, and an agent restarted in the same shell has - not posted one. A shell command marker is no process boundary: Pi paints - OSC 133 zones itself; -- every other agent stays `identity-only`: Claude sends no event when an - approval is denied or Esc stops a tool, so its row can sit at `waiting` or - `working` forever, and the rules keep deciding; -- Codex is `turn-end`: only a `done` decides (it settles the wait), and a - `working` or permission row leaves the decision to the rules. Before its - `Interrupt` hook an Esc mid-turn can leave the row `working`, and an older - TUI can hand its hooks to a newer shared app server, so no version check - tells which Codex posts it. A `done` is a real turn end on every version, so - trusting only that one keeps the headless gain (no quiet window after the - turn) without letting a missing cancel hang the wait. - -The titles stay for display; remote clients read them. - -## What does not change - -- The hook scripts, the OSC 9999 wire format, and the relay protocol. -- The status vocabulary. `working / blocked / done` for rows, - `working / attention / idle` for structured summaries, mapped once. -- The `live / unverifiable / exited` verdicts for remote work. Loss of contact - clears nothing; the SSH exemptions in the admission gate stay. -- Hydration honesty: a restored non-done row is `restoredUnconfirmed` and is - never fresh. - -## PR 1b reliability contract - -- **Invariant (`agent-session.status-host-ownership`):** each execution host has - one agent-status store; OSC, hooks, and structured sessions write it, while - desktop, `worktree ps`, and mobile only project it. Dismissal, certified PTY - exit, and provider-generation replacement remove the same row everywhere; - transport loss alone removes nothing. -- **Failure source:** the deleted runtime row store duplicated OSC observations, - keyed them by a different terminal identity, and outlived a dismissal from the - hook store. Relay replay could also make old evidence look fresh when readers - used its new delivery timestamp. -- **Oracle:** one OSC observation appears through the hook snapshot in - `worktree ps` and mobile, and one store dismissal removes it from both without - stopping the PTY. Focused tests also require leaf/incarnation-handle rejoin, - legacy numeric-pane compatibility, certified-exit and provider-generation - cleanup, evidence-age freshness, and exactly-once startup/stop teardown. -- **Gate:** `terminal-performance.osc-status-scan-budget` covers the unchanged - bounded OSC parser and the runtime projection. There is not yet a dedicated - blocking multi-surface status-store gate; the focused suites below are the - accepted gap until they accumulate reliability-gate soak evidence. -- **Provider/platform coverage:** local and daemon-backed PTYs are covered by - runtime tests, and SSH relay loss/replay semantics by relay integration tests. - The projection is shared by git worktrees and folder workspaces. WSL uses the - same store and admission code but has no live run here; Linux and Windows - runtime execution, native mobile clients, and mixed-version paired clients - remain validation gaps. -- **Performance budget:** publication stays event-driven with no new polling or - subprocesses. One mobile projection clones the status snapshot once, builds - pane/handle indexes once, and has a deterministic call-count test; lifecycle - cleanup is bounded by the existing status and handle inventories, and orcad - tests prove listeners clean up once on failed startup and repeated stop. -- **Diagnostics:** existing hook-listener errors name the pane and PTY, while - status-store tests pin delivery versus evidence clocks. No new telemetry or - raw terminal data is emitted. -- **Residual gaps:** rendered Electron/mobile behavior, live SSH reconnect, and - Linux/Windows/WSL execution require the platform QA pass. The current - cross-version gate does not cover `session.tabs` content. - -## Verification - -- Unit: ingest a structured summary and read it back through - `getStatusSnapshot`, `worktree ps`, and the mobile projection; assert the - serializer never writes a row carrying `structuredHost`; assert a hydrated - file that somehow contains one is dropped. -- Unit: the `worktree ps` suites written against the retained store are rewired - to a real `AgentHookServer` (`agent-status-store-wiring.test-fixture.ts`) - rather than deleted, so each still asserts the listing behavior it named. The - dismissal change is pinned end to end in - `orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts`, which fails with - the retained store restored. -- Live: the parity check from #19217 (working, done, close, reload) repeated - against the merged store, with both surfaces read from the one row. - -## Retired OMP pane recovery - -A desktop renderer retirement carries an optional UUID through the existing -`agentStatus:retirePaneAuthority` IPC message. The hook server retains it with -its bounded retirement fence. A validated live OMP new turn consumes that UUID -and echoes `authorityRestartId` only in the live notification. Cached rows, -persistence and startup replay never carry the acknowledgement. Older peers -omit or ignore it and retain explicit attach restoration. - -The renderer keeps the UUID in its existing non-persisted retirement tombstone; -every re-retirement mints a new one. A matching acknowledgement may clear that -tombstone only with a successful status write for the existing pane and matching -workspace/connection. Closed tombstones remain `true`, including after the tab -LRU evicts its entry. Closing a retired physical alias revokes its whole group. -This is control-plane retirement correlation, not a second agent-status store. - -Fallback restores the hook server's recorded status aliases through the existing -attach-restoration path. The accepted renderer write restores the matching status -alias routes too, preserving group membership for the next retirement. It does -not restore orchestration or launch credentials. -It is scoped to the requesting desktop renderer. A different window's retirement -UUID cannot be cleared by the acknowledgement, and web mirrors keep their existing -host-snapshot/attach behavior. diff --git a/docs/reference/antigravity-native-accounts.md b/docs/reference/antigravity-native-accounts.md deleted file mode 100644 index 29fe943afded..000000000000 --- a/docs/reference/antigravity-native-accounts.md +++ /dev/null @@ -1,90 +0,0 @@ -# Native Antigravity Accounts - -Accounts reads the credential authority on the runtime that owns execution. A client chooses -an owning Orca runtime and a host/distro target before sending an operation; it never replaces -the client's Mac Keychain item for another host. The RPC capability is -`accounts.antigravity-native.v1`. Older paired hosts are refused before account mutations. -The RPC returns account summaries only, never credential JSON, access tokens or refresh tokens. -Displayed quota is tied to the subject and authentication method observed during its refresh; -an external identity change hides the previous account's quota without an automatic fetch. - -## Supported authority - -Normal macOS agy uses service `gemini`, account `antigravity`. Its go-keyring values use the -base64 or legacy hex wrapper. Orca passes writes through `security -i` stdin, validates bounded -output and reads the entire native value back. The command buffer limit is checked before -writing. A missing native item falls back to the CLI-specific -`~/.gemini/antigravity-cli/antigravity-oauth-token` file. The distinct legacy jetski fallback -is not imported. - -The compiled CLI bypasses keyring storage when SSH/WSL environment detectors or WSL kernel -identity apply. A runtime running under that evidenced bypass reads/writes its own CLI file; -it does not contact the client keychain. The file must be private and regular. A macOS -`cache/antigravity-keyring-unavailable` marker makes authority uncertain: Orca refuses instead -of assuming that the keychain or file wins. - -Native Windows Credential Manager, native Linux Secret Service, and operations directed from -Windows Orca to a selected WSL distro are explicitly unsupported pending verified adapters. -Windows file bypass is also refused until private ACL protection is verified. -Windows' `gemini:antigravity` raw blob and 2560-byte limit are different from the Mac wrapper; -Linux uses the login collection with `service=gemini`, `username=antigravity`. No dependency, -PowerShell compilation, credential-home flag, or cross-host fallback is invented here. -A separate SSH relay has no Accounts RPC; use a paired owning runtime that implements it. - -## Identity and snapshots - -A Google ID token supplies the normalized Google issuer and stable subject. The authentication -method also scopes identity. The label uses a verified email when available; email is never the -identity key. Account record IDs are random and survive token, expiry, refresh-token and email -rotation. Profiles without a stable subject can be displayed but cannot be saved for switching. - -Snapshots preserve the exact native JSON, including fields that Orca does not interpret. The -host's vault under `userData/antigravity-accounts/vault` requires meaningful OS encryption and -private permissions. Weak or unavailable encryption is refused. Unreadable/corrupt ciphertext -is preserved; it is never treated as an empty vault. This does not migrate the experimental -candidate's incompatible array vault or token-hash IDs. - -One host service serializes Add, Select, Remove, launch checks and refresh reconciliation. -It re-reads the vault after asynchronous native reads and captures external CLI refreshes into -the same stable account. Selection reconciles the outgoing snapshot, checks the expected native -bytes before writing, and checks native readback before publishing the selected ID. It avoids -writing an old snapshot over an already-active account. The current or selected account cannot -be removed; deletion checks the latest native value again before committing. - -A selected account is checked before new Orca PTY launches, including desktop daemon and -headless runtime paths. An externally changed native identity blocks the launch and asks the -user to select again. Existing sessions can retain their original credentials in memory. -Shell commands typed manually into a running terminal are outside the Orca launch guard. - -## Sign-in and concurrency limits - -Sign-in uses the supported ordinary agy browser/code flow. Users run agy on the owning host; -to add a different account they use its `/logout` command, complete the next sign-in, then save -the actual resulting account in Orca. This implementation does not advertise an Orca-managed -login or invent an agy `login`/`--login` flag. Browser completion and a second real Google -account remain user-driven; tests do not sign out or change the developer's real native item. - -Native keyring does not expose compare-and-swap. Orca's queue serializes its own calls, and -bounded before/after checks detect observed conflicts; another independently running agy or -Orca process can still write between the final check and the write or launch. A failed -verification may mean the native item changed but selection was not persisted. Refresh and -explicit selection resolve that state; automatic rollback could destroy a newer CLI refresh -and is deliberately avoided. The file backend has the same external-writer limit. - -## Evidence and contributor credit - -The foundation adapts the reviewed codec/macOS adapter from #21784 and account-service concepts -from #21797 (nwparker), with fresh identity, persistence, serialization and conflict handling. -The signed-in Accounts card and quota-error visibility acknowledge #19588 by @artile; quota -transport is reused from current main rather than its obsolete extraction code. Targeted -multi-account UI/target concepts acknowledge #23761 by @Tai-DT, replacing its placeholder login -and unused settings selection. The Accounts legacy-Gemini clarification acknowledges #21682 -and the original relevant migration contribution by @siddqamar, as requested in #17345. -No stale development stack was cherry-picked. - -Live proof uses a disposable Mac service/account item, a fully isolated hidden Electron home, -and synthetic accounts. A private task-only copy was also selected through the real service; -installed agy 1.2.14 consumed that verified file credential under its SSH bypass and returned -`command.name=usage`, `num_turns=0`, no conversation. The real native item remained unchanged. -This proves the Mac adapter mechanics and actual CLI file authority, not a second-account -native-keychain switch, native Windows/Linux switching, or WSL/SSH relay deployment. diff --git a/docs/reference/antigravity-readiness-evidence.md b/docs/reference/antigravity-readiness-evidence.md deleted file mode 100644 index 18317d7e5cec..000000000000 --- a/docs/reference/antigravity-readiness-evidence.md +++ /dev/null @@ -1,332 +0,0 @@ -# Antigravity readiness: what the transcripts show - -Antigravity readiness lives in `src/main/runtime/agent-state-rules/antigravity.json`: a screen rule -over the trusted grid, and a text anchor that runs the named scan -`findAntigravityComposerIndex` (`agent-state-rules/antigravity-text-composer.ts`) over the -line-folded tail when no trusted grid exists. That text scan decides whether a pane is ready for a -prompt from its tail alone. It has been written five times, each version tuned -against a five-line screen typed from memory into a `.spec.ts` fixture. Three of the first four -were found worse than the bug they replaced, and the fifth was reverted. - -Real transcripts now exist. They were recorded from a live `agy` on macOS with -[`agent-pty-transcript-capture.md`](./agent-pty-transcript-capture.md) and are committed under -`src/main/runtime/__fixtures__/`. `src/main/runtime/antigravity-readiness-transcripts.test.ts` -replays them through the runtime. - -**Headline (STA-8741, agy 1.2.14): readiness is now read off the live screen, not the text tail.** -The screen's bottom four rows at an idle composer are rule, caret, rule, `? for shortcuts`. The -caret alone is not enough: agy keeps it painted mid-turn and behind the `/model` picker. The hint -row is what changes. [Attempt seven](#attempt-seven-the-live-screen-sta-8741) has the recordings; -the sections after it are the 1.2.0 history that led there. - -## Attempt seven: the live screen (STA-8741) - -Recorded 2026-09-30 on macOS with `agy` 1.2.14 (binary and banner agree), a signed-in Google -account, `AGY_CLI_HIDE_ACCOUNT_INFO=1`, in an already-trusted workspace. All are 120x40 except -where the name says otherwise. `src/main/runtime/antigravity-screen-readiness-transcripts.test.ts` -replays them. - -| Fixture (`antigravity-1-2-14-*.txt`) | Screen at the end | Screen rule | -| ------------------------------------ | --------------------------------------------------- | ----------- | -| `ready` | settled startup, bare `>` | ready | -| `ready-accept-edits` | `> Accept-edits mode: …` (`--mode accept-edits`) | ready | -| `ready-plan` | `> Plan mode: …` (`--mode plan`) | ready | -| `ready-80x24` | settled startup on an 80x24 PTY | ready | -| `picker-dismissed` | `/model` opened, then Esc | ready | -| `turn-ended` | a short turn has ended | ready | -| `model-picker` | `Switch Model` open; the bare `>` is still above it | not ready | -| `command-palette` | `> /` with the palette, hint `esc to cancel` | not ready | -| `busy-thinking` | `Generating...` spinner, bare `>`, `esc to cancel` | not ready | -| `busy-streaming` | answer streaming, bare `>`, `esc to cancel` | not ready | -| `trust-dialog` | untrusted folder, alternate-screen trust menu | not ready | -| `draft` | unsent text in the composer; the hint row is blank | not ready | - -What they show: - -- **The text tail misses three ready screens.** On `ready-accept-edits`, `ready-plan` and - `turn-ended` the line-folded tail never satisfies `findAntigravityComposerIndex`; the screen - rule does. (`turn-ended` is the 1.2.14 form of the old `busy-turn-ended` known defect.) -- **A caret rule is wrong on the screen.** The grid keeps the bare `>` through a turn and behind - the picker. The text tail happened to lose it mid-turn (section 8); the screen does not. -- **The rule can read ready for a moment mid-turn.** Replayed in 64-byte chunks, the submit repaint - clears the composer a moment before `? for shortcuts` becomes `esc to cancel`. So a pane with an - output clock is held to the same 3s quiescence as Codex (tier 1b in `tui-idle-evidence.ts`); an - idle agy goes silent within a second and a turn keeps repainting its spinner. A restored pane with no - clock settles from the screen alone. -- **A name-only title settled the picker.** With the pane titled `agy`, the old ranking reached - its weak title lane and settled with `/model` open. When a trustworthy screen exists it now - decides, and that lane stays shut. -- **A mismatched grid garbles the chrome.** At 80x24, 100x30 or 60x20 the 120x40 recordings lose - the four-row shape, and resizing the model does not make the TUI repaint. So the live screen - counts only when its grid matches the PTY's reported size and is not a reflow the TUI never - repainted for (a re-attach that learned the real size late; a later PTY resize off that size - repaints it). Otherwise every pre-existing lane (text rules, title, quiet process) decides, as - before this change. -- **A readable screen outranks the text.** When the grid is trustworthy and refuses, the text rules - do not overrule it, in the ready-prompt tier or the quiet one; they decide only when there is no - trustworthy grid. The recorded picker-dismissed text over a model-picker screen proves it. - -The visible-read probe's Antigravity branch is retired. It read the provider screen with a looser -rule (any caret after the banner) whenever the pane was Antigravity. The probe now runs the shared -rule for every screen-ruled agent, only for a pane with no output clock. A clocked pane settles -through the poll. The probe's screen read is the draft-blanking read projection, so it restores the -blanked composer row before the rule reads it (Cline's `❯ Ask anything...` otherwise reads as a -bare `❯`). - -Still not captured: a tool-permission prompt (the operator's `toolPermission` is -`always-proceed`, and changing it means editing their settings), and the sign-in, theme, privacy and -update dialogs, for the reasons in the table below. Windows and Linux are unrecorded. - -The 1.2.0 fixtures below were recorded before the recorder stopped writing ahead of shutdown. Their -last bytes are agy's exit teardown (`ESC[J ESC[?2004l`), so their final screens have no hint row. - -## Versions - -| Thing | Value | -| ------------------------- | ----------------------------- | -| `agy --version` | `1.1.25` | -| Banner printed by the TUI | `Antigravity CLI 1.2.0` | -| Captured | 2026-09-10, macOS, 120x40 PTY | - -The binary and its own banner disagree. Any rule keyed to a version string must read the banner, -not `--version`, and must tolerate the two disagreeing. - -## What the captures are - -| Fixture | What it is | -| -------------------------------------------- | --------------------------------------------------------- | -| `antigravity-ready-api-key-gemini-model.txt` | Ready screen, API-key identity, Gemini 3.7 Flash (Low) | -| `antigravity-ready-account-info-hidden.txt` | The same ready screen with `AGY_CLI_HIDE_ACCOUNT_INFO=1` | -| `antigravity-dialog-trust-workspace.txt` | Workspace trust dialog, live and unanswered | -| `antigravity-dialog-model-picker.txt` | `/model` picker, live and unanswered | -| `antigravity-dialog-command-palette.txt` | Slash-command palette, live and unanswered | -| `antigravity-dialog-dismissed.txt` | `/model` picker dismissed with esc, then settled | -| `antigravity-busy-mid-turn.txt` | A real turn, recording stopped while the spinner was live | -| `antigravity-busy-turn-ended.txt` | The same turn after it ended and the composer returned | - -## What could not be captured, and why - -Nothing below was faked. Each is a case the recorder could not reach without changing the -operator's account state or configuration, which is out of bounds. - -| Missing | Why | -| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `antigravity-ready-business-non-gemini.txt` | This machine has no OAuth session — the CLI prints _"You are currently not signed in"_ and authenticates from `GEMINI_API_KEY`. Reaching a Business ready screen means signing someone in. | -| A non-Gemini model on any ready screen | `agy models` offers 11 models, all Gemini, and `settings.json` pins `modelProvider: gemini`. A non-Gemini row is not reachable from this account. | -| `antigravity-dialog-sign-in.txt` | Unsetting `GEMINI_API_KEY` does not reach the sign-in dialog; the CLI refuses to start because `modelProvider` is pinned. Reaching it means editing the operator's `settings.json`. | -| `antigravity-dialog-theme-picker.txt` | There is no `/theme` command in 1.2.0 (`Unknown command: /theme`). The picker appears only in first-run onboarding, which means deleting the operator's config. | -| `antigravity-dialog-privacy-notice.txt` | First-run onboarding, as above. | -| `antigravity-dialog-update-banner.txt` | Cannot be forced; no update was pending during the session. | - -Each remains as a named, skipping case in the suite so it is visible rather than forgotten. - -## What the transcripts show - -### 1. The ready screen's model row is not at the start of a line - -The ready screen prints a block-glyph logo down the left, and the identity, model and path rows are -painted **on the same physical lines as the logo**. What Orca derives is: - -``` -▀▀▀▀▀▀ Gemini API key -▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) -▄▀▀ ▀▀▄ ~ -``` - -The earlier detector required `normalized.startsWith('gemini', trimmedStart)` on a trimmed line. The -trimmed line starts with `▀`, so that rule never matched. The shipped detector uses the bare -composer caret instead. Measured three ways on the real screen: - -| Input | `isKnownReadyPromptPreview` | -| ------------------------------------------------------ | --------------------------- | -| Real ready screen | `true` | -| The same screen with the logo glyphs stripped | `true` | -| Real ready screen followed by the live `/model` picker | `false` | - -So the logo — decoration, and suppressible with `AGY_CLI_HIDE_LOGO` — no longer decides readiness, -and the live dialog cannot reuse the stale composer caret as a ready signal. - -### 2. The dialog used to satisfy the model rule - -`/model` prints its options one per line: - -``` -Gemini 3.8 Flash -> Gemini 3.7 Flash (current) -Gemini 3.1 Pro -``` - -Those lines _do_ begin with `Gemini`, and a bare `>` composer line sits earlier in the same tail -from before the picker opened. Both halves of the old rule were satisfied **while a dialog owned the -screen**, and the pane read ready. The shipped detector now recognizes the active `Switch Model` -surface and rejects that stale caret until it sees `Exited /model command`. - -### 3. `>` is the dialog selection marker, not only the composer caret - -Every dialog uses `>` to mark the highlighted row: `> Yes, I trust this folder`, -`> Gemini 3.7 Flash (current)`, `> /add-dir`. The idle composer is a line whose whole trimmed -content is `>`. That distinction is the only thing separating them, which means the relaxation -proposed in PRs #15840 and #15852 — accept any line _beginning_ with `>` — would make the trust -dialog and the model picker read as ready. On 1.2.0 the idle composer is a bare `>`; those PRs' -1.1.17 mode-banner claim could not be reproduced here and may be mode-specific. - -### 4. There is no email account row, and the row can be switched off entirely - -For an API-key user the identity row reads literally `Gemini API key`. There is no `@`, no -domain, nothing an account-row rule can key on. Separately, `AGY_CLI_HIDE_ACCOUNT_INFO=1` — a -supported environment variable in the binary — removes the row from a fully ready screen, which -`antigravity-ready-account-info-hidden.txt` captures. - -### 5. Dialogs are drawn two different ways, and the banner is never reprinted - -The trust dialog and the sign-in splash take the **alternate screen** (`ESC[?1049h` … `ESC[?1049l`). -The model picker and command palette are drawn **in place on the main screen** with erase-to-EOL. -After dismissal the CLI prints `⎿ Exited /model command` and redraws the composer — it does **not** -reprint the banner. The header stays where it was at startup. - -### 6. Rows are positioned with cursor addressing, not newlines - -The status row is written with absolute and relative moves (`ESC[13;99H`, `ESC[83X ESC[83C`), so -`? for shortcuts` and `Gemini 3.7 Flash · low` end up on one derived line. Any rule that assumes -one screen row equals one `\n`-delimited line is reading a different document than the user sees. - -## 8. Busy frames park the caret exactly like idle frames — the spinner is what differs - -The frame that ends a turn-in-progress and the frame that ends an idle screen park the cursor with -the **same bytes**. Only the hint row differs, and the park erases it: - -``` -idle: ? for shortcuts ESC[83X ESC[83C Gemini 3.7 Flash · low CR ESC[2A ESC[2C ESC[?25h -busy: esc to cancel ESC[85X ESC[85C Gemini 3.7 Flash · low CR ESC[2A ESC[2C ESC[?25h -``` - -So a rule that keys on "the caret is the last thing in the tail" cannot tell busy from idle **on the -frame alone**. What saves it is what comes next. Each spinner tick is its own repaint with its own -park, two rows higher than the frame's: - -``` -ESC[?25l CR ESC[2A ⣯ Generating ESC[11D ESC[?25h -ESC[?25l CR ESC[2A ⣟ Generating. ESC[12D ESC[?25h -``` - -That second `CR ESC[2A` splices the composer row away, so the retained tail during a live turn ends -on the spinner row, not on the caret. Measured on `antigravity-busy-mid-turn.txt`: - -| Capture | last retained line | bare `>` line present | -| -------------------------------------------- | ------------------ | --------------------- | -| `antigravity-ready-api-key-gemini-model.txt` | `>` | **yes** | -| `antigravity-busy-mid-turn.txt` | `⣟ Generating...` | **no** | - -**Consequence for a caret-based rule:** it already answers "not ready" for a real mid-turn capture, -because there is no bare caret in the tail to match. A constructed input that keeps the park bytes -and only edits the status text is not faithful to a live turn — a live turn has a spinner row -repainting _below_ the composer. - -**The residual window, and the clause it implies.** Between a frame park and the next spinner tick -the tail does end on the bare caret and is indistinguishable from idle. The gap is one tick -interval. Any readiness path gated on sustained quiescence is safe, because ticks keep arriving and -the pane is never quiet; a path that only inspects retained text is not. For those paths the -evidence supports one clause, and only one: - -> **A braille glyph (U+2800–U+28FF) on the last visible line of the retained tail means working.** - -That predicate already exists in this file for cursor-agent (`CURSOR_BUSY_SPINNER_RE`) and should be -reused rather than reinvented. It must be scoped to the **last visible line**, not the whole tail: -a first-run transcript prints `⠾ Signing in...` during startup, which would otherwise pin a ready -screen as busy forever. - -Nothing else in the capture distinguishes the two states. The hint row (`esc to cancel` versus -`? for shortcuts`) is erased by the park in both cases, the park offsets are identical, and -`ESC[?25l`/`ESC[?25h` fencing appears around every repaint, idle or busy. - -## Confirmed / refuted, by attempt - -Evidence column names the fixture; all quoted text is from the committed transcripts. - -### Attempt 1 — the rule at HEAD - -| # | Claim | Verdict | Evidence | -| ---- | -------------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1.1 | A ready screen prints the banner `Antigravity CLI` | **Confirmed** | `Antigravity CLI 1.2.0` in both ready fixtures | -| 1.1b | …and its last occurrence in the tail is the live one | **Refuted** | The trust dialog's own body says _"Antigravity CLI requires permission to read, edit, and execute files here"_, so `lastIndexOf` lands inside the dialog | -| 1.2 | The model row begins with the vendor word `Gemini` | **Refuted** | `▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)` — the logo precedes it; never at line start | -| 1.3 | The caret line's whole trimmed content is `>` | **Confirmed** on 1.2.0 idle | bare `>` in both ready fixtures | -| 1.3b | …and only the composer prints `>` | **Refuted** | `> Yes, I trust this folder`, `> Gemini 3.7 Flash (current)`, `> /add-dir` | -| 1.4 | A ready screen prints the workspace path on its own line | **Refuted** | the path shares its line with logo glyphs (`▄▀▀ ▀▀▄ ~`) | - -### Attempt 2 (loop 1) — blacklist the model line - -| # | Claim | Verdict | Evidence | -| --- | ------------------------------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------- | -| 2.1 | Dialog model-row wording is enumerable | **Refuted** | the palette lists 50+ commands with free-form descriptions; the picker prints whatever models the account offers | -| 2.2 | A dialog never reproduces a real model row | **Refuted** | the `/model` picker prints four real model rows, one per line, at line start | - -### Attempt 3 (loop 2) — structural ordering on `headerIndex` - -| # | Claim | Verdict | Evidence | -| --- | -------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------- | -| 3.1 | A live dialog is printed below the ready chrome | **Confirmed** for in-place dialogs | picker and palette append below the composer | -| 3.2 | The banner is reprinted when a dialog is dismissed | **Refuted** | `antigravity-dialog-dismissed.txt` shows `⎿ Exited /model command` and a redrawn composer, no banner | -| 3.3 | Antigravity does not use the alternate screen | **Refuted** | `ESC[?1049h` opens the trust dialog and the sign-in splash | -| 3.4 | No full repaint per keystroke | **Partly refuted** | typing `/mod` repaints the palette region on each keystroke with `ESC[K` | - -Because of 3.2, `headerIndex` cannot be the anchor: it never advances. Ordering can only be -expressed against the model/caret positions, which is what 1.2 and 1.3b just invalidated. - -### Attempt 4 (loop 3) — require a positive account row - -| # | Claim | Verdict | Evidence | -| --- | ---------------------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 4.1 | Every ready screen prints an account row | **Refuted, twice** | API-key identity prints `Gemini API key` (no `@`); `AGY_CLI_HIDE_ACCOUNT_INFO=1` removes the row entirely | -| 4.2 | A startup dialog never contains an `@`-and-`.` token | **Not reachable here** | none of the captured dialogs contains one, but the palette shows free-form skill descriptions, which are user-authored text | -| 4.3 | The account row is distinguishable from prose | **Refuted** | the row is not a distinct line; it shares one with the logo | - -### Attempt 5 (PR #19749, reverted) — ordering + account row - -| # | Claim | Verdict | Evidence | -| --- | -------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 5.1 | Ordering plus an account row separates ready from dialog | **Refuted** | the account row is optional (4.1) and the ordering anchor never moves (3.2) | -| 5.2 | Executing both builds was sufficient verification | **Refuted** | the executed input was the hand-written fixture, so the check reproduced the fixture's assumptions. The real screen disagrees with that fixture on the model row, the path row and the account row | -| 5.3 | The wedge is a model-name problem | **Refuted** | it is a line-start problem. Even `Gemini 3.7 Flash (Low)` — a Gemini model — fails, because a logo glyph precedes it | - -### Cross-cutting - -| # | Question | Answer | -| --- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| X1 | Does `agy` set an OSC title distinguishing busy from idle? | **No.** Not one OSC title sequence appears in any capture. Title-based readiness is unavailable for this agent | -| X2 | Does it repaint with bare `\r`? | **Yes**, constantly, plus `ESC[K` and absolute cursor moves | -| X3 | Does the caret survive in the tail? | **Yes** — a bare `>` line is present in every ready capture | -| X4 | Banner-to-caret distance | ~8 derived lines on a 120x40 PTY; the banner falls outside the 6-line preview window, so only the full retained tail can see it | -| X5 | Pane title on the trust screen versus ready | Identical: none | - -## Attempt six is shipped - -Yes — but not as a variation on any of the five. Every one of them refined a predicate over -`\n`-delimited lines, and that is the layer where the evidence says the information is not. - -What the captures support and the shipped detector now does: - -- **The one stable, dialog-free ready marker is a line whose entire trimmed content is `>`.** It is - present in every ready capture and absent from every dialog capture, because a dialog's `>` always - carries its selected row's label. This is a much narrower rule than any attempt used, and it is - the only one that survived contact with the transcripts. -- **Drop the model-row requirement.** The model rows match dialogs and not the ready screen, so - keeping that requirement inverted the detector. -- **Do not require an account row.** It is optional by environment variable and carries no email for - API-key users. -- **Veto an active model picker.** `Switch Model` followed by a labeled selection row means the - bare caret belongs to the composer behind the picker; readiness resumes after `Exited /model -command`. -- **Do not anchor on `headerIndex`.** The banner is printed once and never reprinted. -- **The blocked-signal path already works** for the trust dialog: `antigravity-dialog-trust-workspace.txt` - is correctly refused today, by wording, not by structure. - -What is still unknown and should be captured: the sign-in, theme, privacy and update dialogs, and -any ready screen where the composer is not idle (accept-edits and plan mode, which PRs #15840 and -#15852 describe from a screenshot). A bare-`>` rule is only as good as the claim that those modes -still end on a bare `>`; that claim is untested. - -The honest summary is that this is a screen-shaped problem being solved with line-shaped tools. A -rule over the derived tail can be made much better than what ships today, but the durable fix is to -ask the terminal emulator what the bottom row of the screen actually is, rather than inferring it -from a byte stream that was written with cursor addressing. Attempt seven does that. diff --git a/docs/reference/antivirus-prerelease-clearance.md b/docs/reference/antivirus-prerelease-clearance.md deleted file mode 100644 index 9efcfb3aaa01..000000000000 --- a/docs/reference/antivirus-prerelease-clearance.md +++ /dev/null @@ -1,117 +0,0 @@ -# Antivirus clearance for future releases - -Orca collects a steady stream of antivirus and EDR false positives — see the -tracking issue for the current grouping. This document covers the part of that -problem worth engineering effort: **stopping the next release from being -flagged.** - -Clearing a *historic* release is explicitly not a goal. A user sitting on a -flagged build should update to a cleared one, not wait for a vendor to whitelist -a version we no longer ship. Retroactive submissions cost the same effort per -vendor and expire the moment we cut a new version. - -For the behavioural side of the problem — the process-tree shapes EDR scores, -which no whitelist fixes — read -[`windows-edr-posture.md`](./windows-edr-posture.md) instead. This document is -about file verdicts on the bytes we ship. - -## The two mechanisms, and only one of them scales - -**Sample submission** clears one build. You send the flagged file to a vendor's -analyst portal, they confirm it is clean, and the verdict is dropped from their -next definition update. This is reactive and per-release: cutting a new version -produces new bytes, new hashes, and a fresh chance of the same heuristic firing. -Doing this every release, across every vendor, is not sustainable. - -**Signer and product whitelisting** clears every future build. The vendor -records the publisher identity or enrolls the product in a dynamic allowlist, and -subsequent releases inherit that trust without another submission. Enrollment is -one-time work per vendor, and it is the only lever that scales with our release -cadence. - -Prefer enrollment. Use submission only to clear a live incident while enrollment -is pending, and only for a release users are actually expected to install. - -## Prerequisites that make enrollment possible - -None of these programs will accept an unsigned or anonymous binary, so these -come first: - -1. **Every shipped PE is Authenticode-signed, and CI fails the release if not.** - Done as of v1.4.217 — the release workflow requires a valid SignPath - Foundation signature on the inner binaries and no longer fails open. -2. **Every shipped PE carries real provenance** — company, product, version, - description, and an explicit `asInvoker` manifest. An anonymous binary scores - worse than an identified one, and several portals reject submissions that - carry no version metadata. -3. **One stable signer identity.** Vendor allowlists key on the certificate - subject. Rotating signers resets accrued reputation, so a certificate change - is a re-enrollment event, not a transparent swap. - -## Vendor programs - -Enrollment state is deliberately left as a task here rather than asserted — fill -each in as it is confirmed, and record the account that owns it so a lapsed -enrollment is traceable. - -| Vendor | Mechanism | Scope | State | -| ------------------------- | ---------------------------------------------------------------------------- | ------------------------ | ----- | -| **VirusTotal** | Monitor — paid; builds rescanned daily, developer and vendor both notified | ~70 engines at once | TODO | -| **Microsoft** | Defender Security Intelligence submission, as a software developer | Defender, Defender FP EP | TODO | -| **Microsoft** | Trusted Signing, or an EV certificate, for SmartScreen and Smart App Control | Reputation gates | TODO | -| **Kaspersky** | Whitelist Program — vendors submit builds for the Dynamic Allowlist | Endpoint, all platforms | TODO | -| **Trend Micro** | Certified Safe Software Service — pre-release software whitelisting | Endpoint, Virus Buster | TODO | -| **Bitdefender** | False-positive submission for software vendors | Endpoint, ATD | TODO | -| **Avast / AVG / Norton** | Gen Digital false-positive and whitelisting channels | Consumer suites | TODO | -| **ESET** | False-positive sample submission | Endpoint | TODO | -| **Tencent iOA** | No public developer channel found; needs a support relationship | iOA, macOS and Windows | TODO | - -VirusTotal Monitor is the highest-leverage single entry, because it is the only -channel built for exactly this workflow: uploads sit in a private store, get -rescanned daily against every engine's current signatures, and when one flags a -file **both we and that vendor are notified automatically**. Pre-publish upload -is a supported use, which is precisely the future-release posture we want. It is -a paid service, monetised on developers and free to the antivirus vendors. - -Be honest about its limit: VirusTotal states plainly that Monitor is not a free -pass to get a file whitelisted. Vendors sometimes keep a detection. What it -reliably buys is *early notice and a real contact path* instead of discovering a -verdict from a user's issue report weeks later. - -Do not treat a plain VirusTotal *scan* as equivalent. A scan tells us a verdict -exists; Monitor is what routes it to someone who can drop it. - -If the subscription is not worth it, the free fallback is the community-maintained -false-positive contact directory (`yaronelh/False-Positive-Center` on GitHub), -which collects the submission addresses and forms each vendor actually reads. -That replaces the hardest part of a submission — finding the right contact — but -keeps the per-release effort that Monitor removes. - -## Where this lands in the release flow - -The check belongs at RC time, not after a stable cut — a verdict discovered after -publication is a verdict users already hit. - -`config/scripts/scan-release-artifacts-antivirus.mjs` reports the current -detection state of built artifacts by hash. Run it against an RC's artifacts, and -treat any engine verdict as a release-blocking question rather than an automatic -stop: these are third-party ML classifiers, so a hard gate on their output would -fail the release for reasons outside our control. Read the report, decide, and -submit if the flagged build is one we intend to ship. - -The script looks up hashes by default and never transmits artifact bytes. Passing -`--upload` sends the file to VirusTotal, which distributes samples to partner -vendors — that is the intended outcome for clearance work, but it is a -publication, so it stays opt-in and out of any automated path. - -## What not to do - -- **Do not ask users to add exclusions** as the resolution. It suppresses the - symptom on one machine, and in several reports here the exclusion did not even - hold because the detection was behavioural rather than path-based. -- **Do not dispute a verdict without a sample.** Every report worth acting on in - this project came with a hash that we verified bit-identical to the published - release asset. That verification is what makes a submission credible. -- **Do not chase a vendor whose detection we cannot reproduce or name.** Route - those back to the reporter for the detection string and the exact flagged path - first. diff --git a/docs/reference/ci-demand-rollout.md b/docs/reference/ci-demand-rollout.md deleted file mode 100644 index d8092ef412f8..000000000000 --- a/docs/reference/ci-demand-rollout.md +++ /dev/null @@ -1,173 +0,0 @@ -# CI demand rollout - -This implements the September 28 runner-demand analysis. The baseline inventory -covered September 27 04:00–September 28 04:00 UTC: 4,028 workflow runs, with -463 stratified job samples. Estimated occupancy was 1,081 runner-hours, dominated -by PR unit shards (414 hours) and Bun qualification (262 hours, since replaced by the -pinned-Node headless lanes). These are -sampled sums of job durations across different runner pools, not billing totals -or a guaranteed forecast of savings. - -## What runs now - -| Work | Ordinary draft update | Ready PR / final checks | Main reference | -| -------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| Static analysis and types | Immediately | Immediately | Existing workflows | -| Unit suite | Full, with shadow selection evidence | Full | Existing daily Node 24/26 x86 suite | -| Packages | After successful static analysis and types | Same | Existing release workflows | -| Headless Node persistence | Deferred until ready | Linux x64 for ordinary runtime changes; explicit platform families or all six for sensitive inputs | All six platforms for relevant main pushes; full nightly qualification at 11:30 UTC | -| Headless Node glibc/musl qualification | Deferred until ready | Linux-specific or full qualification, after persistence succeeds | Both architectures for relevant main pushes and nightly qualification | -| E2E | Existing targeted routing | Existing targeted routing | One complete run at 17:00 UTC | - -Headless draft updates carry no verdict; readiness starts the checks. Relevant -ready PRs retain Linux x64 smoke coverage. Explicit Windows or macOS paths add both -architectures in that family, while Linux paths add Linux ARM and the glibc/musl -lanes. Root/toolchain inputs, native build inputs, shared execution/storage paths, -SSH, providers and relay changes retain every platform. Missing or incomplete -change evidence and failed import analysis also retain full qualification. -Unrelated changes skip through the dependency classifier. Relevant main pushes -qualify all six platforms and both Linux compatibility architectures; detection -uses the whole push before qualification can supersede an older relevant run. - -The detector checks known build inputs with the pinned Node toolchain first. -Other paths install dependencies for import analysis; an uncertain result never -skips qualification. This changes setup cost, not the qualification policy. - -Windows server prebuilds are cached separately by architecture and pinned build -inputs, including the runner image. Only successful qualification on main publishes -them. Consumers validate the payload and still run the pinned-Node load/spawn smoke; -a cache miss or invalid payload builds fresh. Nightly, manual and release builds -remain fresh. No native artifact is shared across platforms or ABIs. - -Expensive PR jobs wait for static/type success. This reduces fan-out for failed -or rapidly superseded commits without sleeping on a runner. Successful isolated -PRs pay the extra stage latency. Existing per-PR cancellation remains in place. -Package assertions, native boundaries, SSH/folder coverage, cache warming and -slow-test assertions are retained. - -The daemon running-work test imports the shared probe directly, with the daemon's -process inspector supplied as its callback. The renderer keeps its existing -adapter and forwarding tests. This removes a mocked renderer dependency from the -headless graph without changing the probe algorithm or skipping backend tests. -At validation, the graph fell from 6,018 inputs (1,070 renderer inputs) to 4,879 -inputs (no renderer inputs), including nine added shared-probe cases. Renderer -adapter changes no longer qualify the headless matrix; shared probe and daemon -test changes still do. Future actual renderer imports remain discoverable. - -## Unit selection rollout - -PR planning runs alongside typechecking after their shared dependency setup; an -explicit join publishes its artifact before the unit matrix can start. Static -analysis's Node 24 install also prepares the native cache before matrix fan-out. -The daily compatibility workflow retains its separate planner and cache primer. - -`ci-unit-plan.mjs` discovers the same include/exclude set as Vitest and follows -static imports, re-exports, literal dynamic imports, CommonJS requires and the -renderer aliases. Consumers of indirect filesystem/process inputs remain in the -candidate set, as do script/tool tests. Global configuration changes, deletions, -renames involving removed paths, unknown inputs and graph failures run the full -suite. A shard verifies the plan's source SHA and complete discovery list before -using it. Missing/stale artifacts fall back to full coverage, even if that means -running the suite on fewer shards. - -The initial policy is **shadow**, with every shard retained. A full run spends -`FULL_SHARD_COUNT` (five) concurrency slots: the account is charged a slot per job -rather than per core, so eight 6.5-minute shards made this matrix 68% of daily slot -demand while the arm pool queued 10.5 minutes at p95. The first -local inventory matched Vitest exactly (9,950 files at validation); representative -source changes retained roughly 88% of files because of indirect input readers. -That is evidence for conservative coverage, not evidence of the analysis's -hypothetical 50% unit-work reduction. Improvements to indirect dependency -modeling should be demonstrated against full results before expanding selection. - -Every shard uploads `unit-selection.json`, `unit-timings.json` (including module -outcomes), and its assignment. The evidence job combines these into -`unit-selection-review-attempt-N/selection-review.json`, reporting: - -- Whether every discovered file appeared once across a complete reference run. -- Failures outside the candidate set, including failures in otherwise red runs. -- Measured worker time that selection would omit; worker times overlap and are - not runner occupancy or a prediction of wall-clock savings. - -Missing/duplicate shards, stale plans, interrupted runs and unhandled errors do -not count as complete references. Diagnostic upload/report failures do not make -tests pass and do not independently fail successful tests. - -After representative complete shadow runs show no missed failures, set repository -variable `ORCA_UNIT_SELECTION_MODE=selected` to enable selection **only for draft -PRs**. Keep full ready-PR checks and the daily compatibility suite. Inspect at -least a week's evidence across renderer, main, shared, SSH and fixture changes -before promotion, including red runs rather than only successful examples. -Unknown variable values retain shadow mode. Unset the variable or set it to -`shadow` to roll back immediately. Selected runs use one to eight timing-balanced -shards based on retained work. - -Ready-for-review result reuse includes `unit full` in its source/workflow -identity. A green selected draft cannot satisfy the final full check, even if -the repository variable changes between runs. An already successful _full_ -identical-source check can still be reused. - -To inspect downloaded shard artifacts locally: - -```sh -node config/scripts/ci-unit-selection-review.mjs ARTIFACT_DIRECTORY -``` - -## Review automation - -Pullfrog recognizes the existing `Review #N [id]` and -`Review new commits on #N [id]` dispatch names. Explicit dispatchers may provide -`pull_request_number` and `head_sha`. Explicit PR identities share concurrency at the workflow boundary. Legacy review -names use a bounded lookup of the latest 100 dispatches and cancel only lower -run IDs for the same PR; a delayed older scope cannot cancel a newer review. -Unrecognized tasks are never grouped. The scope job alone has Actions write -permission for ordered cancellation. Closed PRs and explicitly stale heads -are skipped. A second head check prevents starting an agent after its queued -head has changed. Unrecognized agent tasks remain independent; lookup failures -also retain an independent task rather than cancelling unrelated work. - -This does not introduce a fixed debounce interval or remove final reviews. -Dispatchers should supply `head_sha` for reliable stale-at-dispatch detection; -legacy names identify a PR but do not prove which head the prompt describes. - -## E2E signal - -The daily reference still executes all shards and keeps original verdicts. Each -shard uploads Playwright JSON and publishes expected, skipped, unexpected, flaky -and startup-error counts with the failing test names/messages. Targeted PR and -manual coverage remain available. This change does not fix the historically -red tests or pretend they pass. - -`config/e2e-failure-tracking.json` can separate an evidenced repeated failure -from new failures in the summary. Each entry must have exact `file`, full -`title`, `project`, a nonempty stable `message` substring, an `@owner`, a linked -repository `issue`, and an ISO `expires` review date. Expired/malformed entries -are ignored and reported; changed error signatures appear as untracked. Entries -never skip a test or change its exit status. The initial list is empty because -the analysis established red workflows but did not establish owners and -reproductions for individual failures. Do not blanket-baseline an entire red run. - -## Capacity measurements and acceptance - -`CI runner demand` runs daily at 04:23 UTC and can be dispatched manually. It -reads the previous 24 complete hours in hourly pages, samples up to six runs per -workflow/outcome stratum, and fetches job pages with bounded concurrency. An -hour exceeding the API's 1,000-result search cap fails visibly. The report and -raw evidence are retained for 30 days. No extra runner pool is provisioned. - -The report measures the full job durations of runs **created** in the window, -not occupancy clipped to the window: earlier runs that overlap it are excluded, -and completed sampled jobs may finish after it. This matches the baseline -cohort method. Workflow IDs keep ref-qualified paths in one sampling stratum. - -The report shows weighted runner-hours and cancelled-run hours per workflow, -runner-minutes per completed PR _run_, and weighted queue/provisioning p95 per -runner label. It counts latest attempts only, excludes incomplete jobs, and -retains zero-job observations. It does not measure other repositories competing -for organization capacity. Compare equivalent traffic windows, not raw totals -alone. The collector needs only `contents: read` and `actions: read`. - -After a week, compare runner-minutes per PR run, cancellation occupancy and -queue p95 in each affected pool. Count newly added planning/reference overhead. -A 25–35% overall reduction remains an experiment target, not an achieved result; -selection, coalescing and matrix reductions overlap and cannot simply be added. diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md deleted file mode 100644 index aa8146649bb9..000000000000 --- a/docs/reference/ci-runner-efficiency.md +++ /dev/null @@ -1,2629 +0,0 @@ -# CI efficiency and runner capacity - -The [September 28 demand rollout](ci-demand-rollout.md) documents staged checks, -unit-selection evidence, headless runtime qualification, review cancellation and daily occupancy reports. - -## Headless server follow-up - -[PR #24527](https://github.com/stablyai/orca/pull/24527) adds dependency detection to -main pushes. Unrelated pushes skip qualification; relevant pushes still run all -six persistence targets and five Linux compatibility jobs. Explicit Windows or -Mac PR paths select both architectures plus a Linux smoke, while shared -execution/storage changes, SSH/provider/relay inputs, native inputs, manifests, and incomplete evidence -retain the full matrix. Main pushes use the same validated exact Windows slot -cache as PRs; nightly and release builds still compile freshly. - -Main detection runs cannot cancel each other. Only eligible qualification jobs -share main concurrency groups, so an unrelated push cannot cancel needed tests. -Release templates, explicit refs, and nightly runs remain isolated. - -Draft PRs have no server verdict, so their detector is also skipped. The existing -`ready_for_review` event performs detection and qualification once the PR is ready. -This removes the checkout and dependency installation for a result whose platform -jobs were already ineligible. - -The glibc 2.28 prerequisite step checks all five tools before installing anything. -The pinned ARM image already supplies them, including Git 2.55.0 built under -`/usr/local/bin`; installing the Git RPM does not change the Git on PATH. A -missing-tool fallback still installs the original package list and disables EPEL -for that one command. In -the baseline x64 log, EPEL metadata took 4 minutes 50 seconds to download although -every installed package came from AlmaLinux BaseOS or AppStream. The package list, -compiler image, libc floor, native smoke, and persistence tests stay unchanged. -The [DNF command reference](https://dnf.readthedocs.io/en/stable/command_ref.html) -defines `--disablerepo` as a temporary command-level filter, so later commands -retain the image's repository configuration. - -The [PR qualification run](https://github.com/stablyai/orca/actions/runs/36972824276/job/110734327685) -passed the x64 floor native smoke and persistence suite. Its prerequisite step -finished within GitHub's one-second timing resolution, compared with 5 minutes -37 seconds in the baseline job; the supplied tools needed no package install. -This measures that step, not the complete workflow or its queue time. - -A [completed main run](https://github.com/stablyai/orca/actions/runs/36962172614) -used 42 aggregate runner-minutes across 11 test jobs. The -[latest daily demand report](https://github.com/stablyai/orca/actions/runs/36965354205) -estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are -baseline observations; post-merge savings have not yet been measured. - -## October 4 clock, byte and import test fixtures - -These changes retain production behavior, original case names and platform -outcomes. The paired pilots use three alternating one-worker Node 24 invocations -on Ubuntu 24 ARM. Every median below is a complete focused test invocation; -they do not establish whole-shard savings or queue-delay improvements. - -| Workload | Baseline median | Candidate median | Reduction | Hosted evidence | -| ---------------------------------------------------- | --------------- | ---------------- | --------- | ------------------------------------------------------------------------ | -| Codex settlement and Claude stop deadlines, 35 cases | 35.914s | 10.142s | 71.8% | [37186232658](https://github.com/stablyai/orca/actions/runs/37186232658) | -| Native-chat delivery, 15 cases | 22.321s | 7.376s | 67.0% | [37183731823](https://github.com/stablyai/orca/actions/runs/37183731823) | -| Six profile-storage byte suites, 118 cases | 12.629s | 9.978s | 21.0% | [37183141654](https://github.com/stablyai/orca/actions/runs/37183141654) | -| Encrypted account storage, six cases | 18.277s | 0.958s | 94.8% | [37184007241](https://github.com/stablyai/orca/actions/runs/37184007241) | -| SSH remote commands, 27 cases | 6.840s | 6.078s | 11.1% | [37184454532](https://github.com/stablyai/orca/actions/runs/37184454532) | -| OpenCode subscription, 28 cases | 47.347s | 6.284s | 86.7% | [37184858823](https://github.com/stablyai/orca/actions/runs/37184858823) | -| Window-service attachment, 31 cases | 11.910s | 1.619s | 86.4% | [37186340840](https://github.com/stablyai/orca/actions/runs/37186340840) | -| OpenCode 2 TUI ownership, 41 cases | 16.811s | 2.429s | 85.6% | [37187699312](https://github.com/stablyai/orca/actions/runs/37187699312) | -| Title-send authorization, nine cases | 29.545s | 12.995s | 56.0% | [37188423318](https://github.com/stablyai/orca/actions/runs/37188423318) | -| Range selection, 15 cases | 9.737s | 7.130s | 26.8% | [37188996198](https://github.com/stablyai/orca/actions/runs/37188996198) | - -Provider tests wait for the real fake-child write/ready barrier and drain the -host stream before installing a scoped parent clock. The original 2.5/5-second -Codex and 3-second Claude deadlines remain; before/at assertions check their -boundaries. Early rejection and refusal resolve without waiting on an unreachable -barrier; finally and suite teardown restore clocks and spies even after a native -Vitest timeout. Four healthy failure-path controls pass; old-helper hangs and -removed teardown are caught. Missing-child failure retains a real ten-second observation window. -Six faults for early/late stop deadlines, late queued resend and missing child -output fail the intended assertions. Native-chat tests still use the real React -outbox hooks. Their scoped clock retains probe, retry, churn and target-switch -windows, drains async act work, and unmounts before clock restoration. All eight -hook faults fail; two boundary faults pass the old coarse tests and fail the new -before/at assertions. Node/web typecheck, lint and formatting pass. - -Native Buffer.equals replaces deep per-byte assertion traversal. It checks the -complete original bytes and length; fixtures, SQLite operations, encryption and -processes are unchanged. The six profile suites retain 114 passes and four -existing Linux case-sensitivity skips; all 118 pass locally on macOS. Seven -profile last-byte/length faults and two encrypted-vault last-byte/length faults -fail their exact byte assertions. All six encrypted-vault cases still exercise -52 accounts near the 4 MiB encrypted cap, refused growth, restart/readback and -private permissions. - -The old SSH fixture created 15,197 short stage paths but never exceeded the real -1,048,576 UTF-16-character transport tail cap; its two valid entries also left -the 64-result assertion vacuous. The replacement uses about 1,300 real excluded -stage directories under long Unicode path components, a real shell channel and -the production execCommand limiter. Actual find output exceeds that cap, while -the generated filter retains both original valid entries. A separate population -of 65 valid directories proves the first-64 limit and native find ordering. File, -symlink, nested-install and failed-enumeration checks remain. All 27 case outcomes -match across treatments (23 passes and four unavailable PowerShell 5.1 skips on -the hosted image). Eight cap, ordering, filtering and failure faults are caught. -Paths use platform utilities; local PowerShell availability retains its original -skip policy. The deadline and SSH fixtures reuse existing process/stream code. -OpenCode subscription tests batch only uninterrupted fixture writes between the -original observation barriers. Both SQLite schema versions keep every row, rowid, -read cap, poll, clock position and case. No durability pragma or production code -changes. All 28 cases pass in every pair. Separate captures compare ordered -schema and rows, transaction state, pragmas, signals, page requests/results and -subscriber callbacks: 124,754,101 payload bytes match, canonical digest -`ba0eb6f09281746071d73fae88e2e8eb45332f36892b90998b374b1b8c59b3e3`. -Missing rows, collapsed frontier rowids, read-cap overruns, missing commit and -missing rollback each fail the intended case in both schemas. Positive rollback -controls pass. The unmeasured full-suite timing report ranked this fixture at -134.229 seconds; the paired 47.347-second figure above is the relevant focused -baseline, and the two figures must not be mixed into a claimed saving. - -Window attachment tests mock five unrelated registrar modules using their actual -types. The existing window ownership, reload, media permission, native file drop, -hydration barrier and updater scheduling cases remain real. Exact store/runtime/ -window arguments and daemon registration after the PTY handler are now asserted; -nine actual production wiring/order faults fail. The registrar implementations -retain separate handler tests. Concrete existing stubs moved to one fixture to -stay within the line limit. All 31 cases and statuses match across all pairs. The -final source differs from the timed source only by a required type-assertion -safety comment. -OpenCode 2 TUI tests use a scoped async clock only after the first real native -module import. The unchanged generated plugin runs against the existing fake TUI -and fetch. Original poll, permission, retry, preview, slow POST and endpoint -windows remain. Before/at assertions pin the 100 ms poll, 500 ms permission, -120 ms slow POST and 5-second endpoint boundaries. All 41 original case outcomes -match. Separate ordered captures preserve 678 TUI/event rows and 362 complete -POST start/completion rows; wall timestamps and cross-stream interleaving are -excluded from equivalence. Eleven generated-source faults fail their intended -identity, reload, order, deadline or timer-cleanup assertions. Four import/setup/ -disposer rejection and timeout controls confirm restoration of clocks, fetch, -argv and environment. Teardown holds its fake clock through bounded native cleanup and pending-timer checks, then restores real clocks. The [teardown qualification](https://github.com/stablyai/orca/actions/runs/37195736834) passes all 41 cases and 12 failure and restoration control invocations, including interval and retry leaks missed by the earlier teardown. -Title-send authorization tests retain real terminal creation, graph binding and -positive evidence paths. Negative process-evidence probes use scoped clocks -after setup: both 150 ms polling loops retain their 6,500 ms budget, crossed at -6,600 ms, and the send guard retains its 1,050 ms deadline. Before/at assertions -check 6,599/6,600 and 1,049/1,050 ms. All nine original cases remain. Actual -early/late wrapper and guard deadlines, false spinner identity and unknown-agent -authorization faults fail their intended assertions; native clocks and runtime -instance spies are restored after each failure. -Range-selection tests stub only the saved-note send menu, which their empty -comment populations never render. A facade typed from the actual menu props -throws if invoked, and an afterEach assertion verifies no call with unconditional -mock clearing in finally. The real hook, Monaco constants/model, line and range -drag behavior, draft-card lifecycle and open-inline-card chord remain. All 15 -original test bodies are byte-unchanged. Three actual range/hunk/draft faults -fail their original assertions; a real draft-render menu call hits the facade -and sentinel, and an outer cleanup check proves mock state cleared after failure. -The actual saved-note menu retains its independent component tests. - -## October 2 headless detector compiler cache - -The deferred detector already avoids dependency setup for known build inputs. -For changes that need import analysis, the collector marks package imports external; -only esbuild and its platform binary are needed. A small compiler archive can replace -root dependency setup for this analysis, while qualification jobs still install normally. - -The existing Linux x64 warmer packs these two packages after its frozen, -script-free, policy-checked install. Only main publishes. Readers use an exact key -covering Node/platform/architecture, manifests, install policy, patches and the cache -implementation. The producer and reader use the same archive path. File hashes, -identity and a compiler smoke are checked before availability is reported; missing, -invalid or failed restores use the original full installer. Graph analysis retains -its existing conservative full-qualification verdict on errors. - -A [three-pair hosted comparison](https://github.com/stablyai/orca/actions/runs/37071724200) -passed on Ubuntu x64 with Node 24.21.0 and esbuild 0.28.2. Every pair produced the -same 6,018 source inputs. Sample 2 ran the compiler-only treatment first; samples 1 -and 3 ran the existing installer first. Each used a fresh dependency tree, and the -compiler treatment required a real cache hit and validated its bytes and smoke. - -| Sample | Full installer + graph | Compiler restore + graph | Paired saving | -| ------ | ---------------------- | ------------------------ | ------------- | -| 1 | 11.730s | 2.805s | 8.925s | -| 2 | 14.702s | 4.706s | 9.996s | -| 3 | 14.666s | 3.750s | 10.916s | - -The median paired saving is 9.996 seconds. Inter-step overhead, archive transfer, -validation and the real graph are included. Checkout, initial Node setup, dependency -resets, seed work, post-job cache saves, tests and queues are excluded. These are -warm detector measurements, not whole-workflow or billing savings. The trial uses -the same package layout and validation as the production helper; production also -resolves its policy fingerprint. Cold or changed identities still install fully. - -## SSH Windows slot reuse - -The SSH Windows host workflow uses the same server-slot preparation action as -headless qualification. Its four PR jobs can restore the exact slot published by -fully qualified main runs, then validate its inventory and hashes and run the -required-slot and pinned-Node smoke checks. Misses or invalid payloads compile -freshly. Only main headless qualification publishes; manual SSH qualification -still builds freshly. Both sshd versions, both architectures, all three host -cells, the process-table addon build, and the template/relay builds remain. - -The action is part of the cache fingerprint, so this extraction starts a new -namespace that needs a successful main seed. The existing hosted measurements -below suggest about 160 aggregate runner-seconds saved across four warm SSH jobs; -that is a conditional estimate, not a measured improvement of this consumer. -Private sshd installation and host execution still dominate this workflow. - -## Prepared relay addon reuse - -A [completed SSH Windows run](https://github.com/stablyai/orca/actions/runs/36972043877) -rebuilt the process-table relay addon after native dependency preparation. From -its builder's start message to the validated staged artifact, x64 took 85.5 seconds -and ARM64 took 135.6 seconds. These are single-run observations, not medians. - -An opt-in reuse path checks the same binary architecture, patched reader and -launcher exports as staging, then runs the existing native-load and CreationTime -probe. Repaired source or incomplete evidence requires a fresh build. SSH PRs -request reuse only following an exact prepared native-cache hit; manual SSH and -all release builders retain fresh compilation. Subsequent staging checks still -run. Hosted validation and the reuse interval remain to be measured. - -## Windows root download stores: registry installs finish sooner - -Three paired samples on each Windows architecture compared the existing exact -main download-store restore with a fresh registry install. Each treatment used a -fresh dependency tree, store and pnpm metadata, with registry-first ordering in -sample 2. Both restored the same policy-checked verification record before timing. -All six pairs used Node 24.21.0 and pnpm 12.8.1; manifest digests and installed -lockfile digests matched, and both retained frozen, script-free installation. - -| Runner | Cached totals (seconds) | Registry totals (seconds) | Paired median saving | -| ------------- | --------------------------- | --------------------------- | -------------------- | -| Windows x64 | 26.820 / 28.885 / 27.751 | 13.644 / 14.126 / 12.908 | 14.759 seconds | -| Windows ARM64 | 216.540 / 288.492 / 189.342 | 119.856 / 238.562 / 115.611 | 73.731 seconds | - -The x64 samples are the three successful Windows 2022 jobs in -[run 37064549378](https://github.com/stablyai/orca/actions/runs/37064549378). -Its ARM cleanup guard rejected pnpm's setup-owned store path before measurement; -those incomplete ARM jobs are excluded. The corrected -[ARM-only run](https://github.com/stablyai/orca/actions/runs/37065220916) passed all -three samples. Earlier rejected measurements also stopped before installation -because an optional config file was absent; none count toward these timings. - -Intervals include actual store lookup/restore, inter-step overhead and root -installation. Checkout, toolchain setup, tree/store reset, verification-record -restoration and native preparation are excluded. ARM variation is substantial; -these samples do not measure whole-workflow, queue or billing savings. - -Root-only Windows x64/ARM64 PR installs now skip the download-store restore. -The existing x64 mixed-install exception remains. An explicit store opt-out also -lets Windows headless persistence and SSH jobs avoid the archive on main or -manual runs. Frozen installs, verification records, native caches and every -qualification check remain. Other lockfile sets and platforms keep their -existing policy. Default non-PR writers, including the warmer, still seed stores -for direct setup-node consumers and workflows that run package scripts. - -## October 2 Linux root store comparison - -A [six-job hosted comparison](https://github.com/stablyai/orca/actions/runs/37073978443) -measured the actual main root-store archive against direct registry installation, -with three fresh-runner pairs on each Linux architecture. All six jobs passed. -The middle sample on each architecture reversed treatment order. Between treatments, -the driver removed the dependency tree, store and pnpm metadata, then restored the -same policy-checked verification record before timing. Frozen, script-free installs -preserved policy files and produced identical installed lockfile digests in each pair. - -| Architecture/sample | Store restore + install | Direct registry install | Paired saving | -| ------------------- | ----------------------- | ----------------------- | ------------- | -| x64 / 1 | 6.516s | 5.372s | 1.144s | -| x64 / 2 | 6.743s | 3.950s | 2.793s | -| x64 / 3 | 6.600s | 3.985s | 2.615s | -| ARM64 / 1 | 7.632s | 3.346s | 4.286s | -| ARM64 / 2 | 5.504s | 3.575s | 1.929s | -| ARM64 / 3 | 5.450s | 3.364s | 2.086s | - -Median paired savings are 2.615 seconds on x64 and 2.086 seconds on ARM64; means -are 2.184 and 2.767 seconds. Both used Node 24.21.0 and pnpm 12.8.1. Actual store -lookup/transfer/restore, inter-step overhead and installation are timed. Checkout, -initial toolchain/dependency setup, preparing the existing process wrapper, -dependency resets, verification-record restores, native work, tests, post-job cache -saves and queues are excluded. Package services have already been used by initial -setup. These are warm-policy setup measurements, not workflow or billing savings. - -The shared installer consequently skips root-only Linux x64/ARM64 store restores -on PRs. It still installs and checks every package through pnpm. Mixed mobile and -custom lockfile sets, other architectures, verification/native caches, -main store writers and release installation policies keep their existing behavior. -The measured Windows exceptions remain. Mac restores were retained at this stage; -the following comparison supersedes that policy. No periodic job or cache is added. - -## October 2 macOS root store comparison - -A [six-job hosted comparison](https://github.com/stablyai/orca/actions/runs/37078232553) -used the same paired method on macOS 15 Intel and Apple Silicon. All six jobs -passed with actual main store cache hits. The middle sample reversed treatment -order. Each treatment started with a reset dependency tree, store and pnpm -metadata, followed by the same verification-record restore. Policy files and -installed lockfile digests matched within every pair. - -| Architecture/sample | Store restore + install | Direct registry install | Paired saving | -| ------------------- | ----------------------- | ----------------------- | ------------- | -| x64 / 1 | 87.270s | 60.584s | 26.686s | -| x64 / 2 | 103.280s | 52.640s | 50.640s | -| x64 / 3 | 61.564s | 40.768s | 20.796s | -| ARM64 / 1 | 26.024s | 14.375s | 11.649s | -| ARM64 / 2 | 37.000s | 19.726s | 17.274s | -| ARM64 / 3 | 34.616s | 14.888s | 19.728s | - -Median paired savings are 26.686 seconds on x64 and 17.274 seconds on ARM64; -means are 32.707 and 16.217 seconds. Node matched within each pair: 24.19.0 on -Intel and 24.20.0 on Apple Silicon, as resolved by the existing installer. Both -used pnpm 12.8.1. Timing includes actual cache lookup/transfer/restore, inter-step -overhead and installation. Checkout, initial setup, process-wrapper preparation, -resets, verification restores, native work, tests, cache saves and queues are -excluded. Initial setup has already used package services. These measurements -do not establish whole-workflow or billing savings. - -The existing root-only PR exception now also covers macOS x64/ARM64. Frozen, -script-free installs and pnpm policy checks still run. Mixed/custom lockfile sets, -other architectures, verification/native caches, main/manual store writers and -release installation policies retain their existing behavior. No periodic job -or cache is added. - -## October 2 store producers: keep caches without downloading hits - -The optional `cache-pnpm-store-lookup-only` installer input uses -[`actions/cache` lookup-only](https://github.com/actions/cache#inputs) on non-PR -runs. An exact hit refreshes cache access without extracting the archive; a miss -still installs from the registry and publishes the populated store at successful -job completion. The default remains the existing `setup-node` cache behavior. -The four Linux/Windows dependency warmers and Linux/macOS persistence producers -opt in. Windows persistence retains its existing store opt-out, and PR restore -policies are unchanged. This adds no recurring job or extra cache family. - -A [tiny framework control](https://github.com/stablyai/orca/actions/runs/37082688033) -proved that lookup left the payload absent, refreshed the existing cache's access -time, and published a miss that a fresh job restored. A -[nested composite control](https://github.com/stablyai/orca/actions/runs/37084946789) -then saved and restored a fresh payload using the actual environment-path pattern. -The installer exports its resolved store path through `GITHUB_ENV`: twice-nested composite post-job saves -cannot resolve their internal step outputs. The primary key is captured -by the cache action before cleanup. Paths, architecture and lockfile keys match -`setup-node`, so existing default-branch archives remain reusable. - -The [six-platform installer screen](https://github.com/stablyai/orca/actions/runs/37084946789), -[Linux repeats](https://github.com/stablyai/orca/actions/runs/37085164277), and -[corrected Windows repeats](https://github.com/stablyai/orca/actions/runs/37085248976) -compared the complete shared installer, including toolchain setup, cache actions, -policy verification, frozen installation and native probes where requested. -Each treatment reset dependencies, the store, pnpm metadata and the Windows -registry build directory. Treatment order reversed across architectures and -repeats. Every qualified pair required real main store cache hits, matching -policy/installed-lockfile digests and Node/pnpm versions, plus exact native-cache -hits on Linux and Windows. The initial Windows x64 screen stopped before timing -because its benchmark guard rejected the standard `D:\.pnpm-store` path; that -unqualified job is excluded. - -| Platform / sample | Restore + installer | Lookup + installer | Paired saving | -| ------------------------ | ------------------: | -----------------: | ------------: | -| macOS ARM64 | 37.785s | 20.024s | 17.761s | -| macOS x64 | 75.610s | 46.638s | 28.972s | -| Linux ARM64 / 1 | 10.005s | 7.242s | 2.763s | -| Linux ARM64 / 2 | 8.817s | 6.672s | 2.145s | -| Linux ARM64 / 3 | 8.800s | 6.581s | 2.219s | -| Linux x64 / 1 | 12.309s | 9.735s | 2.574s | -| Linux x64 / 2 | 10.131s | 8.690s | 1.441s | -| Linux x64 / 3 | 13.741s | 9.485s | 4.256s | -| Windows ARM64 / repeat 1 | 145.818s | 78.729s | 67.089s | -| Windows ARM64 / repeat 2 | 152.730s | 106.475s | 46.255s | -| Windows ARM64 / screen | 294.092s | 193.957s | 100.135s | -| Windows x64 / 1 | 30.936s | 20.256s | 10.680s | -| Windows x64 / 2 | 31.021s | 20.098s | 10.923s | - -All 13 qualified pairs improved. Median paired savings were 2.574 seconds on -Linux x64, 2.219 on Linux ARM64, 10.802 on Windows x64 and 67.089 on Windows -ARM64. Each macOS architecture had one pair; its 28.972 / 17.761 second savings -are a screen, supported by the earlier three-pair root-store comparisons. - -All pairs used pnpm 12.8.1. Node was 24.21.0 on Linux and Windows, 24.19.0 on -macOS Intel and 24.20.0 on macOS ARM. Source dependency policies were frozen for -this screen; later main dependency changes do not extend these measurements. -Timing excludes checkout, initial service/bootstrap use, wrapper compilation, -resets, result validation, post-job saves and queues. These are installation -measurements, not whole-workflow or billing savings. Cold publication is verified -separately by the small controls; no large synthetic store cache was uploaded. - -## October 1 Windows and dependency cache follow-up - -[PR #24355](https://github.com/stablyai/orca/pull/24355) merged at `197ea3a3`. -The [next hosted trial](https://github.com/stablyai/orca/actions/runs/36917210453) -ran four alternating pairs on each Windows architecture and both Mac -architectures. All six jobs passed. The exact temporary workflow and drivers -remain available at `ab24952ab5af0f7d8e53a544896ac2486edfb244`; completed trial -tooling is removed from ordinary PR CI. - -### Windows server slots - -The existing dependency-native cache and the server's N-API 8 slot serve different -consumers. Cache the small server slot separately, using the exact compiler image, -architecture, dependency/patch/runtime inputs and compilation/validation source. -PR and main-push qualification restore it. Nightly qualification still compiles -freshly; main saves after persistence/lifecycle tests and the existing x64 Node 18 handoff. -Templates and explicit-ref calls continue to compile freshly. - -| Hosted runner | Fresh build median | Restore median | Difference | -| ---------------- | ------------------ | -------------- | ---------- | -| Windows 2022 x64 | 18.624s | 4.677s | 13.947s | -| Windows 11 ARM64 | 72.002s | 6.111s | 65.891s | - -Both used Node 24.21.0. Each comparison includes actual GitHub cache restoration, -inter-step time, payload validation, required-slot checks and pinned-Node load/spawn -smoke. Every fresh build uses the builder's freshly cleared compilation directory. -Dependency installation, initial seed work, shared download warmup, full qualification -tests and queues are outside these timing intervals. The x64 payload was 2,935,529 -bytes and ARM64 3,533,557 bytes. These are conditional warm-hit gains, rather than a -whole-workflow improvement or the roughly 95–117 seconds seen in earlier cold samples. - -Restored bytes must match current module/version/headers/N-API/host metadata, -the complete inventory and hashes, and current vendored ConPTY files. Existing -patch, PE architecture, post-baseline N-API and MSYS breakaway checks are reused. -Failed or partial restoration and invalid payloads clear only `out/orcad-prebuilds` -and fall back to normal compilation. Both fresh seeds and fresh restored consumers -passed the full existing Windows qualification; x64 also passed Node 18 handoff. -The final key additionally includes the ten transitive process-wrapper sources. - -A bounded sample of 50 first-parent main commits through `197ea3a3` had 45 of 49 -adjacent transitions with identical source inputs, including those ten files. -This one-day sample holds the new validator and runner image constant. Actual -image rotation, seed availability and changed PR inputs can reduce reuse. - -Keep the original Windows native dependency preparation: artifact-mode tests still -import checkout `node-pty`, registry and process-reader addons, and the Windows -artifact builder stages the patched process reader. The cached server slot does -not replace those dependencies. - -### pnpm verification records - -Reuse pnpm's existing policy-checked record on Windows x64/ARM64 and Mac Intel, -while retaining Linux's existing behavior. The exact OS/architecture/pnpm/policy -key, explicit opt-out, frozen installs and main-only production writes remain. -Each treatment resets links and registry metadata, retains the identical warm -download store, and checks unchanged manifests, installed versions and installed -lockfile bytes. Every platform passed real missing/corrupt-record fallback and -changed-policy/changed-integrity rejection controls, plus authentic cross-path reuse. - -| Platform | Baseline install | Cached install | Baseline interval | Cached interval | -| ---------------------- | ---------------- | -------------- | ----------------- | --------------- | -| Windows x64 | 13.105s | 9.767s | 17.717s | 15.146s | -| Windows ARM64 | 96.723s | 88.443s | 164.620s | 157.877s | -| Mac Intel | 31.368s | 21.042s | 47.727s | 41.302s | -| Mac ARM, kept disabled | 11.419s | 8.086s | 17.905s | 15.787s | - -Install columns time pnpm alone. Interval columns also include link reset, -path validation, real cache restoration and inter-step overhead; they exclude -seed setup, parity checks and queues. Key-resolution overhead is outside the paired -trial; the real Windows ARM production-shaped step took 0.296s. Windows ARM's first -pair was slower with the record, while the next three improved, so its median is -not a guaranteed per-job saving. Macs used their hosted Node 24.19.0 Intel and -24.20.0 ARM toolchains; Windows used 24.21.0, and all used pnpm 12.0.0. - -An earlier complete Mac ARM trial saved only 0.681s in its interval median. -That small, variable margin does not justify enabling the extra lookup there. -Only the tiny pnpm-owned record is restored; registry metadata and download-store -policy are unchanged. Changing the shared action also produces a one-time cold -native dependency cache key, whose existing hash includes the action bytes. - -### Further local screens rejected - -A fixed 256-file, 2,471-case cohort preserved every result in twelve invocations. -Lazy per-file user data showed a noisy 2.0% wall difference with no setup-time -improvement; combining DOM setup was 1.8% slower. A separate seven-file leaf-import -screen preserved all 50 assertions and public/hook controls, saving only 0.903 -worker-seconds while elapsed time rose 2.55%. All experiments were reverted. -Documentation-result reuse also lacked positive eligible demand in the bounded -sample, and the apparent example changed the actual tested merge/base inputs. -These results do not justify adding a new coverage-selection or result-reuse policy. - -## October 1 PR concurrency follow-up - -### Where the next gains are - -The [September 30 demand report](https://github.com/stablyai/orca/actions/runs/36816009362) -samples 282 of 4,194 runs across workflow/conclusion strata. It estimates full job -duration for runs created in the reporting window, rather than occupancy clipped -to that window. PR CI accounts for about 537 runner-hours, including about 321 -hours of unit shards, 73 hours of E2E, 41 hours of Windows packaging, 35 hours of -Linux packaging, 18 hours of static analysis, and 11 hours of typechecking. These -are weighted estimates, not exact billing totals. Unassigned and incomplete jobs -are excluded. The report predates the merged planning/setup change below. - -The [full reference run](https://github.com/stablyai/orca/actions/runs/36841821670) -ran 10,310 files exactly once. Across its five shards Vitest reports about 5,063 -worker-seconds importing modules, 2,838 running tests, 685 transforming source, -267 setting up tests, and 390 preparing environments. Workers overlap, so these -figures cannot be added to predict job elapsed time. They identify repeated -imports and real-time test waits as larger targets than line-count reporting, -which uses about 1.1 runner-hours in the same demand sample. - -Parallel steps share the job's CPU and memory. Their -[background/wait support](https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/) -saves repeated runner setup when independent checks fit together, but does not -increase machine resources or the account's concurrent-job allowance. Cheap -preflight checks still gate expensive unit and package jobs. - -Organization metadata reported the Team plan on October 1. GitHub documents -[60 standard concurrent jobs by default](https://docs.github.com/en/actions/reference/limits#job-concurrency-limits-for-github-hosted-runners) -and allows support requests for increases. The effective configured allowance -was not exposed by the API. A seven-second, repository-only sample at 10:48 UTC -found 32 queued jobs and 70 assigned job records marked in progress, including -one Blacksmith label. Those non-atomic records, runner turnover, other repositories -and provider labels cannot establish the actual allowance or simultaneous usage. -These changes reduce demand; they do not change account settings. If queues -remain, ask GitHub Support to confirm the effective organization limit before -choosing a larger allowance or paid runners. - -The [follow-up PR](https://github.com/stablyai/orca/pull/24355) measures virtual -readiness deadlines in captured-transcript tests, E2E allocations with no general -consumer, renderer projection, native setup, Docker fixtures, and Windows store -restoration. Alternating hosted comparisons check output parity. Completed -benchmark workflows and drivers are removed; their trial commits retain the -exact reproduction code. - -A [three-pair hosted transcript comparison](https://github.com/stablyai/orca/actions/runs/36849458799) -on one four-worker ARM runner measured baseline invocations at 127.001 / 114.307 / -114.265 seconds, versus 28.903 / 28.663 / 28.455 seconds with virtual readiness -deadlines. Median elapsed time for these five files fell about 75%. All 259 -original named tests passed in every baseline and candidate, and candidates -also passed three repaint checks. Summed test-body time fell from a median 325.85 -to 31.80 worker-seconds. This comparison includes Vitest startup/import work but -excludes checkout, dependency setup, and queues; it is not a measured percentage -improvement in the full unit matrix. Real emulator setup and drains remain real, -and the same captured bytes, readiness/refusal deadlines, and assertions run. - -The same hosted trial passed three forced Electron native rebuilds while the -external node-gyp path pointed to a nonexistent file. A separate fresh consumer -then restored the native cache, required a real hit, and passed the existing -Electron binary probe. The Linux Node-runtime workaround remains intact. - -For a network-only E2E selection in that trial, both dedicated network jobs -passed. The previous allocations consumed 87 seconds for the Electron build, -34 for the native primer, and 67 for a general job whose log confirmed that -every selected spec belonged to a dedicated lane. The candidate skipped those -three jobs before runner allocation, avoiding 188 runner-seconds in this case. -This single-case measurement excludes queues and does not predict savings for -mixed selections; the existing dedicated SSH, IME, and ordinary E2E routes remain. - -A [controlled cancellation trial](https://github.com/stablyai/orca/actions/runs/36853246785) -verified both condition outcomes. After intentional cancellation, `always()` -started another 60-second follow-up, while `!cancelled()` skipped it. Cleanup -and artifact uploads succeeded in both treatments. A separate deliberately -failed test still ran its later `!cancelled()` test and cleanup. The 60 seconds -are synthetic condition evidence, not a measurement of a real SSH test's cost. -Completed comparison workflows are removed after recording their evidence; -the exact drivers and workflow remain reproducible at the trial's source commit. - -The [first full PR validation](https://github.com/stablyai/orca/actions/runs/36849458648) -passed all five unit shards and both Linux/Windows package checks. Its reports -contain 10,326 unique files, each once, with zero unhandled errors. Full shard -job durations ranged from 544 to 581 seconds. They ran a different merged source -on different allocations from the earlier reference, so comparing their totals -does not establish an end-to-end speedup. The alternating transcript comparison -above is the controlled timing evidence. - -The [updated full PR validation](https://github.com/stablyai/orca/actions/runs/36855833565) -passed all required gates and both package checks. All 10,335 discovered files -appear once across five passing timing reports, with zero unhandled errors; -122 modules have the existing expected skipped status. Named merge-tree discovery -and saved assignment replay match both successful validation runs. The latest -unit jobs took 520–558 seconds. Refreshing weights with that same measurement set -would reduce the largest projected load from 1,756.706 to 1,708.187 worker-seconds -(2.76%), while retaining the same 8,540.831 total. Applying the earlier successful -run's proposed weights to the latest measurements improves the maximum only -1.54% and the median 0.68%. These small, variable projections do not establish -an elapsed-time gain, so the existing weights remain. - -A [three-pair Windows store comparison](https://github.com/stablyai/orca/actions/runs/36853246494) -used fresh dependency trees, stores and pnpm metadata before each treatment. The -middle pair reversed order. Cached totals include archive restoration and both -unchanged frozen installs; the mobile install ran every existing postinstall -generator. Setup/reset time and runner queues are excluded. - -| Pair | First treatment | Cached total | Registry total | Registry saving | -| ---- | --------------- | ------------ | -------------- | --------------- | -| 1 | Cached | 71.092s | 37.500s | 33.592s | -| 2 | Registry | 73.556s | 39.935s | 33.621s | -| 3 | Cached | 75.660s | 37.001s | 38.659s | - -Treatment medians were 73.556s cached and 37.500s registry; median paired saving -was 33.621s. Cache restore and step overhead alone cost a median 27.955s. Policy -and all six generated-output digests matched across all six treatments. Windows -x64 PR jobs using the mixed root/mobile key now skip its download-store restore; -PRs already skip store saves. Root-only Windows stores, Windows ARM64/x86, other -operating systems, non-PR writers, native/Electron caches and frozen-install policy retain their existing -behavior. The trial covers this mixed install on Windows 2022, not every Windows -dependency key or a whole PR's elapsed time. - -A [three-pair daemon fixture comparison](https://github.com/stablyai/orca/actions/runs/36853246776) -reset Docker build caches and the fixture/base image before every treatment. -Warm totals include the existing action's archive restore/load and the unchanged -daemon descendant oracle; reset time and runner queues are excluded. The middle -pair again reversed order. - -| Pair | First treatment | Cold total | Warm total | Warm saving | -| ---- | --------------- | ---------- | ---------- | ----------- | -| 1 | Cold | 28.984s | 20.673s | 8.311s | -| 2 | Warm | 21.798s | 28.953s | -7.155s | -| 3 | Cold | 28.964s | 17.614s | 11.350s | - -Treatment medians were 28.964s cold and 20.673s warm; median paired saving was -8.311s. Oracle medians fell from 28.886s to 4.907s, while archive restore/load -cost 13.025–24.024s (15.766s median) for a 714,643,456-byte archive. BuildKit -confirmed warm provisioning was cached and cold provisioning was not; every -treatment used the same immutable base image, reaped the descendant and kept -the canary alive. The PR keeps restoration in the background during root/mobile -installation. One serial pair was slower, so the roughly 24s oracle reduction -is not a guaranteed total runner saving. The drivers and exact workflows remain -available at source commit `9231d1be6c76ccc1d2fef741a4e68ae29735a5c8`. - -A [three-pair AppImage compression comparison](https://github.com/stablyai/orca/actions/runs/36855833100) -packaged the same complete Linux x64 app with the pinned 1.0.3 toolset and -mksquashfs 4.6.1. Each timing includes the private app copy, electron-builder -and blockmap generation. Tool download, app compilation, extraction, parity -checks and queues are excluded; the middle pair reversed order. - -| Pair | First treatment | Default zstd 15 | PR zstd 3 | Saving | -| ---- | --------------- | --------------- | --------- | ------- | -| 1 | Default | 22.580s | 11.760s | 10.820s | -| 2 | PR | 22.612s | 12.560s | 10.052s | -| 3 | Default | 22.512s | 11.815s | 10.697s | - -Median packaging time fell from 22.580s to 11.815s (47.7%); median package size -grew from 213,873,601 to 237,570,611 bytes (11.1%). All six extracted manifests -matched every path, byte, mode and symlink target: 4,096 files and 601,194,650 -file bytes. The runtime prefix matched the pinned runtime exactly, and stored -SquashFS options confirmed the actual level-3 override. All static checks passed; -the representative baseline and candidate each passed the unchanged headless -and CLI journeys, including all entrypoints and both shutdown signals. The -directory build passed the existing glibc floor checks on all 19 native binaries. - -Only the PR Linux x64 AppImage child receives the private tool overlay. It resolves -the existing custom-tool override first, checks the pinned tool/version and zstd -configuration, and reuses the original runtime, validator and libraries. Cleanup -waits for every package worker even on failure. Release settings, Linux ARM, -Debian/RPM packaging and all native/package gates retain their existing behavior. -The isolated AppImage result does not establish the full three-format job's gain. - -The same hosted comparison projected one already-built renderer three times per -treatment, again alternating order. Baseline times were 8.079 / 8.219 / 8.129s; -candidate times were 2.568 / 2.477 / 2.403s. Median projection fell from 8.129s to -2.477s (69.5%, 5.653s saved). All six web snapshots matched all 1,137 files and -51,957,014 bytes, and the renderer input remained unchanged after every run. -These timings include the projector process but exclude renderer compilation, -checkout, setup and queues. The drivers and workflow remain available at source -commit `8ba5c9bf9f734d585f5e89945519aef4f607face`. - -Two local cache screens do not justify enabling Node's compile cache. A 96-file -screen with an explicit worker flush produced a small, noisy difference. A larger -256-file screen retained all 2,088 tests: baseline elapsed times were -54.630 / 55.105 / 55.171 seconds, fresh caches 53.719 / 54.577, and a warm cache -53.732. The roughly 1.7% median difference is too small to justify cache transfer -and another test hook without stronger hosted evidence. - -Vitest 4.1.11's experimental filesystem module cache is more promising locally, -but raw reuse is unsafe. A 96-file screen fell from about 8.4 to 5.9 seconds with -a warm cache, while a cold cache cost about 3%. Negative controls then reproduced -false passes after adding a preferred import extension, retargeting a symlink, -changing package exports, or changing transform inputs. Cache-disabled controls -failed correctly. A cache key must cover resolution and transform inputs as well -as file contents before any production trial; source hashes alone do not suffice. - -Affected-test selection remains in shadow mode. Its first merge was September -28, so October 1 cannot satisfy the documented week of evidence. Seven sampled -complete reference reports included one red run, but only two evaluated a smaller -candidate set; each omitted about 177–179 worker-seconds out of 8,073–8,392. Five -full fallbacks are not selection-validation evidence, and two other sampled red -runs had no review artifact. These samples support keeping the conservative -policy, rather than claiming that omitting roughly 12% of files would omit the -same fraction of work. - -### Shared planning and typechecking - -PR planning now shares checkout and dependency setup with typechecking. Planning -runs in the background, with an explicit failure-propagating join before its -artifact is published. Static analysis remains on a separate runner. Its existing -native dependency install is pinned to Node 24 so it also fills the unit matrix's -native cache, replacing the separate conditional PR primer. Daily Node 24/26 -reference planning and priming remain independent. - -The planner also avoids constructing the import graph when global changes or -missing change evidence already require the full suite. This keeps the same -fallback reason, discovery list and execution coverage. Five PR unit shards, -static/type gates, package checks and the shadow-selection policy are retained. - -A [three-sample hosted comparison](https://github.com/stablyai/orca/actions/runs/36833782900) -measured standalone type jobs at 35 / 26 / 42 seconds and planning jobs at -36 / 25 / 24 seconds. Shared type/planning jobs took 38 / 37 / 27 seconds. -The sum of the separate job medians fell from 60 to 37 seconds. Using that estimate with -the 139-second median static job models about 12% less preflight occupancy. -This is not a measured reduction in total CI time or queue delay. All twelve -plans matched, and three deliberately fresh native-cache producers were reused -by three successful consumers with the real native dependency probe. - -After updating to the current main branch, a -[six-pair alternating comparison](https://github.com/stablyai/orca/actions/runs/36840171700) -reset compiler state before every measurement. Direct compiler/planner command -time was 11.59 / 11.31 / 10.96 seconds sequentially versus -6.82 / 6.52 / 6.33 seconds together with restored state. With state deleted, -it was 67.36 / 67.05 / 61.75 versus 55.52 / 60.06 / 55.68 seconds. -All commands passed and plans matched within each comparison. These command -timings exclude setup and queues; compiler variation contributes to the cold -difference. The retained arrangement showed no cold compiler penalty. - -The sequential gate in [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity) supersedes the earlier rejection below. - -Combining static analysis too was rejected. An -[alternating same-runner comparison](https://github.com/stablyai/orca/actions/runs/36835091650) -saved runner occupancy, but cold compilation slowed from 61–64 to 83–89 seconds -under contention. The combined gate would finish roughly 40 seconds after the -separate static gate. An -[earlier-compiler trial](https://github.com/stablyai/orca/actions/runs/36837523594) -did not remove that penalty. Small localization scheduling changes also lacked -a repeatable gain. The temporary pilot workflows and benchmark tooling are -available in those runs' commits, rather than retained in normal PR CI. - -Line-count reporting stays separate: it uses a small runner and trusted scripts -with PR write permissions. Sharing that job's credentials with PR-source checks -would provide little resource benefit. Reducing unit shards would trade saved -setup for a longer critical path; enabling selected tests requires the existing -week of representative shadow evidence. - -## September 27 follow-up - -### Shared E2E CLI output - -E2E consumers previously compiled the CLI individually even though they downloaded -shared Electron, web, and relay output. The producer now compiles the CLI once, -in parallel with web projection after Electron has finished clearing `out/main`. -Consumers repair executable permissions and install their own dev launcher with -the same preparation script used by local CLI builds. Older refs without that -script retain their original per-consumer compilation. - -An [eight-sample comparison](https://github.com/stablyai/orca/actions/runs/36307081200) -measured producer time increasing from 26.3–27.9s to 38.8–41.0s, while consumer -CLI compilation fell from 20.9–21.2s to 0.06–0.07s of direct preparation. All 5,519 -output files matched byte-for-byte, and every sample passed the CLI help smoke. -A four-sample -[final implementation comparison](https://github.com/stablyai/orca/actions/runs/36307382635) -also passed parity and CLI smoke checks. Consumer compilation took 4.7 / 12.8s -versus 0.08 / 0.06s of preparation; producer time increased by 0.2 / 7.1s in -the paired trials. Across both runs this models roughly 1.1–4.7 aggregate runner -minutes saved across 14 consumers, before artifact transfer overhead. Runner -variation is substantial; this is not a measured workflow wall-time reduction. -Test coverage and deadlines stay intact. - -[PR #23368](https://github.com/stablyai/orca/pull/23368) overlaps shell installation -with dependency setup, starts localization extraction before the orcad smoke, -and prepares mobile route snapshots while WebKit and the bundle are being built. -Its 27 checks passed without retries; seven existing conditional checks skipped. - -Same-runner comparisons in both orders measured: - -| Work | Before | After | Evidence | -| ----------------------------------- | -------------- | -------------- | ---------------------------------------------------------------------------------- | -| Static block | 63.5 / 74.7s | 38.9 / 55.6s | [Full comparisons](https://github.com/stablyai/orca/actions/runs/36302208990) | -| Shell job, downloads warmed equally | 76.8 / 70.3s | 64.4 / 64.0s | [Controlled shell runs](https://github.com/stablyai/orca/actions/runs/36302612626) | -| Mobile preparation | 19.8–21.6s | 18.0–18.5s | [Eight measurements](https://github.com/stablyai/orca/actions/runs/36302877583) | -| Web projection and mobile build | 17.2–17.3s | 11.7–12.1s | [Eight measurements](https://github.com/stablyai/orca/actions/runs/36302974324) | -| Mobile verifier fixture suite | 25.47 / 25.35s | 20.04 / 19.92s | [Four full-suite runs](https://github.com/stablyai/orca/actions/runs/36302692823) | -| E2E build outputs | 28.6–30.4s | 25.8–27.8s | [Eight measurements](https://github.com/stablyai/orca/actions/runs/36304001325) | - -Full mobile-job timings were dominated by first-run apt installation and browser -test variation; the controlled preparation measurement is the scheduling evidence. -All 1,248 web/mobile output files matched byte-for-byte in the build comparison. -The fixture suite kept all 47 tests, isolated mutable copies, and the verifier's -two fresh builds. No deadline, isolation, or worker-count changes were needed. - -E2E builds reuse the existing isolated main/preload/renderer build wrapper, now -forwarding `--mode e2e` to each target. All 2,640 output files matched byte-for-byte -in both execution orders, including the exposed test store and relay artifacts. -This saves a few build seconds; it does not speed up the E2E tests themselves. - -The existing unit assignment was already balanced at about 919 historical -worker-seconds per shard; fresh x86 elapsed times still ranged from 254 to 433s. -Refreshing weights alone would encode runner variation rather than resolve it. -An [identical-source architecture pilot](https://github.com/stablyai/orca/actions/runs/36302250920) -ran shards 1 and 8 on both four-CPU hosted runners. Complete jobs improved from -449 to 404s and 461 to 384s on ARM, including setup; test and skip counts matched. -A [full ARM run](https://github.com/stablyai/orca/actions/runs/36302906752) then passed -all eight shards in 329–373 test seconds (365–412 job seconds). Uploaded reports -matched the same complete x86 assignment: 9,876 files, each exactly once, no -unhandled errors. These are elapsed samples excluding queue time, not a guarantee -that every ARM allocation is faster than every x86 allocation. - -PR unit shards and their cache primer now use ARM; a main-branch warmer seeds -that architecture's existing native and pnpm cache keys. Native, package, and -relay gates continue on x86. The daily workflow retains complete x86 coverage on -both Node 24 and Node 26, including relay integration. Thus PR unit architecture -changes, while x86 unit coverage remains scheduled; this is an explicit coverage -placement tradeoff rather than a claim of identical per-PR host coverage. - -PR validation exposed a WebRTC probe timeout inside a hidden renderer. Isolated -and four-concurrent probes passed on both architectures; the original cause is -unproven. The probe now uses Electron main for the same three-second observation -interval. A [fault-injection comparison](https://github.com/stablyai/orca/actions/runs/36304349257) -passed with renderer timers unavailable on both architectures, while the original -probe failed the negative control. Packet assertions and deadlines are unchanged. -A subsequent Windows run timed out in the installer's real CIM process query -after verifying restricted policy. Its unchanged probe now runs before the -concurrent native suite, removing that source of contention without relaxing -the twenty-second process deadline or dropping either PowerShell architecture. - -Replacing Vitest deep comparisons with Node assertions in the status-store -oracle saved only about one local second in an initial trial. The change was -not retained: that evidence did not justify changing assertion semantics. - -The combined root/mobile pnpm cache is now present on main and was restored in -the September 27 static comparison, so another warmer for that key is unnecessary. - -A [fixture-warmer overlap trial](https://github.com/stablyai/orca/actions/runs/36303613587) -ran faster after initialization but exposed a first-use action-download race: -both background composites downloaded `actions/cache@v5` simultaneously, and -one briefly could not find `restore/action.yml`. The existing cache fallback -rebuilt the image and the job passed, but that recovery erased the speedup. -Keep the dedicated warmer serial. PR package restores remain safe from this -observed first-use race because their earlier top-level cache action is loaded -before the composites start; the workflow contract now preserves that ordering. - -## Four follow-up changes - -- Keep the readiness event, but reuse required checks only after an Actions API - lookup proves that the same PR head, tested merge commit, and workflow commit - already completed successfully. A changed base, missing proof, failed lookup, - or still-running check falls back to the full checks. Advisory tests retain - their normal readiness routing. The mobile and line-count workflows have no - draft-dependent work, so they no longer run again when a draft becomes ready. -- Route the headless-runtime matrix using the actual headless build and selected tests' - transitive imports, with conservative inclusion for dynamic workers, native - inputs, fixtures, and toolchain changes. A graph failure runs the full matrix; - manual dispatch still runs all ten platform jobs. The shared test selectors - retain the same 85 files. Unrelated shard timings and mobile-test tooling can - skip the matrix; shared shortcut definitions remain real runtime dependencies - and still run it. Building the graph does not execute the imported modules. -- Restore pnpm stores on PRs using setup-node's existing key and store path, - without publishing more PR-private copies. Non-PR setup-node caching and - native/TypeScript caches keep their existing behavior. A missing main store - still installs with the frozen lockfile. The mixed root/mobile store may miss - repeatedly because the existing main warmer only seeds the root lockfile. -- Batch only the PowerShell quota-fixture reservations within each test, using - the original generated scripts in fresh local scopes. Commands under test - retain separate processes, real file identities, and existing race assertions. - A traced local run confirms 44 PowerShell starts become 24, with all 21 cases - passing. Alternating after/before/after elapsed times were 50.00/59.28/37.00 - seconds on a shared macOS arm64 host; that variance does not justify a precise - percentage or hosted runner-time claim. Test budgets and worker counts are - unchanged. - -The reproducible pnpm-store comparison is -`ORCA_BACKGROUND_LAUNCH=1 node config/scripts/ci-pnpm-store-benchmark.mjs --samples=3`. -On macOS arm64 with BSD tar, three alternating fresh-store pairs eliminated a -median 332,746,995-byte archive per miss. Median install time was 17.33 seconds -before and 16.94 after; the removed archive step alone took 53.51 seconds. -Those local disk/CPU measurements exclude uploads and are not a prediction of -Linux or Windows hosted savings. Restore cost is common to both policies. - -## September 26 verification - -[PR #23053](https://github.com/stablyai/orca/pull/23053) was merged before its -latest full run finished. That run, -[36221874572](https://github.com/stablyai/orca/actions/runs/36221874572), ultimately -failed the mobile pending-frame precondition, just as the previous run had. -All eight unit shards passed, but the aggregate did not. An unchanged assertion -was not enough evidence to label the failure an unrelated flake. - -Main subsequently received the deterministic frame hold in PR #22635. The real -terminal refit now queues a frame that the recorder holds until disposal has -finished, then releases surviving work against a remounted terminal. This -follow-up adds a negative control: cancellation is disabled only during disposal, -and the same recorder must report a document-owned callback after disposal. -The normal case retains its pending-work and zero-leak assertions. Both cases -passed five fresh headless Chromium runs locally; the complete terminal-render -file passed all 13 tests. Browser dependencies were required, so these were real -render checks rather than skipped bundles. - -Unit model tests now import Monaco's editor API directly, preserving real models -and undo stacks without loading every language contribution. The registry bridge -requires only the editor and URI interfaces it actually uses. The full application -still imports its existing Monaco entry point; no production runtime behavior, -assertions, worker counts, timeouts or isolation settings changed. -A controlled local comparison (macOS arm64, Node 26.6.0, Vitest 4.1.11, -`--maxWorkers=1` only for this comparison) kept six files and all 32 tests. -Three warm original samples took 14.53/12.84/11.62 seconds; three editor-API -samples took 12.95/9.81/7.99 seconds, alternating back to the original imports -between measurements. Median elapsed time fell 12.84 to 9.81 seconds (23.6%); -median import time fell 10.73 to 7.94 seconds (26.0%). The initial cold original -sample, 20.29 seconds, is excluded. Shared-host variance remains; this is a -focused measurement, not a claim of a 23.6% improvement to the full unit suite. - -The default-branch warmer -[36221917346](https://github.com/stablyai/orca/actions/runs/36221917346) successfully -published native modules and TypeScript state. Fresh PRs -[#23101](https://github.com/stablyai/orca/pull/23101) and -[#23104](https://github.com/stablyai/orca/pull/23104) restored both on their first -runs. Scope inventories showed neither PR had a private copy; the TypeScript -key existed only on main. Native restore took 0.45/0.54 seconds; TypeScript restore -took 0.38/1.31 seconds, with compiler steps of 8/39 seconds versus the warmer's -80-second cold compiler step. PR #23104 used the prefix fallback after its base -advanced, confirming reuse across commits as well as PRs. - -The same warmer saved a 22.5 MB Git cache, but the exact key disappeared before -it was reused. Quota eviction is plausible, not proven: the usage API reported -17.48 GiB while a separate live 100-entry sample contained 15.15 GiB of pnpm -stores alone. These rapidly changing inventories are not atomic. The root-only -warmer does not seed the root-plus-mobile download-store key used by static -analysis, so both fresh PRs saved another roughly 350 MiB store. Controlling that -cache duplication is a remaining opportunity; hourly warming alone cannot -promise retention. Git's checksum-verified cold-build fallback remains required. - -The unit scheduling baseline now comes from all eight successful Node 24 shards in -[run 36294142683](https://github.com/stablyai/orca/actions/runs/36294142683). -All 9,847 measurements match current discovery exactly once; the previous baseline -had 165 unmeasured files and one deleted path. Applying the same measurements to -both assignments reduces the largest projected load from 1,043.811 to 919.695 -worker-seconds (11.9%). This is a scheduling projection, not an elapsed-time claim; -runner variation remains visible in the source run. See -[provenance and reproduction](../../config/scripts/ci-shard-timings.md). - -## Recording compilation reuse - -[Benchmark run 36295773765](https://github.com/stablyai/orca/actions/runs/36295773765) -compared the complete mobile suite on two hosted runners in opposite orders. -Compilation reuse reduced elapsed time from 439.002 to 138.308 seconds and from -560.926 to 200.751 seconds (64–68%). Each run preserved all 9,629 original test -verdicts and passed four additional cache regression tests. Compiled code is -bounded to 512 entries; exports, dependencies, and scenario state remain fresh. - -Splitting family recordings across four files took 145.165 and 217.226 seconds, -5–8% slower than compilation reuse alone, so the original suite structure stays. -All 787 goldens were regenerated from the unchanged pinned product tree; only -the recorder digest changed, with identical recording bodies and value pools. - -Desktop validation in [run 36295671576](https://github.com/stablyai/orca/actions/runs/36295671576) -passed all eight shards. The longest test step was 419 seconds versus 429 in the -source run; summed test time was 3,028 versus 3,031 seconds. Runner variation -prevents attributing that small elapsed-time difference solely to the weights. - -## September 25 follow-up - -The current queue is a bigger part of PR latency than setup. Successful full PR -[36212793136](https://github.com/stablyai/orca/actions/runs/36212793136) used -**74.6 aggregate runner-minutes**, including **55.5** for its eight unit shards. -Those jobs ran for 315–448 seconds but waited 229–1,076 seconds to start. The -three-second final `verify` job waited another 264 seconds. These are observed -job creation-to-start and start-to-completion intervals, not billing figures. - -This follow-up keeps the existing tests, isolation, eight unit shards, platform -coverage, and release behavior: - -- Make the reusable unit-test call and final aggregate respect cancellation. - Their old `always()` conditions kept superseded work alive despite workflow - cancellation. In [36215475069](https://github.com/stablyai/orca/actions/runs/36215475069), - a newer push cancelled ordinary jobs while all eight unit shards remained - queued and the replacement workflow remained pending. `!cancelled()` still - evaluates after failed/skipped dependencies, without resisting cancellation. - Two other superseded PR runs reproduced this: at 04:27 UTC on September 26, - [36216165253](https://github.com/stablyai/orca/actions/runs/36216165253) and - [36215543186](https://github.com/stablyai/orca/actions/runs/36215543186) still held - 11 runners, with two more obsolete jobs queued. They had consumed another - 71.3 runner-minutes after replacement pushes. After rechecking current PR heads - and replacement runs, both obsolete runs were force-cancelled; their replacements - left the blocked pending state. -- Combine root/README guards with change detection. A real sparse checkout kept - all 29,487 index entries while materializing only 12 files (192 KB). This - eliminates one runner allocation and checkout per PR. README link checks still - see tracked targets outside the working tree and run on docs-only PRs. -- Use free `ubuntu-slim` containers for small guard/aggregate/API jobs; trial - free `ubuntu-24.04-arm` for typechecking, which needs no native runtime. - Both share the account's standard concurrency limit. Different labels do - **not** grant extra concurrent jobs; hosted timings determine their value. -- Cancel superseded PR attempts in the Git termination, Pi owner, and Pi provider - runtime workflows, retaining independent manual runs. -- Fetch only complete HEAD ancestry for cloud secret scanning. The old checkout - fetched every branch and tag and took 55 seconds in - [36214130174](https://github.com/stablyai/orca/actions/runs/36214130174). - Real Git fixtures prove both merge parents and deleted historical contents - still produce the identical scanned patches. -- Remove the cloud lockfile from the eight unit shards' download-cache key; the - dedicated relay integration job still includes it. Record actual per-file - environment, setup, import, and test durations for the existing shard planner. - Shard 4 spent 535 worker-seconds importing and 357 executing tests; a uniform - per-file import estimate misses that cost. See [timing refresh](../../config/scripts/ci-shard-timings.md). -- Seed Node 24 native modules, the pinned Git compatibility binary, and TypeScript - state on the default branch when dependency/toolchain inputs change, with - scheduled recovery (originally hourly; now every six hours). One ten-minute-bounded hosted job - reuses existing cache keys and skips typechecking an already-cached commit. - New PRs can restore default-branch caches, while caches saved by another PR - are inaccessible. The audit found 80 entries totaling 10.67 GiB, including - 9.31 GiB of pnpm stores, but no main-branch Node 24 native or TypeScript state. - Seven PRs held separate copies of the same pnpm key (2.36 GB combined). - Git preparation now has one shared action with the unchanged cache key, - checksum, and build command. In - [36212101873](https://github.com/stablyai/orca/actions/runs/36212101873), a new PR - spent 40 seconds compiling the same Git 2.25.5 binary; main-branch warming - makes that cache available to new PRs too. - -The hosted trial also removes repeated work in mobile bundle checks. The -builder keeps private snapshots of the default real output for read-only checks -(15 identical builds become two), and haptics checks reuse each route closure -(32 builds become eight). Determinism, custom inputs, malformed routes, stale -outputs, and tampered manifests retain independent builds. A mutation regression -proves Buffer/manifest consumers cannot change another assertion's fixture. -The grant census reuses parsed references for unchanged file contents, still -reads source every time, and has a real-file edit invalidation regression. -The first hosted mobile lane used 400 seconds for its 448 tests; the grant -census alone took 259 seconds. Locally, the same one-worker invocation of the -three changed files fell from 243.07 seconds (101 passing tests) to 49.49 seconds -(103 passing tests). Hosted run -[36217238462](https://github.com/stablyai/orca/actions/runs/36217238462) retained -the same 43 files and passed all 450 tests: the original 448 plus two regressions. -Its test step fell from 400.26 to 205.17 seconds, and the complete job fell from -498 to 298 seconds (40% less runner time). Builder, haptics, and grant-census file -times fell from 73.70/88.94/258.58 seconds to 29.24/32.98/22.58 seconds. -The later default-worker verification retained the same 450-test coverage, but -its unchanged terminal-render test failed twice on the pre-existing timing -assertion that a frame must be pending at disposal (`expected 0 to be greater -than 0`). Its other 449 tests passed; no mobile source or assertion was relaxed. - -A four-worker experiment reduced aggregate unit job time from 3,386 to 3,133 -seconds (7.5%), but the repeat run exceeded the palette matcher's existing -180ms performance budget at 235ms. The override was removed; retain Vitest's -default worker count, isolation, timeouts, retries, and coverage. The first trial -also found an outdated hook-order snapshot after main added three layout-persistence -hooks. That snapshot was refreshed only after comparing the exact old and merged -hook sequences. Final verification is linked from -[PR #23053](https://github.com/stablyai/orca/pull/23053). Failed timing reports -never replace the checked-in baseline. - -No account settings, paid services, or runner entitlements changed. Standard -public-repository runners remain free. GitHub documents plan concurrency limits -of Free 20, Pro 40, Team 60, Enterprise 500, and permits support requests for -increases. The organization's actual entitlement was not exposed by the API. -See [runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) -and [concurrency limits](https://docs.github.com/en/actions/reference/limits). - -Hosted observations from [PR run 36215718607](https://github.com/stablyai/orca/actions/runs/36215718607): - -| Check | Earlier sample | Trial | Result | -| ----------------------------------------- | -------------: | -----------: | -------------------- | -| Detection plus repository guards | 54s, two jobs | 26s, one job | Passed | -| Typecheck (whole job) | 109s x64 | 81s ARM | Passed | -| Typecheck command, cold incremental state | 76s x64 | 51s ARM | Passed | -| Cloud secret scan (whole job) | 69s | 55s | Passed | -| Cloud checkout/history fetch | 55s | 40s | Identical scan scope | - -The [warmup trial](https://github.com/stablyai/orca/actions/runs/36215718295) -passed in 54 seconds. Its x64 compiler restored the ARM job's incremental state -and rechecked the same source in eight seconds. Unit shard 3 subsequently -restored its pnpm/native cache keys successfully. Actual sharing across different -PRs requires the producer to land on the default branch; this trial validates -commands and key compatibility, not a completed default-branch rollout. -The follow-up warmup built the Git binary in 40 seconds; the PR Git check restored -that exact key and passed in 62 seconds overall. Its TypeScript refresh took -seven seconds after restoring earlier incremental state. - -These are small observational samples from different revisions, not controlled -benchmarks. The cold typecheck log confirms an incremental-cache miss. Queue -changes must be separated from active duration and concurrent account traffic. -At the time of that trial, checked-in shard weights were unchanged; the September -26 refresh above now uses the complete successful unit reports. - -## September 5 audit - -Audit date: September 5, 2026. No paid capacity or provider configuration changed. - -## Measurements and changes - -Three recent successful PR runs used 54.6–64.9 aggregate runner minutes: -[33998366568](https://github.com/stablyai/orca/actions/runs/33998366568), -[33998220287](https://github.com/stablyai/orca/actions/runs/33998220287), and -[33998181502](https://github.com/stablyai/orca/actions/runs/33998181502). -These are sums of active job durations, excluding skipped jobs; they are not -billing minutes or queue time. This small sample is not a historical average. - -- Consolidate E2E routing into the existing code-path detector. The removed - detector occupied 20–22 seconds and required another runner allocation and - full-history checkout per nondraft code PR. The same routing commands remain, - including SSH and native IME selection; actual E2E results remain advisory. - A routing-script error now fails the required code-path detector. -- Use gzip for PR-only Debian/RPM artifacts. The two sampled Linux packaging - jobs took 8m10s and 8m19s overall; one spent 3m47s in electron-builder. Its - default Debian/RPM compression is xz. PR artifacts are inspected on the same - runner, so their download size offers no benefit. Keep all AppImage, Debian, - RPM, payload, launcher, and shutdown checks. Release compression is unchanged. - Hosted validation in [33999422341](https://github.com/stablyai/orca/actions/runs/33999422341) - reduced the package-build step to 2m13s and the full Linux job to 6m17s, with - all existing checks passing. This is a small observational sample. -- Cancel superseded Mobile Checks and Skill update round-trip PR runs. The - skill matrix has 13 jobs. Preserve non-cancelling main/merge-group skill runs, - with separate concurrency groups per event. -- Reuse the existing script-free root dependency action in Mobile Checks, - including the pnpm cache keyed by both root and mobile lockfiles. The root - install remains necessary because mobile types import root dependencies. - -The repository already has eight unit shards, path-scoped platform checks, -native caches, one shared E2E build, PR cancellation, incremental TypeScript -caching, and changed-spec E2E routing. Increasing shards would increase setup -work and simultaneous runner demand. Do not adjust the count without comparing -critical-path time and aggregate job time on the same commit. - -## Follow-up savings - -- Move the hourly main/release freshness lookup to a five-minute Ubuntu - preflight without a checkout. In unchanged run - [33986205749](https://github.com/stablyai/orca/actions/runs/33986205749), - Blacksmith macOS was occupied for 40 seconds, including a 30-second checkout, - before skipping. The new job-level gate avoids that Mac allocation. Actual - builds gain an Ubuntu scheduling hop; pin the Mac checkout and downstream - Windows identity to the SHA that the preflight checked. -- Avoid global `npm install -g node-gyp` for validated Linux Node-runtime cache - hits. Use the existing native-module load/provenance check before skipping; - misses, broken addons, and Electron jobs still install the rebuild toolchain. - The action file participates in cache keys, so this rollout creates fresh - native caches once. No measured warm-cache seconds are claimed yet. - -## Runner recommendations - -The repository is **public**, verified using the GitHub API. Standard -GitHub-hosted Linux, Windows, and macOS runners have free compute minutes for -public repositories. Queue pressure and third-party provider allowances still -matter; artifact storage and larger runners have separate billing rules. -See [GitHub Actions billing](https://docs.github.com/en/billing/concepts/product-billing/github-actions). - -1. Keep standard GitHub-hosted runners as the default. Ask GitHub Support for a - higher concurrent-job limit before paying for more capacity. The documented - standard limits depend on the account plan (Free: 20 total/5 macOS; Team: - 60/5; Enterprise: 500/50), and increases are subject to approval. The actual - account entitlement was not verified. See [limits](https://docs.github.com/en/actions/reference/limits). -2. Reserve existing Blacksmith allowance for macOS if that is the priority. - Blacksmith documents 3,000 free x64 2-vCPU-equivalent minutes per organization; - a 6-vCPU Mac minute consumes 20 equivalents, or 150 actual Mac minutes if - it uses the entire free pool. Cloud workflows also use Blacksmith Linux. - Moving Linux to hosted GitHub saves shared allowance, but does not necessarily - free Mac hardware capacity. Account-specific contracts and usage were not - inspected. See [Blacksmith runners](https://docs.blacksmith.sh/blacksmith-runners/overview). -3. Treat Ubicloud as an optional small Linux overflow trial. Its documented - $2.50 monthly credit buys 1,250 premium 2-vCPU minutes at $0.002/minute, or - 2,000 standard 2-vCPU minutes at $0.00125/minute. New accounts default to - premium and require a credit card. No enforceable hard spending cap was - verified, so changing runner labels cannot guarantee the no-spend constraint. - One PR's roughly 55–65 runner minutes also makes clear how small this pool - is relative to repository activity (hardware speeds differ). - See [pricing](https://ubicloud.com/docs/about/pricing) and - [setup](https://ubicloud.com/docs/github-actions-integration/quickstart). - -### A bounded Ubicloud candidate - -The Linux leg of `performance-contracts.yml` took 48 seconds in -[33994756657](https://github.com/stablyai/orca/actions/runs/33994756657). -Its daily schedule and 20-minute timeout make it a small candidate: 31 ordinary -scheduled attempts permit at most 620 job-runtime minutes, before runner -startup/cleanup billing. Actual timings on Ubicloud's 2-vCPU hardware still need -measurement; the GitHub timing is only a sizing reference. - -If enabled later, route only the first attempt of the scheduled Linux job to -Ubicloud; keep PRs, manual dispatches, reruns, and macOS/Windows on GitHub. This -avoids spending the allowance on unpredictable PR volume. Check other account -usage and available credit before enabling; a workflow timeout is not an -account-wide billing cap. On September 5, the organization's GitHub App -installation list contained Blacksmith but no Ubicloud installation, so this -follow-up leaves runner selection on GitHub rather than queueing work against -an unprovisioned label. - -## Machines that also run coding agents - -Do not register the credentialed host directly as a public-PR runner. A PR can -execute arbitrary build/test code, and a persistent host lets it access local -credentials or affect subsequent jobs. Docker alone is not adequate isolation -when it exposes the host home, Docker socket, SSH agent, or office network. - -A possible no-new-hardware experiment is a disposable VM per job, preferably on -a dedicated spare machine, with a just-in-time single-job runner, no shared -home/keychain/SSH agent or host mounts, restricted network access, and CPU/RAM -limits that leave room for coding agents. Destroy the VM after every job; -ephemeral runner registration by itself does not clean the machine. Start with -trusted branch/manual workloads and keep public fork PRs on hosted runners. -Provisioning and ongoing patching are real operational costs even when the -machine is already owned. See GitHub's -[self-hosted runner security guidance](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions). - -## Release waits - -The latest successful sampled Windows release used 13m59s of a 21m56s job in -signing wait/download steps. The same release held an Ubuntu job for 11m38s -polling the isolated Mac build. These are stronger occupancy opportunities than -small checkout savings, especially when approval takes hours. - -[Windows signing without occupying a runner](windows-signing-runner-time.md) -describes a staged, same-run design, required protected environments, and -rehearsal criteria. No callback integration or protected Windows signing -environments currently exist. An environment-gated design adds a GitHub -approval after each SignPath approval and changes the current automatic inner -signing timeout fallback; those are explicit release-policy decisions, so this -PR leaves production signing behavior unchanged. - -## Second audit and hosted trials - -- Cloud Verify ran 100 times in a sampled 39-hour window (84 PR and 16 push - runs). Move its four Ubuntu 22.04 jobs from Blacksmith to standard hosted - Ubuntu 22.04, preserving Postgres, secret scanning, build, tests, and Terraform - validation. Baseline [34001538145](https://github.com/stablyai/orca/actions/runs/34001538145) - used 64/72/26/19 seconds for security/test/build/Terraform respectively. - This conserves the shared provider allowance; hosted latency must be checked. -- Keep full tag history for the 13-job skill round-trip matrix, but fetch blobs - lazily. Only two historical SKILL.md files are materialized. Baseline - [33999994876](https://github.com/stablyai/orca/actions/runs/33999994876) - spent 42–84 seconds per checkout, about 14 aggregate runner minutes. A hosted - trial must verify historical blob fetches on all three operating systems. -- Use the existing Electron/native dependency cache for native IME CI. Keep - both deterministic boundary and real IBus tests. Add pnpm store caching to - terminal perf and release golden/evidence lanes; retain their raw installs - because manually selected older refs may not contain the shared action. -- Disable ZIP recompression only for already-compressed NSIS installers sent - to SignPath. Installer contents, release compression, and signing stay intact. -- Advance existing placement and startup deadlines with scoped fake timers in - three renderer test files. All 34 tests pass in 62 ms of local test execution, - versus 65.182 seconds in the sampled hosted baseline. Imports and transforms - still dominate invocation time; this is not a claim of equal PR wall savings. - -Eight unit shards already have balanced 260–296-second sample durations. -Reducing shards or removing test isolation lacks evidence of a net gain. Real -subprocess tests intentionally cover lifecycle behavior and retain real clocks. -The 14-way E2E split retains headroom after earlier 12-way timeouts. Lowering -coverage or schedule frequency is outside this efficiency pass. Cache complexity -for a seven-second docs install is unlikely to pay back. Release build reuse -across modes risks differing telemetry identities and native platform artifacts. - -Terminal Perf's baseline [33955846492](https://github.com/stablyai/orca/actions/runs/33955846492) -failed waiting 30 seconds for workspaceSessionReady in its shared-page fixture, -before measuring terminal performance. Compare hosted trials against that known -failure rather than attributing it to dependency cache changes. - -Hosted trials for the second audit: - -- [Cloud Verify 34002295216](https://github.com/stablyai/orca/actions/runs/34002295216) - passed all four jobs on standard hosted Ubuntu: security 57s, test 102s, build - 35s, Terraform 19s. The test lane is 30s slower than the Blacksmith sample; - retain this modest latency tradeoff to conserve shared allowance. -- [Skill matrix 34002295221](https://github.com/stablyai/orca/actions/runs/34002295221) - passed all 13 legs, including historical blob materialization. Checkout took - 18–20s on Linux, 39–45s on macOS, and 49–58s on Windows, versus the earlier - 42–84s range across platforms. These are observational samples. -- [Native IME 34002299594](https://github.com/stablyai/orca/actions/runs/34002299594) - passed both deterministic and real IBus checks. Shared dependency setup took - 29s, versus 35s for the old install/toolchain steps in the sampled baseline. -- Native-IME-only source/spec changes no longer allocate the reusable E2E - build, cache, and consumer jobs just to filter out the native spec. The - separate native workflow still runs; SSH-only and mixed spec lists still - allocate the reusable workflow. Routing contracts exercise these cases. -- [Hourly 34001816449](https://github.com/stablyai/orca/actions/runs/34001816449) - exercised the new five-second preflight and successfully published macOS. - The Windows follow-up failed in its unchanged input-vetting fetch because - remote refs differ only by case on its case-insensitive filesystem. The - requested SHA was correct; this does not validate an unchanged-main skip yet. - -Moving the daily Mac freshness check has lower expected value than hourly: -only one potential idle allocation per day, and active development usually -requires that build. Defer another release-graph change until skip frequency -justifies it. The substantive remaining release occupancy opportunity is the -separately documented asynchronous signing policy decision. - -## Persistent Vitest transform cache: rejected for now - -A local 96-file import-heavy sample with Vitest 4.1.11 took 8.31/8.53 seconds -without its filesystem module cache, 8.63/8.69 seconds cold, and 5.91/5.91 -seconds warm: about 30% faster warm. The cache held 3,770 modules and 83 MiB. -These timings exclude hosted cache transfer and do not establish a PR saving. - -Correctness probes found eight changes that incorrectly kept a test passing -against the old transformed import or compiler output: - -| Change after warming | Raw cache | Startup fingerprint | -| ----------------------------------------------------------------- | ---------- | ------------------- | -| Add preferred `value.js` beside previously resolved `value.ts` | False pass | Correctly fails | -| Retarget a source symlink while its old target still exists | False pass | Correctly fails | -| Change an inlined package's `exports` to another existing file | False pass | Correctly fails | -| Add a preferred extension in a generated source directory | False pass | Correctly fails | -| Change TypeScript's JSX factory in `tsconfig.json` | False pass | Correctly fails | -| Create the preferred file from setup after startup fingerprinting | False pass | False pass | -| Add a preferred file inside an external symlinked directory | False pass | False pass | -| Change an external file read by a transform plugin | False pass | False pass | - -The fingerprint included file names/types, symlink targets, package/config/ -TypeScript metadata contents, and effective alias/define options. Following -external symlink inventories and hashing declared transform inputs repaired the -last two rows, but did not repair files created after fingerprinting. All ten -cache-disabled changed-input controls failed correctly; initial and repeated -warm controls passed. Effective alias and simple define changes also invalidated -correctly without the added fingerprint. - -The [Vitest 4.1.11 documentation](https://github.com/vitest-dev/vitest/blob/v4.1.11/docs/config/experimental.md#known-issues) -documents incomplete plugin-input tracking. Its -[cache implementation](https://github.com/vitest-dev/vitest/blob/v4.1.11/packages/vitest/src/node/cache/fsModuleCache.ts) -hashes the module and selected configuration, but retains previously resolved -import URLs. [Upstream fix #11381](https://github.com/vitest-dev/vitest/pull/11381) -merged September 29 and revalidates those URLs. A disposable Vitest 5.0.3 probe, -which contains that fix, reproduced all eight false-pass categories: the old -target still exists, so checking its resolved URL misses a newly preferred file -or changed package export. Upgrading alone does not make reuse safe. - -To reproduce the simplest negative control outside the worktree: - -1. Create `value.ts` containing `export const value = 1`, and a test importing - `./value` and asserting `expect(value).toBe(1)`. Use an isolated config/cache, - one fork worker, `ORCA_BACKGROUND_LAUNCH=1`, and - `NODE_DISABLE_COMPILE_CACHE=1`. -2. Run the installed CLI with `--experimental.fsModuleCache=true` to warm it. - Add `value.js` containing `export const value = 2`; retain `value.ts` and the - unchanged test. The same cached invocation incorrectly passes. -3. Repeat with `--experimental.fsModuleCache=false`. The assertion correctly - fails. Vitest 5 uses `--fsModuleCache` for the equivalent controls. -4. For the startup-inventory control, use unchanged setup code that creates - `value.js` only when a runtime environment switch is enabled. Remove that - file before each invocation/fingerprint; warm with the switch off, then run - with it on. The cached importer still points at `value.ts`, while the fresh - module graph correctly fails. This is an additional persistent-cache error, - not a claim that normal in-process module reuse supports arbitrary mutation. - -Orca currently resolves only pinned Vitest/Vite built-in transform plugins. -Its setup files install runtime guards/shims and temporary user data, rather -than custom transforms. A narrower policy could cache only proven immutable -source/dependency inputs and leave tests, setup, virtual modules, external -fixtures, and unknown plugins cold. That requires a validated transitive input -boundary and mutation policy; hashing every source tree on each lookup would -also spend the gain. Until that policy and hosted transfer cost are measured, -the local warm result does not justify adding a persistent cache to CI. - -## Test fixture imports: reuse the existing narrow builder - -The pointer-drag test imported only `makeWorktree` from `store-test-helpers`, -which also loads the real store slices. Its existing identical export in -`worktrees-slice-test-fixtures` supplies the same defaults without that graph. -Changing this single import preserves the five tests, fork workers, -isolation, and disabled filesystem/Node compile caches. - -Three local interleaved before/after pairs took 2.066/2.047/2.031 seconds versus -0.351/0.388/0.353 seconds: the isolated median fell 82.7%, from 2.047 to 0.353 -seconds. Transformed modules fell from 1,086 to 15. This is an isolated test -result, not a whole-shard estimate: other tests need the store modules anyway. -A broader 20-file screening sample saved only 0.295 seconds at its median, -which does not justify splitting the fixture module across those consumers. - -Two further import-only reuses passed the same six-run controls. The kanban -lane test mocks its card component, so switching its builder import reduced -the isolated median from 2.202 to 0.495 seconds (77.5%) and transformed modules -from 1,082 to 13, with all six tests unchanged. The autosave fixture needs -the real editor slice, but not every store slice: the same import change across -its three consuming suites reduced the median from 3.027 to 1.301 seconds -(57.0%), with 1,107 to 320 modules and all 17 tests unchanged. These -results also measure isolated file groups; they are not additive shard savings. -The remaining inspected builder-only imports already load the full store as -their subject, or use builders whose defaults differ from existing exports. - -## Vitest threads: retain forks after the scoped pilot - -Three local interleaved comparisons kept four workers, `isolate: true`, both -persistent caches disabled, and the same test assertions/module graph. The -94-file happy-dom renderer cohort passed all 570 tests: forks took -17.480/17.382/17.657 seconds and threads 15.048/14.890/15.013 seconds, a 14.1% -median reduction. A 23-file shared JavaScript cohort passed all 248 tests, -with its median falling from 1.435 to 1.274 seconds (11.2%). No main-process -module or native addon loaded; guards reject native loading, `chdir`, and -process signals. These Mac/Node 24 timings motivated the hosted comparison. - -The [pinned Vitest pool documentation](https://github.com/vitest-dev/vitest/blob/v4.1.11/docs/config/pool.md) -defaults to forks and documents thread limitations around process APIs and -native libraries. [Node's worker documentation](https://nodejs.org/docs/latest-v24.x/api/worker_threads.html#new-workerfilename-options) -also excludes V8 flags from worker `execArgv`. Orca's `--expose-gc` worker flag -fails with `ERR_WORKER_INVALID_EXEC_ARGV` under threads. The experiment starts -both parent processes with that flag, retains it on fork workers, and removes -it only from thread worker arguments; GC availability is checked in every -test environment. Process, native, lifecycle, and GC-retention tests stay out -of this comparison. - -The [hosted pilot](https://github.com/stablyai/orca/actions/runs/36855833033) -passed on Linux ARM64, four CPUs, Node 24.21.0, and Ubuntu image -`20260927.135.1`. Three alternating pairs preserved source hashes and complete -module graphs, with no main-process module or native addon loaded: - -| Audited cohort | Forks, seconds | Threads, seconds | Median saving | -| -------------------------------------- | ------------------------ | ------------------------ | --------------------- | -| 94 renderer files / 570 tests | 48.591 / 48.144 / 47.299 | 42.486 / 42.402 / 42.221 | 5.742 seconds (11.9%) | -| 23 shared JavaScript files / 248 tests | 3.386 / 3.330 / 3.424 | 3.203 / 3.237 / 3.278 | 0.149 seconds (4.4%) | - -Each timed group also included two isolation sentinels: totals were 96 files / -572 tests and 25 files / 250 tests, respectively, with no skips. -Their graph hashes matched in every pair (4,015 and 251 modules). Separate -single-worker positive controls passed both sentinels in each pool. With -isolation disabled, the second sentinel correctly failed on leaked state. -Missing GC failed setup, and native loading, `chdir`, and process-signal probes -failed at the guard in both pools. Those expected failures did not pass silently. - -The fixed 117-file sample accounts for about 1.5% of the baseline's aggregate -module time; its isolated gains are not a whole-suite estimate. Maintaining -that exact file list for this benefit is not justified. A broader route needs -a safe eligibility policy, Node 26/Windows evidence, and a mixed full-shard -comparison: separate Vitest projects can repeat shared transforms and erase -the pool-startup saving. A renderer path alone does not prove that future -imports avoid process or native behavior. Production retains forks, and the -temporary workflow, driver, and cohort list were removed after measurement. - -## Oxlint scan consolidation: rejected - -The [hosted comparison](https://github.com/stablyai/orca/actions/runs/36855833063) -used one Linux ARM64/four-CPU runner, Node 24.21.0, and three alternating -baseline/candidate pairs. The baseline kept root lint and anti-slop in parallel, -then native and type-aware audits in parallel. The candidate merged the first -three scans and ran the unchanged type-aware audit alongside them. - -| Pair/order | Baseline stage | Candidate stage | Change | -| ------------------ | -------------- | --------------- | ------ | -| 1: baseline first | 49.409 s | 53.197 s | +7.7% | -| 2: candidate first | 50.249 s | 60.297 s | +20.0% | -| 3: baseline first | 50.040 s | 56.672 s | +13.3% | -| Median | 50.040 s | 56.672 s | +13.3% | - -These complete stage timings include anti-slop synchronization in both variants -and candidate configuration generation. Candidate preparation took only -0.141–0.255 seconds. The unchanged type-aware scan took 16.386–17.279 seconds -in the baseline wave, versus 33.561–55.736 seconds beside the merged scan; -these timings are consistent with contention on the four-CPU runner. - -The corrected local Mac/16-CPU comparison had reduced the median from 16.676 -to 14.381 seconds (13.8%). Both comparisons limited each Oxlint invocation to -four threads and used identical source configuration hashes. The hosted result -shows why the local gain did not justify adoption on the actual CI runner. - -Coverage controls passed: the merged scan matched the exact 28,621-file union -with no missing or extra files. Thirty-one fault fixture files produced the -exact 16-diagnostic union, including all seven active root JavaScript rules. -Eighteen focused controls preserved nested-mobile exemptions, type-aware -exclusions, and exit behavior. The native audit's warnings still failed its -original `--deny-warnings` gate and became errors in the merged scan; root -warnings remained non-fatal. Every full-repository scan passed cleanly. - -Keep the existing production waves. The temporary workflow and 601-line -benchmark driver were removed after recording this rejected result. - -## Native cache ownership: retain the extraction - -Native restoration, toolchain recovery, and preparation now belong to -`.github/actions/prepare-native-runtime/action.yml`. The installer forwards -its requested key and three build paths; Windows packaging saves the Node -build before calling the same action for Electron. Existing native load, -patched-build, Windows job-ownership, registry, and process-table probes remain -unchanged on restored consumers. Exact keys still separate OS/image or Linux -container libc, architecture, runtime, resolved Node version, and actual pnpm -version, without partial-key restoration. - -The source hash covers the dedicated action, `pnpm-lock.yaml`, -`pnpm-workspace.yaml`, `.npmrc`, `.pnpmfile.cjs`, both native dependency patches, -and these complete build/probe inputs: - -- `config/scripts/ensure-native-runtime.mjs`, `rebuild-native-deps.mjs`, - `node-pty-job-ownership.cjs`, `windows-pe-machine.cjs`, - `windows-process-tree-gyp-rebuild.mjs`, and - `windows-process-tree-creation-time.cjs`; -- `config/scripts/install-electron-package-binary.mjs`, - `electron-platform-path.mjs`, `zip-extractor-command.mjs`, - `shared-electron-dist-cache.mjs`, `space-sharing-copy.mjs`, and - `src/shared/zip-extractor-command.ts`; -- `native/windows-registry/src/addon.cc`, `binding.gyp`, `package.json`, and - `index.js`. - -The patches are `config/patches/node-pty@1.1.0.patch` and -`config/patches/@vscode__windows-process-tree@0.8.0.patch`. Root app version and -script metadata are excluded; installed package versions remain owned by the -full lockfile, and the external node-gyp pin belongs to the native action. -A negative control changing only the installer's -pnpm verification condition preserves the native key and paths. Every declared -native input mutation changes the key, and main warming watches those inputs. - -This policy creates one cold namespace. The bounded 50-head main sample has -49 adjacent transitions and three native-key changes under both the old and -expanded policies: the added node-pty helper export still invalidates #24448. -There is no measured historical net saving. - -The [cold warming run](https://github.com/stablyai/orca/actions/runs/36945655208/attempts/1) -published all four exact Node keys, and its -[warm rerun](https://github.com/stablyai/orca/actions/runs/36945655208/attempts/2) -restored them on fresh runners with the same frozen inputs, Node 24.21.0, and -pnpm 12.0.0. All five jobs passed in both attempts. These times cover the entire -native action: runtime validation, key resolution, restore, any toolchain -recovery, and the unchanged native preparation probes. - -| Native Node lane | Cold action | Warm action | Cold post-job save | -| ---------------- | ----------- | ----------- | ------------------ | -| Linux x64 | 18.288 s | 0.993 s | 0.387 s | -| Linux ARM64 | 11.540 s | 1.062 s | 1.113 s | -| Windows x64 | 104.482 s | 1.381 s | 2.424 s | -| Windows ARM64 | 256.662 s | 3.832 s | 1.243 s | - -Cold Windows jobs rebuilt all three native addons. Warm jobs loaded and probed -the restored builds; Linux also ran the existing check-only probe before -skipping the external node-gyp installation. Warm post-job steps recognized -their primary keys and did not save again. An earlier trial exposed unavailable -nested composite outputs during post-job saving; both cache variants now use -the same literal path inventory as the requested output, and the fixed cold -jobs published their caches without missing-path warnings. - -Both [PR package jobs](https://github.com/stablyai/orca/actions/runs/36945659474) -passed. Windows packaging consumed the same-run Node seed in 1.374 seconds -before its Node tests and Electron transition. Its Electron cache initially -missed while the modules were already healthy, so that stage does not establish -an avoided compilation. Linux's Electron cache was also published, and all 19 -bundled native binaries passed the existing glibc floor check. - -The [first six-platform headless run](https://github.com/stablyai/orca/actions/runs/36945658897) -ran every persistence lane: five passed, while Mac Intel failed waiting for a -cancel-test worker's ready file before its 500 ms timeout. That lane deliberately -uses `native-runtime: none`; its separate slot build and smoke passed. The failure -blocked the five downstream Linux glibc/musl qualifications, so this run does not -establish complete headless qualification. All six persistence lanes, Node 18 -handoffs, and Linux floor/musl gates remain; final qualification is tracked in -the [PR's latest checks](https://github.com/stablyai/orca/pull/24476/checks). - -These are single cold/warm observations, not paired medians or a measured -whole-workflow saving. They demonstrate usable exact-key reuse after publication; -future savings depend on cache availability and unchanged native inputs. The -trial seeds belong to this PR's merge ref. Other PRs require a main-branch seed -after merging this new namespace; the existing main-push and scheduled warming -jobs provide that seed. - -## Separate mobile install verification: retain the current policy - -Three local paired pnpm 12 mobile installs reduced the median from 17.155 to -15.871 seconds, a 1.284-second difference before cache transfer and postinstall -scripts. That narrow margin does not establish a net hosted saving, so the -separate mobile verification record was not adopted. - -## Unit shard weights: retain the current allocation - -The latest five shard wall times were 526/495/503/508/510 seconds. Reweighting -projected roughly a 4% reduction in the slowest shard without reducing total -CPU work; the evidence across runs was weak. That estimate does not justify -changing allocation, so the current weights remain. - -## Serializer oracle allocations: retain the change - -The serializer round-trip oracle now reloads one xterm cell per buffer traversal -and writes flag digits directly, avoiding fresh cell objects and flag arrays for -every comparison. Independent replay terminals, cell descriptors, transcript -fixtures, resize schedules, ConPTY modes and seeds remain unchanged. - -The [hosted comparison](https://github.com/stablyai/orca/actions/runs/36944080887) -used one Ubuntu 24.04 ARM64 runner, image 20260927.135.1, Node 24.21.0 and one -isolated fork. The baseline formatter was frozen from f69052e. Byte-parity capture -ran separately; these three alternating pairs had no payload instrumentation. -Times cover the complete Vitest invocation, including startup and shutdown. - -| Pair/order | Baseline | Candidate | Change | -| ------------------ | -------- | --------- | ------ | -| 1: baseline first | 70.631 s | 61.661 s | -12.7% | -| 2: candidate first | 70.619 s | 62.284 s | -11.8% | -| 3: baseline first | 70.750 s | 62.091 s | -12.2% | -| Median | 70.631 s | 62.091 s | -12.1% | - -Median test-body time fell from 69.519 to 60.953 seconds. All eight full-cohort -invocations preserved the same 89 passes and two existing conditional skips -across three files. Separate baseline/candidate captures produced identical -95,017,559-byte payloads for all 1,435 scenarios and 7,649 checkpoints, with zero -source crashes; both SHA256 hashes matched the local captures. - -Seven focused controls compare against the original allocating oracle, including -all 128 text-flag combinations, styled blanks, wide cells, cell reuse and immutable -snapshots. Five deliberate faults were detected: stale cell contents, a missing -bold flag, changed empty-cell policy, removed scratch reuse and a source-parser -crash. The last control also proved that crash returns enter the capture. - -This measures the three-file oracle cohort. Whole-shard timings include other -test bodies, imports and transforms, so a whole-suite saving needs separate -measurement. - -## Cache warming: let scheduled ticks wait for active work - -The hourly warmer previously cancelled an active warmer, even when both used -the same source. On October 2, the [merge-triggered run](https://github.com/stablyai/orca/actions/runs/36965832780) -at 8ff6296 was interrupted by the [hourly run](https://github.com/stablyai/orca/actions/runs/36966367896) -at the same commit. The Windows ARM dependency installation had run for 356 -seconds before cancellation; its native verification was skipped. The other -four lanes had already succeeded. - -Scheduled events now wait in the existing concurrency group. Push, PR and manual -events still replace active work. This keeps one active workflow and the default -single pending slot, using GitHub's documented -[conditional cancellation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency). -All cache probes, platforms and publication rules remain. - -This avoids the observed discarded installation. It does not remove the next -scheduled run or its repeated successful lanes, and pending replacement still -applies regardless of the cancellation expression. The bounded 20-run sample -contains this collision; it does not establish a recurring or whole-CI saving. - -## Cache warming: six-hour recovery interval - -Scheduled warming now runs at 00:41, 06:41, 12:41 and 18:41 UTC instead of hourly. -Main pushes that change cache inputs still seed immediately, and manual dispatch -remains available. All five jobs, probes, keys and publication rules remain. -This removes 20 scheduled workflows and 100 scheduled job starts per day (83%). - -Four consecutive October 2 scheduled runs used the same source. The -[18:50 UTC run](https://github.com/stablyai/orca/actions/runs/37050194510) used 474 -aggregate runner-seconds across five jobs, including 242 seconds on Windows ARM. -That job restored exact package, verification and native caches; package-store -restore alone took about 70 seconds. Repeating that observed duration twenty -fewer times would avoid about 158 runner-minutes daily, but this one-run estimate -is not a billing forecast or measured post-rollout saving. - -The longer interval can delay background repair after eviction or runner-image -changes. Existing consumers retain cold-cache installation/build fallback, and -normal cache reads update last access. Storage was near the repository limit -when audited, so retention and unchanged hit rates are not guaranteed. Observe -misses before reducing the recovery frequency further. - -## Daemon shutdown fixture: remove build tools after compilation - -The fixture now removes compiler and Python build dependencies, plus npm and -node-gyp caches, in the same Docker layer that installs node-pty. It restores the base image's manual -package marks, keeps procps and util-linux, and retains the packages owning the -shared libraries used by Node and the actually loaded PTY addon. This extends -the [official Node image's package-ownership approach](https://github.com/nodejs/docker-node/blob/main/22/bookworm-slim/Dockerfile) -to native addons. Dependency checks and a real PTY spawn fail the build if cleanup -breaks the runtime. - -The [hosted comparison](https://github.com/stablyai/orca/actions/runs/36969120786) -used Ubuntu x64, Docker 28.0.4 and the same resolved Node 22.23.3 base digest for -both images. All 3,418 common entries under `/usr/local` retained their bytes, -modes and symlink targets; all seven resolved runtime libraries matched. Only -two directory-only Python paths disappeared. The 52 removed Debian packages -were build dependencies; retained package versions stayed identical. - -| Image payload | Baseline | Candidate | Reduction | -| ------------------ | ------------- | ------------- | --------- | -| Docker archive | 714,643,456 B | 329,967,616 B | 53.8% | -| Compressed archive | 205,819,558 B | 91,888,840 B | 55.4% | - -Each timed arm started a separate Docker daemon with an empty image store, -decompressed the archive, loaded it, rebuilt from its inline cache and ran the -unchanged descendant/canary shutdown check. Every provisioning layer was cached. - -| Pair/order | Baseline | Candidate | Saving | -| ------------------ | -------- | --------- | ------- | -| 1: baseline first | 16.320 s | 10.198 s | 6.122 s | -| 2: candidate first | 16.389 s | 10.141 s | 6.248 s | -| 3: baseline first | 16.351 s | 10.223 s | 6.129 s | -| Median | 16.351 s | 10.198 s | 6.153 s | - -Median decompression fell from 1.059 to 0.493 seconds and loading from 10.723 to -5.125 seconds. Cached rebuild and shutdown times stayed close. Both seed images -and all six restored consumers passed the original shutdown/canary assertions; -both deliberate no-op disposal controls failed with the descendant still live. -Byte and retained-directory mode faults also failed the inventory comparator. -All six owned daemons stopped gracefully without a forced kill. - -These private daemons used separate classic overlay2 stores and the untouched -host containerd service. Production storage settings were not captured, the -filesystem cache was not flushed, and network transfer is excluded. Production -restores overlap dependency installation, so this 37.6% fixture-sequence saving -does not establish a six-second PR wall-time improvement. Single cold image -builds took 19.313 and 22.623 seconds. The Dockerfile change creates one new -fixture key; the existing main warmer seeds it after merge. - -## WebRTC egress fixture: avoid GPU initialization for the data channel - -The Linux-only probe disables hardware acceleration before Electron readiness. -It still creates two independent processes/profiles, a real data channel, offer -and local description, and checks the exact proxy and UDP policy. The three-second -host observation, 500 ms drain and 20/30/45-second deadlines remain unchanged. - -Two fresh Ubuntu x64 runners compared three alternating pairs each, with identical -phase instrumentation. The [first trial](https://github.com/stablyai/orca/actions/runs/36967511524) -started with the baseline; the [second trial](https://github.com/stablyai/orca/actions/runs/36969120786) -started with the candidate. Their first baseline peer constructors took 4.059 -and 2.546 seconds and logged the GPU command-buffer error seen in an earlier -package timeout. In the second trial, that baseline delay followed the cold -candidate's 2.3 ms constructor. Every candidate constructor took 2.0–2.6 ms. - -Typical process time stayed near 8.3 seconds: baseline/candidate medians were -8.288/8.273 seconds in the first trial and 8.298/8.380 in the reverse trial. -The evidence supports removing an avoidable startup delay, without a measured -typical throughput gain or an estimate of future timeout frequency. - -All 12 full case invocations preserved actual unprotected UDP and zero protected -UDP. Both trials rejected seven faults: missing policy, a packet at 2.9 seconds, -a packet during the drain, a missing peer factory or local description, a broken -packet counter and a hung renderer. The original assertions and deadlines caught -each fault. The normal package gate runs the uninstrumented fixture. - -## Remote resync fixture: keep coalesced frames in one decoder pass - -The first [full PR run](https://github.com/stablyai/orca/actions/runs/36971375340) -passed both package jobs but failed one remote-workspace ordering assertion: -it observed revisions `[2, 3]` where the fixture expected `[3]`. The decoder can -yield between two frames after its four-millisecond work budget. Under slow -scheduling, the first response's promise can publish revision 2 before the -second frame's revision 3 notification is dispatched. - -The fixture now holds its delivery clock at the actual timestamp from multiplexer -construction through the first coalesced-buffer delivery, following the existing -decoder test pattern. It restores the clock before asynchronous assertions and -again before disposal. All four source/order cases retain their exact cache, -publication, client-identity and follow-up-read assertions. Production decoding, -its fairness budget and remote messages are unchanged. - -Normal focused runs passed all 18 tests. Advancing the clock by four milliseconds -per call reproduced `[2, 3]` in both original response-first cases; the fixed -fixture passed all 18 under the same control. Removing the freeze reproduced both -failures. Bypassing the production read-safety guard still caused `[3, 2]` rollback -in all four ordering cases and eight failing tests overall. All controls preserved -the same 18 test identities. This corrects a reproducible fixture assumption; -one CI failure does not establish a failure-rate reduction. - -## Windows server cache metadata: retain the current key - -The bounded 50-head main sample ending at 8ff6296 contained no root package -metadata changes. Removing app-version metadata from the Windows server cache -key would not improve reuse in that sample, so the key remains unchanged. - -## Windows ARM SSH: prepare the inbox capability during independent builds - -The ARM inbox lane starts guarded Windows capability preparation after the pure -provisioning self-test and waits for it before any private SSH server or host cell -runs. Dependency installation and the unchanged native artifacts can run during -that preparation. Preview and x64 lanes keep their existing serial provisioning; -the registered background step completes without mutation in those lanes. - -The preparation and the foreground provider use the same installer and isolation -guards. The receipt must match the source, run, attempt, runner, image and native -architecture. The foreground provider still reads the installed capability and -verifies every native binary and Microsoft signature. Account ownership, ACLs, -DefaultShell, private service identity, host cells and cleanup remain independent -checks. A background failure propagates through the unconditional native wait. - -Two full four-lane pairs used frozen source refs and the same dependency and -native-install policy. The [first baseline](https://github.com/stablyai/orca/actions/runs/36986929163) -ran before the [first candidate](https://github.com/stablyai/orca/actions/runs/36986970976); -the [second candidate](https://github.com/stablyai/orca/actions/runs/36991232037) -was dispatched before the [second baseline](https://github.com/stablyai/orca/actions/runs/36991234729). -Runner image versions matched within each platform in both pairs. - -| Active job, seconds | First baseline | First candidate | Second baseline | Second candidate | -| ------------------- | -------------: | --------------: | --------------: | ---------------: | -| ARM inbox | 2,403 | 1,644 | 2,353 | 1,667 | -| ARM preview | 1,002 | 935 | 886 | 872 | -| x64 inbox | 636 | 732 | 616 | 620 | -| x64 preview | 562 | 561 | 623 | 566 | - -The ARM inbox observations improved by 759 and 686 seconds. Baseline dependency -installation and artifact builds consumed 501 and 498 seconds before capability -installation could start. Candidate capability installation ran during that -work, but also took about 261 and 232 seconds less than the baseline. Candidate -dependency installation was slower, particularly in the second pair. These -observations support overlap on ARM; they do not establish a guaranteed 11–13 -minute saving, a reduction in queue time, or the cause of installer variability. -The x64 lane showed no repeatable gain, so it keeps serial preparation. - -All 16 actual Windows providers and 48 host-cell verdicts passed across the two -pairs. Receipts verify native machine identity, private service absence, owned -process exit, account removal and key removal. Loaded profile disposition remains -separate from those required cleanup checks. Hosted execution also verified the -native background/wait syntax; older actionlint versions do not recognize it. - -### Overlap the private profile observation budgets - -After service deletion and owned process exit, profile cleanup polls each owned -SID with its own full 30-second monotonic budget. Independent budgets now run -together. Every deletion follows a fresh targeted read; loaded profiles remain -for disposable VM destruction. Service identity, PID ownership, process exit, -account removal and key removal still fail the complete provider on error. - -The maintained diagnostics self-test executes the actual cleanup try/catch with -scoped Windows API and clock controls. Eight positive cases cover full windows, -late unload, reload, query overhead, mixed states and missing SIDs; ten specific -failure cases cover foreign profiles and the required cleanup gates. Disposable -shortened-deadline and stale-snapshot mutations fail those controls. A separate -mocked real-clock observation took 30.179 seconds for three loaded profiles, -compared with about 90 seconds for serial full budgets. This measures polling, -not an actual Windows provider or the entire job. - -The third profile no longer gains incidental extra time while earlier profiles -consume their budgets. A profile unloading at 45 seconds may therefore remain -where serial cleanup removed it. This uses the existing disposable-VM fallback; -it does not remove a loaded profile or relax mandatory account/key cleanup. -Hosted qualification of the combined workflow remains pending. - -## Coordinator mail tests: advance observation windows without removing them - -Six cases advance their original six 1,500 ms and ten 100 ms observation windows -with a scoped clock. Real filesystem, SQLite, journal, RPC and runtime work still -finishes asynchronously. The original journal-read gate and all counter and -operation assertions remain. Cancellation during delayed startup and the -Date-only age case retain real timers. Teardown stops the host and closes the -database before advancing the known 2,000 ms orphan repair, then asserts no fake -timers remain and restores the clock in `finally`. - -Two opposite-order local pairs passed the same 23 cases and unchanged source -hashes. Selected-case totals fell from 13.674 to 3.318 seconds and from 13.276 to -6.323 seconds. Whole-file test totals fell from 24.845 to 10.829 seconds and from -21.500 to 19.410 seconds. Process wall times were 41.488/37.810 seconds and -42.140/78.450 seconds; the reverse candidate spent 56.31 seconds importing under -unrelated local load. Local process-wall savings were inconclusive. - -The later [hosted x64 and ARM comparison](https://github.com/stablyai/orca/actions/runs/37001891871) -passed the same 23 cases in `structured-chat-coordinator-mail.test.ts` in both -orders on each architecture, with frozen case and policy hashes. Median full-file -wall time was 33.506 → 23.149 seconds on x64 and 33.438 → 22.504 seconds on ARM. -Median test-body totals were 19.329 → 9.343 and 19.695 → 9.103 seconds, respectively. -These measurements qualify this file; they do not measure whole-PR time. - -Injected extra deliveries at 1,499 ms and 99 ms still fail the original assertions -in both clock modes. The latter candidate fails the unchanged journal-read gate -with the same extra provider start. A separate control confirms the orphan repair -actually executes against the closed database and leaves no fake timers. The -change retains all 121 original expectation sites and adds one teardown check; -it does not shorten the runtime's observation interval or claim a whole-PR gain. - -## Stub child shutdown clocks: Codex and Claude - -[Merged Codex change #24893](https://github.com/stablyai/orca/pull/24893) scopes timeout -clocks to two synthetic-child cases in `codex-app-server-connection.test.ts`. -The full platform graceful deadline and 1,000 ms forced wait remain; the test -waits for the actual stub SIGKILL before advancing the forced window. Streams, -process-table reads, Date and immediate callbacks remain real. Fault controls -still detect late exit, missing EPIPE, missing exit proof and unwanted notification. - -The [hosted ARM comparison](https://github.com/stablyai/orca/actions/runs/37074124526) -passed the same 32 full-file cases in baseline/candidate and candidate/baseline -order. File wall times were 13.671 / 13.674 seconds originally and 1.658 / 1.649 -seconds with scoped clocks. Installer time is excluded; generated caches remain -across the disclosed order. Real-child coverage and production shutdown code remain. - -[Merged Claude change #24897](https://github.com/stablyai/orca/pull/24897) changes only -two synthetic-child cases in `claude-agent-sdk-exit-proof.test.ts`. Both full -33-case runs passed, including the unchanged five real-child cases. In one local -macOS pair, the two bodies took 2,503 / 1,502 ms originally and 1.37 / 0.39 ms with -scoped clocks. They cross a real immediate callback before advancing the complete -1,500 ms graceful and 1,000 ms forced windows, restore timers in `finally`, and -retain the original false exit verdicts. Fault controls detect either deadline -shortened by one millisecond, an unproved true verdict and a leftover timer. -[Normal PR CI](https://github.com/stablyai/orca/actions/runs/37075819218) passed; -these local body measurements do not establish hosted or whole-PR time savings. - -## Sequential static analysis and typecheck: retain separate jobs - -The earlier recommendation below is superseded by [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity). - -A four-trial hosted screen kept the slim router unchanged and compared the two -independent ARM jobs with one ARM job running their unchanged checks sequentially. -The [compiler/planner census](https://github.com/stablyai/orca/actions/runs/37069472888) -matched all compiler inputs and the full 10,477-file unit inventory in separate, -shared root-only and shared mixed-install states. The [safety qualification](https://github.com/stablyai/orca/actions/runs/37075043747) -verified native joins after compiler failure and a real late action-post failure; -all four guarded downstream sentinels skipped and the audit passed. - -| Trial | Mode | Active ARM seconds | Router finish to heavy finish, seconds | -| -------------------------------------------------------------- | -------- | -----------------: | -------------------------------------: | -| [1](https://github.com/stablyai/orca/actions/runs/37075574191) | Separate | 157 | 124 | -| [2](https://github.com/stablyai/orca/actions/runs/37075887937) | Combined | 134 | 150 | -| [3](https://github.com/stablyai/orca/actions/runs/37076222202) | Combined | 129 | 134 | -| [4](https://github.com/stablyai/orca/actions/runs/37076786281) | Separate | 159 | 194 | - -Both pairs saved active ARM time: 23 and 30 seconds, or 14.6% and 18.9%, with one -heavy admission instead of two. The active critical path was 15 and 8 seconds -longer. Downstream eligibility changed by +26 and −60 seconds; observed ready-to-start -delay differences of +11 and −68 seconds explain that reversal. Created-to-start -delay is recorded separately and does not establish a quota or queue cause. - -Retain separate jobs for now. This screen shows a capacity saving, with a longer -active critical path and no repeatable latency gain. All trials used frozen -`cc73c8e1a72b0e9ee9c29e57458ce307f5f019c2` source, manual workflow dispatches, -Node 24.21.0 and the same four exact primary cache hits. Main's later -[Linux PR root-store policy change #24896](https://github.com/stablyai/orca/pull/24896) -is outside this screen. The trial ran actual heavy checks and proved unit and both -package eligibility, without launching those downstream matrices or measuring a -whole-PR speedup. - -## Linux headless runtime build overlap - -The historical pinned Bun artifact now builds in a native background step while -current native preparation and Node bundling run in the foreground. An -unconditional join precedes the unchanged artifact and cross-runtime tests. Bun -setup stays Linux-only; other platforms register and join a successful no-op. -The producer publishes step outputs consumed only by those tests. The existing -selector, native floors, template builders and cache policies remain. - -A [hosted alternating comparison](https://github.com/stablyai/orca/actions/runs/37072923774) -ran four serial/overlap arms on each of two Linux VMs: - -| Architecture | Serial preparation, seconds | Overlapped preparation, seconds | -| ------------ | --------------------------: | ------------------------------: | -| x64 | 20.831 / 19.576 | 11.149 / 10.914 | -| ARM64 | 15.155 / 14.396 | 8.566 / 8.553 | - -Every arm passed the same 961 cases across 92 files: 930 passed and 31 skipped. -Both cross-runtime persistence cases passed. The two live daemon-handover cases -kept their existing protocol-version skips. All four x64 arms passed actual Node -18 loading and pinned-runtime handoff. Installed/source inputs and artifact -inventories matched; each normal owned-process ledger was clean before cleanup. -Common native compiler warmup preceded timing and retained its generated Python -caches in the strict installed ledger. These are preparation savings of 5.8–9.7 -seconds, excluding setup, cold installs, runner start delays and whole-PR time. - -Actual [Bun failure](https://github.com/stablyai/orca/actions/runs/37078015921) and -[Node failure](https://github.com/stablyai/orca/actions/runs/37078021568) controls -qualified genuine compiler errors with fresh live opposite builders, native joins, -skipped consumers, restored inputs and verified exits. A [normal cancellation -control](https://github.com/stablyai/orca/actions/runs/37079655167) received SIGINT -while the actual Bun builder was freshly live; both builders and the detached -owned child had simultaneous earlier readiness. All three native joins had terminal dispositions of cancelled, success and -cancelled, and every consumer skipped. The temporary observer retired its owned processes; -the collector independently verified their absence and unchanged inputs. This -proves signal delivery and observer-owned retirement, without establishing -runner-only descendant cleanup at the join. The unchanged historical builder -starts finite build/smoke work, and its children retain GitHub's normal orphan -tracking marker. - -Earlier cancellation trials remain excluded from live-build qualification: one -collector stopped its observer before signal routing, and the corrected trial -received the signal after both builders finished. The qualifying trial requested -normal cancellation earlier in the same preparation sequence to account for -observed delivery delay; no workload, wait or proof predicate was shortened. - -## October 3 Terminal Perf dependency preparation - -The daily/manual Terminal Perf workflow still installed current dependencies through -raw lifecycle scripts and a global node-gyp installation. Its historical `ref` -input also accepts revisions that lack the shared installer, so replacing that -path unconditionally would break older runs. The current-profile path now uses -the existing shared installer with explicit Electron preparation and archive -caching. A guard requires GitHub-hosted Linux x64, Node 24/pnpm 12.8.1, the -native-only root postinstall and the needed local action inputs/files. Other -profiles and historical revisions keep their original frozen install. - -The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37101695800) -ran both preparation paths in each of two Linux x64 jobs, reversing their order. -Legacy/shared preparation took 25.164/16.956 seconds and 27.434/18.032 seconds: -8.208 and 9.402 seconds saved. Both used Node 24.21.0, pnpm 12.8.1 and Electron -43.7.5. Both shared native-module cache lookups missed, so this improvement did -not depend on a warm native build. Electron archive and root pnpm cache lookups -hit. Dependency trees, pnpm data and Electron archives were reset between paths; -compiler headers and external services were not. Bootstrap, resets, validation, -post-job cleanup, queueing and the production guard step are outside those times. -These are preparation measurements, not whole-workflow or billing savings. - -Both paths passed a native-module probe inside the actual Electron executable -with `ELECTRON_RUN_AS_NODE=1`, and built the same Electron-vite e2e application. -The candidate's 18 focused routing/fallback tests, workflow actionlint and changed -code-quality checks passed. Performance tests, budgets and report uploads remain -unchanged. The [existing October 2 run](https://github.com/stablyai/orca/actions/runs/36985792125) -failed the same-workspace 50/100-terminal budgets (46.9/50.2 ms against 25 ms). -This dependency change does not claim to resolve those application regressions. - -The [full candidate integration](https://github.com/stablyai/orca/actions/runs/37104625474) -passed on `df71ad849cd854a232f7063562785563743b641a`: current preparation was -selected, its native cache missed and rebuilt, the app built and all 32 report -annotation rows passed the unchanged budget checker. The downloaded report also -passed the same checker locally. This is integration evidence; it does not -attribute application latency changes to dependency preparation. Subsequent -rebases resolved report documentation and incorporated fixture teardown fixes. -Workflow, installer-action and toolchain content stayed unchanged. Main also -added an import and a Windows-only MSBuild setting to the native-runtime script: -the imported helper has no top-level side effects, and the Linux rebuild branch -is unchanged. Focused tests verify its Linux/macOS no-op behavior. Final-head PR -checks qualify separately. - -## October 4 reusable cells for terminal context scans - -Terminal cursor-context scans now request one reusable cell per invocation when -the adapter offers getNullCell, and pass it through all unchanged text/style -scans. Adapters without that optional method keep the existing allocating path. -The scratch cell is local and no cell reference escapes into returned context. -Browser composer/readiness text, colors, bold flags and wrapping are unchanged. - -Three alternating one-worker ARM pairs in -[37182789677](https://github.com/stablyai/orca/actions/runs/37182789677) -ran all 19 original cases from readiness census suite 2. Baseline complete -invocations were 37.141 / 37.879 / 37.090 seconds; candidate invocations were -33.887 / 33.387 / 32.916 seconds. Median 37.141 to 33.387 seconds saves 10.1%. -This is a focused workload measurement, not a whole-shard or queue-delay claim. - -Separate baseline/candidate captures retained all 192 cases across six census -suites. Every context and visible projection matched: 643,926 of each, with -7,465,308,324 complete length-prefixed payload bytes hashed per test/type/order. -The canonical capture digest was -`f7440c0f1b5bbb57127cd29245530029415c8e9e243c1744359f330b3c7ace19`. -These captures run outside the timing samples. All 41 cursor/composer/browser -consumer checks passed. Seven faults for lost dim filtering, wide continuation, -bold prompt, custom foreground, wrap preservation, adapter fallback and scratch -reuse failed their intended assertions. Node and web typecheck, lint and format -passed. Two added controls prove per-call scratch lifetime and adapter parity. - -## October 3 producer follow-up: automatic selection for the measured profile - -The first producer rollout in [#24927](https://github.com/stablyai/orca/pull/24927) -passed all 46 PR checks, all five manual warmers and all 11 manual Headless -qualifications on `a2c489c0cca5e46d24333a4d40ba910af0de0208`. The same root installer -also serves recurring unit, browser and performance workflows that had not opted -in. The follow-up defaults the existing input to `auto`, reusing lookup mode for -non-PR root-only installs on GitHub-hosted Linux/macOS/Windows x64/ARM64 runners, -with no job container, the manifest's Node 24/pnpm 12.8.1 profile and no conflicting -Node override. Explicit `true` and `false` retain their previous meanings. Mixed -lockfiles, other toolchains, containers and self-hosted runners retain full cache -restoration; PR policies are unchanged. The manifest check runs only when the -context is potentially eligible, before setup-node chooses its cache behavior. - -A second cleanup audit distinguished nesting depth. The -[twice-nested control](https://github.com/stablyai/orca/actions/runs/37087090689) -published the environment-path payload and lost the output-path payload with an -`Input required and not supplied: path` warning. The -[direct control](https://github.com/stablyai/orca/actions/runs/37087211236) published -and restored both payloads. Current Electron archive callers are direct, so they -need no cache-path change. Keeping the producer's exported path also makes its -new lookup mode safe for callers that nest the shared installer. These tiny -controls establish publication behavior, not installer time savings. - -The [actual automatic-mode cold publisher control](https://github.com/stablyai/orca/actions/runs/37097980789) -passed both jobs on `7b8858bdc8f`. A twice-nested wrapper called the installer -without overriding its default input. The writer selected lookup, missed its -unique root-lockfile key, completed the frozen policy-checked install and saved -that key during cleanup. A fresh reader restored the exact key and installed the -same dependency successfully. The fixture retained the manifest toolchain and -applicable workspace policies; its one dependency keeps the publication check -small. Two earlier trials failed fixture assertions (the pnpm multi-document -header placement, then its empty cache-miss output), and are excluded. This proves -automatic selection and cold publication, not a new timing result. Local -verification passed eight suites / 184 tests, the changed-code quality gate and -compiled-composite actionlint. - -## October 4 shared PR preflight capacity - -Static analysis and the unchanged compiler now share one ARM runner and guarded -Node 24 install. Static checks finish and all background work joins before the -compiler starts; unit planning still overlaps compilation. Each phase keeps its -classifier output. Successful no-op background bodies register every required -join when a phase is unselected or an earlier step failed. Unit and package -consumers depend on physical job success, including action cleanup. - -Three counterbalanced pairs in -[37180613601](https://github.com/stablyai/orca/actions/runs/37180613601) -used the same frozen checkout `f199a20c3acd`, Node 24.21.0, pnpm 12.8.1, -policy hashes, native cache hits, warm TypeScript cache and 10,787-file unit plan. -Both arms used the PR root-only download-store policy. Total active job time was -152 / 153 / 151 seconds separately and 138 / 133 / 129 combined. Excluding the -extra measurement-only evidence steps gives 151 / 151 / 149 versus -136 / 132 / 128 seconds: median 151 to 132, saving 19 seconds (12.6%). -Two heavy runner admissions become one. This saves capacity; it does not prove a -whole-PR latency or queue gain. The median active dependency barrier increases -from 116 to 132 seconds because compilation follows static checks. - -The separate physical-failure run -[37180755694](https://github.com/stablyai/orca/actions/runs/37180755694) -proved that an included TypeScript error failed the actual compiler, its planner -still joined, and unit/package admissions skipped. A registered late action post -failure also blocked both consumers after successful foreground checks and -published shards. All 12 unselected/prior-failure no-op backgrounds joined, and -the downstream audit passed. Local workflow contracts passed 239 tests across -12 suites; lint and formatting passed. - -## October 3 retired-cache collection observation - -The same owner-collection assertion failed in unit shard 3 of -[37098089274](https://github.com/stablyai/orca/actions/runs/37098089274/attempts/1) -and [37100365037](https://github.com/stablyai/orca/actions/runs/37100365037/attempts/1), -requiring a full shard retry despite the focused suite passing locally. Its -three-turn collection budget was shorter than the six-turn plus final yield -pattern already used by the GitLab known-host retirement tests. - -The fixture now uses that existing observation budget. All seven tests, their assertions, -expiry clocks and production code are unchanged. The focused suite passes. A -local fault control changed only the production timer callback to hold its owner -strongly: the owner-collection assertion failed, with the other six tests passing. -The source was restored afterward. Extra collection turns therefore preserve the -strong-retention oracle. Hosted qualification is still required; these observations -do not prove a particular VM-retention cause or quantify avoided retries. - -## October 4 terminal oracle execution - -Three measured test-support changes preserve the original seeds, payloads, -chunk boundaries and meaningful assertions. Serializer comparisons reuse cells -and format only the first mismatch instead of allocating descriptors for every -cell. The terminal parity writer submits every original chunk in FIFO order and -awaits the final parser callback. The independent legacy frame oracle memoizes -measured code-point widths. Its discarded algebra-only case never called -production and still passed when production always threw. - -Three alternating one-worker hosted ARM pairs measured complete invocations: - -| Cohort | Baseline median | Candidate median | Saving | -| ---------------------------------------- | --------------- | ---------------- | ------ | -| Serializer replay/fuzz/descriptor checks | 71.675s | 46.581s | 35.0% | -| Emulator/reconciliation/color parity | 24.095s | 5.411s | 77.5% | -| Frame equivalence | 18.472s | 13.736s | 25.6% | - -[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143) -retained 116 timed serializer passes and three existing/paired-control skips. -Separate captures matched all 190,796,645 raw bytes over 1,611 scenarios and -8,617 checkpoints (SHA256 `00ab219cfb31456af2ecd5e766d1b82d47abc751f6f2de0d7f795e36a936d3c7`), -including complete outputs and diagnostic payloads. Twenty candidate controls -passed; formatting/color/blank/clipping fault controls detected regressions. - -[37181073275](https://github.com/stablyai/orca/actions/runs/37181073275) -retained all 16 parity cases and default fuzz counts. Captures matched 2,325 -batches, 28,182 original chunks and 1,698,285 input bytes, with identical -terminal state and serialization per terminal/batch. Independent terminal -completion order differs, so comparison uses canonical per-terminal ordering -(SHA256 `c38ac1dbbefb9f6dc33ecfe7c495d65b707c1664614544622af93cfc1850e421`). -All 73 callback/parser/other-consumer controls passed; first-callback, reversed -chunks, missing empty boundary and early-completion faults failed. - -The frame candidate passed all 19 retained cases directly against the original -uncached legacy oracle, preserving 4,000 short and 800 near-cap seeded trials. -Sequence, surrogate width, byte width and span-transform faults failed real -assertions. A part-array alternative was rejected after adding time locally. -Hosted Node typecheck passed. These are focused workload savings, not measured -whole-shard or queue-delay improvements; application behavior is unchanged. - -## October 3 unit-selection evidence: include failed references - -The caller's `needs.test.result == 'success'` condition prevented the advisory -collector from reading failed unit runs, despite the reviewer's existing support -for failed tests. A six-run screen from the October 3 occupancy sample found only -one review artifact; it was a full fallback, so it did not validate selection. -Missing artifacts cannot establish that selection catches red tests. - -The caller now permits both success and failure while excluding cancellation and -skipped tests. The collector remains advisory and absent from `verify` dependencies. -Incomplete, interrupted or inconsistent shard records still cannot become complete -reference evidence. Existing omitted-failure tests preserve that negative control. - -The five artifacts from failed [run 37098089274, attempt 1](https://github.com/stablyai/orca/actions/runs/37098089274/attempts/1) -were reviewed locally using the unchanged script. It recognized a complete failed -reference covering 10,606 files and 9,270,307 worker-ms. Its candidate was the full -fallback, so `selectionEvaluated` remained false and no selection promotion is -justified by this control. Focused workflow/reviewer checks passed 24 tests, -including actual caller-expression outcomes for success, failure, skipped and -cancelled states. This repair supplies needed evidence for a later optimization; -it claims no runner-time savings and does not enable selected tests. - -The updated caller also passed the hosted red-run control in -[37100365037](https://github.com/stablyai/orca/actions/runs/37100365037). -The collector succeeded after one unit shard failed, while required verification -remained red. Its review recognized all five shards as a complete reference -(10,608 files, 8,965,977 worker-ms). This was again a full fallback with -`selectionEvaluated: false`, not evidence for enabling selected tests. - -## October 4 runtime imports and recovery fixtures - -Three helper-only tests now import the existing terminal modules directly rather -than initializing the runtime service. Ten copied-loop cases never exercised -runtime memoization: they passed with its cache, timestamp update or prune -invalidation disabled. Two actual helper checks remain. The existing runtime -prune suite now exercises real leaf cache reuse, split prompt timestamps, -ordinary output, fresh prompts and detection after retained-history eviction. -Each of those three production faults fails a real runtime assertion. - -Recovery tests now seed three exact fixture variants once, after the seed child -has closed. Each crash still receives an independent byte-for-byte copy of the -entire database/WAL family and remapped paths. Buffer.equals retains exact byte -comparison without recursive matcher overhead. All 46 original crash boundaries -and retries remain. Four additional copy-isolation/WAL checks run, and teardown -requires that all seed bytes remain unchanged after the full suite. - -Three alternating one-worker hosted ARM pairs in -[37182181976](https://github.com/stablyai/orca/actions/runs/37182181976) -measured these complete invocations: - -| Cohort | Baseline seconds | Candidate seconds | Median saving | -| --------------------------------- | ------------------------ | ------------------------ | ------------- | -| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% | -| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% | -| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% | - -The final runtime cohort has seven real cases versus 16 including the copied -loops; its new runtime case is included in candidate timing. Recovery has 50 -passes versus the original 46. Hosted Node typecheck passed. Recovery faults for -last-byte database/WAL corruption, shared database paths, missing WAL copies and -accepted/unaccepted seed collision failed the intended assertions. These are -focused workload savings, not measured whole-shard or queue-delay gains. - -An independent local cache screen left both caches disabled. Across 14 unchanged -files and 92 cases, a warm Vitest transform cache reduced median invocation time -3.090 to 1.948 seconds, excluding archive costs; its cold arm increased time to -3.281 seconds. Node compilation caching showed no gain. Controls reproduced stale -transforms after TypeScript configuration or plugin-option changes, so persisted -reuse requires a complete transform-input stamp and hosted net-cost evidence. -A separate 130,000-pane leaf-collection optimization was restored: its complete -migration-file timing stayed within noise. The regression fixture remains. - -## October 3 removal fixture cleanup ordering - -[37105566358](https://github.com/stablyai/orca/actions/runs/37105566358) -failed unit shard 4 with `ENOTEMPTY` removing the failed-removal fixture's temporary -directory; the other four shards passed. A client's removal reply intentionally -precedes the detached job's final record persistence. This fixture reset tracking -and removed the directory before waiting for that persistence, allowing a writer -to race cleanup. Its teardown now awaits the existing settlement helper before -resetting tracking or deleting the fixture. Production removal behavior and all -assertions are unchanged. - -All 1,348 runtime tests passed (one existing skip). A temporary controlled queue -held the final record write after the client replied: waiting before reset stayed -pending and passed; resetting before waiting lost the tracked job and failed the -same ordering assertion. The gate was released, both controls drained the captured -job, and the instrumentation was removed. Changed-code quality passed. This proves -the teardown ordering mechanism, not a measured avoided-retry saving. Final-head -hosted qualification remains required. - -## October 4 store oracle and retention fixtures - -The randomized in-place-store test validated the copying oracle twice after -accepted mutations and compared snapshots through the same production parser. -Its 5,000-step retention fixture generated enough tombstones to hit the count -limit, but never reached the 4,096-revision age boundary. - -The test retains all four seeds and 1,500 mutations per seed, removes the duplicate -validation, and projects snapshots directly from the copying oracle's validated -maps. Separate fixtures now check the revision before, at and after expiry and -count overflow. Production code is unchanged. - -Three alternating one-worker pairs on `ubuntu-24.04-arm` in -[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143) -measured baseline invocation times 33.551 / 33.304 / 33.529 seconds and candidate -13.848 / 13.816 / 13.875 seconds: median 33.529 to 13.848 seconds, saving 19.681 -seconds (58.7%). Baseline passed seven tests; candidate passed eight. This is a -focused test saving, not a measured whole-shard or queue-delay change. - -Hosted Node typecheck passed. Separate fault controls failed the intended -assertion for early, late and disabled age expiry, disabled count compaction, -and a snapshot that drops child descriptions. The description fault passes with -the original parser-sharing oracle and fails with the independent projection. - -## October 4 Git contention and remaining readiness waits - -The full Git admission benchmark compared a disabled arm with no correctness -assertions to an enabled arm with structural ledger checks. Its default CI test -now saturates the real base and headroom budgets with FIFO-gated child processes, -queues older background and newer interactive work, releases base slots, and -requires interactive priority, matching outputs and complete permit release. -The full original diagnostic remains opt-in through -`ORCA_GIT_ADMISSION_STORM_MEASUREMENT=1`; both opt-in tests passed locally. -The existing Windows real-Git parity tests remain unchanged; this fixture retains -its existing POSIX platform scope. - -Two remaining Antigravity transcript tests used real 5,000ms refusal windows. -They now use the existing scoped `waitForTranscriptIdle` timer harness after the -emulator drains. All 60 tests, original captured transcripts, deadlines and -readiness assertions remain. - -Three alternating one-worker hosted ARM pairs in -[37180614492](https://github.com/stablyai/orca/actions/runs/37180614492) -measured these complete focused invocations: - -| Suite | Baseline seconds | Candidate seconds | Median saving | -| --------------------- | ------------------------ | ------------------------ | --------------- | -| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) | -| Antigravity readiness | 27.347 / 27.910 / 27.550 | 13.855 / 13.894 / 13.800 | 13.695s (49.7%) | - -Each candidate passed its original meaningful checks. Hosted Node typecheck -passed. Separate scheduler faults for bypassed admission, withheld release and -FIFO-only priority failed the queued-contention or interactive-start assertion. -Two additional local transcript faults failed the original picker-rejection and -repaint-readiness assertions. These are focused suite savings; whole-shard time -and queue delay were not measured by this experiment. - -## October 4 aggregate unit-test comparison - -A [counterbalanced hosted comparison](https://github.com/stablyai/orca/actions/runs/37197643399) -measured 128.605 seconds less summed test-process time (4.36%) and a 37.694-second -reduction in the slowest shard (5.98%). It compares the accepted optimizations -with their original file snapshots on the same source, five fixed shard -assignments, Node 24, Ubuntu ARM and four workers per process. This is one paired -trial, not a population estimate or a measurement of PR queue delay. - -| Test process | Original snapshots | Accepted optimizations | -| ----------------------------- | ------------------ | ---------------------- | -| Shard 1 | 574.221s | 533.116s | -| Shard 2 | 572.553s | 569.593s | -| Shard 3 | 630.629s | 592.935s | -| Shard 4 | 565.412s | 546.759s | -| Shard 5 | 609.410s | 581.218s | -| Sum: runner time during tests | 2952.225s | 2823.621s | -| Maximum: test critical path | 630.629s | 592.935s | - -Both arms cover exactly 10,838 timed modules on source `9574c8adb253` and tree -`4d5487e8b827`. One added eight-case batching qualification passes in a separate -0.770-second invocation outside the table, completing the 10,839-module ordinary -census. The complete timed case and outcome -comparison accounts for eight approved coverage changes: 105,231 original cases -versus 105,242 candidate cases. Removed copied simulations and an algebra-only -case are accompanied by real runtime, retention, recovery and reusable-cell -regressions. No unexpected case or outcome difference is accepted. - -Each arm starts with distinct empty transform and result caches; Node compilation -caching is disabled. Cold-cache execution ordering remains Vitest's default and -can change with source size. Source snapshots, assignments, raw reports and case outcomes -are checked. Only three case-title fields containing random temporary paths or -a UUID use stable identities, bound to the exact two test-source hashes; raw -titles remain in the artifacts. - -The five dependency setups total 84.284 seconds and are shared by both arms. -The actual paired jobs consumed 5,969 seconds and spanned 2,031 seconds from the -first start to the last completion. Those job figures include both treatments, -setup, uploads and staggered starts; they cannot be assigned to either arm or -used as a workflow saving. The table measures test-process wall time, not CPU -time or the complete CI workflow. Focused-suite percentages elsewhere in this -report are separate measurements and must not be summed into these results. - -The [earlier aggregate trial](https://github.com/stablyai/orca/actions/runs/37193799646) -is rejected because a real test failed; its timings do not qualify a gain. Two -local invocations sharing one XDG directory reproduced the Muse refresher failure. -The fixture now isolates and restores that setting in both arms. The historical -serializer case ledger was also independently corrected from the original source -before this fresh trial; its seven original cases and twenty candidate cases are -an explicit coverage change rather than an assumed equal census. - -## October 4 shard-weight holdouts - -Fresh shard weights were generated with the production importer from a complete -successful run of the accepted source. Two subsequent hosted holdouts used those -same weights and assignments without retraining. The -[first pair](https://github.com/stablyai/orca/actions/runs/37199891967) alternated -existing and fresh assignments across the five jobs; the -[second pair](https://github.com/stablyai/orca/actions/runs/37201939057) reversed -each job's treatment order. - -| Test-process measurement | First: existing | First: fresh | Reversed: existing | Reversed: fresh | -| ------------------------ | --------------- | ------------ | ------------------ | --------------- | -| Sum across five shards | 2813.595s | 2776.421s | 2924.689s | 2878.481s | -| Maximum shard wall | 578.735s | 584.709s | 603.995s | 614.408s | - -Fresh weights reduced summed test-process time by 1.32% and 1.58%, but increased -the slowest shard's time by 1.03% and 1.72%. The small capacity saving comes with -a repeated critical-path regression, so the existing weights remain. The 3.01% -improvement projected from training module durations is not a measured speed -gain. - -Every arm covers the same 10,839 modules and 105,250 case outcomes on source -`9574c8adb253`, tree `4d5487e8b827`, Node 24.21.0, Ubuntu ARM and four workers. -Both trials use cold caches and the same training data, weights, plans and case -identity rules. Complete raw reports, assignments, hashes and opposite treatment -orders are checked before combining the results. Two pairs supply no statistical -confidence or account-wide queue measurement. The second run's jobs started 119 -seconds apart; that stagger and the paired jobs' setup and upload costs are -separate from the treatment timings above. - -## October 4 remaining unit-test opportunities - -The audit retained real child-process, PTY, SSH and crash-boundary tests. It -removed copied simulations or algebra-only cases after fault controls showed -that they could pass with production behavior broken. The retained or replacement -tests exercise production behavior directly. The focused timings in this report -use the final qualified checks. They must not be added together to estimate a -whole-workflow saving. - -Several further changes did not justify promotion: - -- A synchronous readiness-clock screen retained all 49 shard cases and their - outcomes, but complete invocation time changed only from 34.210 to 33.518 - seconds in one local pair. That 2% result was too small to ship without a - stronger result; the original implementation remains. -- A larger local transform-cache screen reduced warm test execution. Separately - measured medians for warm tests (17.251 seconds), extraction (3.539 seconds) and - archive creation (3.092 seconds) sum to 23.882 seconds, versus 23.473 seconds - with caching disabled. This component estimate excludes transfer costs; it is not an - end-to-end measurement. Unkeyed plugin options, inherited configuration and - import priority also produced stale reuse. Persisted test transforms remain - disabled. -- Two successful full-run shadow references identified about 1.9% of - recorded worker time as omittable. That is advisory worker time, not measured - runner occupancy. It does not supply the failed-reference evidence or a - complete selected-run comparison needed to enable test selection. -- Six sampled failed PR runs contained no failed unit job that could trigger - unit-matrix fail-fast. Successful unit siblings of failures in other jobs - cannot be counted as savings from that policy. The sample is too small to - establish a population-wide rate, and the policy remains unchanged. - -These screens reject the examined changes; they do not establish that every -future optimization is exhausted. Shorter admitted jobs and one shared preflight -reduce demand on the existing runner allowance. They do not increase that -allowance or prove lower queue delay under different account traffic. - -## October 5 remaining import, diagnostic and checkout work - -The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37247027814) -used three alternating pairs per treatment at source -`b18b174cd463f852051dc22adc8478b82dee4d1f`. Focused tests ran as fresh one-worker -processes on the four-core, 16 GB Linux ARM runner with Node 24.21.0, pnpm 12.8.1 -and Vitest 4.1.11; persisted transforms and Node compile caches were disabled. - -| Work | Original median | Candidate median | Median paired saving | -| ------------------------------------------------- | --------------- | ---------------- | -------------------- | -| Real incumbent-process test file | 16.478s | 5.976s | 10.520s | -| Commentable-line lifecycle test file | 9.595s | 6.880s | 2.714s | -| Agent-status diagnostic plus semantic test cohort | 11.547s | 8.235s | 3.361s | -| Windows x64 SSH checkout | 22.052s | 16.884s | 5.020s | -| Windows ARM SSH checkout | 46.758s | 33.910s | 13.456s | - -The incumbent cases own distinct sockets, shim directories and process groups. -Running them concurrently preserves all nine outcomes and every real five-second -lsof deadline. Both original and candidate still reject an unreaped helper and -false claims of clean enumeration. Fault receipts prove the actual modified probe -was imported and all owned helpers, groups, sockets and directories were cleaned. -The Windows platform gate retains all nine skips. - -The renderer lifecycle tests keep their six original bodies and real decorator, -zone and model behavior. Only the unrelated saved-note delivery menu is replaced -by a typed throwing facade. Its cleanup assertion rejects unexpected use. Actual -faults in memoization, value-equal refreshes and model replacement still fail their -original assertions; a real menu call fails the facade and the cleanup guard. - -The agent-status benchmark reports counters and timings but asserts only a -nonempty status map and positive elapsed time. It remains available through -`ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.agent-status-benchmark.config.ts`. -Ordinary discovery removes exactly that reporting test. Its 15 meaningful routing, -index-retention and batch cases remain unchanged. Three real production faults -pass the old reporting test and fail those retained contracts. Manual execution -preserves its JSON schema and all nine deterministic counters at 423 worktrees, -634 tabs and 1,000 events. - -Windows SSH hosts retain complete main, shared, relay, type, configuration, -native, resource and test trees, plus root files. Six actual checkouts per -architecture pinned candidate source `ce69b84d675b0a8b4763b03329c9d6549ac723f1`; -all 16,568 retained files match the full checkout's contents, modes and index. -The original 5,209 required inputs and six further inputs added by the source -rebase are present. Local full/sparse builds match 54 generated artifacts; -explicit test discovery is identical. Missing imported source, a named test or a -required descriptor still fails. Full Windows native/provisioning lanes remain -a separate qualification gate. - -Test timings exclude dependency setup, checkout and queues. Checkout timings -include the action and shell/runner observation boundaries but exclude later -builds and provisioning. These scoped savings must not be summed or treated as -measured changes to full-shard occupancy or PR latency. - -## October 5 mobile typecheck overlap - -The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37249591578) -ran three alternating pairs on the four-core, 16 GB Linux ARM runner, with Node -24.21.0 and TypeScript 6.0.3. The production compiler and test-typecheck ratchet -use installed tools after dependency installation. Both resolved compiler -programs have `noEmit: true`, with no incremental, composite or build-info writes. -The change moves the existing unconditional production join after the foreground -ratchet and before tests; it changes no compiler command or failure policy. - -| Complete typecheck stage | Original serial | Overlapped | -| ------------------------ | --------------- | ---------- | -| Pair 1 | 68.284s | 41.582s | -| Pair 2, reversed order | 65.385s | 41.042s | -| Pair 3 | 64.240s | 40.418s | -| Median | 65.385s | 41.042s | - -The median paired saving is 24.343 seconds, or 37.2% of this stage. The stage -bracket includes native background/wait boundaries and observation overhead, -but excludes dependency installation and the later test suite. All six stages -preserve the three workers' compiler/ratchet verdicts and stdout/stderr hashes. -The test compiler's existing diagnostics remain subject to the unchanged -ratchet. Neither compiler attempted native loading or recorded filesystem -mutations. Maximum aggregate owned-process RSS sampled every 200 milliseconds -was 5.137 GiB; this is a sampled value rather than a kernel peak. The ratchet now -runs even when the concurrently running production compiler later fails. - -Two real, independent type faults still prevent tests from starting. The -production fault preserves the unaffected ratchet and child output; the test -fault preserves the production output and fails the ratchet. Their explicit -control receipts pass even though their intentionally failing jobs are allowed -to finish collecting evidence. - -The separate [external cancellation control](https://github.com/stablyai/orca/actions/runs/37251289372) -held the two real installed compiler entrypoints before checking, while retaining -the production-background/ratchet-foreground topology. All three owned workers -were observed alive 14.275 seconds before the cancellation request. Both native -step outcomes became cancelled, tests did not start, and the runner's final -cleanup log names all three exact worker PIDs. The attempted earlier assertion -of PID absence failed: GitHub performs orphan cleanup after the always-tail -observer and artifact upload. Post-cleanup absence was not observed and is not -claimed. This control supplies no compiler-completion or timing measurement. - -## October 5 explicit RPC registry test setup - -The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37251277897) -ran three alternating pairs of the complete 156-file mixed cohort on source -`d1e08ddd666e3099fc51c79f01f0305f5162186d`, using four workers on the four-core -Linux ARM runner, Node 24.21.0, pnpm 12.8.1 and Vitest 4.1.11. Persisted module -transforms and Node compile caches were disabled. All 153 candidate test files -pass explicit method lists to their dispatchers. A shared throwing fixture -prevents those tests from loading the unused default method catalog and rejects -any accidental iteration of it. The three real catalog subjects remain -unchanged and run beside the candidate subjects in every arm. - -| Complete cohort process | Original | Candidate | Paired saving | -| ----------------------- | -------- | --------- | ------------- | -| Pair 1 | 142.184s | 89.238s | 52.946s | -| Pair 2, reversed order | 142.833s | 88.933s | 53.900s | -| Pair 3 | 141.274s | 90.186s | 51.088s | -| Median | 142.184s | 89.238s | 52.946s | - -The median paired saving is 37.2% of this fixed cohort. All six arms preserve -the complete ordered ledger, including duplicate parameterized case names: -1,469 passes and one existing setup-dependent skip, totaling 1,470 outcomes. -The real catalog subjects contribute 63 of those cases. Candidate bodies and -assertions are unchanged. One file at its line limit uses the dispatch method's -parameter type in place of its equivalent type-only import; its emitted code -matches the measured candidate. - -Seventeen control invocations preserve actual terminal-handler fault detection, -reject unexpected default-catalog consumption, and still detect a missing real -catalog registration. A plain consumption counter prevents global mock-history -resets from erasing the guard; actual `clearAllMocks` and `resetAllMocks` controls -demonstrate that distinction and preserve unrelated call history. The isolated -driver restores all source files after success, faults and a real cancellation. -Independent review also confirms all 153 candidate sources, the fixture, three -catalog subjects and declared qualification inputs survive the rebase unchanged. - -These are fresh-process wall times for this cohort, excluding dependency setup, -queues and other test shards. They do not measure full-shard balance, total PR -runner demand, or a change to the dashboard's PR runtime percentiles. - -## October 5 fused terminal cursor row scans - -Readiness checks repeatedly read terminal rows to recognize composer text. -The shared reader now collects undimmed text and the first visible glyph's style -in one pass. The cursor suffix remains a separate scan; dim glyph attributes, -empty cells, wide characters and wrapped spaces keep their existing behavior. -No grid data is retained between calls. - -The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37263358478) -used source `1bec53ceb23b5f296a38ffa0e085778d32fc7849`, Linux ARM, -Node 24.21.0, pnpm 12.8.1 and one worker. All six fresh processes ran the same -49-case readiness census module, with separate empty Vite caches, filesystem -transform caching disabled and Node compile caching disabled. - -| Complete focused process | Original | Candidate | Paired saving | -| ------------------------ | -------- | --------- | ------------- | -| Pair 1 | 66.412s | 60.464s | 5.948s | -| Pair 2, reversed order | 66.880s | 61.061s | 5.819s | -| Pair 3 | 68.056s | 65.821s | 2.234s | - -The median paired saving is 5.819 seconds, or 8.7% of this fixed workload. -All 294 timed case outcomes passed. Separate qualification preserves complete -context/composer outputs on 98 recordings and the logical cursor/projection -outputs of all 192 Runtime census cases. Nine actual scanner faults fail the -intended assertions; the 89-case IME/composer slice also passes. Blank-row tests -bound cell reads to 72 instead of the original 132 for a 12-column, five-row grid, -with and without a reusable cell adapter. - -The raw timer-driven polling trace differed and was excluded from equivalence -evidence. Logical per-frame output captures match; no raw polling-count equality -is claimed. These measurements exclude setup, queues and other modules and do -not establish a change in full-shard balance, PR percentiles or runner demand. - -## October 5 Qoder test import guards - -The direct Qoder Runtime tests now import the existing unused-default-RPC guard -before their Runtime fixture. Their complete test bodies remain unchanged. -The guard rejects an unexpected registry access instead of loading the full -default-method graph. The three real registry catalogs remain unmocked. - -The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37266875139) -used fixed source `1bec53ceb23b5f296a38ffa0e085778d32fc7849`, Linux ARM, -Node 24.21.0, pnpm 12.8.1 and four isolated fork workers. Every fresh process ran -both Qoder files and all three catalogs: 85 cases across five files. Each invocation -used a distinct empty Vite cache, with results, filesystem transform and Node -compile caching disabled. All 510 timed outcomes passed. - -| Complete five-file process | Original | Candidate | Paired saving | -| -------------------------- | -------- | --------- | ------------- | -| Pair 1 | 17.677s | 16.323s | 1.354s | -| Pair 2, reversed order | 16.827s | 16.021s | 0.806s | -| Pair 3 | 17.324s | 16.211s | 1.113s | - -The median paired saving is 1.113 seconds, or 6.4% of this fixed workload. -Separate actual faults in retained launch recipes, Qoder command selection and -method registration fail the same intended assertions before and after the -imports. Generated launch IDs and shifted stack lines differ in the raw failure -messages; they were preserved and are not claimed byte-identical. - -The single hosted trial occupied 134 runner-seconds including all six samples, -shared setup and upload. That is trial cost, not a production saving. These -focused process measurements do not establish full-shard savings, PR runtime -percentiles, queue relief or a change in the organization's runner allowance. - -## October 5 localization gate work - -The localization coverage audit now classifies only AST nodes that can emit string -parts, and reuses the class-property exclusion set. It still visits every child and -handles JSX text separately. The extraction scope skips only source extensions and -test paths explicitly excluded by the real extractor configuration. Catalogs, -assets, unknown extensions, configuration changes and mixed production changes -still select extraction; both sides of a rename remain covered. - -The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37360932616) -used fixed source `761d63a4e52ff7915d4432c004df13182db63010`, Linux ARM, -Node 24.21.0 and pnpm 12.8.1. Dependency setup was shared outside the samples. -Each timed check was a fresh process with Node compile caching disabled. - -| Complete coverage audit | Original | Candidate | Paired saving | -| ----------------------- | -------- | --------- | ------------- | -| Pair 1 | 9.182s | 4.924s | 4.258s | -| Pair 2, reversed order | 9.133s | 4.924s | 4.209s | -| Pair 3 | 8.930s | 4.924s | 4.007s | - -The median paired saving is 4.209 seconds, or -46.1% of this audit. All three pairs improved; the original range was -0.252 seconds. Complete JSON finding inventories match byte-for-byte, -including all 13 findings and their source locations. All 53 candidate tests pass. -Four real audit mutations fail their intended assertions. An ignored test fixture -passes the real extraction gate; renaming it into production source selects the -gate and fails on the deliberately missing translation key. - -For an existing ignored test path, three conditional comparisons avoid a median -27.628 seconds of extraction work. Original arms really run the full gate -and pass; candidate extraction arms are explicitly `not_selected`. The routing -tests pin the actual extractor inputs and exclusions, so configuration drift fails -the tests. This saving applies only when every changed source path is ignored. - -Relay integration also omits desktop native preparation and the Electron archive -cache. Its existing two files use Node's SQLite, HTTP and WebSocket paths; general -unit shards retain their desktop setup. The existing 16 relay cases pass locally -on Node 24.20.0 and 26.7.0. Removed setup work is not a measured timing saving. - -These comparisons exclude queues and other checks. Extraction already overlaps -other preflight work, so conditional avoidance does not translate directly into -preflight wall time. No change to full-PR percentiles or the concurrency allowance -is established. diff --git a/docs/reference/cline-and-prime-agent-readiness-evidence.md b/docs/reference/cline-and-prime-agent-readiness-evidence.md deleted file mode 100644 index 6509c185f7b0..000000000000 --- a/docs/reference/cline-and-prime-agent-readiness-evidence.md +++ /dev/null @@ -1,85 +0,0 @@ -# Cline and Prime Agent readiness: what the transcripts show - -Both agents paint their composer with cursor addressing on the alternate screen, so the line-folded -text tail cannot see it (#23268, #22153). Their readiness is read off the live screen by the -`composer_ready` rules in `src/main/runtime/agent-state-rules/cline.json` and `prime-agent.json`, -through the same tiering as -Antigravity ([`antigravity-readiness-evidence.md`](./antigravity-readiness-evidence.md)): a pane -with an output clock is believed only once quiet, and when a trustworthy screen exists it decides, -so the quiet-process lane cannot settle a dialog it cannot see. Without a readable screen (a -re-attached pane whose grid is untrusted) both keep the quiet-process lane they had before; no -rest-signal entry closes it. Recordings follow -[`agent-pty-transcript-capture.md`](./agent-pty-transcript-capture.md) and are replayed by -`cline-screen-readiness-transcripts.test.ts` and `prime-agent-screen-readiness-transcripts.test.ts`. - -## Cline - -Recorded 2026-09-30 on macOS, `cline` 3.0.66, OpenRouter's free router, with an isolated -`--config`/`--data-dir`. `cline-3-0-65-win32-startup.txt` is a Windows capture from PR #23269. - -| Fixture (`cline-3-0-66-*.txt`) | Screen at the end | Rule | -| ------------------------------ | --------------------------------------------------- | ----------- | -| `ready`, `ready-80x24` | startup composer, `❯ What can I do for you?` | ready | -| `ready-plan` | Plan mode, `❯ Plan something...` | ready | -| `turn-ended` | after a turn, `❯ Ask anything...` | ready | -| `promo` | "Introducing Cline Desktop" drawn over the composer | not ready | -| `permission` | `Approve tool call?` with `[y] Approve [n] Deny` | not ready | -| `slash-menu` | `❯ /` with the command list under it | not ready | -| `draft` | unsent text in the composer | not ready | -| `busy-streaming` | a reply streaming, spinner scrolled off the top | reads ready | - -- **The streaming screen is the idle screen.** Once a long reply scrolls its spinner row away, the - grid is the same composer box as at rest. Only quiescence separates them. A clockless restored - pane still settles from the screen, like Antigravity and Prime: `onPtyData` stamps - `lastOutputAt` on every chunk, so a streaming pane has a clock from its first byte after attach, - and only a pane that has printed nothing since attach is judged on the screen alone. A reply that - stalls for 3s with its spinner off screen would read ready; that is not captured and not ruled - out. -- **The placeholder is not fixed.** It changes with mode and history, so the rule accepts the three - captured placeholders and nothing else. A typed draft looks the same to the read projection, which - is why screen-ruled agents read raw rows (`readScreenRuledLines`, which also requires the PTY's - own grid). Every other agent keeps `readLiveTerminalScreenLines` exactly as before: replaying all - 93 other fixture/grid pairs frame by frame gives identical verdicts on this branch and its base. -- **The promo popup appears about 40ms after the composer** and returns on each launch until it - is dismissed once (`cli-notices.json`). The quiet lane covers that race; the popup carries no - blocked wording. -- **The approval prompt is quiet and unworded.** No blocked rule matches it, so before the screen - decided, the quiet-process lane would have settled it. That lane stays open only while the pane - has no readable screen. -- **Windows:** the reported bug (#23268) is Windows, where the text tail reorders rows. Only the - 3.0.65 contributor capture covers it, and it reads ready from the screen. The rule depends on - rendered rows, not byte order, but no Windows turn or dialog is recorded. - -## Prime Agent - -Recorded 2026-09-30 on macOS, `prime-agent` 0.9.8, OpenRouter -`inclusionai/ling-3.0-flash-sante:free`, with an isolated `HOME` (the first-launch question only -reappears in a fresh one). `prime-agent-0-9-5-*.txt` are 120x35 captures from PR #22154. - -| Fixture (`prime-agent-0-9-8-*.txt`) | Screen at the end | Rule | -| ----------------------------------- | ---------------------------------------------- | --------- | -| `ready`, `ready-80x24` | bare `>` over the `← manage` footer | ready | -| `ready-after-question` | trace question answered "Not now" | ready | -| `turn-ended`, `tool-turn` | a turn (one with a Python tool call) has ended | ready | -| `trace-question` | animated "Share agent traces" question | not ready | -| `slash-menu` | `> /` with the command list | not ready | -| `busy-streaming` | `⠦ Writing · 6s` status row above the composer | not ready | -| `draft` | unsent text in the composer | not ready | - -- **The footer and caret stay up mid-turn.** Only the braille status row Prime keeps directly above - them says a turn is running, so the rule vetoes on it. -- **The rule reads ready for moments it should not.** Replayed in 64-byte chunks, Prime erases that - status row before redrawing it, and on first launch it paints the idle composer a few tens of - milliseconds before the trace question covers it. Both are covered by quiescence: the spinner and the question's - animation repaint continuously, and an idle Prime is silent. -- **No permission prompt exists to capture.** With default settings Prime ran the tool call without - asking. -- **Why the Prime captures are large.** Prime does no cell diffing. Each synchronized frame - (`ESC[?2026h`…`ESC[?2026l`) erases and rewrites every row it touches, so a streaming turn costs - about 4 KB per spinner tick or token (337 frames, 8,593 `ESC[2K` in 1.45 MB). The first-launch - welcome animates a full-screen dotted background with a colour code per glyph, about 10 KB a - frame at roughly ten frames a second. `busy-streaming` and `trace-question` are truncated to the - first frame that shows the screen their tests need (see each `.meta.json`). - `ready-after-question` cannot be: the animation precedes the answer, and truncation only drops - the end. -- 0.9.4's `← agents/resume` layout is not supported; the rule needs 0.9.5 or later. diff --git a/docs/reference/codebuddy-harness.md b/docs/reference/codebuddy-harness.md deleted file mode 100644 index e59eaec855ed..000000000000 --- a/docs/reference/codebuddy-harness.md +++ /dev/null @@ -1,44 +0,0 @@ -# CodeBuddy terminal harness - -CodeBuddy uses its own identity in the desktop and mobile agent catalogs, process -detection, telemetry, hooks, resume records and AI Vault. `codebuddy` and `cbc` -identify the interactive CLI; print, server, ACP and detached background modes do -not identify an interactive pane. Model and effort selections use `--model` and -`--effort`; the CLI resolves its stable model aliases for the current account. - -Managed hooks use the existing Claude-compatible installer and transport, with -CodeBuddy's own `.codebuddy/settings.json` and hook source. Installation preserves -user hooks and statusline configuration. Local and SSH installers share the same -plan; Windows explicitly selects CodeBuddy's supported PowerShell hook shell. -Status flows through the execution host's canonical hook store. - -## Observed lifecycle - -Verified with the authenticated CodeBuddy 2.159.0 CLI on macOS: - -- `UserPromptSubmit` starts work. `SessionStart` can arrive afterward and must not - settle that work. -- An unanswered `AskUserQuestion` emits a `Notification` with - `notification_type: permission_prompt` and the message - `needs your permission to use AskUserQuestion`. -- In this version, `PreToolUse(AskUserQuestion)` arrives **after** the user answers; - it resumes working, followed by `PostToolUse` and `Stop`. -- `Stop` supplies the final assistant message and the provider session identity - supplies the resume command. - -The sanitized real event sequence is -`src/shared/__fixtures__/codebuddy-question-hooks.jsonl`. The regression test -replays it through the shared hook listener. Qoder keeps its existing lifecycle -semantics while sharing the common event projection. - -AI Vault reads CodeBuddy's `type: message` JSONL records with top-level roles and -`input_text` / `output_text` content blocks. Local, WSL and SSH discovery use the -provider's `.codebuddy/projects` tree and the same incremental parser. - -## Validation scope - -Live macOS checks exercised launch through Orca's agent menu, model switching, -question waiting, answer submission, working and completion indicators, history -discovery and a resumed session recalling its earlier answer. Hidden-renderer CDP -screenshots record the working, question and completed states. Windows, Linux, -WSL and SSH runtime execution have not been exercised live on this machine. diff --git a/docs/reference/deepseek-build-observation.md b/docs/reference/deepseek-build-observation.md deleted file mode 100644 index 50686cf308d6..000000000000 --- a/docs/reference/deepseek-build-observation.md +++ /dev/null @@ -1,28 +0,0 @@ -# DeepSeek Build terminal identity - -DeepSeek Build is the third-party [`innocarpe/deepseek-build`](https://github.com/innocarpe/deepseek-build) product, published as `@innocarpe/deepseek-build`. It is distinct from official DeepSeek Harness (`@deepseek-ai/dsh`), Reasonix, DSH Console and generic DeepSeek TUI wrappers. - -Orca recognizes manually started Build terminals through its existing process and title observations. `TerminalAgent` includes `dsb`; the launchable `TuiAgent` registry does not. No Build launcher, hook, readiness profile, resume command or history reader is registered. Existing generic terminal input remains available. - -The source and actual macOS release were checked at **v6.9.0**, source commit `74df67a56988e9a32845c4565cc62b021ea68c7d`. The darwin-arm64 release tarball SHA-256 is `a57f225a537fc5c027ac4592e3f37f7bdc28cc2d6e27a366934511ea565cb874`. - -- `package.json` publishes `dsb.js` and `deepseek-build.js` npm shims; the native child is `deepseek-build-agent`. -- `crates/dsb-cli/src/main.rs` separates the full-screen entry from `run`. Global value options such as `--cwd` can precede `run`; those invocations remain excluded from interactive recognition. -- `crates/dsb-cli/src/agent_launch.rs` emits the product OSC 0 title. The vendored pager's `notifications/title.rs` composes spinner, activity and product segments with ` - ` separators. -- The committed `dsb-6-9-0-folder` PTY fixture records the released binary's welcome screen and actual title in an isolated home and plain folder. It makes no successful-authentication or completed-model-turn claim. Its runtime test feeds raw chunks through `onPtyData` with foreground inspection unavailable. - -Explicit native owner markers retain their existing precedence. A Claude task merely mentioning Build is not a Build identity. Runtime publication reuses the existing optional `agentIdentity` string; no new RPC, stream opcode or status producer is added. Older hosts can omit identity, while older readers retain their existing unknown-agent handling. Execution-host process/title observations work without a Git repository; local source tests do not establish native Windows, Linux or SSH device coverage. - -For rendered proof, isolate both Electron and the actual PTY. On macOS, `login(1)` can replace the shell's inherited home. Test-only `ORCA_DISABLE_MACOS_LOGIN_SHELL=1` avoids that wrapper; do not change production launch policy for a proof. Require a nonce-bound file written by a helper executed in the spawned PTY, containing its actual `HOME`, `USERPROFILE`, `DEEPSEEK_BUILD_HOME`, `GROK_HOME` and trust-RPC flag, and verify it before agent launch. A terminal-text assertion can match command echo and is not isolation evidence. Explicit provider environment at the final execution boundary protects the test even after shell startup. - -The observation-type propagation and title/process recognition adapt Wooseong Kim's (`innocarpe`) [PR #23485](https://github.com/stablyai/orca/pull/23485), with source-backed corrections for the second npm shim and value options before `run`. Keep that predecessor open until a reviewed successor merges. - -Independent review follow-up: upstream 6.9.0 outer `Commands::Agent` forwards native PagerArgs options. Native `-p`/`--single` (alias `--print`), `--prompt-json` and `--prompt-file` are one-shot forms and are excluded from interactive process/foreground identity, including equals/compact short forms, npm wrappers and preceding value options. Positional interactive prompt text, native option values and the native `--` terminator stay distinct. Actual release native `--help` confirms exposed flags; source alias and forwarding are pinned above. - -Title follow-up confines Gemini identity/normalization and status sniffing before inspecting Build activity text. A verified Build title uses its leading own braille frame for working and leading `⚠ Action Required - ` for permission; embedded Gemini glyphs in activity/session/cwd text do not change its identity or status. Source-backed frame tests cover wrapped and alert variants, plus OSC input through the actual runtime/listing path. Native Gemini and other provider corpus contracts stay covered. - -Actual released outer `dsb agent -- --help` prints the native TUI help (`outer-forwarded-help.txt`), confirming Clap consumes the outer separator before forwarding. The observer distinguishes this from the native `--`: `dsb agent -- --print task` is one-shot, whereas `dsb agent -- -- --print` and direct native `-- --print` retain literal interactive prompt text. - -Native grammar follow-up: only the outer wrapper's `run` subcommand is one-shot. Native `deepseek-build-agent run`, forwarded `dsb agent run`, and `--leader-socket run` remain interactive; the last consumes `run` as a path value. Native `-c` is boolean, so Clap accepts `-cp task` and `-cptask` as continue plus single-turn prompt. Attached `-m`/`-r`/`-s`/`-w` values (including after `c`) do not expose a prompt flag. The released binary accepted the five review argument topologies with `--help` under an executed private-child environment assertion (`native-grammar-oracle.json`); this proves parsing/help, not successful model generation. - -Attached prompt values can begin with hyphens: native `-p-` and `-cp--print` consume `-` and `--print` as the single-turn prompt. The observer accepts the entire remainder after `p`, while the attached m/r/s/w value shields stay covered. The released native binary and outer `agent` wrapper accepted both forms with `--help` in a nonce-asserted private child (`attached-p-oracle.json`). diff --git a/docs/reference/dsh-harness-integration.md b/docs/reference/dsh-harness-integration.md deleted file mode 100644 index 9a850e069001..000000000000 --- a/docs/reference/dsh-harness-integration.md +++ /dev/null @@ -1,44 +0,0 @@ -# DeepSeek Harness integration - -Orca detects the community `@deepseek-harness-tui/dsh-tui` launcher (`dsh-tui`, alias -`dst`) and requires the official `@deepseek-ai/dsh` executable too. The launcher -boots the `dsh-tui` profile; Orca passes `.` to select the current workspace and -reach its composer on the first launch. The official Harness does not bundle this community TUI. -DSH Console and DeepSeek Build are separate products and are not interchangeable -with this launch contract. - -The official DSH 0.2 CLI accepts both `dsh --profile headless` and `dsh headless`. -Orca excludes the known `web`, `headless`, `sdk`, `sdk-minimal`, `acp`, and `desktop` -profiles from interactive process recognition, along with plugin management and -configuration dumps. Custom profile names remain eligible because profiles are -user configurable. Only launcher arguments are inspected; app prompts, resume IDs, -and patch filenames cannot change the selected profile's identity. - -Status hooks use the official `@deepseek-ai/dsh-hooks-claude-code` plugin, installed -as an owned block in `$DSH_HOME/cordis.patch.yml`. User entries outside the block are -preserved. Local installation respects `DSH_HOME`; the existing SSH installer uses -the execution host's default `~/.dsh` because SFTP cannot read its environment. -Hooks report session start, prompt submission, tool start/end, and stopping through -Orca's host status store. Approval has no dedicated hook; it is not inferred from -an uncaptured screen. Subagent lifecycle events are ignored for parent-pane status. - -DSH 0.2 still emits an empty `transcript_path` in Claude-compatible hooks. Its -session persistence defaults to compressed JSONL under `$DSH_HOME/sessions`. -Orca can resume a hook-associated session through `dsh-tui --resume `, but -currently does not discover DSH logs in Agent Session History. Resume support alone -does not establish transcript-history support. - -## Reproduce the official launcher check - -Install `@deepseek-ai/dsh@0.2.0-rc.2` into a disposable prefix, then run: - -```sh -ORCA_BACKGROUND_LAUNCH=1 ORCA_REAL_DSH_CLI=/path/to/prefix/node_modules/.bin/dsh \ - pnpm test src/shared/dsh-real-cli.test.ts -``` - -The opt-in test checks published version, composed profile configurations, and -headless help in an isolated home and working folder without a model request. -Interactive readiness is separately pinned to the captured community TUI transcript -in `src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt`; that older capture is -not proof of current TUI compatibility or paid generation. diff --git a/docs/reference/git-compatibility.md b/docs/reference/git-compatibility.md deleted file mode 100644 index bbfb4a848766..000000000000 --- a/docs/reference/git-compatibility.md +++ /dev/null @@ -1,94 +0,0 @@ -# Git Compatibility Policy - -## Scope - -Orca executes the user's Git binary on three kinds of execution host: native, -WSL, and SSH. Each host can have a different Git version, so compatibility -state must be scoped to the host that actually runs the command. - -Git 2.25 is the core-workflow compatibility baseline for command selection. It -is the oldest line that covers Orca's baseline use of porcelain v2, `branch ---show-current`, `restore`, and sparse checkout. Optional features that need a -newer Git must degrade safely and cache the missing capability. Orca does not -currently block older Git at startup, but new command construction should not -assume features introduced after this baseline. - -## Capability Rules - -When a newer Git feature materially improves correctness or performance: - -1. Keep a baseline-compatible command or parser as the fallback. -2. Detect rejection with a narrow predicate for that option or subcommand. -3. Run the preferred command through `GitCapabilityCache` so a rejection is - remembered for the native host, WSL distro, or SSH provider that produced it. -4. Retry after the cache interval so an in-place Git upgrade self-heals without - restarting Orca. -5. Test the first fallback, later calls that skip the rejected probe, concurrent - probe coalescing, and execution-host isolation where applicable. - -Do not branch only on a parsed `git --version`. Vendor builds can backport -features, and wrappers can report a host version that differs from the binary -used inside WSL or SSH. A behavior probe plus a precise fallback is the final -authority. - -## Current Capabilities - -| Capability | Preferred behavior | Compatibility behavior | -| --------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `fetch-no-write-fetch-head` | Fetch a private rebase ref without changing worktree-local `FETCH_HEAD` | Serialize all Orca fetch/pull operations per worktree Git directory before Git 2.29 | -| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 | -| `worktree-add-lock-reason` | Create a prepared checkout with its ownership marker already present | Before Git 2.33, add without checkout, then exclusively create the same reason marker before materializing files | -| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo | -| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca | -| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 | -| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form | - -Prepared creation registers and locks without checking out files while its shared -exact-base fetch runs. A cancellable in-process barrier waits for fetch settlement, -including offline failure, then resolves the current commit OID on the owning host -and materializes files once. Existing preparations queue a tip refresh on that same -barrier before a create can claim them. Finalization still resolves the latest base -and runs the post-checkout hook only when attaching the requested branch. This uses -baseline-compatible `rev-parse` and `reset --hard`; the barrier never enters Git -transport options or the remote wire. - -### Placeholders That Fail Open - -`GitCapabilityCache` records commands Git _rejects_. A `git log --format` -placeholder Git does not know is not rejected: Git echoes it verbatim and exits -zero, so there is no error to remember and no probe to cache. Ask for both forms -in one record and pick at parse time. - -| Placeholder | Preferred behavior | Compatibility behavior | -| --------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `%(decorate:…)` | Git 2.43 separates commit decorations with `\x1f`, so ref names containing commas survive | The same record also carries `%D` (Git 2.10); an unexpanded `%(decorate` placeholder selects it, at the cost of comma-splitting | - -## Why Not `simple-git` - -`simple-git` is a process wrapper around the installed Git binary. Its custom -options and `raw` API pass arguments through to Git, so it cannot make a newer -flag work on an older binary or choose Orca's semantic fallback automatically. -It provides version reporting and subprocess queueing, but Orca already needs -its own WSL/SSH routing, cancellation, tracing, redaction, process cleanup, and -bounded output handling. Replacing the runner would move—not remove—the -capability problem. - -## CI Contract - -PR checks run the capability contract against real Git 2.25.5, 2.38.1, and -2.49.1 binaries. This spans the pre-2.29 serialized `FETCH_HEAD` fallback, the transitional -`merge-tree --write-tree` behavior before `--merge-base`, and current Git. - -The three lanes run in parallel and each Git call in the container lanes costs a -container start, so their wall clock is runner contention, not Git. Build the -2.25.5 binary and pull the images before the lanes start: anything heavy left -running alongside them is charged to whichever boundary case is in flight and -surfaces as a Vitest timeout rather than as a slow setup step. - -The idle maintenance contract also verifies `multi-pack-index write` and packed -object reads. The command arrived in Git 2.20 and needs no newer-Git fallback; -Orca uses only index metadata writes, respecting `core.multiPackIndex=false`. - -Keep the unit tests alongside that matrix. They cover concurrent probes, -native/WSL/SSH/relay isolation, and error-stream shapes that a single real -binary invocation cannot exercise deterministically. diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md deleted file mode 100644 index 8bbfc0af6d80..000000000000 --- a/docs/reference/headless-linux-server.md +++ /dev/null @@ -1,1019 +0,0 @@ -# Headless Linux Server - -Use this guide when you want to run `orca serve` on a Linux machine without a -desktop session, such as an Ubuntu VPS or a remote build box. - -`orca serve` starts the Orca runtime without opening the desktop window. On -Linux, the packaged AppImage still needs the libraries that Electron expects at -startup. Current Orca builds start Xvfb automatically for `orca serve` when no -`DISPLAY` is set, but Xvfb must be installed first. A separate D-Bus session is -not required. When `DISPLAY` is set, Orca uses that display instead of starting -a competing Xvfb process, provided the display is usable: its socket must exist, -and if an X lock file is present it must name a running process. A `DISPLAY` -whose lock names a dead process is refused rather than replaced, and `orca serve` -exits — unset `DISPLAY` to let Orca start its own Xvfb. A socket published with -no lock at all (a container bind-mounting `/tmp/.X11-unix`, or WSLg) is accepted. - -The supported deployment matrix covers Ubuntu 20.04, 22.04, and 24.04 and -current Debian stable — anything with glibc 2.31 or newer (see -[Linux glibc compatibility](./linux-glibc-compatibility.md)). Package names can -differ on other Debian-derived releases. - -## Ubuntu and Debian prerequisites - -Install the CLI tools, Xvfb, and the shared libraries Electron links against. -A minimal server or container image ships none of the Electron libraries, and -`orca serve` then fails before Electron starts: - -```bash -sudo apt-get update -sudo apt-get install -y \ - curl file jq xvfb zlib1g-dev ca-certificates git \ - libgtk-3-0t64 libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 libgbm1 libasound2t64 \ - libxtst6 libcups2t64 libdrm2 libxkbcommon0 libpango-1.0-0 libcairo2 libatspi2.0-0t64 \ - libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libxrender1 libx11-xcb1 \ - libxcb-dri3-0 libxss1 -``` - -That command is for Ubuntu 24.04 and newer and Debian 13 and newer. Those -releases carried out the 64-bit `time_t` transition, which renamed six of the -packages with a `t64` suffix. On Ubuntu 20.04, Ubuntu 22.04, and Debian 12, -substitute the unsuffixed names: - -- `libgtk-3-0t64` becomes `libgtk-3-0` -- `libatk1.0-0t64` becomes `libatk1.0-0` -- `libatk-bridge2.0-0t64` becomes `libatk-bridge2.0-0` -- `libasound2t64` becomes `libasound2` -- `libcups2t64` becomes `libcups2` -- `libatspi2.0-0t64` becomes `libatspi2.0-0` - -The other names are identical on every supported release. The substitution is not -symmetric, so use the list that matches the release. A `t64` name on Ubuntu 20.04, -Ubuntu 22.04, or Debian 12 fails immediately with `E: Unable to locate package -libgtk-3-0t64`. In the other direction the old names mostly still resolve, because -each renamed package declares `Provides:` its unsuffixed name — except `libasound2` -on Ubuntu 24.04, where `liboss4-salsa-asound2` in `universe` claims that name too. -apt will not choose between two providers and exits with `E: Package 'libasound2' -has no installation candidate`, which aborts the entire install line and leaves none -of the libraries installed. - -On Ubuntu 20.04 and 22.04, install `libfuse2` to execute the AppImage through -FUSE. On Ubuntu 24.04 and Debian 13 the package is `libfuse2t64`, though the plain -`libfuse2` name also resolves there because nothing else provides it. FUSE is -optional: without it, use the AppImage's supported extraction path. CLI -registration does this once automatically, so registered commands do not need -FUSE. - -Download and make the AppImage executable: - -```bash -sudo mkdir -p /opt/orca -sudo curl -L https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage \ - -o /opt/orca/orca-linux.AppImage -sudo chmod +x /opt/orca/orca-linux.AppImage -``` - -To extract it without FUSE, run the extraction as root because the installation -directory is root-owned: - -```bash -cd /opt/orca -sudo ./orca-linux.AppImage --appimage-extract -sudo chmod -R a+rX /opt/orca/squashfs-root -/opt/orca/squashfs-root/AppRun serve --port 6768 -``` - -The `chmod` is required whenever the extraction runs as a different user than -the server: `--appimage-extract` creates `squashfs-root` as `drwx------` owned by -the extracting user, so anyone else — including a dedicated service user — cannot -even traverse it, and the run fails before Electron starts. - -Docker commonly has no FUSE device. Use `--appimage-extract` once or -`--appimage-extract-and-run`; neither requires a privileged container. The -extract-and-run wrapper can print extracted paths before Orca starts, so -automation that requires stdout to contain only the ready JSON should extract -once and invoke `squashfs-root/AppRun`. - -If `Xvfb` was installed somewhere other than `/usr/bin`, confirm systemd can -find it later: - -```bash -command -v Xvfb -``` - -## Run In The Foreground - -Start with a foreground run before creating a service: - -```bash -LIBGL_ALWAYS_SOFTWARE=1 /opt/orca/orca-linux.AppImage serve --port 6768 -``` - -For remote clients, pass the address they should use to reach this server. A -Tailscale address is usually the safest option for private servers: - -```bash -LIBGL_ALWAYS_SOFTWARE=1 /opt/orca/orca-linux.AppImage serve \ - --port 6768 \ - --pairing-address 100.64.1.20 -``` - -`--pairing-address` is only the address advertised to clients. It does not -change the listener bind address. Orca binds its WebSocket listener, then -combines the actual bound port with the advertised host when the address omits -a port. Use a reachable LAN/Tailscale hostname or IP, or a complete reverse -proxy URL such as `https://orca.example.com/runtime` (`http(s)` is normalized -to `ws(s)`). Wildcard addresses such as `*`, `0.0.0.0`, and `::` cannot be -advertised. - -The command writes one ready block to stdout after the listener bind and -pairing initialization complete: - -```text -Orca server ready -Bound endpoint: ws://0.0.0.0:6768 -Advertised endpoint: ws://100.64.1.20:6768 -Pairing URL: orca://pair?code=... -``` - -For supervisors, request the versioned single-line JSON contract: - -```bash -/opt/orca/orca-linux.AppImage serve --port 6768 \ - --pairing-address 100.64.1.20 --json -``` - -The actual output is one compact line; this example is pretty-printed for -readability: - -```json -{ - "type": "orca_server_ready", - "schemaVersion": 1, - "runtimeId": "...", - "endpoint": "ws://0.0.0.0:6768", - "boundEndpoint": "ws://0.0.0.0:6768", - "advertisedEndpoint": "ws://100.64.1.20:6768", - "managedWslCliReconciliation": "settled", - "pairing": { - "available": true, - "url": "orca://pair?code=...", - "endpoint": "ws://100.64.1.20:6768", - "deviceId": "...", - "webClientUrl": "...", - "scope": "runtime", - "qr": null - } -} -``` - -`endpoint` remains a compatibility alias for `boundEndpoint`; new automation -should use the explicit bound and advertised fields. - -When the server remains usable but cannot mint an offer, `pairing` remains an -object with `available:false`, a stable `reason`, and operator `guidance`; it is -never silently omitted. `--recipe-json` is stricter and exits with that reason -because its contract requires a pairing URL. Stop a foreground server with -`Ctrl+C`. Stable reasons are `disabled_by_operator`, `websocket_unavailable`, -`device_registry_unavailable`, `e2ee_key_unavailable`, and -`invalid_advertised_endpoint`. - -## Systemd Service - -Create a dedicated service user and install directory. Run the service as this -user instead of root so the AppImage can keep Chromium's sandbox enabled. Keep -the install directory root-owned: the service needs to read and execute the -AppImage, but must not be able to replace it or the rollback artifacts. - -```bash -sudo useradd --system --create-home --shell /usr/sbin/nologin orca -sudo chown root:root /opt/orca /opt/orca/orca-linux.AppImage -sudo chmod 755 /opt/orca /opt/orca/orca-linux.AppImage -# Only if you ran --appimage-extract: extraction leaves squashfs-root root-only. -sudo chmod -R a+rX /opt/orca/squashfs-root -``` - -The last line matters because the two halves of this guide combine badly without -it. `--appimage-extract` writes `squashfs-root` as `drwx------ root root`, so the -`orca` service user cannot read or traverse the extracted tree and the unit fails -at startup. `chmod 755 /opt/orca` alone does not reach into it. - -For most hosts, one `orca serve` service is enough because Orca starts Xvfb on -display `:99` when no display exists: - -```ini -# /etc/systemd/system/orca-serve.service -[Unit] -Description=Orca runtime server -After=network-online.target -Wants=network-online.target -StartLimitIntervalSec=300 -StartLimitBurst=5 - -[Service] -Type=simple -User=orca -WorkingDirectory=/home/orca -Environment=LIBGL_ALWAYS_SOFTWARE=1 -ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20 -StandardOutput=journal -StandardError=journal -KillMode=mixed -Restart=on-failure -RestartPreventExitStatus=3 -RestartSec=5 - -[Install] -WantedBy=multi-user.target -``` - -Replace `100.64.1.20` with the LAN, Tailscale, tunnel, or public hostname that -clients should use. - -`KillMode=mixed` sends the graceful stop signal only to Orca's main process, -then `SIGKILL`s whatever is still in the cgroup the instant that main process -exits — `TimeoutStopSec` only governs how long systemd waits for the main -process itself, never a grace window for the cgroup's remains. This lets Orca -keep its owned Xvfb alive until Electron disconnects cleanly. - -The detached terminal daemon is preserved by a different mechanism: it is -launched through `systemd-run --user --scope`, so it and its PTYs live in their -own transient `orca-daemon-.scope` unit rather than in -`orca-serve.service`'s cgroup. A `systemctl stop` or `restart` of this unit -leaves that scope running, so live terminals and agent processes survive the -restart and the successor adopts them. - -That requires a reachable systemd **user** manager for the service account. -With `User=orca` and no interactive login there is none by default, so enable -lingering once: - -```bash -sudo loginctl enable-linger orca -``` - -Without it — or on a host without systemd as PID 1, or without `systemd-run` -on `PATH` — the daemon falls back to launching directly inside -`orca-serve.service`'s cgroup, and is then killed when the stop completes: -every `systemctl stop` or `restart` ends live terminals and agent processes, -even though their persisted layout and terminal history remain. Check which -case a running host is in with the `cgroupUnit` field of the daemon health -payload: a `orca-daemon-*.scope` value means isolated, `null` means the -unscoped fallback. - -None of this applies inside a Docker container. There the capability probe -fails closed (no `/run/systemd/system`), but that is the least of it: a -`docker restart` tears down the container's PID namespace, so no in-container -setting — lingering, kill mode, or scope — preserves the daemon or its PTYs -across it. Run the container with `--init` so a real PID 1 reaps exited PTY -subprocesses; without it, Orca is PID 1 and those children accumulate as -zombies because nothing reaps them. - -Exit status `3` means another process already owns this userData profile, so -`RestartPreventExitStatus=3` stops the unit instead of retrying a launch that -cannot succeed. Any other permanent startup fault is capped at 5 starts per -5 minutes; systemd's defaults (10s window, 5 starts) can never trip at -`RestartSec=5`, which is how one bad launch could restart thousands of times. -The start limit counts operator-initiated starts too, so once it trips systemd -refuses a plain `systemctl start` until the 5-minute window rolls over. Run -`sudo systemctl reset-failed orca-serve.service` first to clear it — the -[Upgrade](#upgrade-steps) and [Roll back](#roll-back) scripts already do. -On systemd older than 230 those two directives are spelled -`StartLimitInterval=`/`StartLimitBurst=` and belong in `[Service]`; Ubuntu -20.04, Orca's oldest supported base, ships systemd 245. - -Enable the service: - -```bash -sudo systemctl daemon-reload -sudo systemctl enable --now orca-serve.service -sudo journalctl -u orca-serve.service -f -``` - -`journalctl -o cat` removes journal metadata but still mixes the service's -stdout and stderr. Parse each line as JSON and require the readiness type and -schema before treating the service as ready: - -```bash -sudo journalctl -u orca-serve.service -o cat \ - | jq -Rrc 'fromjson? | select(.type == "orca_server_ready" and .schemaVersion == 1)' -``` - -A bounded health check should require that contract within its startup timeout; -otherwise inspect earlier diagnostics for the precise pairing reason, listener -error, or missing library. - -## Managed Xvfb Service - -If you prefer to own the virtual display lifecycle in systemd, run Xvfb as a -separate service and set `DISPLAY=:99` for Orca. - -```ini -# /etc/systemd/system/orca-xvfb.service -[Unit] -Description=Virtual X display for Orca -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -ExecStart=/usr/bin/Xvfb :99 -screen 0 1280x1024x24 -nolisten tcp -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=multi-user.target -``` - -If `command -v Xvfb` returned a different path, update `ExecStart` to that -absolute path. - -Then add the display dependency to the Orca service: - -```ini -# /etc/systemd/system/orca-serve.service -[Unit] -Description=Orca runtime server -After=network-online.target orca-xvfb.service -Wants=network-online.target orca-xvfb.service -StartLimitIntervalSec=300 -StartLimitBurst=5 - -[Service] -Type=simple -User=orca -WorkingDirectory=/home/orca -Environment=DISPLAY=:99 -Environment=LIBGL_ALWAYS_SOFTWARE=1 -ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20 -KillMode=mixed -Restart=on-failure -RestartPreventExitStatus=3 -RestartSec=5 - -[Install] -WantedBy=multi-user.target -``` - -`KillMode=mixed` matters as much here as in the single-service unit: without it -the unit silently defaults to `KillMode=control-group`, which `SIGTERM`s the -whole cgroup at once and then stalls the full `TimeoutStopSec` before the -`SIGKILL`. - -Enable both units: - -```bash -sudo systemctl daemon-reload -sudo systemctl enable --now orca-xvfb.service orca-serve.service -``` - -## CLI Install Note - -The registered Linux CLI command is `orca-ide`, not `orca`, to avoid shadowing -the GNOME Orca screen reader. Desktop-managed terminals receive a -terminal-scoped bare-`orca` shim. A packaged headless `orca serve` also makes a -best-effort dispatcher at `$HOME/.local/bin/orca` for the service user's own -shell, so the Claude Teams launcher can resolve its bare command; it does not -replace another user's `orca`. From an ordinary shell outside that service -user's managed environment, substitute `orca-ide` for `orca` in commands below. - -On a headless host, you do not need to open the desktop UI just to run the -server. Invoke the AppImage directly: - -```bash -/opt/orca/orca-linux.AppImage serve --help -``` - -Running an AppImage as root requires Chromium's `--no-sandbox` switch before -the command: - -```bash -/opt/orca/orca-linux.AppImage --no-sandbox serve --port 6768 -``` - -This disables a security boundary. Prefer a dedicated unprivileged service -user, especially when the listener is reachable beyond localhost. - -The Linux CLI is named `orca-ide`, not `orca`, so it never shadows the GNOME -Orca screen reader at `/usr/bin/orca`. The `.deb` and `.rpm` packages put -`orca-ide` on `PATH` themselves at install time; with the AppImage it arrives -as `~/.local/bin/orca-ide` when the CLI is registered. - -A packaged `orca serve` start also writes a bare `orca` into `~/.local/bin` -that execs the same launcher, which is why the skills commands below can be -typed as `orca`. It writes it while starting, so it is never the command that -starts the server — the first launch is `orca-ide serve`, or the AppImage -invoked directly as above. The write is best-effort: it is gated on a packaged -build, it is skipped when no bundled launcher resolves, and it is skipped when -a file Orca does not own already holds that name (ownership is a marker on the -second line of the file). A host that really does run the screen reader keeps -its own `orca`. - -## Pairing troubleshooting - -- A pairing offer is a capability containing a device credential and E2EE - material. Share it only with the intended client and do not put it in proxy - access logs. -- `boundEndpoint` is where the process listens; `advertisedEndpoint` is what a - client dials. A valid-looking offer still cannot connect if DNS, firewall, - Docker port publishing, Tailscale policy, or a reverse proxy does not route - the advertised endpoint to the bound port. -- An omitted advertised port uses the actual bound port, including a fallback - port selected after a collision. An explicit proxy port is preserved. A port - mismatch therefore means the supplied external routing is wrong, not that - Orca changes it. -- Reverse proxies must support WebSocket upgrade and route the advertised path. - Use `wss://` or `https://` when TLS terminates at the proxy; do not advertise - `ws://` through an HTTPS-only endpoint. -- Hostnames, IPv4, bracketed IPv6, and raw IPv6 literals are supported. IPv6 - still requires an IPv6-reachable listener/network path. -- Background push notifications to a paired phone do not fire from a headless - server: agent-completion detection runs in the desktop renderer, which is not started in serve - mode, so nothing reaches the push gateway even though the phone - registers successfully. -- `xvfb-run` and `dbus-run-session -- xvfb-run` remain valid diagnostic launch - shapes, but neither should be needed when `Xvfb` is installed and no display - is configured. Repeated D-Bus messages without a ready block indicate startup - did not reach serve mode; confirm the AppImage version and exact argument - order, especially `--no-sandbox serve`. - -If you later install the desktop CLI from Orca settings, use that CLI for normal -shell workflows. Keep the AppImage path in systemd so service restarts do not -depend on an interactive shell profile. - -## Upgrade - -`orca serve` never updates itself. In headless mode Orca wires up no auto-updater -at all — the built-in updater only runs in the desktop GUI, and no paired mobile -or web client can trigger it remotely. Upgrading is always a deliberate step: -replace the AppImage and restart the service. - -Two facts make the persisted-state transition predictable: - -- **State lives in the service user's home, not next to the binary.** Persisted - data is under `/home/orca/.config/` (Orca uses both an `orca` and an `Orca` - directory there), fully independent of `/opt/orca/orca-linux.AppImage`. - Replacing the binary never touches projects, worktree metadata, terminal - history, orchestration state, or paired-device keys — so mobile and web - clients reconnect after an upgrade without re-pairing. -- **New builds migrate old state on load.** Orca imports older `orca-data.json` - into each profile's `profile-state.db`, so a forward upgrade needs no manual - data step. Normal writes, shutdown and profile switching save SQLite; they - no longer refresh the legacy JSON file. - -These guarantees preserve live processes only when the daemon is in its own -`orca-daemon-*.scope`, as reported by `health.terminalDaemon.cgroupUnit`. The -unscoped fallback remains destructive: a service restart kills every terminal -and agent in the service cgroup; an agent conversation may be resumable, but -its current process and any in-flight command are gone. Treat a stop as -destructive unless `health.terminalDaemon.cgroupUnit` names an -`orca-daemon-*.scope` on that host. - -When `cgroupUnit` is `null` or unverifiable, immediately before stopping the -service, obtain a fresh census as the service's -OS account and home. Use the installer's absolute launcher path so `sudo`'s -`secure_path` cannot hide a per-user registration: -`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`. -Replace both `orca` and `/home/orca` with the service account and home used by -your unit; for an extracted deployment, use its absolute `resources/bin/orca-ide` -launcher instead. Proceed only when the result is -untruncated, has an explicit `hostScope`, covers every execution host affected -by this service stop, and lists no terminals on those hosts. Every -`omittedHostIds` entry must be explicitly accounted for outside this service's -execution boundary. A separately paired runtime is outside that boundary; local -execution and SSH hosts reached through this runtime are not. An affected or -unknown omission, missing scope, failed request or lost connection is -`unverifiable`, so defer the restart. Do not allow new work between that census -and the stop; Orca does not yet provide an atomic census-and-stop fence. - -Rolling back is the case that needs care — see [Roll back](#roll-back). - -### Record the version you deploy - -The bundled CLI launcher prints the Orca build with `orca-ide --version`. For an -extracted deployment, that launcher is -`squashfs-root/resources/bin/orca-ide`; deb/rpm installs and CLI registration put -it on `PATH`. Do not use `orca-linux.AppImage --version` for this audit because -Electron owns the direct binary's version flags and may report its own runtime -version. For an AppImage service, choose a release tag explicitly and record it -next to the binary. The steps below keep that record in `/opt/orca/VERSION`. - -### Upgrade steps - -Never download straight onto `/opt/orca/orca-linux.AppImage`. The AppImage is -FUSE-mounted, so overwriting it in place while the service runs can crash or -corrupt the live process — and even with the service stopped, a failed or partial -download would clobber the working binary. Instead download to a temporary name -on the same filesystem, verify it, then swap it in with an atomic rename. - -Check capacity before starting: - -```bash -sudo chown root:root /opt/orca -sudo chmod 755 /opt/orca -sudo test ! -L /opt/orca/orca-linux.AppImage -sudo chown root:root /opt/orca/orca-linux.AppImage -sudo chmod 755 /opt/orca/orca-linux.AppImage -# Clear predictable staging names left by an older attempt after locking the directory -sudo rm -f /opt/orca/orca-linux.AppImage.new /opt/orca/VERSION.new \ - /opt/orca/orca-linux.AppImage.recovering /opt/orca/VERSION.recovering -sudo du -sh /home/orca/.config -df -h /opt/orca /home/orca -``` - -`/opt/orca` needs room for the compressed Orca profile archive, the staged -build, and the rollback binary. A rollback extracts the old profile and preserves -the post-upgrade Orca profile directories, so `/home` needs room for both copies. - -Run the following block as one Bash script so its fail-fast and recovery traps -remain active for the whole operation: - -```bash -set -euo pipefail - -# Replace this example with the release tag you intend to deploy -ORCA_VERSION=v1.4.147 - -# Select the release asset on the server where Orca runs -case "$(uname -m)" in - x86_64) - ORCA_ASSET=orca-linux.AppImage - ORCA_FILE_MACHINE=x86-64 - ;; - aarch64 | arm64) - ORCA_ASSET=orca-linux-arm64.AppImage - ORCA_FILE_MACHINE='ARM aarch64' - ;; - *) - echo "Unsupported architecture: $(uname -m)" >&2 - exit 1 - ;; -esac - -ORCA_ROLLBACK_NEW= -ORCA_ROLLBACK= -ORCA_SERVICE_STOPPED=0 -ORCA_BINARY_PROMOTED=0 -recover_failed_upgrade() { - exit_status=$? - trap - EXIT - set +e - if ((exit_status != 0)); then - sudo rm -f /opt/orca/orca-linux.AppImage.new /opt/orca/VERSION.new \ - /opt/orca/orca-linux.AppImage.recovering /opt/orca/VERSION.recovering - fi - if ((exit_status != 0)) && [[ -n "$ORCA_ROLLBACK_NEW" ]] && \ - sudo test -d "$ORCA_ROLLBACK_NEW"; then - sudo rm -rf -- "$ORCA_ROLLBACK_NEW" - fi - if ((exit_status != 0 && ORCA_SERVICE_STOPPED)); then - recovery_ok=1 - if ((ORCA_BINARY_PROMOTED)); then - if ! sudo cp -a "$ORCA_ROLLBACK/orca-linux.AppImage" \ - /opt/orca/orca-linux.AppImage.recovering || \ - ! sudo mv -f /opt/orca/orca-linux.AppImage.recovering \ - /opt/orca/orca-linux.AppImage; then - recovery_ok=0 - fi - if sudo test -f "$ORCA_ROLLBACK/VERSION"; then - if ! sudo cp -a "$ORCA_ROLLBACK/VERSION" /opt/orca/VERSION.recovering || \ - ! sudo mv -f /opt/orca/VERSION.recovering /opt/orca/VERSION; then - recovery_ok=0 - fi - elif ! sudo rm -f /opt/orca/VERSION; then - recovery_ok=0 - fi - fi - sudo rm -f /opt/orca/orca-linux.AppImage.recovering \ - /opt/orca/VERSION.recovering - if ((recovery_ok)); then - # A tripped StartLimitBurst refuses a plain start - sudo systemctl reset-failed orca-serve.service || true - sudo systemctl start orca-serve.service || true - else - echo 'Upgrade recovery failed; service remains stopped' >&2 - fi - fi - exit "$exit_status" -} -trap recover_failed_upgrade EXIT - -# 1. Stage and verify the new build while the server stays online -sudo curl -fL --retry 3 "https://github.com/stablyai/orca/releases/download/${ORCA_VERSION}/${ORCA_ASSET}" \ - -o /opt/orca/orca-linux.AppImage.new -sudo chown root:root /opt/orca/orca-linux.AppImage.new -sudo chmod 755 /opt/orca/orca-linux.AppImage.new - -# Both checks must match; either grep stops this fail-fast block otherwise -ORCA_FILE_INFO=$(LC_ALL=C file /opt/orca/orca-linux.AppImage.new) -grep 'ELF .* executable' <<<"$ORCA_FILE_INFO" -grep -F "$ORCA_FILE_MACHINE" <<<"$ORCA_FILE_INFO" - -# 2. Assemble the prior binary and version in a root-only rollback bundle -ORCA_ROLLBACK_BASE=/opt/orca/orca-rollback-$(date +%F-%H%M%S-%N) -ORCA_ROLLBACK_NEW=${ORCA_ROLLBACK_BASE}.new -ORCA_ROLLBACK=${ORCA_ROLLBACK_BASE}.ready -sudo install -d -m 700 "$ORCA_ROLLBACK_NEW" -sudo cp -a /opt/orca/orca-linux.AppImage "$ORCA_ROLLBACK_NEW/orca-linux.AppImage" -if sudo test -f /opt/orca/VERSION; then - sudo cp -a /opt/orca/VERSION "$ORCA_ROLLBACK_NEW/VERSION" -fi - -# Stage the new version record before the stop window -printf '%s\n' "$ORCA_VERSION" | sudo tee /opt/orca/VERSION.new >/dev/null -sudo chown root:root /opt/orca/VERSION.new -sudo chmod 644 /opt/orca/VERSION.new - -# 3. Stop the server so the profile backup is consistent -ORCA_SERVICE_STOPPED=1 -sudo systemctl stop orca-serve.service - -# Add only Orca-owned profile directories, then publish the complete bundle -ORCA_PROFILE_DIRS=() -for profile_dir in orca Orca; do - if sudo test -L "/home/orca/.config/$profile_dir"; then - echo "Refusing symlinked Orca profile: /home/orca/.config/$profile_dir" >&2 - exit 1 - fi - if sudo test -d "/home/orca/.config/$profile_dir"; then - if [[ "$profile_dir" == Orca ]] && \ - sudo test /home/orca/.config/orca -ef /home/orca/.config/Orca; then - continue - fi - ORCA_PROFILE_DIRS+=("$profile_dir") - fi -done -if ((${#ORCA_PROFILE_DIRS[@]} == 0)); then - echo 'No Orca profile directory found under /home/orca/.config' >&2 - exit 1 -fi -sudo tar czf "$ORCA_ROLLBACK_NEW/profile.tgz" \ - -C /home/orca/.config "${ORCA_PROFILE_DIRS[@]}" -sudo chmod 600 "$ORCA_ROLLBACK_NEW/profile.tgz" -sudo mv "$ORCA_ROLLBACK_NEW" "$ORCA_ROLLBACK" - -# 4. Atomically replace the binary and version record, then start -ORCA_BINARY_PROMOTED=1 -sudo mv -f /opt/orca/orca-linux.AppImage.new /opt/orca/orca-linux.AppImage -sudo mv -f /opt/orca/VERSION.new /opt/orca/VERSION -# Clears a start-limit hit left by the version being replaced -sudo systemctl reset-failed orca-serve.service -sudo systemctl start orca-serve.service -ORCA_SERVICE_STOPPED=0 -trap - EXIT -``` - -The profile archive created in step 3 captures both Orca profile directory names -when present without rewinding unrelated tools under `/home/orca/.config`. The -`.ready` suffix is published only after the prior binary, version record, and -profile archive are complete. If you run the managed Xvfb unit, only -`orca-serve.service` needs restarting — leave `orca-xvfb.service` running. - -### Verify - -```bash -sudo journalctl -u orca-serve.service -f -``` - -A healthy start prints one `Orca server ready` block with the actual bound and -advertised endpoints. Verify those values rather than assuming the configured -port, because a collision can select a fallback port. -Confirm a client reconnects before you discard the backup. The timestamped -rollback bundles are not pruned automatically. After the new version satisfies -your retention policy, select and inspect the newest complete bundle before -removing it: - -```bash -shopt -s nullglob -ORCA_ROLLBACK_SETS=(/opt/orca/orca-rollback-*.ready) -((${#ORCA_ROLLBACK_SETS[@]} > 0)) -ORCA_ROLLBACK=${ORCA_ROLLBACK_SETS[${#ORCA_ROLLBACK_SETS[@]} - 1]} -printf 'Removing rollback bundle: %s\n' "$ORCA_ROLLBACK" -sudo test -d "$ORCA_ROLLBACK" -sudo rm -rf -- "$ORCA_ROLLBACK" -``` - -Each `.ready` directory is a self-contained rollback generation; never combine -files from different bundles. - -### Roll back - -A rollback is **not** binary-only safe. New builds save profile state in -SQLite, while a JSON-only build would read a stale legacy `orca-data.json`. -The rollback below restores the complete pre-upgrade profile directories from -step 3 and swaps the binary back. It deliberately returns to the pre-upgrade -state rather than preserving changes made since the upgrade. Run this block -as one Bash script: - -To preserve the latest state when moving to a JSON-only build, stop the service -and use the newer build's CLI to run -`orca profile state rollback --latest-json --profile-id ` for **every** -profile the older build may open, then install the older binary manually. -Without `--profile-id`, the command prepares only the active profile. This -explicit handoff archives the SQLite authority and publishes the current JSON; -ordinary exports write revisioned `.sqlite-export.N.json` files and do not -prepare a downgrade. Orca's updater refuses JSON-only targets automatically. - -```bash -set -euo pipefail - -# Select and validate one complete generation before taking the service offline -shopt -s nullglob -ORCA_ROLLBACK_SETS=(/opt/orca/orca-rollback-*.ready) -((${#ORCA_ROLLBACK_SETS[@]} > 0)) -ORCA_ROLLBACK=${ORCA_ROLLBACK_SETS[${#ORCA_ROLLBACK_SETS[@]} - 1]} -sudo test -f "$ORCA_ROLLBACK/orca-linux.AppImage" -sudo tar tzf "$ORCA_ROLLBACK/profile.tgz" >/dev/null - -# Extract and validate the old profile while the current server stays online -sudo test ! -L /home -ORCA_HOME_OWNER=$(sudo stat -c %u /home) -ORCA_HOME_MODE=$(sudo stat -c %a /home) -if [[ "$ORCA_HOME_OWNER" != 0 ]] || ((8#$ORCA_HOME_MODE & 0022)) || \ - sudo -u orca test -w /home; then - echo 'Refusing rollback because /home is not root-controlled' >&2 - exit 1 -fi -ORCA_RESTORE=$(sudo mktemp -d /home/.orca-restore.XXXXXX) -ORCA_SERVICE_STOPPED=0 -ORCA_MOVED_CURRENT_DIRS=() -ORCA_INSTALLED_RESTORE_DIRS=() -ORCA_CURRENT_BINARY_MOVED=0 -ORCA_CURRENT_VERSION_MOVED=0 -ORCA_VERSION_REPLACEMENT_STARTED=0 -ORCA_POST_UPGRADE= -ORCA_ROLLBACK_BINARY_STAGED= -ORCA_ROLLBACK_VERSION_STAGED= -ORCA_ROLLBACK_HAS_VERSION=0 -restart_after_rollback_error() { - exit_status=$? - trap - EXIT - set +e - if ((exit_status != 0 && ORCA_SERVICE_STOPPED)); then - recovery_ok=1 - if ((${#ORCA_INSTALLED_RESTORE_DIRS[@]})); then - for profile_dir in "${ORCA_INSTALLED_RESTORE_DIRS[@]}"; do - if sudo test -d "/home/orca/.config/$profile_dir"; then - if ! sudo mv "/home/orca/.config/$profile_dir" \ - "$ORCA_RESTORE/$profile_dir.failed"; then - recovery_ok=0 - fi - fi - done - fi - if ((${#ORCA_MOVED_CURRENT_DIRS[@]})); then - for profile_dir in "${ORCA_MOVED_CURRENT_DIRS[@]}"; do - if sudo test -d "$ORCA_POST_UPGRADE/$profile_dir"; then - if ! sudo mv "$ORCA_POST_UPGRADE/$profile_dir" /home/orca/.config/; then - recovery_ok=0 - fi - elif ! sudo test -d "/home/orca/.config/$profile_dir"; then - recovery_ok=0 - fi - done - fi - if [[ -n "$ORCA_POST_UPGRADE" ]]; then - sudo rmdir "$ORCA_POST_UPGRADE" 2>/dev/null || true - fi - if ((ORCA_CURRENT_BINARY_MOVED)); then - if sudo test -f "$ORCA_CURRENT_BINARY"; then - if ! sudo mv -f "$ORCA_CURRENT_BINARY" /opt/orca/orca-linux.AppImage; then - recovery_ok=0 - fi - elif ! sudo test -f /opt/orca/orca-linux.AppImage; then - recovery_ok=0 - fi - fi - if ((ORCA_CURRENT_VERSION_MOVED)); then - if sudo test -f "$ORCA_CURRENT_VERSION"; then - if ! sudo mv -f "$ORCA_CURRENT_VERSION" /opt/orca/VERSION; then - recovery_ok=0 - fi - elif ! sudo test -f /opt/orca/VERSION; then - recovery_ok=0 - fi - elif ((ORCA_VERSION_REPLACEMENT_STARTED)); then - if ! sudo rm -f /opt/orca/VERSION; then - recovery_ok=0 - fi - fi - if ((recovery_ok)); then - # A tripped StartLimitBurst refuses a plain start - sudo systemctl reset-failed orca-serve.service || true - sudo systemctl start orca-serve.service || true - else - echo 'Rollback recovery failed; service remains stopped' >&2 - fi - fi - if [[ -n "$ORCA_ROLLBACK_BINARY_STAGED" ]]; then - sudo rm -f -- "$ORCA_ROLLBACK_BINARY_STAGED" - fi - if [[ -n "$ORCA_ROLLBACK_VERSION_STAGED" ]]; then - sudo rm -f -- "$ORCA_ROLLBACK_VERSION_STAGED" - fi - sudo rm -rf -- "$ORCA_RESTORE" - exit "$exit_status" -} -trap restart_after_rollback_error EXIT - -if [[ "$(sudo stat -c %d "$ORCA_RESTORE")" != \ - "$(sudo stat -c %d /home/orca/.config)" ]]; then - echo 'Refusing rollback because staging and the Orca profile are on different filesystems' >&2 - exit 1 -fi -sudo tar xzf "$ORCA_ROLLBACK/profile.tgz" -C "$ORCA_RESTORE" -ORCA_RESTORE_DIRS=() -for profile_dir in orca Orca; do - if sudo test -L "$ORCA_RESTORE/$profile_dir"; then - echo "Rollback bundle contains a symlinked profile: $profile_dir" >&2 - exit 1 - fi - if sudo test -d "$ORCA_RESTORE/$profile_dir"; then - if [[ "$profile_dir" == Orca ]] && \ - sudo test "$ORCA_RESTORE/orca" -ef "$ORCA_RESTORE/Orca"; then - continue - fi - ORCA_RESTORE_DIRS+=("$profile_dir") - fi -done -if ((${#ORCA_RESTORE_DIRS[@]} == 0)); then - echo "Rollback bundle has no Orca profile directories: $ORCA_ROLLBACK" >&2 - exit 1 -fi -for profile_dir in "${ORCA_RESTORE_DIRS[@]}"; do - sudo chown -R orca:orca "$ORCA_RESTORE/$profile_dir" -done - -ORCA_ROLLBACK_STAMP=$(date +%F-%H%M%S-%N) -ORCA_ROLLBACK_BINARY_STAGED=/opt/orca/orca-linux.AppImage.rollback-staged-$ORCA_ROLLBACK_STAMP -sudo cp -a "$ORCA_ROLLBACK/orca-linux.AppImage" "$ORCA_ROLLBACK_BINARY_STAGED" -if sudo test -f "$ORCA_ROLLBACK/VERSION"; then - ORCA_ROLLBACK_HAS_VERSION=1 - ORCA_ROLLBACK_VERSION_STAGED=/opt/orca/VERSION.rollback-staged-$ORCA_ROLLBACK_STAMP - sudo cp -a "$ORCA_ROLLBACK/VERSION" "$ORCA_ROLLBACK_VERSION_STAGED" -fi - -ORCA_SERVICE_STOPPED=1 -sudo systemctl stop orca-serve.service - -# Preserve and replace only Orca-owned profile directories -ORCA_CURRENT_DIRS=() -for profile_dir in orca Orca; do - if sudo test -L "/home/orca/.config/$profile_dir"; then - echo "Refusing symlinked Orca profile: /home/orca/.config/$profile_dir" >&2 - exit 1 - fi - if sudo test -d "/home/orca/.config/$profile_dir"; then - if [[ "$profile_dir" == Orca ]] && \ - sudo test /home/orca/.config/orca -ef /home/orca/.config/Orca; then - continue - fi - ORCA_CURRENT_DIRS+=("$profile_dir") - fi -done -ORCA_POST_UPGRADE=/home/orca/.config/orca-rollback-$ORCA_ROLLBACK_STAMP -sudo install -d -o orca -g orca -m 700 "$ORCA_POST_UPGRADE" -if ((${#ORCA_CURRENT_DIRS[@]})); then - for profile_dir in "${ORCA_CURRENT_DIRS[@]}"; do - ORCA_MOVED_CURRENT_DIRS+=("$profile_dir") - sudo mv "/home/orca/.config/$profile_dir" "$ORCA_POST_UPGRADE/" - done -fi -for profile_dir in "${ORCA_RESTORE_DIRS[@]}"; do - ORCA_INSTALLED_RESTORE_DIRS+=("$profile_dir") - sudo mv "$ORCA_RESTORE/$profile_dir" /home/orca/.config/ -done - -ORCA_CURRENT_BINARY=/opt/orca/orca-linux.AppImage.rollback-current-$ORCA_ROLLBACK_STAMP -ORCA_CURRENT_BINARY_MOVED=1 -sudo mv /opt/orca/orca-linux.AppImage "$ORCA_CURRENT_BINARY" -sudo mv -f "$ORCA_ROLLBACK_BINARY_STAGED" /opt/orca/orca-linux.AppImage - -ORCA_CURRENT_VERSION=/opt/orca/VERSION.rollback-current-$ORCA_ROLLBACK_STAMP -if sudo test -f /opt/orca/VERSION; then - ORCA_CURRENT_VERSION_MOVED=1 - sudo mv /opt/orca/VERSION "$ORCA_CURRENT_VERSION" -fi -ORCA_VERSION_REPLACEMENT_STARTED=1 -if ((ORCA_ROLLBACK_HAS_VERSION)); then - sudo mv -f "$ORCA_ROLLBACK_VERSION_STAGED" /opt/orca/VERSION -else - sudo rm -f /opt/orca/VERSION -fi -# The crash-looping build you are rolling back from tripped StartLimitBurst -sudo systemctl reset-failed orca-serve.service -sudo systemctl start orca-serve.service -ORCA_SERVICE_STOPPED=0 -sudo rm -rf -- "$ORCA_RESTORE" -trap - EXIT -``` - -Restoring the complete backup is required for this rollback procedure: swapping -only the binary leaves the newer SQLite authority and stale legacy JSON in -place. Keep the pre-upgrade backup until the new version is proven -on your host. The `orca-rollback-*` directory inside `.config` is also retained -deliberately. The post-upgrade binary and version record are retained in -`/opt/orca` with the same `rollback-current-` suffix. Inspect these -artifacts and remove them according to your retention policy after the rollback -is resolved. - -## Installing Agent Skills Without A Desktop - -Orca's agent skills (CLI usage, orchestration, computer use, etc.) are normally -installed from Orca Settings, which pre-fills an `npx skills add ... --global` -command in a terminal for you to run. A headless host has no Settings UI, so -use `orca skills install` instead: - -```bash -orca skills install # list installable skills -orca skills install --skill orca-cli --skill orchestration # install globally (default) -orca skills install --skill orca-cli --local # install into the current project only -orca skills install --all # install every bundled skill -orca skills install --all --dry-run # print the npx command without running it -``` - -This resolves the same `npx skills add --skill ...` command -Settings would show you (adding `--global` unless `--local` is passed), then -runs it and forwards its output and exit code. It requires `node`/`npx` on the -host; it does not need a running Orca runtime. - -Unlike the command Settings shows, the spawned one adds `npx --yes` and `-y`. -Without them the `skills` CLI opens an interactive agent picker and blocks -forever on any allocated TTY — which includes a normal `ssh` session. Use -`--dry-run` to see the exact command that will run. - -Settings keeps that picker deliberately, because choosing which agents get a -skill is a real decision. A headless run cannot answer it, so instead of dropping -the choice Orca makes it explicitly: it passes an `--agent` list built from the -coding agents it detects on the host, plus the shared `.agents/skills` directory -it reads itself. Left to decide on its own with no agent detected, the `skills` -CLI installs into all ~75 agents it knows and leaves a config directory for each. -Override the targets yourself, or narrow to the shared directory alone: - -```bash -orca skills install --skill orca-cli --agent claude-code,codex -orca skills install --skill orca-cli --agent universal -``` - -If Orca detects no agent at all, `orca skills install` stops and asks for -`--agent` rather than guessing. - -To refresh already-installed skills, `orca skills update` mirrors the same -selection flags (`--skill`, `--all`, `--local`, `--dry-run`) and resolves to -`npx skills update ` with a matching scope flag — `--global`, or -`--project` when you pass `--local`: - -```bash -orca skills update --all # update every bundled skill globally -orca skills update --skill orca-cli --dry-run # print the npx command without running it -``` - -`orca skills update` only refreshes skills that are already installed — it exits -0 without doing anything for a skill that is missing, so install it first. More -generally, a 0 exit means the `skills` CLI ran without erroring, not that it -wrote anything; read its output to confirm what changed. - -`--json` covers the skill listing and `--dry-run`. A real run streams the -`skills` CLI's own non-JSON output and rejects `--json`. - -Both commands install onto the machine that runs them. In an Orca SSH workspace -or the WSL bridge the `orca` shim forwards commands to the Orca host, so they -refuse to run there and print the command to run on the machine you want. - -## Troubleshooting - -- `dlopen(): error loading libfuse.so.2`: install `libfuse2`. -- `Missing X server or $DISPLAY`: install `xvfb`, or start the managed Xvfb - service and set `DISPLAY=:99`. -- `[serve] Xvfb failed to start` or `[serve] Could not start Xvfb`: confirm - `command -v Xvfb` and that it is on the service `PATH`. -- GPU or DRI warnings on a VPS: keep `LIBGL_ALWAYS_SOFTWARE=1` in the service - environment. -- Chromium sandbox errors: confirm the service is running as the non-root - `orca` user and that `/opt/orca` is readable by that user, including - `/opt/orca/squashfs-root` if you extracted the AppImage. -- Clients cannot connect: make sure `--pairing-address` is an address reachable - from the client, and make sure firewalls allow the selected `--port`. -- Journal shows `Another Orca instance is already running for this userData -profile` and the unit exits `3`: another process already owns the profile, so - `RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the - owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or - keep it and leave the unit down), then run - `sudo systemctl reset-failed orca-serve && sudo systemctl start orca-serve` — - `reset-failed` clears the failed state and any start-limit counter. If no owner - exists, the lock is stale (Chromium recorded a pid that - has since been reused): remove `SingletonLock` and `SingletonSocket` from the - userData directory and start again. If an earlier crash-loop already leaked - AppImage mounts, list them with `findmnt -rn -t fuse.orca-linux.AppImage` and - release only the ones with no live owner using `fusermount -uz ` (or - `umount -l `), leaving the running instance's mount alone. -- Service crash-loops right after an upgrade: use [Roll back](#roll-back) with - the pre-upgrade `.ready` bundle. Do not rerun the upgrade first; doing so would - make the crashing version the next rollback binary. The loop trips - `StartLimitBurst`, so any manual `systemctl start` outside that script needs - `sudo systemctl reset-failed orca-serve.service` first. -- Diagnosing other missing libraries: extract the AppImage without launching it - with `./orca-linux.AppImage --appimage-extract`, then run - `ldd squashfs-root/orca-ide` to list any shared libraries the host is missing. - The Electron binary is `orca-ide`, not `orca`; `ldd` on a path that does not - exist prints nothing and exits cleanly, which reads as a clean result in - exactly the situation where you are hunting a missing library. diff --git a/docs/reference/ime-regression-checklist.md b/docs/reference/ime-regression-checklist.md deleted file mode 100644 index bad36127dd53..000000000000 --- a/docs/reference/ime-regression-checklist.md +++ /dev/null @@ -1,146 +0,0 @@ -# IME Regression Checklist - -## Follow-up issue ledger - -These reports define durable acceptance contracts, not only the symptoms from -one machine. - -| Issue | Root cause | Ownership invariant | Required evidence | -| ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [#16911](https://github.com/stablyai/orca/issues/16911) Native Chat preedit overwritten by streaming or attachment settlement | React reconciliation or an asynchronously resolved attachment writes an application draft while the browser owns the composing textarea; duplicate settlement can then re-adopt stale DOM. | From `compositionstart` until the first `compositionend` or blur, the browser owns the textarea. Resolved paths queue at the shared semantic sink; settlement adopts the browser DOM before flushing once. | Repeated stale streaming rerenders preserve the element and preedit; idle external drafts still synchronize; concurrent SSH completions preserve completion order and duplicates; both settlement orders run once; disable discards queued work; blur does not steal focus; native composition commits once. | -| [#16949](https://github.com/stablyai/orca/issues/16949) terminal preedit has no visible cursor | The opaque composition overlay covers the renderer cursor; at the final cell an over-wide inline preedit can also place its caret beyond the clipped screen. | The existing xterm `CompositionHelper` owns a visible caret after the preedit and before any row remainder; final-cell composition end-aligns within the screen while mid-line composition stays left-anchored. | Start, update, arbitrary-width final-cell containment, mid-line remainder placement, cleanup, and update-without-start are covered; preview and normal terminals inherit the live cursor theme. | -| [#16950](https://github.com/stablyai/orca/issues/16950) typing diagnostic records no CJK samples | The probe observes echoing keydowns but not reconciled composition commits, then guesses which queued input owns opaque TUI output. | A reconciled composition is observed even when `compositionend.data` is empty; only an isolated input enters exact percentiles, while overlap or a dropped-input gap produces one aggregate ambiguous burst. | Recorded Linux IBus empty-data commit, isolated direct and IME samples, mixed-source ambiguity, timeout/cap gaps, UTF-8 output bytes, and stop/drain cleanup are covered. | -| [#17104](https://github.com/stablyai/orca/issues/17104) Korean preedit repeats the Codex placeholder | Generic xterm row-tail reproduction exposed an application-semantic Codex or Claude composer placeholder that presentation style cannot identify safely. | Xterm always preserves generic covered row text. Orca's existing structural composer classifier masks only a verified placeholder during the exact active composition session; repaint reclassification runs only while composing, and end, blur, or disposal clears ownership, class, and listeners. Arbitrary dim output and shell lookalikes remain visible. | Codex prompt/footer and Claude prompt/frame classification, arbitrary all-dim and shell-lookalike negatives, repaint entry and exit, end/blur/disposal cleanup, and rendered Electron proof at cursor column 2 preserving generic row text are covered. | - -## Preedit cell advances (#19315) - -Single-codepoint CJK graphemes use the active Unicode provider's cell width and -measured font advance. Ordinary inline spans preserve browser bidi and baseline -layout; equal corrections share a run. Keep glyphs unscaled and the underline, -caret, and candidate textarea aligned with the rendered preedit. Appending ASCII, -emoji, or another script must not change an existing CJK prefix's correction. -Combining sequences, emoji, other scripts, and whitespace retain native shaping. -Font loading, typography changes, and renderer metric changes must update an open -composition; row-tail repaints preserve its unchanged nodes. - -Cold font measurements and styled runs share a fixed work budget. Repeated CJK -can remain one corrected run; after the budget is exhausted, the remaining text -keeps its native advance. This deliberately leaves the original spacing mismatch -in the tail of unusually varied long compositions, without switching the prefix -back to native spacing or rebuilding thousands of spans. - -`terminal-ime-xterm-preedit-cell-grid.test.ts` covers text preservation, native -clusters, lifecycle, and bounded work. `terminal-ime-preedit-cell-grid.spec.ts` -checks rendered glyph origins, caret/textarea geometry, underlines, font changes, -and native shaping at DPR 1, 1.25, and 2 with WebGL on/off. -`terminal-ime-preedit-continuity.spec.ts` covers mixed suffixes and budget crossings. -These checks use Chromium composition through CDP; they do not replace native OS -IME evidence. - -## Bounded-state and ownership contracts - -Every transient collection and ownership tracker must have an explicit lifetime and bound: - -- Native Chat uses `NATIVE_FILE_DROP_MAX_PATHS` (`256`). If a resolved completion would cross the cap, the whole batch is rejected atomically and the overflow notice remains visible through settlement; accepted paths keep order and duplicates. The queue is cleared before re-entry and on disable or pane-owner remount. -- The terminal placeholder mask tracks one scalar `activeSessionId` because xterm renders one composition view. A newer start supersedes an older one, a stale end cannot clear the latest owner, and blur or disposal clears it. The composition route keeps its per-ID reference-counted map intentionally for transport ownership; it is not replaced by the scalar. -- Typing diagnostics cap pending and ignored echo candidates at `MAX_PENDING_ECHO_CANDIDATES` (`64`), cap pending user-input signals, drain timed-out candidates, and clear all series on pane detach. Overflow becomes an explicitly ambiguous burst rather than an arbitrary attribution. - -## Native Chat asynchronous attachment settlement - -Attachment resolution is an external semantic write, including local file -selection, pasted-image temp saves, and SSH uploads. While composition is active, -it must not replace the browser-owned textarea value. - -- Start two concurrent SSH uploads, resolve the second first, and return one path - twice. Preserve completion order and both duplicates. -- On the first settlement event, adopt the browser DOM before flushing queued - paths. Exercise `compositionend` then blur and blur then `compositionend` in - one React batch; both orders must adopt and flush exactly once. -- If the composer becomes disabled before an upload resolves or before the queue - flushes, discard that result. -- If `compositionend` is omitted, blur performs the same one-time settlement - without focusing the textarea or stealing focus back. - -## Cross-platform verification - -Synthetic DOM events prove Orca's event and rendering contracts, but they do -not exercise the operating system's input method. Changes must also cover: - -| Environment | Native evidence | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| macOS | A native Korean 2-set composition in Native Chat and a terminal; preedit survives external renders, the caret remains visible, and commit occurs once. | -| Windows | Microsoft Korean IME over an untouched Codex placeholder; the preedit is the only visible text, the placeholder returns after cancel, and ordinary mid-line content remains visible. | -| Linux / SSH | IBus Hangul with an SSH-hosted PTY; an empty-data `compositionend` still produces one diagnostic sample and one committed syllable. | - -For remote evidence, `live` means the owning host reported the current -verification session or process identity. `exited` requires positive -host-owned evidence that the same identity terminated or is absent. Any -transport failure, stale identity, timeout, or inability to ask the owning host -makes the result `unverifiable`; it is never evidence that the composition or -PTY process exited. - -## Code elegance gate - -Each fix must pass all of these checks: - -- Reuse the component that already owns the state or overlay; do not install a - second composition state machine. -- Make browser, renderer, and PTY ownership boundaries explicit. Provisional - text must not leak into committed state or PTY input. -- Route local selection, pasted-image temp saves, and SSH upload results through - one resolved-attachment sink; queue semantic paths, never whole draft snapshots. -- Keep correctness changes separate from unrelated micro-optimizations. -- Use bounded per-composition state and work. Dispose every listener, timer, - observer, and DOM node with its owner. -- Preserve ordinary Latin input, mixed styled terminal content, local and SSH - PTYs, preview terminals, and folder workspaces with paired negative tests. -- Keep platform quirks behind event contracts or runtime platform checks; do - not branch on an IME vendor, language, or terminal agent name. -- Treat the canonical xterm source patch as the only hand-edited source, then - regenerate its bundle patch and lockfile together. -- Prefer deterministic replay or state-transition tests. Native evidence is a - second layer, never a substitute for regression coverage. - -## Enter in application text fields (#25035) - -Use `Input`, `Textarea`, or `CommandInput` for styled fields. Existing unstyled -fields with keyboard actions use `ImeInput` / `ImeTextarea` from -`lib/ime-text-field.tsx`; those preserve the DOM element, styles, refs, and -composition callbacks. They share `useImeEnterGestureOwnership` and keep -IME-owned keys out of both field actions and bubbling form/menu shortcuts. -Overlay primitives also reject IME-marked Escape in document capture, where -field-level propagation guards cannot intercept dismissal. -Do not add a second tracker at a call site already using a guarded field. -Native Chat and the File Explorer inline name field retain their existing -trackers because they also own specialized composition or element lifetimes. - -Required cases: - -- `isComposing`, `keyCode: 229` without `isComposing`, and `Process/229` must - never submit, choose a suggestion, or dismiss the field. -- The unmarked Enter redispatch stays owned on either side of keyup, including - a `Process/229` release. A - subsequent ordinary typing/navigation key ends that carry immediately; - hidden renderers may defer animation frames, and typing a filename suffix - must not cause the next deliberate Enter to disappear. -- Composition callbacks, blur, refs, and keyed remount cleanup still work. - Normal Enter, modifier submits, and Shift+Enter newlines remain available. -- Test the actual shared field when a consumer delegates IME handling to it; - a mock that replaces `CommandInput` with a raw input removes the protection. - -`ime-text-field.test.tsx` covers primitives, raw fields, parent handlers, and -command selection. File Explorer component tests cover all three operations -and input replacement. `file-explorer-ime-enter.spec.ts` drives Chromium -composition in New File, New Folder, and Rename in a folder workspace, then -checks the complete name in the Explorer and on disk, with both continued typing -and a redispatch followed by deliberate Enter. Overlay tests cover IME Escape -and ordinary dismissal; Markdown tests preserve an unmarked save shortcut while -composition state lingers. These are CDP event -contracts, not native OS keyboard evidence. - -The audit also covers settings and title fields, issue/review creation and -pickers, comments and annotations, search fields, Native Chat questions, -notebook execution shortcuts, and Markdown menu handlers. Terminal input keeps -its existing xterm/PTY ownership; mobile native fields use `onSubmitEditing` -instead of desktop DOM keydown actions. Remote workspaces use the same renderer -fields; file-operation routing and mixed-version wire contracts are unchanged. diff --git a/docs/reference/linux-glibc-compatibility.md b/docs/reference/linux-glibc-compatibility.md deleted file mode 100644 index f1dcdda47109..000000000000 --- a/docs/reference/linux-glibc-compatibility.md +++ /dev/null @@ -1,182 +0,0 @@ -# Linux glibc Compatibility - -Orca's Linux builds target **stock Ubuntu 20.04 and newer** — glibc 2.31 and -libstdc++ `GLIBCXX_3.4.28` (also Debian 11, RHEL 9), on both x64 and arm64. -Packaging enforces this floor automatically; keep it in mind when adding or -upgrading native dependencies. (The optional speech feature is the one -exception — see below.) - -## Local package build prerequisites - -`pnpm run build:linux` produces AppImage, deb, and RPM artifacts. The RPM target -requires `rpmbuild` on `PATH`; install `rpm` on Ubuntu/Debian, `rpm-build` on -Fedora/RHEL, or `rpm` through Homebrew on macOS, then verify it with -`rpmbuild --version` before packaging. Cross-host builds have the same -requirement. - -## Why this needs attention - -A native module (`.node`) links against the glibc of the machine that compiled -it. Our release CI compiles node-pty from source on GitHub's `ubuntu-latest` -runner, whose glibc rises over time as the image is bumped. A binary compiled on -a newer glibc can reference symbol versions that do not exist on an older target, -and the dynamic loader then refuses to load it: - -``` -/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by .../pty.node) -``` - -Because the Orca main process loads node-pty at startup, that failure crashes the -whole app before a window appears — this is exactly what shipped in v1.4.150 and -broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/9902)). - -The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved -several long-stable functions into libc under brand-new symbol versions: - -| Symbol | New version | node-pty use | -| ----------------- | ------------ | ----------------------- | -| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask | -| `openpty` | `GLIBC_2.34` | allocate the pty | -| `forkpty` | `GLIBC_2.34` | fork the shell | - -Electron itself (glibc 2.25) and the other bundled native modules -(`sherpa-onnx`, `@parcel/watcher`, both prebuilt on old glibc) stay well under -the floor, so node-pty was the sole blocker. - -## How we keep the floor - -**1. Pin the relocated symbols (the fix).** -[`config/patches/node-pty@1.1.0.patch`](../../config/patches/node-pty@1.1.0.patch) -adds a `.symver` shim in `src/unix/pty.cc` that binds `openpty`, `forkpty`, and -`pthread_sigmask` to their pre-merge version node — `GLIBC_2.2.5` on x64, -`GLIBC_2.17` on arm64 (each architecture's baseline glibc). glibc still ships -those as compatibility aliases, so the reference resolves on both new build hosts -and old targets. - -The catch: gcc defaults to `--as-needed` and, since the pinned symbols now -resolve from libc's compat aliases at build time, it drops `libutil`/`libpthread` -from `DT_NEEDED`. On the target those libraries are where the symbols actually -live, so the patch's `binding.gyp` `ldflags` force -`-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0` back into `DT_NEEDED`. -The shim is guarded by `#if defined(__linux__)`; macOS and Windows are untouched. - -**2. Gate packaging (the regression guard).** -[`config/scripts/verify-linux-glibc-floor.cjs`](../../config/scripts/verify-linux-glibc-floor.cjs) -runs in the electron-builder `afterPack` hook for Linux. It reads every bundled -native binary's version needs (`objdump -p` "Version References" — the -authoritative load-time list, which also captures symbol-less markers like -`GLIBC_ABI_DT_RELR`) and fails the build if any strong `GLIBC_`/`GLIBCXX_`/ -`CXXABI_` node is newer than stock Ubuntu 20.04 provides, naming the file and the -offending node. Weak needs are ignored (the loader tolerates them). It also -asserts the flip side of the `.symver` fix: any binary that imports -`openpty`/`forkpty` must keep `libutil.so.1` in `DT_NEEDED` — otherwise the -pinned `openpty@GLIBC_2.2.5` resolves from libc's compat alias at build time (so -the version check passes) yet fails to load on 20.04, where those functions live -only in libutil. A future runner bump, a new native dependency, or a dropped -ldflag therefore fails the release build instead of shipping a Linux app that -crashes on launch. - -> The gate is a static invariant, not an integration test. The load path was -> verified by hand for this fix (real Ubuntu 20.04, x64 + arm64: `require` -> node-pty and spawn a shell). A CI smoke test that loads the packaged -> `pty.node` in a glibc-2.31 container and spawns a shell is the recommended -> follow-up — it would make the load path self-verifying and stay valid even if -> the build ever moves to an old-glibc sysroot. - -The one carve-out is the `sherpa-onnx` speech prebuilt, which already requires -`GLIBCXX_3.4.29` (GCC 11). It loads lazily in the speech worker -(`src/main/speech/stt-worker.ts`), never at app launch, so it is exempt from the -libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore -needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the -app itself still launches on stock 20.04. - -**3. Check before loading, on hosts that ship without a compiler (`orcad`).** -The two gates above protect the packaged desktop app, where the binary is built and -verified by the same pipeline. `orcad` is deployed to hosts Orca never built on, so it -adds a runtime precondition -([`src/main/orcad/node-pty-precondition.ts`](../../src/main/orcad/node-pty-precondition.ts)), -run from `main.ts` before anything requires `node-pty`. It loads the addon in a **child -process**, so a binary the loader refuses — or one that aborts outright — is data rather -than this process's death, and the operator gets a sentence naming the host's libc, its -Node ABI, its prebuild slot and the command to run. A proven-unloadable binary exits 78 -(`EX_CONFIG`) instead of reaching the `require`; a probe that never answered is reported -as unverifiable and boots anyway, because a silent probe is not evidence. Whatever it -finds is published in `status.get`'s `degradations[]` under `terminal_unavailable`. - -**4. Ship the binary, built from patched sources.** -[`config/scripts/build-orcad-prebuilds.mjs`](../../config/scripts/build-orcad-prebuilds.mjs) -(`pnpm run build:orcad-prebuilds`, before `build:orcad`, which copies its target's slot into -the package's `node_modules/node-pty/build/Release`) compiles node-pty for the current -host against the pinned Node's hash-verified headers at N-API 8, and files it under -`out/orcad-prebuilds//`, where a slot is `linux-{x64,arm64}-{glibc,musl}`, -`darwin-{x64,arm64}` or `win32-{x64,arm64}`. Its `manifest.json` records each file's -sha256, the N-API level and, for glibc slots, the highest `GLIBC_` version the binary -needs; the loader checks N-API, libc, arch and that glibc version before it installs a -slot. glibc slots are built in a `manylinux_2_28` (AlmaLinux 8) container and pass the -same gate at a **glibc 2.28 / `GLIBCXX_3.4.25`** floor instead of the desktop's 2.31, because -the pinned Node they ship beside already runs on 2.28 and a 2.31 slot would leave 2.28–2.30 -hosts with a runtime but no terminal (design D6). The container's gcc-toolset supplies C++20 -and links newer libstdc++ symbols statically, so the slot needs only RHEL 8's system -libstdc++. musl slots skip the gate, since they never meet glibc's libraries. libc is part -of the slot name because node-pty's own loader falls back to `prebuilds/-` -and cannot tell glibc from musl — a glibc binary parked there is loaded on Alpine and dies at `dlopen`. -The script refuses to compile a tree where `config/patches/node-pty@1.1.0.patch` is not -applied: without the patch the prebuilt is a #9902 crash shipped as an artifact rather -than a first-connect error. CI runs it once per slot inside the matching container -(`--slot=` forces the label), merges the trees, and `--require-slots` fails a release with -a hole in the matrix; `--require-slots ` checks one slot's files against their -hashes and `--smoke` loads it under the pinned Node and spawns a PTY -(`.github/workflows/node-server-tests.yml` runs both on every slot's runner). - -The opt-in `linux-x64-glibc217` compat slot (rung B, not part of the default matrix) is -built in `manylinux2014_x86_64` (glibc 2.17, devtoolset C++20) with `-static-libstdc++`, -gated at a glibc 2.17 floor, refused if `libstdc++.so`/`libgcc_s.so` remains in -`DT_NEEDED`, and smoked under the unofficial glibc-217 Node pinned in -`NODE_RUNTIME_COMPAT_ASSETS`. Nothing installs it yet: the loader and the SSH deploy -still choose only default slots. - -## Adding or upgrading a native dependency - -- Prefer packages that ship prebuilt binaries compiled against an old toolchain - (manylinux / `glibc 2.17`-class), like `@parcel/watcher`. -- For a module we compile from source, if the gate flags it, either pin the - offending symbols the way node-pty does, or build it in an old-glibc container. -- To check locally on a Linux host, list what a binary requires (skipping the - weak `0x02`-flagged needs the loader tolerates): - - ```bash - objdump -p path/to/module.node | sed -n '/Version References/,/^$/p' - ``` - - No strong `GLIBC_` node may exceed `2.31`, and no `GLIBCXX_`/`CXXABI_` node may - exceed `3.4.28`/`1.3.12` — what stock Ubuntu 20.04 ships. - -## Runtime floor: the `environ` race below glibc 2.41 (Electron ≥ 43.7.0) - -Separate from the build floor above, one glibc runtime bug constrains which -Electron we may ship. Before glibc 2.41, `setenv`/`unsetenv` reallocate the -`environ` array and **free** the old one, so a concurrent `getenv()` on another -thread reads freed memory. Ubuntu 20.04–24.04 (2.31–2.39) are all below that -line, so every Linux target we support is exposed. - -Electron 43.5.0 made that latent race reachable on every launch: it started -setting `GDK_GL=disable` around `gtk_init()` and unsetting it right after, while -in the same change moving FontConfig warm-up onto a thread-pool thread that runs -concurrently and calls `getenv()` constantly -([electron#53070](https://github.com/electron/electron/pull/53070)). The result -is a browser-process use-after-free about a second into startup — no window, no -GPU child involved, and the corruption surfaces wherever the next allocation -lands, which is why reports name unrelated frames (`gtk_widget_realize`, -libxcb-dri3, FontConfig/expat). Orca 1.4.199/1.4.200 shipped that runtime and -died on launch on Ubuntu + NVIDIA/X11 -([#20081](https://github.com/stablyai/orca/issues/20081)). - -Electron 43.7.0 fixes it by overriding `setenv`/`unsetenv`/`putenv`/`clearenv` -so a published `environ` is never freed, deferring to glibc on 2.41+ -([electron#53491](https://github.com/electron/electron/pull/53491), backported -to 42/43/44/45). **Do not downgrade Electron below 43.7.0, or move to another -line, without confirming that backport is in the target release** — -`config/scripts/electron-runtime-floor.test.ts` fails the suite if the pin drops -below the floor. Orca itself writes `process.env` during early startup -(`patchPackagedProcessPath`, `configureOrcaUserDataPathEnv`, -`hydrate-shell-path`), so it is a first-class trigger, not just a bystander. diff --git a/docs/reference/macos-press-and-hold.md b/docs/reference/macos-press-and-hold.md deleted file mode 100644 index 946a1ff71250..000000000000 --- a/docs/reference/macos-press-and-hold.md +++ /dev/null @@ -1,58 +0,0 @@ -# macOS press-and-hold and key repeat - -macOS opens the accent picker when a key is held unless an application opts out in its preferences -domain. That prevents held keys from repeating in terminal applications such as vim. On the first -eligible launch, Orca writes: - -```sh -defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool false -``` - -The write is scoped to Orca's packaged bundle domain. Bare Electron development bundles and -non-macOS platforms are left untouched. A fresh write is conservatively treated as taking effect -on the next launch. - -## Precedence and decision record - -Orca checks for an explicit domain value before writing. Either `true` or `false` is treated as a -user choice and preserved. Only an unset key receives the `false` default. - -The decision is stored once in -`/macos-press-and-hold-default.json`. An `applied` or -`kept-user-preference` decision prevents future launches from touching the domain again. -Probe and write failures remain retryable so a transient failure does not permanently disable the -fix. - -`defaults read ` is used instead of -`systemPreferences.getUserDefault`: the Electron API cannot distinguish an unset key from an -explicit `false`. Only the missing-key exit status is interpreted as unset; spawn failures, -timeouts, and other exit statuses leave the preference alone. - -## Restoring the accent picker - -Set the preference explicitly, then restart Orca: - -```sh -defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true -``` - -After Orca has recorded its one-time decision, deleting the key also restores the macOS default -without Orca recreating it: - -```sh -defaults delete com.stablyai.orca ApplePressAndHoldEnabled -``` - -Development and prerelease channels may use a channel-suffixed Orca bundle identifier; use that -domain instead when applicable. - -## Reverting - -Deleting the startup code is not enough. AppKit reads the persisted preference, so a code revert -must also arrange to delete the key for users who ran an affected build. - -## Test coverage - -Unit tests cover platform guards, explicit-value preservation, retry behavior, domain ownership, -record persistence, and subprocess exit interpretation on CI. A macOS-only test additionally pins -the real `defaults(1)` behavior, but current PR CI does not execute tests on macOS. diff --git a/docs/reference/malformed-worktree-registration-removal.md b/docs/reference/malformed-worktree-registration-removal.md deleted file mode 100644 index 34a32e58caf7..000000000000 --- a/docs/reference/malformed-worktree-registration-removal.md +++ /dev/null @@ -1,43 +0,0 @@ -# Malformed worktree registration removal - -Git can report a linked worktree at `/.git` when its administrative -`gitdir` backlink incorrectly ends in `.git/.git`. That reproduces #17316's -validation error. The reproduction establishes the malformed registration, not -which program created it; current OMP uses ordinary `git worktree add`. - -Orca's desktop and runtime removal entry points use registration-only recovery -when Git positively marks the row prunable, the row has a named local branch and -HEAD, it is neither main nor locked, and the execution filesystem confirms the -selected `.git` path is a regular file. Missing or unknown evidence does not -permit this recovery. A symlink or directory is not a regular-file proof. - -Recovery reuses `git worktree prune` followed by a strict worktree listing that -must confirm the selected registration is gone. It does not delete the selected -file, infer a parent path for deletion, or delete the branch. Archive hooks and -checkout teardown are skipped because the selected row is not a checkout. - -Two consequences are intentional: - -- Git's prune also clears other stale, unlocked registrations in the repository; - it is not a path-scoped command. Live and locked registrations remain Git's - responsibility, and Orca verifies that the requested registration disappeared. -- The surviving checkout's `.git` file points at removed administrative metadata. - Files and its named branch are preserved; recovery removes the broken navigation - entry and does not repair or claim to restore that checkout. - -Native and WSL checks use the existing execution-filesystem accessor. WSL prune -and verification use the same selected distro. Paired runtimes run the recovery -on their owning host. Direct SSH does not enter this local recovery: its current -provider has no registration-only removal operation, and a failed remote removal -never authorizes a local fallback. - -The Git commands already exist in the 2.25-compatible cleanup path. On an older -Git that cannot positively attest this file-shaped registration as prunable, Orca -refuses this recovery. Deferred deletion independently rejects non-directory and -symlink targets, so force cannot move a `.git` file into deletion trash. - -Regression coverage is in `worktree-prunable-git-file.test.ts`, -`worktrees-removal-recovery.test.ts`, and -`worktree-deferred-removal-real-git.test.ts`. The latter reproduces the exact -malformation against the installed Git binary in a disposable repository and -checks surviving file contents, branch HEAD, and removed registration. diff --git a/docs/reference/managed-data-accounts.md b/docs/reference/managed-data-accounts.md deleted file mode 100644 index c0a78d001d51..000000000000 --- a/docs/reference/managed-data-accounts.md +++ /dev/null @@ -1,23 +0,0 @@ -# Managed OpenCode and Devin accounts - -Run enrollment in a terminal on the machine running Orca: - -```sh -orca account add --agent opencode --label Work -orca account add --agent opencode --integration opencode-go --label Work -orca account add --agent devin --label Work -orca account list --agent opencode --json -orca account select --agent opencode --account -orca account select --agent opencode --account system -orca account rm --agent opencode --account -``` - -OpenCode enrollment requires OpenCode 2 and runs its official `auth login --standalone` command. Devin runs `auth login --force-manual-token-flow`; obtain the enrollment token through Devin's supported login flow. These commands neither reuse a guessed token nor sign out the system account. Settings → AI Provider Accounts provides the enrollment command, refresh, selection, and removal for the selected Orca host. - -Each profile belongs to the execution host. OpenCode's SQLite credentials and Devin's credential TOML stay in private Orca user-data directories. Enrollment isolates XDG data/config/cache/state, copies only authenticated credentials, and then deletes the temporary directory. OpenCode capture rejects databases containing conversations and includes SQLite WAL contents. RPC summaries contain labels, IDs, and integration names, never tokens or credential paths. Only the authenticated local runtime socket can import a credential directory; paired clients cannot ask the host to read arbitrary paths. - -Selection affects newly launched explicit OpenCode/Devin commands and agent launches. It redirects XDG data and state; OpenCode inline-auth/database overrides cannot bypass the profile. Shell wrappers restore this selection after user startup files. Existing provider configuration and environment-based integrations remain available. Running terminals retain their current profile. Stop agents before removing a profile: removal also deletes conversations created in that private profile, without changing the system login. - -For SSH, enroll by running the command on a headless Orca runtime on the remote machine. The remote runtime owns its profiles and selection; a desktop client's credential paths never cross SSH. Direct SSH relay launches and Windows-hosted WSL panes do not consume the desktop host's profiles. Run a headless runtime inside that execution environment instead. Folder workspaces use the same host account store as git worktrees. Older Orca hosts reject new operations before login through capability negotiation. - -Validation covers OpenCode 2.0.16 on macOS and Linux arm64, including isolated official enrollment, selected and System background-terminal credential checks, reselection, and profile deletion. Linux checks used the Node headless runtime in an Ubuntu 24.04 container. Devin 3000.10.31 saved-login recognition was checked on macOS. Fresh Devin manual-token enrollment, a physical SSH host, Linux desktop UI, Windows, and Windows-hosted WSL still require verification. diff --git a/docs/reference/monaco-language-associations.md b/docs/reference/monaco-language-associations.md deleted file mode 100644 index ee985d859597..000000000000 --- a/docs/reference/monaco-language-associations.md +++ /dev/null @@ -1,34 +0,0 @@ -# Monaco filename associations - -Orca imports Monaco's full `editor.main.js` entry point, which registers the built-in -languages and loads their grammars on demand. Filename detection must not load the -editor itself: it also runs during session restoration and before the editor mounts. - -`monaco-language-associations.json` contains the registration metadata from the -installed package's entry point, plus curated Ruby associations in the generator. -Those add `.rake`, `.ru`, `.jbuilder`, `.thor`, `Guardfile`, `Capfile`, `Podfile`, -`Brewfile` and `Vagrantfile` to the existing Ruby grammar. Change these in -`config/scripts/generate-monaco-associations.mjs`, not the generated JSON. -Regenerate after changing the curated associations or upgrading Monaco: - -```sh -node config/scripts/generate-monaco-associations.mjs -pnpm exec oxfmt --write src/renderer/src/lib/monaco-language-associations.json -``` - -The generator reads syntax trees without executing contributions or grammar loaders. -Its test compares the checked-in metadata to the installed package and curated associations. The original -list was verified against a clone of `microsoft/monaco-editor`, tag `v0.55.1`, commit -`516f350bdaf7a82f6731bd128a9ec86a6e5fa47d` (`src/basic-languages` and `src/language`). - -Existing Orca filename and extension choices take precedence. This preserves custom -Vue, Svelte, Astro, Nim, Typst, JSONL, notebook and preview handling, as well as the -Markdown mapping for MDX. The fallback matches exact filenames before the longest -extension, case-insensitively, and resolves duplicate associations in upstream -registration order (the last registration wins, so `.pp` selects Ruby over Pascal). - -Monaco 0.55.1 has 90 registrations, 81 with filenames or extensions. Registrations -without either remain available in Monaco but cannot be inferred from a path. This -does not add VS Code extensions or language servers, guess from file contents, or -change mobile's separate lowlight grammar set. The renderer uses only the basename, -so local, Windows, SSH and folder-workspace paths share the same detection. diff --git a/docs/reference/omp-fresh-launch.md b/docs/reference/omp-fresh-launch.md deleted file mode 100644 index ea6679381725..000000000000 --- a/docs/reference/omp-fresh-launch.md +++ /dev/null @@ -1,51 +0,0 @@ -# Fresh OMP launches - -Orca's new-session and draft launch plans apply a one-time `--config` overlay -containing `autoResume: false`. OMP's configured session directory, settings, -authentication and extensions remain in their usual locations. Saved launch -configuration omits the overlay so explicit resume keeps its normal semantics. -Custom commands with session selectors, unknown flags, positional arguments or -shell compounds are left unchanged. - -The execution host creates the overlay. Local and WSL terminals use Orca userData -(with WSLENV path translation); SSH relays use their own managed directory. Config -creation is independent of status-hook preferences and does not require plugin -source installation. An unavailable file produces a terminal diagnostic and skips -OMP. Filesystem failures do not prevent unrelated agents or bare shells starting. - -The guard invokes OMP in the current shell, preserving functions, aliases and the -managed status wrapper. A nonzero agent exit never triggers a second launch. -POSIX commands also support fish; environment presence is checked before expansion -so an old host under `set -u` reports the same missing-settings diagnostic. - -## Mixed versions - -The existing command and environment transport carries the launch unchanged; no -new RPC or stream opcode is introduced. A new host exports the path before shell -startup, including bare shells that receive an OMP command later. An old client -continues its existing launch behavior on a new host. A new fresh-launch command -on an old host without the managed environment fails visibly and requests a host -update and terminal restart. It must not silently fall back to OMP auto-resume. - -## Verification - -`src/shared/omp-fresh-launch-shell.test.ts` runs actual available bash, zsh and fish -shells, checking exact argv, a single invocation, nonzero exit, deleted settings -and an absent environment variable. `src/relay/omp-fresh-launch-environment.test.ts` -checks the guarded command through relay environment assembly and the actual OMP -shell wrapper, retaining both extension and config arguments and prefill. -Local host assembly tests recognize guarded POSIX, cmd and PowerShell commands. - -Run the actual OMP storage smoke against a read-only OMP checkout: - -```sh -ORCA_BACKGROUND_LAUNCH=1 bun tests/tools/omp-fresh-session-runtime-smoke.mjs /path/to/oh-my-pi -``` - -For Windows, bundle `tests/tools/omp-fresh-launch-windows-smoke.ts` with -`bun build --target=node --outfile=/tmp/omp-fresh-windows-smoke.mjs`, transfer the -bundle to the host and run it using Node with `ORCA_BACKGROUND_LAUNCH=1`. -The smoke uses temporary files and process-local environment only. Both cmd and -PowerShell must pass existing/missing/unset/directory settings cases, preserving -exit 17 for the single successful launch and returning exit 1 without launching -when settings are unavailable. This passed on Windows host `awin` on 2026-09-14. diff --git a/docs/reference/omp-history-titles.md b/docs/reference/omp-history-titles.md deleted file mode 100644 index f238574ef2a0..000000000000 --- a/docs/reference/omp-history-titles.md +++ /dev/null @@ -1,34 +0,0 @@ -# OMP history titles - -The message-graph scanner uses persisted OMP names ahead of the first user prompt: -`session.title`, version-1 `title` slots, `title_change.title`, and legacy -`session_info.name`. Empty or unsupported metadata leaves the previous name or -prompt fallback intact. Non-OMP graph parsing keeps its existing title policy. - -Explicit user names outrank automatic names. Within the same source, timestamps -prevent the current first-line title slot from being replaced by older rename -entries later in the file. Newer appended renames still update the row. Legacy -records without timestamps retain file-order handling. - -The graph fold stores title authority alongside the existing accumulator. Clones -retain it without sharing mutable accumulator or preview state, while preserving -the existing identity and message-consumer contracts. Cached append parsing uses -the normal durable offset; no extra scan, process, poll or watcher is introduced. - -The parser is shared by local and remote content readers and uses transcript data -from the execution host. It performs no client-side path lookup and changes no -wire shape. Folder workspaces require no git metadata. - -Run actual persistence and cache validation with a read-only OMP checkout: - -```sh -ORCA_BACKGROUND_LAUNCH=1 bun tests/tools/omp-history-title-smoke.mjs /path/to/oh-my-pi -``` - -The smoke persists a first prompt, performs a real OMP user rename, and verifies -both cold and incrementally cached scans. It checks one full parse, one append -parse and identical-object reuse on an unchanged scan. All home/config/data roots -are disposable; no model requests are made. - -This is the OMP subset of the history-name behavior proposed in PR #15696 by -Brennan Benson. Pi naming and title changes in the terminal are separate concerns. diff --git a/docs/reference/omp-resume-transcript-locator.md b/docs/reference/omp-resume-transcript-locator.md deleted file mode 100644 index 75e38b798cbb..000000000000 --- a/docs/reference/omp-resume-transcript-locator.md +++ /dev/null @@ -1,28 +0,0 @@ -# OMP recorded transcript resume - -A sleeping OMP session can retain its transcript path from a hook without an -explicit `launchConfig.ompResumeFilePath`. Both cold-restore startup and generic -sleeping-session launch already forward the provider metadata to -`getAgentResumeArgv`; that builder must keep the recorded path. - -Resolution order is explicit launch path, recorded transcript path, then UUID. -The existing shell-aware builder quotes the selected argument for the execution -host. An older metadata record without a path retains UUID fallback. OMP provider -claim keys and equality remain UUID-based, so a later hook adding the path does -not create a second automatic-resume identity. - -`tests/tools/omp-resume-transcript-locator-smoke.mjs` creates an actual OMP session -outside its default session store. UUID lookup fails there; the absolute path and -Orca's generated argv resume the original session. Run it with Bun and a read-only -OMP checkout as argv[2], under `ORCA_BACKGROUND_LAUNCH=1`. It uses a disposable home -and makes no model requests. - -This bounded correction follows the resume-locator portion of -[PR #16276](https://github.com/stablyai/orca/pull/16276) by @CodeHourra. It does not -adopt that PR's reattach injection or title changes. The reattach proposal treats -missing snapshot/replay as permission to type a resume command, but -`daemon-pty-spawn-result.ts` explicitly permits `isReattach: true` without a -snapshot. That payload absence is not positive evidence of a newly created shell. -The proposal also adds the path to OMP claim identity, which separates UUID-only -metadata from a later path-enriched record for the same provider session. Those -changes require separate evidence and are outside this patch's review scope. diff --git a/docs/reference/omp-runtime-session-provenance.md b/docs/reference/omp-runtime-session-provenance.md deleted file mode 100644 index 8d6e33577d97..000000000000 --- a/docs/reference/omp-runtime-session-provenance.md +++ /dev/null @@ -1,23 +0,0 @@ -# OMP runtime session provenance - -OMP computes whether a runtime session is a task child, but the released -`ExtensionContext` does not expose that value. The status extension therefore uses -the session manager's parent header and nested task transcript path only when a -root owner is already known. A nested transcript with no known owner remains -eligible because it may have been resumed directly as the pane's main session. - -The remaining child-first case is inherently ambiguous to Orca: task children and -resumed child transcripts have the same public session-manager shape. A complete -child-first fence requires OMP to expose its computed `agentKind` through -`ExtensionRunner.createContext`; until then the conservative fallback avoids -silencing valid resumed sessions. - -Older runtimes retain the manager-identity guard. That guard assumes the main -session reaches Orca's callback before any child. An earlier user extension can -initialize a child during session_start and violate that assumption. Keep the -ownership merge assessment conditional until the runtime API is available and the -combined flow is validated. Neither callback timeouts, UI presence, nor transcript -paths establish runtime ownership. - -The guard remains scoped to one pane and launch token. It does not define how -several independent SDK/ACP roots sharing one process and pane should be attributed. diff --git a/docs/reference/omp-session-roots.md b/docs/reference/omp-session-roots.md deleted file mode 100644 index 481d5f205c21..000000000000 --- a/docs/reference/omp-session-roots.md +++ /dev/null @@ -1,53 +0,0 @@ -# OMP transcript roots - -Native chat lookup, history discovery and the history path allowlist use -`src/main/ai-vault/omp-session-root.ts` on the execution host. The resolver follows -the active runtime environment rather than scanning every profile. - -The behavior matches OMP's `packages/utils/src/dirs.ts`: - -- On Linux and macOS, an existing `$XDG_DATA_HOME/omp` selects that app's `sessions` - directory, even when legacy transcripts coexist. The sessions directory itself - need not exist yet. There is no implicit `~/.local/share` fallback. -- A named profile uses XDG only when its own `omp/profiles/` path exists; - otherwise it uses the profile's config-root `agent/sessions` directory. -- `OMP_PROFILE` takes precedence over `PI_PROFILE`, including an explicitly empty - canonical value. Named profiles ignore custom `PI_CODING_AGENT_DIR` values. - Default mode respects custom agent directories, except an inherited agent path - derived from the lower-priority profile. `PI_CONFIG_DIR` selects the config root - relative to the owning host's home, as upstream specifies. -- Orca retains its legacy `OMP_CODING_AGENT_DIR` sessions-root override and prefix - normalization. Explicit scan roots override environment discovery. Empty or - filesystem-root scan overrides and invalid profile names refuse discovery; - they never fall back to a different profile or the process working directory. - -The desktop scanner child allowlist forwards only the required directory/profile -variables. SSH relay discovery still builds legacy roots from its host-owned home -and does not gain XDG/profile discovery here. Client XDG/profile values are not -applied to WSL home roots. Exact hook -paths and existing WSL attestation/refusal remain authoritative. No wire fields -or opcodes change; older clients receive the existing session record shape. - -This resolves environment-visible configuration. Per-command `--profile` choices -or directory values loaded only inside the agent are not inferred by a runtime -that never received them; hook-reported transcript paths remain the exact route. - -Run the read-only upstream parity smoke with: - -```sh -ORCA_BACKGROUND_LAUNCH=1 bun tests/tools/omp-session-root-upstream-smoke.mjs /path/to/oh-my-pi -``` - -The smoke uses disposable home/data roots and compares Orca's result with OMP's -actual directory resolver. It makes no model requests. Unit tests also cover -Windows XDG exclusion, legacy override normalization and refusal paths. - -For actual persistence-to-reader validation, run: - -```sh -ORCA_BACKGROUND_LAUNCH=1 bun tests/tools/omp-transcript-root-reader-smoke.mjs /path/to/oh-my-pi -``` - -This creates default and named-profile transcripts through OMP's SessionManager, -with legacy directories still present, then resolves and decodes each by session -ID through Orca's native reader. All files use disposable roots; no model runs. diff --git a/docs/reference/omp-startup-keyboard-query.md b/docs/reference/omp-startup-keyboard-query.md deleted file mode 100644 index 7a675e4e0b26..000000000000 --- a/docs/reference/omp-startup-keyboard-query.md +++ /dev/null @@ -1,15 +0,0 @@ -# OMP startup keyboard capability query - -OMP's ProcessTerminal sends `CSI ? u` and then a DA1 sentinel before selecting its keyboard encoding. A fresh desktop terminal already advertises Kitty support, but a direct New Tab launch can query before that renderer owns replies. Previously startup ingress answered only OSC color queries. - -The renderer now supplies optional `terminalKittyKeyboardProtocol: true` from its actual xterm `vtExtensions.kittyKeyboard` setting. The existing local/SSH/paired spawn route places it in startup ingress as optional `kittyKeyboardProtocol`. Missing or false capability leaves behavior unchanged, including panes that deliberately withhold Kitty on native Windows. The ingress version and stream opcodes do not change. Terminal creation accepts additive fields, but host-authoritative `terminal.createAgentSession` and `terminal.ensureAgentSession` use strict schemas. Clients send the keyboard flag on those methods only after the host advertises `agent-session.keyboard.v1`. The negotiated payload stays fixed across launch retries; old hosts receive the original payload and retain the renderer fallback. New hosts accept older clients that omit the flag. Paired background launches use the same negotiated support and default paired-terminal advertisement; legacy terminal creation receives the additive flag. - -Source ingress answers only the exact first `CSI ? u` before its deadline/renderer handoff. It uses the existing mode tracker for preceding flag pushes and the existing reply-delivery echo guard. Its transformed source span consumes the query once, while the following DA1 and Kitty mode-setting bytes retain their sequence ranges and reach the renderer. Keyboard intent does not require theme colors. Color and Kitty authority end independently: answering both colors does not end Kitty handling, and ConPTY's persistent color ownership does not retain Kitty ownership after handoff. - -Run the actual OMP protocol smoke with a read-only reference checkout: - -```sh -ORCA_BACKGROUND_LAUNCH=1 bun tests/tools/omp-startup-keyboard-smoke.mjs /path/to/oh-my-pi > /tmp/omp-startup-keyboard.json -``` - -This uses OMP's real ProcessTerminal with intercepted process-local stdin/stdout, a disposable HOME, and no model call. It verifies negotiation before any renderer attaches, a single reply, preserved mode push, and contiguous raw sequence coverage. It does not constitute live Windows/SSH or rendered shortcut proof. diff --git a/docs/reference/omp-status-input-redaction.md b/docs/reference/omp-status-input-redaction.md deleted file mode 100644 index 80df07dbb6c1..000000000000 --- a/docs/reference/omp-status-input-redaction.md +++ /dev/null @@ -1,39 +0,0 @@ -# Pi/OMP status tool-input redaction - -Generated tool_call and tool_execution_start hooks sanitize inputs on the agent's -execution host before passing them to the existing status transport. Inputs that -reference `.ssh`, `.ssh-mcp`, `.mcp-secrets.env`, or -`.omp-backups-archive/omp-bak-keyfile` become `{ redacted: true }`. Matching includes -nested values, property names, Windows separators, case variants and shell token -boundaries. Sibling names such as `.ssh-backup` remain ordinary data. - -The sanitizer copies data descriptors into objects without prototypes. It never -passes the source object's toJSON or getters to the transport. Cycles, accessors, -class instances, symbols, functions and non-JSON primitives redact the whole input. -Repeated ordinary object references are allowed. Depth, visited values, reserved -array slots (including holes) and inspected text have conservative bounds to avoid -moving unbounded work into synchronous JSON serialization. - -This policy targets credential-path references in status tool inputs. It does not -scan transcript files, tool outputs, prompts or arbitrary secret values, and does -not erase previously persisted status. Proxy reflection traps can still run when -JavaScript inspects a proxy; the sanitizer is not an isolation boundary against a -malicious extension in the same process. - -Ordinary question envelopes and preview input shapes remain unchanged. Older -clients already accept object-valued tool_input; no RPC fields or opcodes change. -The same generated code runs on local, WSL and SSH agent hosts; no local filesystem -lookup or substitution is introduced. Folder workspaces require no special path. - -This follows PR #9554's credential-reference policy, with descriptor copying and -bounded serialization correcting its validation-then-original-object approach. - -Run the actual OMP loader/native HTTP smoke with a read-only checkout: - -```sh -ORCA_BACKGROUND_LAUNCH=1 bun tests/tools/omp-status-input-redaction-smoke.mjs /path/to/oh-my-pi -``` - -It loads Orca's generated extension through OMP, invokes synthetic tool events, -and inspects three real loopback HTTP payloads. Home/config/data roots are -disposable; it makes no model requests and does not claim an interactive tool run. diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md deleted file mode 100644 index 961b7fa1e004..000000000000 --- a/docs/reference/orcad-operations.md +++ /dev/null @@ -1,399 +0,0 @@ -# Running orcad - -`orcad` is the Orca runtime served from plain Node. This is the contract between it and -whatever supervises it: what it binds, what it owns on disk, who restarts what, and what its -readiness payload actually proves. - -## Two long-lived processes, not one - -A deployment is **orcad** plus **the terminal daemon**. - -| | orcad | terminal daemon | -| ---------- | -------------------------------- | ------------------------------------- | -| Started by | the supervisor | orcad, detached | -| Owns | RPC, git, worktrees, persistence | every local PTY | -| Lifetime | one supervised run | detached from orcad, not its service | -| Endpoint | `ws://:` | `/daemon/daemon-v.sock` | - -orcad detaches the daemon and calls `disconnectDaemon()`, never `shutdownDaemon()`. The -built-in remote deployment path stops only the recorded orcad PID, so the daemon and its PTYs -survive. The successor adopts the current endpoint and routes supported previous protocol -versions through legacy adapters. This makes a PID-scoped update, rollback or restart -non-destructive to live work. - -Process detachment is not service isolation. A daemon that orcad launches directly, and every -PTY it owns, remain in the same systemd service cgroup. `KillMode=mixed` does **not** preserve -them: it sends the graceful stop signal only to the main process, then sends `SIGKILL` to every -process remaining in the cgroup the moment that main process exits — `TimeoutStopSec` never gets -the chance to apply. `KillMode=control-group` is destructive too. `KillMode=process` leaves -service-owned processes unmanaged and is not a supported preservation mechanism. - -Service-restart survival therefore requires a separately supervised cgroup, and orcad now asks -for one: on Linux it launches the daemon through `systemd-run --user --scope`, which places the -daemon and its PTYs in their own transient `orca-daemon-.scope` unit under the -user slice instead of the caller's service cgroup. A stop or restart of the service unit then -leaves that scope — and the live terminals in it — running, and the successor adopts the -endpoint as it always has. - -A newly launched private daemon scope also follows the daemon's own lifetime. A small -detached shell holds an input pipe from the daemon; after that pipe closes and `/proc` -confirms the daemon PID is gone, it asks the user manager to stop that exact scope. -Systemd sends remaining processes SIGTERM and escalates after five seconds. This includes -children that double-forked or called `setsid` and can no longer be found by parent PID. -Disconnecting or restarting the runtime does not close the pipe: the daemon owns it. - -The cleanup only arms on a fresh scoped launch with a matching launch nonce. Adopted -legacy scopes can contain GUI processes and are never armed retroactively. Unscoped -launches and children deliberately moved into another systemd unit remain outside this -cleanup. `nohup`, `disown`, and `tmux` alone do not move a process out of its cgroup, so -those children now end when their terminal daemon dies. Work intended to outlive that -daemon needs its own service or scope. - -The scope is requested only where it can work. All of these must hold: - -- **Linux with systemd as PID 1** (`/run/systemd/system` exists). -- **A reachable user bus** — a connectable `bus` socket in the per-UID runtime dir - (`/run/user/`, or whatever `XDG_RUNTIME_DIR` points at). For a service account that is - not otherwise logged in, that means `loginctl enable-linger `; a unit whose - `RuntimeDirectory=` hardening moves `XDG_RUNTIME_DIR` off the per-UID path is handled, because - the real per-UID path is probed first. -- **`systemd-run` on `PATH`** and answering `--version`. - -Any of those missing, or a `StartTransientUnit` call that fails anyway, falls back to the -direct launch — and in that unscoped fallback case the paragraph above still describes reality: -the daemon shares the service cgroup and a combined-unit stop ends live terminals. Read the -`cgroupUnit` field in the daemon health payload to tell the two cases apart on a running host; -it is populated from `/proc/self/cgroup`, so it reports the isolation the daemon actually has -rather than what the launcher intended. - -## `orca serve` on this machine - -`orca serve` runs on the local orcad slot by default. The CLI asks the app's -`out/main/orcad/orcad-local-serve-selection-entry.js` (run as plain Node on the app's executable) -which host to use. Any reason orcad cannot serve falls back to Electron serve with one -`[serve] using Electron serve: ` line on stderr. Those reasons are: no slot for this host, -no template in the install, the pinned Node could not be fetched, or a failed native preflight. - -- `ORCA_SERVE_RUNTIME=electron` keeps Electron serve and skips the question. `orcad` (or unset) is - the default, and any other value falls back with a reason. -- Packaged macOS stays on Electron: only Electron serve, supervised by the CLI, can take a remote - app update there, and orcad has no updater. Recipe-JSON serve has no handoff and uses orcad. -- Windows serves on orcad too. Both hosts share `\daemon`, so the daemon pipe name - (hashed from that path) is the same, and the relocated Electron daemon host changes only the - executable, not the pipe. The `orcad-serve-mode-switch-windows` e2e job checks D7 there, in the - daily run and on PRs routed to it; it does not block merges. - -The slot and its pinned Node live under the desktop's `/orcad-artifacts`. - -## Bind policy - -`--bind `, **default `127.0.0.1`**. - -Only literal IPs are accepted; hostnames are refused because DNS would decide which -interface got bound. `localhost` maps to `127.0.0.1`. `0.0.0.0` / `::` are the explicit -opt-ins to network reach, and the startup log says so on every launch. - -The bind is **pinned**, not defaulted. Two things widen the desktop's listener on their own — -`orca serve`'s wide default, and a startup where some device has connected before — and an -unattended host's exposure must be exactly what the operator asked for on every launch. A -mobile pairing offer, which normally rebinds to all interfaces, is refused while the bind is -pinned to loopback and reports `network_exposure_failed` rather than advertising an endpoint -nothing can reach. - -Under the shipping design a client reaches a remote orcad over an SSH local port-forward, so -loopback is the correct default and the pairing credential travels over SSH. - -A host whose sshd refuses forwarding (`AllowTcpForwarding no`) is reached through the stdio -bridge instead: the client keeps the same local port, and each connection to it opens one SSH -exec channel running a small script on the host's pinned Node that dials orcad's loopback port. -Windows hosts run it as the host script's `stdio-bridge` op and frame bytes as base64 lines, -because a PowerShell DefaultShell re-decodes native output. Bridges are capped below OpenSSH's -default `MaxSessions` of 10 per connection; further connections wait for a free one. The choice -is made each time the tunnel starts (`orcad-managed-tunnel-transport.ts`), so nothing is -recorded per host, and only a host where even the bridge cannot run keeps the relay, recorded as -`ssh_tunnel_unavailable`. - -## Data root and the instance lock - -The data root is `$ORCA_USER_DATA`, else `$XDG_DATA_HOME/Orca`, else `~/.orca`. - -Before the profile index or the store is touched, orcad takes `/orcad.lock`. -It refuses to start when: - -| Code | Meaning | -| -------------------------------------- | ------------------------------------------------------------- | -| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) | -| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened | -| `orcad_instance_lock_held` | another live orcad owns this root | -| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity | -| `orcad_data_root_unusable` | the root cannot be created, stat'd or written | - -A root that is merely too permissive and that we own is tightened to `0700` rather than -refused — orcad stores credentials there unsealed (no OS keyring on this host), so the goal -is a private root, and refusing when we could just fix it helps nobody. We refuse when the -permissions are not ours to fix. Windows has no owner or mode check, because ACLs are not -expressible as a POSIX mode and `statSync().mode` there reports a synthesized one. Instead -orcad restricts the root's ACL to its own user with `icacls` (the same verified restriction -`secure-file.ts` applies to credential files) and refuses with `orcad_data_root_shared` when -that cannot be applied. - -A dead holder's record is reclaimed (PID plus process start time, so a recycled PID does not -read as alive). On Windows the start time is the kernel creation time read through the -process-tree addon the slot stages; without the addon it is null and the PID alone fences, -which errs toward "held". A record belonging to a different identity is never reclaimed. - -**The lock scopes one role — who is the runtime.** It deliberately says nothing about the -daemon, which lives under `/daemon` and fences its own endpoint with its own PID -record. A lock that asked "is any process using this root" would refuse exactly the restarts -a live daemon makes worthwhile. - -## Supervision - -### Process-scoped and cgroup-wide stops - -The built-in remote updater performs a PID-scoped stop and keeps the daemon's install version -pinned while it owns sessions. A combined-unit systemd stop or restart is different: unless the -daemon holds a durable cgroup scope of its own (see -[Two long-lived processes, not one](#two-long-lived-processes-not-one)), it reaps the daemon and -every live terminal after the graceful window. Treat a stop as destructive unless -`health.terminalDaemon.cgroupUnit` names an `orca-daemon-*.scope` on that host. - -Before a cgroup-wide stop, obtain a fresh `orca-ide terminal list --json` result using the same OS -account and home as the daemon. Invoke the installer's absolute launcher path so `sudo`'s -`secure_path` cannot hide a per-user registration (for example, -`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`). Replace both `orca` and -`/home/orca` with the service account and home used by the unit; an extracted deployment may use -its absolute `resources/bin/orca-ide` launcher instead. A safe empty census is untruncated, has an explicit `hostScope`, covers every -execution host affected by the stop, and lists no terminals on those hosts. Every -`omittedHostIds` entry must be explicitly accounted for outside the target service's execution -boundary. A separately paired runtime is outside that boundary; local execution and SSH hosts -reached through this runtime are not. An affected or unknown omission, missing scope, -truncation, a failed request or lost contact makes the result `unverifiable`: defer the stop. Do -not admit new work after the census. Orca does not yet provide an atomic census-and-stop fence. - -### Who supervises orcad - -An external supervisor (systemd, launchd, a process manager). orcad conforms to it: - -- **Readiness.** One JSON line on stdout (`--json`), `type: "orca_server_ready"`, published - after the listener is bound and the daemon verdict is in. There is no separate readiness - socket; the line is the signal. Set the supervisor's start timeout generously — the daemon - launch has its own retries and can take tens of seconds on a cold host. -- **Shutdown.** `SIGTERM` or `SIGINT` starts one graceful stop. Repeated signals share - that stop because a supervisor may signal both the launcher and its child. A 15s deadline - exits with code 1 if teardown stalls. The bundled runtime also stops gracefully if its - launcher's IPC channel closes. On POSIX, both the launcher and runtime ignore `SIGHUP`, - so terminal hangups do not stop a headless host. Use `SIGTERM` or `SIGINT` to stop it. -- **Stop requests.** A file stops orcad the same way `SIGTERM` does, without a PID that may - since have been reused by another process: - - `.orcad-stop-request` beside `orcad.js` in the running slot. orcad deletes it and stops. - - An instance-bound request in the data root, named - `.orcad-managed-stop-request.`. orcad stops only when it - names this orcad's version, runtime ID, PID, start time and lock nonce, and while the - instance lock still holds that record. The file is kept as evidence. - - `orcad --complete-managed-stop ''` writes that request, waits for the - instance to exit, and prints one JSON line whose `verdict` is `live`, `unverifiable` or - `exited`. `exited` needs proof: no process with that PID, or a PID whose start time shows - it now belongs to another process. On `exited` it writes - `/orcad-stop-receipts/.json`. It exits 0 whenever it printed a - verdict, 64 for a malformed invocation, and 1 for a failure before any verdict, which is - never evidence of exit. - - A request with `retireIdleDaemon: true` asks orcad to retire the terminal daemon too. This - is best effort and never blocks or fails the stop: - - The daemon is retired only when it proves it owns no live session across every - generation. - - A busy daemon (`live`) or one whose state cannot be proven (`unverifiable`) stays up with - its terminals, and orcad reopens new-terminal admission before exiting. - - The completed-stop receipt records `retirement` as `retired`, `live` or `unverifiable`. - If orcad exits without recording an outcome, the receipt says `unverifiable`. - - `orcad --cancel-managed-stop ''` withdraws a request orcad has not acted on. - orcad and the canceller each try to create `.decision.json` exclusively, - so exactly one wins. `canceled` means orcad keeps running and the request file is removed; - `dispatched` means orcad already began stopping, and only the completion can say how it - ended. - - A build advertises all of the above with `health.stopRequests: 1` in its readiness line. - Clients stop such a build through the slot request file and older builds with `SIGTERM`, - after corroborating the PID with readiness either way. A launch clears a slot request - that the previous process never consumed. -- **Decommissioning a managed slot.** An Orca client decommissions through the same activation - journal and fence as deploy and rollback. It refuses while the terminal census reports live - or uncounted terminals, stops the instance with a managed request that also asks to retire - the daemon, and records that no version is active only after `exited` is proven. A stop - that did not finish is cancelled; if orcad already acted on it, or the host cannot answer, - the fence stays for recovery. -- **Instance lock.** `/orcad.lock` names the running orcad. A record that is - unreadable, malformed or over 64 KiB is never reclaimed: orcad exits 78 until an operator - removes it. A shutdown whose teardown failed keeps the lock until the process exits, so a - second orcad cannot start beside a writer that may still be running. -- **Exit codes.** - - | Code | Meaning | Supervisor should | - | ---- | ------------------------------------------------------------ | -------------------- | - | 0 | clean shutdown | restart per policy | - | 1 | startup or shutdown failure | restart with backoff | - | 78 | configuration fault (bind address, data root, instance lock) | **not** restart | - - 78 is `EX_CONFIG`. Put it in systemd's `RestartPreventExitStatus`: restarting on a data - root owned by someone else is a restart-spin, not a recovery. - -- **Logs.** orcad writes human-readable diagnostics to **stderr** and its readiness contract - to **stdout**; the supervisor owns capture and rotation. The daemon, being detached, writes - its own NDJSON lifecycle log to `/logs/daemon.log` (suppressed by - `ORCA_DIAGNOSTICS_DISABLED=1`). Rotation of that file is not implemented — see - [What is not covered](#what-is-not-covered). orcad records every trace span it emits - (git commands, worktree paths, terminal spawns, structured-chat failures and the rest) to - `/logs/orcad.trace.ndjson`, rotated at 10 MB × 10 files, private to its user and - redacted for secret-shaped strings. It stays on the host: a desktop's diagnostics bundle does - not collect it. `ORCA_DIAGNOSTICS_DISABLED=1` turns it off, and a logs folder orcad cannot - open leaves it off with one stderr warning rather than stopping orcad. - -### orcad supervising the daemon - -- **Launch.** On Linux, through `systemd-run --user --scope` so the daemon gets its own - transient cgroup and survives a service-unit restart; everywhere else, and wherever that - scope is unavailable, forked detached. Either way it runs `daemon-entry.js` beside - `orcad.js` with its own PID record, token and socket under `/daemon`. -- **Adoption before spawn.** A daemon already answering the endpoint is adopted, not - replaced, unless it is unhealthy, foreign, or built from a superseded bundle _and_ owns no - live sessions. Replacing a healthy daemon kills its PTYs, so code freshness always defers - to live work. -- **Restart.** The adapter respawns the daemon on death, transparently to callers. -- **Crash-loop containment.** At most **5 launches per 60s rolling window** per orcad run; - past that, launches are refused with `daemon_crash_loop` and terminals fail with that - message instead of the process forking forever. The window slides, so a repaired host - recovers without restarting orcad. An operator-initiated daemon restart clears it — that - is the deliberate "try again". -- **No macOS login-session watch.** That watch retires the daemon when the spawning GUI login - session dies. An orcad daemon must survive its SSH session ending. -- **Shutdown.** orcad never stops the daemon. A daemon that was never adopted retires itself - after its adoption window; an adopted one stays resident (see Decommissioning). - -### Decommissioning - -After a PID-scoped stop, an adopted daemon stays resident so the next orcad can reattach. A -combined-unit systemd stop also leaves a scope-isolated daemon resident, but kills one that -fell back to the service cgroup. To retire a process-scoped deployment, apply the census rule -above, stop orcad, then stop the daemon named by `health.terminalDaemon.pid`. -Only report it `exited` after verification on the execution host; loss of contact is -`unverifiable`. - -### Windows hosts - -What differs on a Windows SSH host, and what deliberately does not: - -- **Stop path.** A signal is TerminateProcess on Windows: no flush, no lock release. The - slot's `.orcad-stop-request` file (and the managed, instance-bound request) is therefore the - only graceful stop. A detached orcad receives no console control events, so the listener - (`fs.watch` plus a one-second poll) is what stops it; the packaged-slot test proves it exits - cleanly within the 15 s shutdown deadline on every server lane, Windows included. -- **Exit proof.** `--complete-managed-stop` proves a reused PID by the addon's creation time. - Without the addon a live PID stays `live` or `unverifiable`, never `exited`. -- **Daemon endpoint.** The terminal daemon listens on a named pipe - (`\\?\pipe\orca-terminal-host-v-`), not a socket under the data root. -- **Leaving sshd's job.** orcad is started outside the SSH session's kill-on-close job, so the - daemon it forks inherits no such job and outlives the connection the same way. -- **Per-PTY jobs.** Each ConPTY child gets its own job (`windows-pty-job.ts`), and Git Bash / - MSYS panes follow [`windows-msys-job-breakaway.md`](./windows-msys-job-breakaway.md) - unchanged. A ConPTY smoke test runs inside a process started exactly that way (breakaway, - no window) on the Windows server lanes. -- **No daemon-host relocation.** The desktop copies its runtime to `%LOCALAPPDATA%` because - the NSIS updater deletes the install directory under a running daemon - ([`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md)). orcad slots are - versioned directories that nothing deletes while a process runs from them: Windows refuses - to delete a running image, and GC treats an in-use slot as live. - -## Idle exit (client-managed orcad only) - -An orcad that a desktop client launched over SSH stops itself, like the relay, once its host has -been unused for 15 minutes. The client's launch sets `ORCA_ORCAD_MANAGED_ACTIVATION_ROOT`; an -orcad started by hand, by a supervisor, or as a paired server never carries it and never idles -out. - -"Unused" means every one of these held on every check for the whole period: - -- no client socket open and no RPC request running; -- no terminal in the PTY provider, and the daemon answered with zero live sessions (a daemon - that does not answer keeps orcad up); -- no agent reporting `working`; -- no staged migration into this server; -- no enabled automation and no automation run still in flight (nothing on the host would start - orcad again for the next scheduled run, so a server with an enabled automation never idles out); -- no activation fence on the host (an update, rollback, decommission or recovery in flight). - -The stop is the ordinary graceful shutdown, which disconnects from the daemon and never shuts it -down, so it cannot kill a terminal. It then asks the daemon to retire only if the daemon itself -proves it holds no session. Before stopping, orcad writes `/orcad-idle-stop.json`; -the next start reports it once as `health.previousIdleStop` and removes it, so a later crash is -never read as an idle stop. A managed start with no record reports `previousIdleStop: null`. - -The client starts a stopped server again, whatever stopped it (an idle stop, a kill, a host -reboot): on every connect, on every fresh tunnel (including after the client wakes from sleep), -and before a call through an environment the client restored at launch. A server that does not -answer is checked on the host; only a proven exit starts the activated slot, under the activation -fence, and the status line shows "Starting managed server…". A daemon that survived is adopted -with its terminals; after a reboot both start fresh. A process that is live or cannot be proven -gone is left alone, and a start that fails keeps the host managed with the reason and orcad.log's -tail, never as a verdict about its terminals. `ORCA_E2E_ORCAD_IDLE_TIMEOUT_MS` shortens the idle -period for tests; the client forwards it to the servers it launches. - -## Health - -The readiness payload carries a `health` object: - -``` -buildHash sha256 (16 hex) of the running orcad bundle — build identity that a version - string cannot give, so a rollback that did not replace the file is visible -buildVersion ORCA_VERSION -nodeVersion / nodeAbi process.versions.node / .modules — the ABI native addons must match -platform / arch / pid -terminalDaemon: - state live | degraded | absent - ownsFreshSessions whether NEW terminals are daemon-owned; this supports PID-scoped - restart recovery, not supervisor or service-cgroup isolation - pid the live daemon's pid, from its own PID record - buildVersion the build the LIVE daemon was forked from (may legitimately predate - this orcad after an update — reporting orcad's version for both would - hide exactly that) - entryPath / protocolVersion - selfTest { ok, coverage, verdict, durationMs } -``` - -### What the self-test proves - -`selfTest` runs `checkDaemonHealth` against the daemon's socket. It is green only when the -daemon **opened its socket, completed the protocol handshake, and ran `ptySpawnHealth` — a -real short-lived PTY spawned inside the daemon's own process**. It therefore spans both -processes: orcad drives it, the daemon performs it, the verdict crosses the socket. - -- `coverage: 'pty-spawn'` — the full round trip above. -- `coverage: 'handshake'` — **win32 only**, where `checkPtySpawnHealth` returns without - spawning anything. A green verdict there covers the handshake and nothing more. It is - reported separately rather than folded into `ok` so nobody reads it as a PTY round trip. - -`state` is `live` only when the self-test passed **and** `ownsFreshSessions` is true. A -daemon that answers but has fallen back to local spawning for new terminals is `degraded`, -because those terminals die with orcad. A daemon that answered and then failed its spawn -probe is also `degraded`, not `absent`: it still holds live sessions, and calling those -exited would be the verdict `ssh-execution-boundary.md` forbids guessing. - -## What is not covered - -Named here so nothing reads as implemented that is not: - -- **A continuous health endpoint.** `health` is published once, in the readiness payload. A - supervisor's periodic liveness/readiness probe needs an HTTP or RPC surface over the same - `collectOrcadHealth()`; that surface does not exist yet. -- **Supervision of an unscoped fallback daemon.** When the durable `systemd-run --user --scope` - launch is unavailable (see [above](#two-long-lived-processes-not-one)) orcad and its daemon - share one service cgroup, and a combined-unit stop cannot preserve live terminals. There is - no mechanism that re-isolates such a daemon after the fact. -- **libc slot.** There is no honest health value to publish until native libc detection owns - it. -- **`degradations[]`.** The readiness contract does not publish this collection yet. -- **Credential administration** (list / revoke / rotate devices, expiring pending offers, - structured security logging). -- **Pinned-port fail-closed.** A pinned `--port` still falls back to an OS-assigned port on - conflict. -- **Reconciling `webClientUrl` with reachability** under the loopback default. -- **State-schema rollback rules.** -- **Daemon log rotation.** `/logs/daemon.log` grows unbounded. diff --git a/docs/reference/orchestration-configured-agent-aliases.md b/docs/reference/orchestration-configured-agent-aliases.md deleted file mode 100644 index 2dc95ccd100f..000000000000 --- a/docs/reference/orchestration-configured-agent-aliases.md +++ /dev/null @@ -1,22 +0,0 @@ -# Configured command aliases for orchestration workers - -A worker can use the name of a direct executable configured in **Settings → Agents**. -Choose the built-in agent whose command-line interface the executable implements, -then set that agent's command override to the executable name or quoted full path. -For example, configure Codex's command as `codex-fugu`, then select it with -`orca orchestration worker-start --agent codex-fugu` and the normal placement options. - -The execution host resolves its own configuration. Launch receipts use the canonical -agent (`codex` in this example), and model/effort handling reuses that agent's existing -launch rules. A receipt records applied launch preferences; it does not prove provider -entitlement, successful generation, or an arbitrary vendor's model selection behavior. - -Aliases require a single executable token. Commands containing interpreter arguments, -environment assignments, or shell wrappers are not aliases. Multiple built-in agents -configured with the same executable name are ambiguous and require the canonical agent -ID. Disabled launchers remain disabled. An unconfigured name is refused even if it is -on PATH; Orca cannot infer a compatible launch interface from a process name. - -The same rule applies to folder workspaces and git worktrees. For a remote worker, -configure the command on its execution host. Older hosts may refuse aliases they do not -support. Configuring a command does not create new status producers or grant permissions. diff --git a/docs/reference/pnpm-install-policy.md b/docs/reference/pnpm-install-policy.md deleted file mode 100644 index 6e53ecac77a9..000000000000 --- a/docs/reference/pnpm-install-policy.md +++ /dev/null @@ -1,92 +0,0 @@ -# Native dependency install policy - -Ordinary `pnpm install` installs optional native dependencies for the current OS -and CPU only. This applies to local development and root-project CI jobs, -including jobs using `.github/actions/install-node-dependencies`. Mobile and -cloud projects with their own workspace configuration are separate. - -The one cross-target build in the repo is macOS: `pnpm build:mac` and the four -macOS packaging workflows produce both x64 and arm64 artifacts from an arm64 -runner. Before packaging for another architecture, widen the CPU set: - -```sh -pnpm install:release -``` - -This runs `pnpm install --frozen-lockfile --cpu=current,x64,arm64`. It never -widens the OS set: every packaging job runs on a runner whose OS matches its -target, so cross-OS installs are never needed. The macOS workflows pass -`--cpu=current,x64,arm64` directly; Windows and Linux packaging jobs use a plain -host-only `pnpm install --frozen-lockfile`. Keeping `current` in the list -preserves the host's build tools alongside the target resources. An install for -another target does not itself cross-compile native addons. - -## Packaging guard - -electron-builder only logs a warning for a missing `extraResources` source and -continues, so without a check a foreign-architecture slice would ship silently -broken. `beforePack` in -[`config/electron-builder.config.cjs`](../../config/electron-builder.config.cjs) -therefore calls `assertPackagedNativeVariantsInstalled` in -[`config/packaged-runtime-node-modules.cjs`](../../config/packaged-runtime-node-modules.cjs), -which fails the build when the target platform/architecture's native variants -are not installed: `sherpa-onnx-*`, `@parcel/watcher-*`, and on Windows the -node-gyp addon `@vscode/windows-process-tree`. The error names every missing -package and gives the remedy that fits: another architecture's variants come -from `pnpm install:release`, the Windows addon does not (see below). - -Windows packaging requires a Windows host. `@vscode/windows-process-tree` is an -`os: win32` npm addon, so it is installed only where that matches; -`@orca/windows-registry` is a workspace package that links on every host, but -its native binary is still compiled only on Windows. Both are compiled only by -the Windows-only rebuild in `config/scripts/rebuild-native-deps.mjs` -(`allowBuilds` in `pnpm-workspace.yaml` keeps pnpm itself from running node-gyp -for them). The guard checks `@vscode/windows-process-tree` alone because the -workspace link is present everywhere and proves nothing. `pnpm install:release` -does not help on macOS or Linux because it does not widen the OS set. - -Tests that inspect installed Windows addons and their packaging closure run on -Windows, where those dependencies are required. The PR Windows lane explicitly -includes them. Loading the packaging config tolerates absent Windows addons; only -`beforePack` rejects Windows packaging until they are installed. Patch-source -assertions, fixture tests, and the isolated real patch-install test continue to -run on other hosts. - -## Existing checkouts - -A narrowing incremental install can leave previously installed variants behind. -Stop development processes using this checkout, remove **this checkout's** -`node_modules`, then run `pnpm install --frozen-lockfile` to obtain a fresh host -install. Do not use `--force`: pnpm 12 documents that it installs optional -packages even when their OS/CPU/libc do not match. The shared pnpm download store -is separate; this change does not clear it. - -## Measurement: macOS arm64, pnpm 12.0.0 - -Measured 2026-09-12 with the same package manifest, lockfile, patch files, and -existing download store in two fresh install directories, both with -`--frozen-lockfile --ignore-scripts --offline`. One used the earlier broad -policy (`--os=current,darwin,linux,win32 --cpu=current,x64,arm64`); the other -used `--os=current --cpu=current`. Numbers are logical file bytes in the virtual -store, **not unique disk usage**: pnpm hardlinks and APFS clones may share -storage. - -| Measure | Broad install | Host install | Reduction | -| ------------------------------------------ | ------------: | ------------: | --------------------: | -| Package directories | 1,296 | 1,206 | 90 | -| Regular files | 53,972 | 52,915 | 1,057 | -| Logical package bytes | 2,485,153,773 | 1,159,396,649 | 1,325,757,124 (53.3%) | -| Logical package GiB | 2.31 | 1.08 | 1.23 | -| Install wall time, single warm-cache trial | 11.98 s | 11.25 s | 0.73 s | - -| Native family | Broad MiB | Host MiB | -| ------------------- | --------: | -------: | -| Canvas | 235.7 | 25.8 | -| Sherpa speech | 235.2 | 71.6 | -| oxlint and tsgolint | 234.0 | 32.7 | -| SWC | 225.8 | 24.4 | -| TypeScript | 158.4 | 26.2 | - -Electron downloads and native rebuild outputs are absent from both trials. The -single warm-cache timing pair does not establish a speedup, and Linux/Windows -results were not measured; do not present the macOS numbers as CI savings. diff --git a/docs/reference/qoder-integration.md b/docs/reference/qoder-integration.md deleted file mode 100644 index 5aa5c55147e3..000000000000 --- a/docs/reference/qoder-integration.md +++ /dev/null @@ -1,90 +0,0 @@ -# Qoder CLI integration - -Orca registers `qodercli` as `qoder`: detection, picker/settings, desktop and mobile -identity, prompt launch, permission flags, managed hooks, workspace trust, and -session resume use the existing TUI-agent and hook-status paths. - -## Verified contract - -Verified on macOS with Qoder CLI 1.1.64, including its versioned executable -`qodercli-1.1.64`: - -- `--prompt-interactive` starts an interactive prompt; `--resume ` resumes a - session; `--dangerously-skip-permissions` is the permission bypass flag. -- Hooks use the Claude-shaped nested configuration in `~/.qoder/settings.json`. - Orca registers its own `/hook/qoder` source and preserves user hooks. -- Real SessionStart, UserPromptSubmit, Notification and SessionEnd events are - captured in `src/shared/__fixtures__/qoder-no-account-hooks.jsonl`, including - `source: resume` with the original session ID. -- Trust is `permissions.trustDirectories` in that settings file, using the - canonical workspace path. A workspace `.trusted` marker did not bypass the - trust dialog in this version. Remote trust uses the execution host filesystem. -- Qoder emits its `◇ … | Ready` title while the trust menu owns input. Readiness - therefore requires the live composer text, not merely this title or silence. - Raw PTY fixtures under `src/main/runtime/__fixtures__/qoder-*` cover trust, - unauthenticated prompt handling and the ready composer. -- A hidden Orca dev instance launched Qoder from New Tab in a folder workspace. - Its icon, label, terminal rendering and failed-turn indicator were inspected. - A harmless prompt reached Qoder, which reported its credit usage limit; the - canonical hook store recorded Qoder identity, session metadata and failure. - -## Compatibility and limits - -Windows hooks explicitly select Qoder's documented PowerShell shell, avoiding -an assumption that Qoder uses Git Bash just because it is installed. They reuse -the existing managed `.cmd` payload without adding an encoded PowerShell hop. -The configuration shape and preservation are tested; Windows, Linux and SSH -execution have not been exercised live here. - -Resume requests to remote hosts require `agent-session.qoder-resume.v1`, so an -older host is not sent an agent enum it cannot accept. Remote hook installation -and trust preservation have automated coverage. - -Successful model output, tool execution and live permission dialogs remain -unverified because the available account has exhausted its credits. Hook event -mapping for these paths follows the official documentation. The China executable -`qoderclicn` is not registered; it was not available for verification. - -Every launch Orca starts (New Tab, workspace and draft launches, automations) -pre-trusts the workspace at spawn while the agent-wide "Trust the folder when -Orca starts an agent" setting is on. A hand-typed `qodercli` in a plain terminal -can still show Qoder's trust dialog. - -## Sources - -- [Official hooks documentation](https://docs.qoder.com/cli/hooks) -- [#15291](https://github.com/stablyai/orca/pull/15291): registration and hook proposal -- [#13311](https://github.com/stablyai/orca/pull/13311): icon asset (commit - `b56197025530adb1b82d97a364a8c3d4a02c0d42`) and agent integration -- [#8611](https://github.com/stablyai/orca/pull/8611) and - [#12910](https://github.com/stablyai/orca/pull/12910): contributor implementations -- [#16540](https://github.com/stablyai/orca/issues/16540): Qoder misidentified as Gemini - -Contributor patches informed the integration; they were not applied wholesale. -In particular, the trust marker proposal was replaced using the installed CLI's -observed settings write, and no renderer workaround was added because the live -WebGL terminal rendered correctly. - -## Cross-review of open proposals - -Reviewed the current patches for all six open Qoder PRs on 2026-09-28: - -| PR | Incorporated or confirmed | Deliberate differences | -| --- | --- | --- | -| #7502 (vincent-lxc) | Agent registration, mobile parity, identity before Gemini | Use structural titles and the current public permission flag. | -| #8611 (Eridanus117, building on #7502) | Qoder hook source, notification/permission mapping, session resume | 1.1.64 supports `--prompt-interactive`; preserve startup session identity and ignore compact restarts. | -| #9655 (xingqingzzp-gif) | Cross-checked minimum registration coverage | Placeholder icon and detection-only scope are superseded by the fuller integration; no code copied. | -| #12910 (jyang2004) | Reuse the Claude-compatible installer with an event subset, plus remote installation | Use observed `.qoder` path and `/hook/qoder`; Qwen and the China build remain separate work. | -| #13311 (sorrycc) | Icon, structural title disambiguation, headless detection, display glyph cleanup | No DOM renderer override: 1.1.64 renders correctly in the live WebGL terminal. No unverified executable alias. | -| #15291 (adlternative) | Versioned binary recognition, trust preflight wiring, skills mapping and resume | Trust comes from settings, not `.trusted`; include notifications, permission and failure events with Qoder-specific normalization. | - -The rendered left sidebar was checked in the hidden Electron app using a temporary -folder workspace and real Qoder hooks. The workspace fixture needed a parent path -on its project group to appear in the sidebar. No agent status was injected into -the renderer. A real prompt updated the row text and returned a credit-limit error; -the row and workspace card showed failure with the Qoder icon. A DOM observer -recorded the prompt-row transition before failure. Permission/waiting and successful -completion remain automated event-mapping tests, not live model/tool verification. - -Commit co-authors credit all six proposal authors for the implementation and -registration groundwork, including xingqingzzp-gif’s minimal registration proposal. diff --git a/docs/reference/relay-regional-placement.md b/docs/reference/relay-regional-placement.md deleted file mode 100644 index 2e984ab0789d..000000000000 --- a/docs/reference/relay-regional-placement.md +++ /dev/null @@ -1,39 +0,0 @@ -# Relay regional placement - -Orca selects a Relay region in the Electron main process before requesting a new assignment. The -director publishes an allowlisted region catalog containing only HTTPS cell subdomains of that -director. Orca discards one warm-up `/health` request per probe origin — a cold request pays TCP and -TLS setup that can exceed the round trip it measures — then takes three bounded samples and compares -regions by their minimum. A wide spread still rejects a region, but only a genuinely flapping one. -The stable choice is cached for 24 hours, and a cached region changes only when the alternative is -materially faster. - -A region wins only against a measured competitor. If any region in the catalog is rejected or cannot -be measured, Orca sends no hint rather than selecting the sole survivor. Sending no hint is not -neutral placement: the director assigns `preferredRegion ?? RELAY_DEFAULT_REGION`, and the default -is `us-central1`. So an `asia-east2` user whose `us-central1` probe fails or flaps once is placed in -`us-central1` for that refresh. That trade is accepted because the relay database is -`us-central1`-only, and it is bounded: the withheld hint is cached for one hour, not the 24 hours a -chosen region gets, so the next hour re-measures. An origin that fails its warm-up probe is dropped -before the sampling rounds, so an unreachable region costs one probe timeout rather than four. - -After a control socket registers, Orca probes the cell it actually landed on, once per cell URL per -process. The cache is deleted only when it names a region other than the best measured one and the -assigned cell is more than three times slower than that region — a far cell under a cache that still -names the best region means the director declined the hint, and re-measuring would return the same -answer. Self-heal skips an absent, expired, or no-hint cache, and never runs under -`ORCA_RELAY_REGION_OVERRIDE`. - -The assignment request sends only `preferredRegion`. It does not send latency, IP address, country, -pairing data, or credentials. Catalog, probe, and cache failures fall back to an assignment without -a region preference. A rolled-back director that rejects the new field is retried once without -only that field while preserving reconnect behavior. - -The selection measures the desktop network path. Folder workspaces and SSH workspaces share the -same local broker and do not run probes on remote hosts. The phone continues to connect to the -exact cell URL in the desktop pairing payload, so its location is not measured independently and -no mobile protocol update is required. - -For deterministic local diagnostics, set `ORCA_RELAY_REGION_OVERRIDE` to `us-central1` or -`asia-east2` before launching Orca. The override is not an end-user setting and is not written to -the preference cache. diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md deleted file mode 100644 index 6e3be37219d1..000000000000 --- a/docs/reference/remote-wire-compatibility.md +++ /dev/null @@ -1,397 +0,0 @@ -# Remote wire compatibility - -Orca's remote-server feature pairs a desktop client to a remote Orca runtime, and -users update the two independently. **Mixed versions are the normal state**, not an -edge case. This page is the contract for changing anything a paired client and host -exchange: the runtime RPC envelope, the terminal binary stream, and the content -either side publishes over them. - -`src/shared/protocol-version.ts` says when to bump `RUNTIME_PROTOCOL_VERSION`. This -page covers the changes that do _not_ bump it and are therefore easy to get wrong. - -## Rule 1 — a new optional JSON field on an existing frame is safe - -Every JSON payload is parsed with a decoder that ignores unknown keys (zod `.strip()` -on RPC params, `JSON.parse` on stream frames). An older peer that has never heard of -the field simply does not read it. - -Safe: - -```ts -// host adds a field; older clients ignore it -encodeTerminalStreamJson({ kind, cols, rows, hiddenOutputReason }) -``` - -**The field is safe only for as long as every reader treats it as optional.** The -moment a newer client _requires_ it, that client is broken against every host that -predates the field — which is the same defect as removing a field, just discovered -later. If new behavior depends on the field being present, that is Rule 2: negotiate -it, or make the reader fall back. - -## Rule 2 — a new stream opcode is NOT safe; negotiate it - -`decodeTerminalStreamFrame` returns `null` for an opcode it does not know, and -`runtime-rpc.ts` drops that frame without an error: - -```ts -const frame = decodeTerminalStreamFrame(bytes) -if (!frame) { - return // silently dropped — the sender never learns -} -``` - -So a new opcode sent to an older peer does not fail loudly. It vanishes, and the -feature behind it appears to hang. Input sent under a new opcode is swallowed. - -A new opcode must be announced in the subscribe handshake and sent only after the -peer confirms it. The existing pattern is `SetOutputPaused` (opcode 16): - -- the client advertises support in the `Subscribe` frame's `capabilities`; -- the host echoes `capabilities: { outputPause: 1 }` on the `subscribed` event; -- the client sends opcode 16 only after that echo (`stream.supportsOutputPause`); -- the host only acts on opcode 16 when it negotiated it (`stream.supportsOutputPause`). - -Reuse an existing opcode with a new optional payload field (Rule 1) whenever that -expresses the change; reach for a new opcode only when framing genuinely differs. - -Opcode numbers are permanent. See the `Ack = 13` and `ClaimViewport = 14` comments -in `src/shared/terminal-stream-protocol.ts` for why a shipped number cannot be -reused even if the feature behind it is removed. - -## Rule 3 — changing what the host publishes breaks old clients with no wire change - -The frame shape can be untouched and the skew still real, because clients react to -frame _content_. PR #12641 is the worked example: the host stopped synthesizing a -finished agent status, and clients running older code saw different content in an -identical frame. - -Treat these as wire changes even though nothing in the codec moves: - -- a field the host stops populating (an old client reading it now sees `undefined`); -- a value whose meaning, units, or nullability changes; -- content the host stops synthesizing, trims, or starts deriving from a new source; -- a frame the host stops sending, or starts sending, on an existing path. - -If old clients cannot interpret the new projection correctly, gate it behind a -runtime capability the same way Rule 2 gates an opcode. - -## Rule 4 — an enum arm set is a wire surface; unknown arms must degrade, never reject - -A closed `z.enum` in a client-side reply schema is a version claim: it asserts the host -will never send an arm this build has not heard of. A newer host that adds one arm then -has its whole reply refused, or has the row carrying it silently dropped, even though -every member the client actually reads is present and well-formed. - -Declare the arm set open instead, with `openEnum` in `src/shared/zod-salvage.ts`: - -```ts -// unknown arm degrades to a member the reader already handles; a non-string stays fatal -status: openEnum(GIT_BRANCH_COMPARE_STATUS, 'error') -``` - -Do not reach for `.catch()`. It swallows absence and the wrong type as well, which turns -a member the reader depends on into a silent default. - -A fallback does not have to be an arm. `git.status`'s `area` is the worked example: -`staged`, `unstaged` and `untracked` each grant an affordance, so coercing an unknown area -to one of them offers stage, unstage or commit against a row the client cannot place. It -degrades to absent instead, which withholds all three — every reader is an equality check -against a known arm, so the row lands in no section — while keeping the row itself. That -last part is the point. Dropping the row would also drop it from the unresolved-conflict -gate, and a conflicted worktree that looks clean is granted a hosted-review create it -should not have. Withholding an affordance is a degrade; removing the evidence a gate -reads is not. - -A fallback is only ever allowed to shape a _reading_. If the member is sent back to the -host — a token the client echoes into a later call's params — pass it through as -`z.string()` and let the send site keep it verbatim. `hostedReview`'s `provider` is the -case: the eligibility reply names it and the create call returns it, so an -`openEnum(..., 'unsupported')` there does not soften how the client reads a newer host's -provider, it puts `unsupported` on the wire and makes that host refuse its own. A -reply-schema fallback must never shape a param. Gate on the token instead, where the -client decides what it is willing to do with an arm it does not know. - -## Worktree activation belongs to a viewer - -Worktree creation and activation default to the host desktop for host/CLI requests, -and to the caller for paired desktop/web requests. Mobile creation still activates -the host renderer to provision setup and default tabs. A headless host does not -borrow a paired client's view. Catalog and session updates continue to reach every -subscriber independently of navigation. -Headless host/CLI creates provision their shells, setup, and default tabs on the -execution host in the background, without depending on an observer to open them. -The same fallback applies when an attached host renderer is unavailable or -reloading, including explicit `all` requests that target the host; availability is -checked after creation, rather than before its awaits. - -`activateWorktree` client events carry an optional `navigation` field. Only explicit -`clients` or `all` requests publish these events; updated clients ignore events -without that address, including implicit broadcasts from older hosts. This is an -optional JSON field (Rule 1): older clients ignore it and still understand explicit -activation. The new host's default publication changes deliberately remove implicit -navigation for older clients too, rather than retaining the unwanted behavior. -An older host cannot express explicit follow intent to an updated client, so that -client must open the workspace itself. -Accepted navigation is also fenced through repository/worktree discovery; bridge -cleanup, reconnection, or re-pairing revokes any activation still awaiting a fetch. - -Older CLIs hardcode `navigation: 'all'` for `--activate` and `--run-hooks`. The host -recognizes their `cliProvenanceRequest` and normalizes that automatic target to -`host`. An API caller without the CLI marker can still explicitly request `clients` -or `all`. Neither creation nor activation navigates remote clients by default. - -Coverage: `multi-client-navigation-isolation.integration.test.ts` exercises real -paired WebSocket clients and host/headless creation, the renderer bridge tests -exercise older unaddressed publications, and -`paired-worktree-activation-isolation.spec.ts` checks a paired desktop remaining in -a local workspace while its remote catalog updates. - -## Session search agent negotiation - -`aiVault.searchStatus` optionally advertises `supportedAgents`; current search clients -send their own `supportedAgents` with `aiVault.searchSessions`. These are string lists, -so a future provider name does not make a peer reject the capability reply. The client -narrows explicit agent filters to the host's list before calling its request parser. -The host narrows retrieval to the client's list before publishing a page. - -A peer without this field uses the frozen v1.4.211 search vocabulary. The existing -`supportsQoderHistory` flag proves CodeBuddy, ZCode, and Qoder support; -`supportsJcodeHistory` independently proves Jcode support. An explicit list takes -precedence over both flags. If only the status method is missing, the client still -searches the conservative legacy subset; other status errors propagate. Empty host -intersections keep the requested filters and use the existing no-match scope, so -consent, readiness, and unknown-scope results retain their normal precedence. -Local IPC advertises this build's full list, and every remote leg negotiates separately. - -## Enforcement - -`tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts` runs the real -host RPC methods and the real renderer multiplexer from two builds against each -other — current working tree against the newest release tag, in both skew -directions — over one scripted terminal journey (subscribe, input, hide/reveal -snapshot, drop, reconnect). - -Run it with: - -```bash -pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts -``` - -It fails when a frame is refused by the receiving build's decoder (Rule 2), when the -observed frame sequence changes (Rule 3), or when published snapshot content or -negotiated capabilities differ from the contract. Repeated frame shapes are compared -by corresponding journey occurrence (initial, reveal, reconnect), so a field removed -from one occurrence cannot hide behind a sibling that still publishes it. Adding an -optional field keeps the suite green (Rule 1); making a client depend on that field -turns the new-client/old-host pairing red. - -### Never write down what the old side has - -The baseline is whichever release tag is newest, so it moves on every cut. An -expectation of the form "the old side does not have X" — a `not.toHaveProperty`, a -`not.toContain`, a hard-coded field list — stops being true the first time a release -ships X. The suite then reddens on whatever pull request is in flight, with no code -change anywhere, and the job trains people to ignore it. That is worse than no test, -because a rolling baseline eventually contains every additive field the wire has, and -adding one is the sanctioned way to evolve it. - -Derive the expectation from the baseline that was actually checked out: - -- for a published frame, pair each build against a client of its own version and - compare the skewed pairing against that same-version reference, so the expectation - is whatever that build publishes today. Compare repeated frames by corresponding - occurrence with `comparePublishedFieldOccurrences` in - `tests/e2e/cross-version-wire/published-field-shape.ts`; never union keys across - initial, reveal, and reconnect frames, because a sibling can mask one occurrence's - removed field; -- for a negotiated surface, read the old build's advertised capabilities and - registered method names from its checkout, and assert they agree with each other - rather than asserting the old build lacks them; -- for a "client too old to know X", derive that client's advertised list by removing - X from the baseline's own list, so the gate stays exercised after X ships. - -Name the direction in the assertion. `new client against old server` and `old client -against new server` fail for different reasons, and the host is the only side that -authors a published frame — the terminal `terminalOwner` false positive on 2026-08-29 -was misread as a new client sending an unknown field when the old server was -publishing it. Two things are still safe to state literally: the current build's own -contract, and an invariant that holds for every version. - -Pinning a legacy ref is the fallback when a contract genuinely needs a release from -before a feature shipped, as `cross-version-browser-placement.unit.test.ts` does with -`LEGACY_BROWSER_PLACEMENT_RELEASE_REF`. It does not rot on a cut, but it is -hand-maintained, so prefer deriving. - -`tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts` pairs the -same two builds over the structured `agentSession.*` surface. Because a released build -cannot name a capability string its own source never contains, the old side's advertised -list and registered method names are read from the extracted checkout rather than -hand-written. It covers the three skews that surface can fail on: - -- an old client — advertising the baseline's list minus this capability — is told the - whole surface does not exist and reaches no host method; -- a new client against the old dispatcher always gets an answer rather than silence, - and `method_not_found` for every method that release does not register, so the - absence is visible during negotiation instead of by calling; -- a cursor survives a host restart: a reattach at the client's fence is refused as stale - with the live one attached, a write still carrying that fence is delivered (writes are - named by their target, and every released client still sends a fence), and resuming from - the held cursor replays only what it missed. - -Run it with: - -```bash -pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts -``` - -The harness covers the terminal stream and the structured agent-session surface. It does -**not** cover the session-tab sync channel, legacy agent-session publications, file or Git -RPCs, mobile/E2EE framing, or the relay transport. A change on those paths still needs its -own reasoning against the three rules above. - -## Worked example: `agentWait` on terminal and worker reads - -`terminal.show`, `orchestration.workerShow` and `orchestration.federationShow` carry an -optional `agentWait` naming a pane parked on a prompt only a human can answer. It is Rule 1 — -a new optional field — but it has a second state that Rule 1 alone does not describe, and -getting that wrong turns a skew into a false "nothing is blocked". - -- **present object** — this pane is waiting, with the evidence that proved it. -- **present `null`** — the host evaluated this pane and nothing proves a wait. -- **absent** — the host never evaluated it: it predates the field, the worker identity was - unverifiable, the pane was unreadable, or the agent probe did not answer in time. - -A new client against an old host sees the field absent, which is why absence must read as -_unknown_ and never as _not waiting_. Collapsing absent into `null` at any hop — including a -convenience `?? null` in an RPC handler — makes an old or unreachable peer indistinguishable -from a healthy idle worker, which is the exact failure the field exists to remove. - -An old client against a new host ignores the key, as Rule 1 allows. New members added to -`RuntimeTerminalWaitBlockedReason` are also Rule 1: no consumer switches exhaustively on it, -and both the CLI and worker-start interpolate it as an opaque string. - -## Worked example: the `turn` journal item and its transitional downgrade - -The structured chat journal records a turn as a first-class item, -`{ kind: 'turn', turnId, state, userItemId?, startedAt?, completedAt?, durationMs? }`, where it -used to write `{ kind: 'status', text, turnLifecycle }`. Nothing in the codec moves, but it is -Rule 3: a client that predates the item does not know the kind and renders it as a text bubble -with no text. So the item is gated on a client capability, `agent-session.turn-item.v1`. - -The gate lives at the RPC boundary only, in -`src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts`, composed -around `agentSession.history` and `agentSession.subscribe` next to the background-task -projection. A client that does not advertise the capability receives every `turn` item -rewritten to the legacy status form with the full lifecycle under `turnLifecycle`; a client that -advertises it, and any in-process caller, receives the canonical body. The journal, the status -feed, and every host-side reader keep the `turn` item; `readAgentJournalTurn` in -`src/shared/agent-session-turn-record.ts` reads either form, so a new client against an old -host that still writes the status row also works. - -An old client against a new host sees the status row it always did. A new client against an old -host advertises a capability the host ignores and reads the status row through the shared -reader. The downgrade is transitional: once no supported release lacks the capability, delete -the projection module and the capability check, and leave the reader. - -The cross-version suite derives the old client's list by removing this capability from the -baseline's own list, per the rule above, so the downgrade stays exercised after a release ships -it. - -## Known debt: JSON-RPC errors drop Node's string code - -An error raised on an SSH host crosses the relay as JSON-RPC, and -`ssh-channel-multiplexer` rebuilds it with the TRANSPORT's numeric `code`. Node's -string code — `'ENOENT'`, `'EACCES'` — does not survive, so a caller on this side -cannot ask what kind of failure it was. - -`isENOENT` in `src/main/ipc/filesystem-path-containment.ts` pays for that by also -matching Node's canonical message text, which is what makes remote worktree creation -work. The cost is that a host can make an unrelated failure read as "absent" by -putting that sentence in a message. - -The exit is Rule 1: carry the original string code in a new optional field on the -error payload and read that instead. An old host omits it and the message match still -covers them; once hosts that send it are the floor, the message match can be deleted -rather than lived with at its ~10 call sites. Narrowing `isENOENT` back to `.code` -without doing this reinstates the bug — the transport has already overwritten it. - -## Known hazard: clients ignore host-published failure fields on client-placed pages - -`RuntimeMobileSessionBrowserTab` — the browser tab a host publishes on the session-tab sync -channel — permits `placement`, `loadError` and `certificateFailure` together. But for a tab -whose `placement.kind` is `'client'` the engine runs in the client's own app: the failure is -raised by the local guest webview, and the host has no view of it (`RuntimeBrowserClientPage`, -what the registry actually publishes from, carries neither field). Clients from -this version on therefore refuse host ownership of both records for client-placed pages -(`web-session-tabs-sync.ts`, the `placement?.kind !== 'client'` carve-outs) — without that, -each metadata snapshot deletes the locally recorded failure and the page's failure overlay -disappears mid-navigation. - -The hazard is forward-facing and Rule 3 shaped. A host that later starts publishing -`loadError` or `certificateFailure` for a client-placed page reaches these clients as content -they silently drop, so the host would see no error and no effect. Publishing it has to be -capability-gated, with the carve-out narrowed to clients that did not negotiate the -capability. Note the cross-version harness does not exercise the session-tab sync channel, so -nothing fails if this is forgotten — this note is the only record. - -A related carve-out covers `title`, `url`, `loading`, `canGoBack` and `canGoForward` -(`resolveMirroredBrowserPageContent`), and for those the hazard is already live rather than -forward-facing: the host does publish them, from a `RuntimeBrowserClientPage` it can only learn -about second-hand through the client's own `browser.clientHost.pageMetadata` calls. Its copy -therefore starts at the registry defaults (`'Browser'`, the create-time url), and while those -publishes are failing it never leaves them. - -That copy is not simply behind, though, and a client must not treat it as such. When a lease -reattaches, the host refreshes the page from the client host's own inventory -(`runtime-browser-client-page-recovery.ts`), which reads the live guest — so it can be strictly -fresher than a local row whose pane is unmounted and whose metadata publisher was disposed with -it. A client that ignores the host url is relying on its own guest to re-answer on remount, -which `ClientHostedBrowserPagePane`'s mount-time `syncNavigation` is what makes true. - -These five are therefore refused only by the client whose guest actually runs the page: -`placement.browserHostClientId` is compared against this client's own host id -(`readBrowserClientHostId`). Main stamps that id into the guest-hosting window's -`additionalArguments` at creation, and the preload reads it back out of its own argv — the answer -has to be there before the first snapshot is interpreted, which is earlier than any IPC handler a -renderer could wait on. Every other viewer — a second desktop, the web client, which installs no -page renderer at all, the dashboard pop-out, which is deliberately left unstamped — keeps tracking -the host, which is the only reason a mirrored viewer shows anything but its first snapshot -forever. Improving what a _second_ client sees still means fixing the publish, not the carve-out; -the carve-out no longer stands in the way of it. - -The two failure fields above are deliberately left on the looser `placement?.kind !== 'client'` -predicate. It is unobservable today — the host publishes neither field for a client-placed page at -all, so a mirror has nothing to take either way. If the capability-gated publish this section -anticipates ever lands, narrow them the same way rather than by placement kind: a mirror should -take a failure it cannot otherwise see, and only the hosting client should refuse it. - -## Known hazard: on the mobile surface a scope refusal is not a missing method - -The agent-session harness above asserts that a peer probing an unknown method is told -`method_not_found`. That holds for the runtime-scoped surface and **not for the mobile one**. - -`runtime-rpc-websocket-dispatch.ts` checks `MOBILE_RPC_METHOD_ALLOWLIST` and answers -`forbidden` _before_ it calls the dispatcher. A method a desktop predates is on neither the -allowlist nor the registry, and the gate answers first, so a phone never sees -`method_not_found` for it. `method_not_found` would reach a mobile-scoped device only for a -method that is allowlisted but unregistered, and `src/main/runtime/mobile-rpc-allowlist.test.ts` -requires every method the phone calls to be both, so that combination cannot ship. The reverse -— a registered method that no release has allowlisted yet — is the skew that does occur. - -So a phone-side "is this host new enough to serve X?" probe must read **both** codes as -absence. Keying it on `method_not_found` alone compiles and passes every same-version test -while never firing. The Files and Git fallbacks have read both since they shipped -(`isMobileMethodUnavailableError`, `isMobileGitUnavailable`); the Relay pairing probes were the -outlier, and the cost was a write-once direct-relay upgrade journal, holding a pending resume -secret, that an old desktop could never retire -(`mobile/src/transport/pairing-relay-rpc-unavailable.ts`, with the gate pinned desktop-side by -`src/main/runtime/runtime-rpc-mobile-unknown-method-scope-refusal.test.ts`). - -Widening is safe only where `forbidden` cannot also mean a real authorization failure. Prove -that per probe rather than globally: the pairing handlers cannot emit it (an unwired provider -answers `runtime_error`), a bad or revoked token answers `unauthorized`, and the gate is one -of only two places in `src/main` that emits the code at all. A probe whose handler _can_ -refuse by authorization must not be widened. - -The cross-version harness dispatches as a mobile client but calls the dispatcher directly, so -it never runs this gate and nothing reddens if any of the above is forgotten. diff --git a/docs/reference/renderer-agent-status-performance.md b/docs/reference/renderer-agent-status-performance.md deleted file mode 100644 index cffad695d25a..000000000000 --- a/docs/reference/renderer-agent-status-performance.md +++ /dev/null @@ -1,340 +0,0 @@ -# Renderer agent-status performance - -## Status - -This design is adopted for the renderer's high-frequency agent-status path. It -keeps the existing event semantics while bounding the amount of synchronous -store fanout performed for one IPC burst. - -It lands in slices. This document and the `bench:idle-cpu` harness land first so -the store changes can be reviewed against a baseline someone else measured. Until -the store slice lands, the `setAgentStatuses` / `transactAgentStatuses` actions -and the agent-status write workload described below are not yet on `main`; the -harness measures scale, listener census, and raw publication fanout only. - -## Context - -Orca can display a large expanded worktree lineage inside one virtualized list -row. Virtualizing the root row does not virtualize its descendants, so a -100-worktree lineage can mount 100 `WorktreeCard` instances at once. - -Agent-status IPC events are bursty. The renderer already groups live events into -a 33 ms window, but the original flush applied every queued event with a -separate Zustand write. Zustand synchronously visits every listener for every -publication. The resulting work therefore grew with both the number of status -events and the number of mounted subscriptions: - -```text -burst work ~= status events x store listeners x selector work -``` - -A production trace captured the renderer repeatedly entering -`flushLiveAgentStatusBurst -> applyAgentStatus -> setAgentStatus -> setState` -through `Set.forEach`. A deterministic 100-worktree fixture reproduces the -structural multiplier; see "Baseline on `main`" below for the currently measured -listener count and publication cost. - -The production app later recovered substantially when all configured remote -hosts were removed. Host removal can stop relay/reconnect traffic, remove -mounted remote worktrees, or both, depending on host type and removal options. -That observation identifies remote presence as the production trigger but does -not by itself distinguish traffic volume from mounted-listener fanout. - -A read-only reconnect audit ruled out systematic double status emission from a -full PTY replay: replay bytes bypass OSC status parsing. Reconnect still causes -a full terminal-buffer repaint for every attached remote pane, which is a -separate source of renderer work and remains a follow-up investigation. - -## Goals - -- Keep a large expanded lineage responsive during dense agent-status traffic. -- Preserve every ordered status transition, including repeated updates for one - pane inside the same burst. -- Publish agent-status state once for a deferred live burst. -- Preserve selector identity and child render isolation. -- Make the regression reproducible without relying on a user's production data. - -## Non-goals - -- Changing the client/server status payload or remote protocol. -- Deduplicating status events by pane. -- Changing agent freshness, retention, history, title, completion, or provider - session behavior. -- Changing remote reconnect, PTY replay, or terminal repaint behavior. -- Redesigning lineage presentation or collapsing worktrees automatically. - -## Design - -### Bound mounted subscription fanout - -Sidebar components select cohesive state bundles with shallow equality instead -of registering one listener per field. Derived arrays and maps retain their -existing shallow identity behavior so unrelated store writes do not rerender a -card. Full agent-list mode keeps its child-level subscription boundary; compact -mode passes the already selected rows to avoid selecting the same inputs twice. - -The deterministic 100-worktree fixture pins the resulting listener budget: - -| Surface | Listener budget | -| ------------------------------ | --------------: | -| Worktree card state and caches | 2 | -| Agent-row inputs | 1 | -| Worktree activity status | 1 | -| Closed context menu | 1 | - -Unmount tests require the listener count to return to its prior baseline. In the -bundled prototype, the fixture without seeded agents fell from 8,518 listeners -to 1,218; with 100 visible agent rows the candidate mounted 1,618. Compare -against the census in "Baseline on `main`", which the harness reports directly. - -### Share working-spinner phase without synchronous mount queries - -Working rows keep the existing compositor-driven CSS animation and shared -visual phase. `animationstart` anchors each animation to document time zero. -Deferring the animation query until that event avoids a synchronous style flush -at each mount and restores the shared phase after `display:none` or a motion -preference change. A negative mount-time delay cannot preserve that phase after -an animation restarts. - -### Bound spinner animation overhead - -Working rings keep compositor-driven CSS animation, but repeat the animation -once per day rather than once per second. The same 12 steps per second now -avoid recurring React animation-iteration dispatch. The existing stationary -wrapper and ring rendering stay unchanged. Offscreen containment was evaluated -and rejected after a pixel regression at low zoom on 1x displays. - -The history, isolated measurements, full-app workspace/agent/subagent benchmark, -and limitations are documented in [Spinner rendering performance](./spinner-rendering-performance.md). - -### Fold a burst in event order - -The store exposes the single-update action and two batch forms: - -- `setAgentStatus(paneKey, payload, ...)` retains the positional single-update - API for the immediate live path. -- `setAgentStatuses(updates)` applies a prebuilt ordered list, while - `transactAgentStatuses(operation)` lets IPC derive each update against the - exact staged state before the single commit. - -Both entry points reuse the same single-update state transition. The batch -reducer passes each resulting state into the next update, so a sequence such as -`working -> waiting -> done` retains the same history and timestamps as three -sequential calls. Updates are never keyed or deduplicated before the fold. - -The live IPC queue is spliced before it is processed. This preserves the -existing reentrancy guarantee: a synchronous subscriber can enqueue another -event without causing the current queue to be drained recursively. The first -event outside an active burst remains immediate; events accumulated within the -33 ms window are applied as one ordered transaction. Startup snapshots and -bounded pending-hydration retries use the same transaction path instead of -publishing once per restored pane. - -Each transaction builds pane-routing ownership once with the same first-match -semantics as the standalone resolver. Split-layout leaf membership is indexed -once per layout root, so a large snapshot performs linear tab and leaf work -instead of rescanning every mounted worktree for every pane. - -### Run effects after the transaction - -Generated-title work that requires committed state is deferred until after the -transaction. Accepted updates also request freshness scheduling; the outer batch -coalesces those requests and schedules the shared freshness timer once after its -single commit. Generated-title requests are folded in event order and published -together, including first-write and forced-replacement semantics. Resolved tab -titles are projected while the transaction folds, then final title changes are -published together. Completion-triggered review refreshes remain deferred -microtasks. - -Bulk title application preserves event order and duplicate-tab behavior while -indexing owners once, cloning each changed owner array once, and replacing each -top-level map once. This keeps the post-commit title phase linear in mounted -tabs plus changed titles. - -This separation is important: invoking store actions from inside a Zustand -updater would re-enter the store, while running an effect before the commit -would let it observe stale state. - -## Semantic invariants - -Sequential and batched application must agree on: - -- live and retained agent maps; -- state history, `updatedAt`, and `stateStartedAt`; -- agent identity, model, prompt, tools, assistant messages, and subagents; -- orchestration and provider-session continuity; -- sleeping-session and launch-config recovery records; -- retired/closed-pane rejection and inherited-status suppression; -- retention cleanup and live-map eviction; -- `agentStatusEpoch` and `sortEpoch`; -- automation completion observation across intermediate transitions; -- generated-title inputs, freshness scheduling, and completion refreshes. - -Equivalence tests use fixed timestamps and include repeated same-pane -transitions. A publication-count test subscribes to the real store and requires -one notification for a non-empty batch and none for an empty batch. - -## Benchmark contract - -The benchmark launches an E2E-mode Electron build with the store exposed only -for instrumentation. It creates 100 worktrees in one expanded lineage, verifies -100 mounted cards, captures the store listener census, and then applies seeded -ordered agent-status traffic through the real store action. - -The benchmark measures the synchronous store action, not the live IPC leading -edge or post-commit notification path. A real-store snapshot test covers the -end-to-end budget for 100 panes with auto-generated titles enabled: one status, -one bulk generated-title, and one bulk resolved-title publication. Disabling -generated titles removes that middle publication, independent of pane count. - -The artifact records only fixed diagnostic fields needed for comparison: - -- requested and completed batches and updates; -- store action calls and observed publications; -- elapsed time, throughput, and scheduling drift; -- final-state verification; -- renderer mean, p95, and maximum CPU; -- renderer timer drift and long tasks; -- mounted-card and listener counts. - -Raw process inventories, temporary paths, pane identifiers, and DOM text are -diagnostic-only and must not be embedded in the shareable report. - -Run baseline and candidate on the same machine and OS with the same Electron -build mode. CPU samples from macOS and Linux are comparable within that -constraint; Windows process CPU collection currently cannot support this -comparison. - -## Harness - -`pnpm run bench:idle-cpu` drives `config/scripts/run-idle-cpu-benchmark.mjs`, -which composes four modules: - -| Module | Responsibility | -| ------------------------------------- | ------------------------------------------------------------------------------------------------- | -| `idle-cpu-renderer-scale-fixture.mjs` | Seeds the lineage, agent rows, and sidebar view state; takes the mounted-card and listener census | -| `idle-cpu-renderer-timing-probe.mjs` | In-page timer drift and long-task probe; runs the no-op publication workload | -| `idle-cpu-process-sampling.mjs` | Classifies the Electron process tree and samples per-role CPU/RSS | -| `idle-cpu-synthetic-spinners.mjs` | Measurement-only visible spinners | - -The sampling window extends past `--sample-ms` until the workload settles, and -fails the run rather than reporting a truncated window if the workload overruns -the guard. That is why a 2,000-publication run reports a measured window longer -than the requested one. - -`--zustand-publications` publishes an empty partial through the real store, so -each publication costs exactly one full subscriber visit and nothing else. It -isolates the `listeners x selector work` half of the burst-cost model from -agent-status payload work, and it is store-API independent — it measures the -same thing before and after the batching slice. - -The agent-status write workload (`--agent-status-batches`, -`--agent-status-write-mode`) is not in this harness yet. It depends on -`setAgentStatuses`, so it lands with the store slice. - -## Baseline on `main` - -Measured on `main` at `077f5a11cd4` (macOS, arm64, 16 CPUs), Electron built with -`electron-vite --mode e2e`, headless, 100 worktrees at lineage depth 99 with 100 -seeded agent rows, 10 s warmup and a 30 s sampling window. - -Fixture scale is confirmed by the census rather than assumed: 100 store -worktrees, 100 mounted cards, 100 mounted agent rows, and **9,279 store -listeners**. That listener count is the multiplier the design targets. - -2,000 no-op store publications at a 1 ms cadence, three repetitions. The -listener census was 9,279 in every run. - -| Measure | Median | Runs | -| ------------------------ | ----------: | ------------------------------ | -| Wall time to complete | 12,325.7 ms | 13,148.6 / 12,325.7 / 11,870.5 | -| p50 scheduling drift | 5,150.3 ms | 5,432.3 / 5,150.3 / 4,945.2 | -| p95 scheduling drift | 9,802.9 ms | 10,570.4 / 9,802.9 / 9,390.0 | -| Renderer mean CPU | 18.25% | 20.25 / 18.25 / 15.88 | -| Renderer p95 CPU | 32.59% | 40.07 / 32.59 / 31.28 | -| Renderer timer drift p95 | 7.0 ms | 7.3 / 5.3 / 7.0 | - -2,000 publications requested over 2 s take about 12 s, so the renderer sustains -roughly 160 publications per second at this scale. Each publication is -individually short - the long-task observer recorded zero entries in all three -runs - so the cost surfaces as scheduling drift and sustained CPU rather than as -discrete long tasks. Compare drift and CPU here, not long-task counts. - -The idle control at the same scale with `--zustand-publications 0` reports 6.63% -renderer mean CPU, 17.11% p95, and 1.6 ms p95 timer drift. Roughly 11.6 points -of mean renderer CPU are therefore attributable to publication fanout rather than -to the mounted fixture itself. 200 spinner animations run in both cases, so the -control also bounds the animation cost out of the comparison. - -Reproduce with: - -```bash -pnpm run bench:idle-cpu -- --worktrees 100 --lineage-depth 99 \ - --agents-per-worktree 1 --warmup-ms 10000 --sample-ms 30000 \ - --zustand-publications 2000 --zustand-publication-interval-ms 1 \ - --output /tmp/idle-cpu-baseline.json -``` - -## Results - -Three repetitions used 100 mounted worktrees, lineage depth 99, 100 seeded -agent rows, and verified final state. Medians from the regenerated evidence set -are: - -| Single 2,000-update burst | Sequential | Batched | -| ------------------------- | ---------: | ---------: | -| Status-state publications | 2,000 | 1 | -| Store action time | 3,692.0 ms | 188.7 ms | -| Update throughput | 541.7/s | 10,598.8/s | -| Renderer mean CPU | 36.2% | 2.9% | -| Renderer p95 CPU | 107.3% | 8.2% | -| p95 long task | 4,653 ms | 216 ms | - -The direct store transaction performs 99.95% fewer status-state publications, -spends 94.9% less time in the store action, and processes updates 19.6 times -faster. Renderer mean CPU falls 92.0%, renderer p95 CPU falls 92.4%, and the p95 -long task falls 95.4%. - -The 60-burst × 32-update case at 33 ms is a sustained saturation stress, not a -real-time production SLO. Publications fall from 1,920 to 60 and median store -action time falls from 2,791.9 ms to 323.3 ms. Median completion time falls from -5,298.2 ms to 2,710.6 ms, p95 scheduling drift falls from 3,073.0 ms to 664.9 -ms, and long-task count falls from 57 to 1. Renderer p95 CPU remains saturated -and noisy in this cadence, so it is not used as the discriminating measure. - -The 20-pane artificial OpenCode regression passes with 12.4 ms median key echo, -25.2 ms worst key echo, 19.4 ms maximum timer drift, and zero dropped renderer -backlogs. - -These figures come from the bundled prototype and are restated here as the -target. They are re-measured with the harness when the store slice lands. - -## Acceptance criteria - -- The 100-worktree fixture stays at or below the pinned listener budgets. -- A deferred transaction performs one status-state publication while preserving - ordered final state, including live-map eviction at the 500-row cap. -- A 100-pane startup snapshot performs one status and one bulk resolved-title - publication with generated titles disabled; enabling generated titles adds at - most one ordered bulk publication while preserving final statuses and titles. -- Sequential-versus-batch equivalence tests pass across same-pane transitions - and side-effect-bearing updates. -- Renderer CPU tails and scheduling drift improve in repeated candidate runs. -- The 20-pane artificial terminal test reports no dropped output backlog and no - material typing-latency regression. -- Web typecheck, focused unit tests, lint, max-lines ratchet, and E2E build pass. - -## Compatibility - -This is renderer-local. It adds no RPC field, stream opcode, persisted data, Git -command, or provider-specific contract. Native, WSL, SSH, relay, folder -workspace, and git-worktree status events enter the same renderer action. Mixed -client/server versions therefore need no capability negotiation. - -## Failure containment - -The first live event remains immediate. Startup replay and bounded pending -retries fold synchronously without waiting for the 33 ms live-burst window, but -publish their accepted updates together. Empty batches are no-ops. If an update -is stale or targets retired authority, the reducer skips only that update and -continues folding later events in order. diff --git a/docs/reference/runtime-file-base64-padding.md b/docs/reference/runtime-file-base64-padding.md deleted file mode 100644 index 3715855eaa02..000000000000 --- a/docs/reference/runtime-file-base64-padding.md +++ /dev/null @@ -1,92 +0,0 @@ -# Runtime file Base64 padding - -Padded runtime file writes must have a length divisible by four. Empty strings and -unpadded Base64 with length modulo four equal to zero, two, or three remain valid. -The change rejects exactly the previously accepted strings containing trailing -padding whose total length modulo four is two or three. It does not enforce -canonical unused pad bits or change the alphabet. - -## Boundary evidence - -| Input | Before | After | -| ------------------------- | ------ | ------ | -| `A=` | Accept | Reject | -| `AA==` | Accept | Accept | -| `AAA=` | Accept | Accept | -| `AAAA` | Accept | Accept | -| `''` | Accept | Accept | -| `A` | Reject | Reject | -| `==` | Accept | Reject | -| `AA=A` (interior padding) | Reject | Reject | -| `AA=` | Accept | Reject | -| `A==` | Accept | Reject | -| `AAAA==` | Accept | Reject | -| `AA`, `AAA` (unpadded) | Accept | Accept | - -`Buffer.from('A=', 'base64')` decodes to zero bytes. Rejecting malformed padding at -the RPC boundary prevents an accepted request from silently writing different bytes. - -## Caller census - -| Caller / surface | Reachability and compatibility verdict | -| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Desktop `runtime-file-import-client.ts` → `uploadRuntimeFileWithoutClobber` → `writeRuntimeBase64File` | The only production producer of `files.writeBase64` and `files.writeBase64Chunk`. Staging in `filesystem-runtime-upload-staging.ts` encodes the complete file with `buffer.toString('base64')`; newly rejected values cannot be produced. | -| Desktop single-frame uploads | Sends the staged string unchanged when its length is at most 512 × 1024 characters. Standard Node Base64 always has length divisible by four. Empty files remain accepted. | -| Desktop chunked uploads | Slices the encoded stream at 512 × 1024 = 524,288 characters, divisible by four. Every offset and every complete chunk is quartet-aligned. The final chunk is the difference of two multiples of four, including when it ends in `=` or `==`. No separately assembled final chunk or per-chunk padding is added. | -| Web implementation of `stageExternalPathsForRuntimeUpload` | Returns an empty source list; no file-write payload is produced. | -| Mobile file editor | Uses `files.writeTerminalArtifact` with text content and its separate schema. Does not reach this predicate. | -| Mobile clipboard / image attachments | Uses `clipboard.startImageUpload`, `clipboard.appendImageUploadChunk`, `clipboard.commitImageUpload`, and the `clipboard.saveImageAsTempFile` fallback. Their validator is `isValidBase64` in `clipboard-params.ts`, not `isValidRuntimeFileBase64`. Unchanged, including the mobile normalizer's existing permissive padding behavior. | -| CLI | No producer of either Base64 file-write method. File commands in `src/cli/handlers/file.ts` call `files.open` / `files.openDiff`; other CLI RPC call sites do not construct Base64 file writes. | -| Generated params catalog | References both schemas; `RpcParams` consumers use inferred types. Mobile's entry point is `export type` only, so no new client-side parsing occurs. | -| RPC dispatch | `files-mutation-methods.ts` registers both schemas. The chunk schema extends the whole-file schema; these are the only runtime consumers of the predicate. Direct runtime/provider calls do not parse these schemas. | - -Repository-wide searches covered method names, schema names, the predicate and its -pattern, and all callers of the upload/staging functions. Targeted history search -on `HEAD` under `mobile/src` found no introduction/removal of the affected methods -or predicate. The local release refs `mobile-ios-v0.0.27` and `mobile-v0.0.13` also -contain no callers of either Base64 file-write method; the iOS ref uses the separate -clipboard and terminal-artifact methods above. No shipped mobile producer of a -newly rejected value was found in this source/history audit. - -## Remote and workspace compatibility - -Old desktop clients using the audited producer send valid quartets to a new host. -A new client still sends the same bytes to an old host. No method, field, opcode, -or host-published content changes. This follows the mixed-version requirements in -[remote-wire-compatibility.md](./remote-wire-compatibility.md). - -The RPC validation runs before workspace resolution and provider selection, so the -same rule applies to folder workspaces, git worktrees, local hosts, and SSH hosts. -SSH ownership fences and provider writes are unchanged. Arbitrary external RPC -callers sending malformed padding will now receive a validation error; valid -padded and unpadded payloads remain accepted. - -## Regression evidence - -`src/main/runtime/rpc/methods/files-base64-padding.test.ts` exercises both actual RPC -registrations, asserts rejected input never reaches the writer, and verifies -accepted content is forwarded unchanged. With the original predicate, the test -run produced **16 failed / 16 passed**; all 16 failures were newly rejected padding -shapes accepted by the old implementation. This was run before editing the predicate. - -The existing desktop external-import test now uses a final `AA==` chunk after a -524,288-character first chunk, pinning padded final-chunk forwarding in the real -upload path. No producer changes or clipboard validation changes were necessary. - -## Validation results - -All test/typecheck commands used `ORCA_BACKGROUND_LAUNCH=1`. - -- `pnpm tc`: exit 0; all root typecheck projects passed. -- `pnpm exec vitest run src/main/runtime/rpc`: 277 files passed, one failed; - 2,419 tests passed, two timed out, one skipped. Both timeouts were in the unchanged - `terminal-output-frame-chunks-equivalence.test.ts` (5s surrogate-range test and - 30s 800-payload fuzz test). -- `pnpm --dir mobile typecheck`: exit 0 (`tsc --noEmit`). -- `pnpm run check:code-quality:changed`: exit 0; code quality, type-aware code - quality, and React Doctor each reported zero new findings across three source files. -- Focused run with `--config config/vitest.config.ts --maxWorkers=2`: all three - files / 58 tests passed, covering padding, desktop external imports, and the - terminal-output equivalence file that timed out in the initial run. -- Full RPC rerun with `--maxWorkers=2`: exit 0; all 278 files passed, - 2,421 tests passed and one skipped (198.34s). No timeout overrides were needed. diff --git a/docs/reference/sharing-agent-skills.md b/docs/reference/sharing-agent-skills.md deleted file mode 100644 index 9b9b53706a3a..000000000000 --- a/docs/reference/sharing-agent-skills.md +++ /dev/null @@ -1,88 +0,0 @@ -# Share and install agent skills - -Orca can put one skill or a bundle of skills behind one unlisted, revocable link. Shared bundles -do not appear in search, a catalog, or a public index. Anyone who has an active link can inspect -and install its contents without signing in, so treat the link like a credential. - -## Share skills - -Publishing and link management require an Orca account in the desktop app. - -1. Open **Skills** and choose **Share skills**. -2. Select one or more skills. One link can contain a large collection, such as 30 skills. -3. Review the bundle name, included skills and files, scripts, executable files, digest, account, - and optional release notes. -4. Choose **Publish skill**, **Publish bundle**, or **Publish new version**, then copy the link. - -Orca publishes an immutable version. Later changes do not silently alter a link's current bytes; -publish a new version to update the Cloud package. - -Use **Settings → Share Skills** to copy or revoke active links. Revocation blocks new previews and -download grants. A grant issued immediately before revocation can remain usable for up to five -minutes, and revocation does not remove copies that recipients already installed. - -## Install from a link - -Opening an Orca skill link shows a preview before changing any files. You can also open **Skills**, -choose **Install from link**, and paste the URL. - -1. Verify the author and organization. -2. Review the version, release notes, included skills, scripts, executable files, and digest. -3. Select all skills or only the ones you want. -4. Choose the destination machine and either global or workspace scope. -5. Review new, unchanged, updated, and conflicting skills, then choose **Install N skills**. - -Supported destinations include the local machine, paired Orca runtimes, WSL, and SSH hosts. The -destination runtime resolves its own home and workspace paths, so folder workspaces and remote -filesystems do not borrow paths from the client machine. - -Orca keeps one canonical installed copy and places it where supported agents can discover it. -Current provider coverage is documented in -[Agent skill provider paths](./agent-skill-provider-paths.md). - -## Conflicts, updates, and rollback - -**Keep local** is the default when an existing skill differs. Orca replaces modified content only -after you explicitly choose to discard it. - -Open **Skills → Manage installs** to inspect managed skills and their immutable version history. -Installing the latest version performs an update; selecting an older retained version performs a -rollback. Both use the same protected install transaction. If a bundle changes between versions, -Orca updates only the selected skills that still exist in that version. - -An interrupted install is recovered on restart. If Orca reports a conflict or partial result, -review the named skill and retry; completed skills do not need to be installed again. - -## Remove an installed skill - -Use **Skills → Manage installs → Remove**. Orca removes only copies and provider placements that it -owns and can verify. Modified or unowned files are preserved and reported. Discarding modified -content requires a separate explicit confirmation. - -Removing a local install does not revoke its share or delete its Cloud package. Likewise, -revoking or deleting Cloud data does not reach into recipients' machines. - -## Retention and deletion - -- Upload grants expire after 15 minutes. -- Abandoned upload bytes are removed from quarantine after one day. -- Published versions have no automatic age-based deletion. -- Deleting a package revokes its links before unreferenced objects are deleted. -- Deleted GCS objects remain operator-recoverable through a seven-day soft-delete window. -- Installed copies remain until someone removes them on each destination machine. - -Organization legal or retention requirements can override normal rollback and deletion timing. - -## Trust and privacy - -A skill is code from its author. `SKILL.md` can change agent behavior, and included scripts or -executables may run later when a person or agent uses the skill. Orca validates the package and -never executes its contents during installation, but you should install only from people you trust -and review unexpected scripts or executable files. - -Orca records bounded operational identifiers and outcomes. Normal logs, telemetry, and support -bundles exclude skill contents, filenames, manifests, local paths, share URLs, upload policies, -download grants, credentials, and access lists. - -If a link no longer works, ask its owner for an active link. Missing, expired, revoked, and deleted -links intentionally show the same response so Orca does not disclose private package existence. diff --git a/docs/reference/spinner-rendering-performance.md b/docs/reference/spinner-rendering-performance.md deleted file mode 100644 index 4340027474b7..000000000000 --- a/docs/reference/spinner-rendering-performance.md +++ /dev/null @@ -1,203 +0,0 @@ -# Spinner rendering performance - -## ELI5 - -Imagine a wheel that tells the front desk every time it completes a lap. The -front desk is also handling your typing. CSS already turns the wheel for us, -but React still receives its once-per-second lap notifications. - -We put a day's worth of laps into one animation. The wheel moves at the same -speed, while sending one lap notification a day. Drawing visible wheels still -costs something. This removes recurring bookkeeping from the input thread; it -does not make rendering or the rest of Orca free. - -## How this builds on earlier changes - -| Change | What it achieved | Remaining cost | -| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| [#9380](https://github.com/stablyai/orca/pull/9380): shared JavaScript clock | Reduced frame-pipeline CPU in the original one-agent measurement | Wrote each spinner's style 12 times per second on the input thread | -| [#12359](https://github.com/stablyai/orca/pull/12359): compositor CSS rotation | Removed those recurring JavaScript style writes; fixed the reported typing regression | React still receives CSS iteration events | -| [#13987](https://github.com/stablyai/orca/pull/13987): synchronize on animationstart | Avoided a synchronous style query at every mount | Steady-state animation overhead stayed the same | -| This change | Preserves both later fixes and removes almost all iteration boundaries | Compositing, other app work, mount/reveal work, and a daily iteration boundary remain | - -The historical measurements in #12359 reported 41 rings causing about 490 style -writes per second, with typing input-delay p90 of 363 ms versus 19 ms when those -writes stopped. Those are historical production measurements, not numbers from -this benchmark or a direct comparison with today's app. - -## Implementation - -The production change is entirely in CSS. `AgentWorkingSpinner`, its callers, -markup, border, animation-start handler, and reduced-motion behavior stay the -same. No DOM node, pseudo-element, containment boundary, timer, observer, or -JavaScript animation loop is added. - -The transform travels 86,400 turns in 86,400 seconds with 1,036,800 steps: exactly -one revolution and 12 steps per second. `animationstart` sets `startTime = 0` as -before, preserving shared phase after mount and animation restart. The step -count is a timing-function parameter, not a million-entry keyframe list. - -React installs delegated `animationiteration` listeners even when the component -has no iteration handler. A native 2.2-second trace of 200 isolated rings counted -400 iteration events and 800 JavaScript calls before the change, versus zero of -either with the long cycle. That trace installed no animation-event listener. -These are event dispatches, not component rerenders or 400 separate OS wakeups. - -## Full-app benchmark - -The opt-in Playwright benchmark launches a fresh, hidden Orca app for each -scenario. It creates real Git workspaces and seeds working statuses through the -existing renderer fixture, including in-process subagent data. It renders the -normal sidebar, virtualizer, lineage, agent rows, tabs, and terminal. - -| Scenario | Git workspaces | Root agents | Subagents | Mounted / visible rings | Layout | -| ------------- | -------------: | ----------: | --------: | ----------------------: | -------------------------------------------- | -| `one-agent` | 1 | 1 | 0 | 3 / 3 | One working agent | -| `one-family` | 1 | 2 | 4 | 8 / 8 | All family rows expanded | -| `200-flat` | 200 | 400 | 800 | 162 / 15 | Normal virtualization; 23 workspaces mounted | -| `200-lineage` | 200 | 400 | 800 | 1,401 / 15 | Expanded lineage; all 200 workspaces mounted | - -Measurement-only styles switch between the original one-second cycle and the -new long cycle on the same elements. The real React root, callers, status data, -and app stay the same. The reported run alternates A/B and B/A, with four -ten-second CPU samples per variant after warmup. CPU samples use cumulative -Electron process CPU and CDP main-thread task/script/style/layout metrics. No -renderer polling, screenshots, or benchmark iteration listeners run during -those CPU windows. No samples are discarded. - -Typing is measured separately using the existing paced terminal-typing probe: -64 keys at 113 ms cadence, twice per variant, after two seconds of warmup with -status traffic. Status updates arrive in groups of up to eight every 200 ms. -Keys pass through the DOM, real PTY, and xterm. A sidecar timestamps arrival at -the PTY, and a bounded terminal-buffer scan observes each echo. Missing input -or echoes fail the benchmark. Echo measurements include the 10 ms scan interval; -they do not measure native display presentation. Native animation traces also -run separately from CPU and typing samples. - -The statuses are deterministic test data, not hundreds of paid model sessions. -The test exercises UI cost under agent-status traffic, not the compute or network -cost of model inference, SSH traffic, or hundreds of streaming PTYs. - -## Results - -CPU values are medians of four samples. "CPU ms/s" means milliseconds of -processor time used in one wall-clock second: 100 ms/s is about 10% of one CPU -core. Renderer + GPU-process CPU includes their other app work and CPU used by -the graphics process; it is not GPU hardware utilization or whole-machine CPU. -The main thread handles input and is included in renderer CPU, not extra work. -Echo p90 means 90% of sampled keys were observed within that time; ranges show -the two runs, not confidence intervals. No keys or echoes were missing. - -| Scenario | Renderer + GPU CPU ms/s, old → new | Main-thread ms/s, old → new | Echo p90 ms, old → new | -| ------------- | ---------------------------------: | --------------------------: | ---------------------- | -| `one-agent` | 37.0 → 38.2 | 5.4 → 3.5 | 19 → 18–19 | -| `one-family` | 46.2 → 44.6 | 7.8 → 4.4 | 17–19 → 18–19 | -| `200-flat` | 141.6 → 122.8 | 28.2 → 16.3 | 26–28 → 26–28 | -| `200-lineage` | 324.6 → 295.6 | 140.8 → 70.0 | 159–239 → 93–160 | - -The consistent gain is less main-thread work: about 35%, 43%, 42%, and 50% -less in these four scenarios. Native 2.2-second traces counted 6, 16, 324, and -2,802 iteration events before, and zero in each new variant, without adding an -iteration listener. That avoided work also exists in Orca itself, independently -of the isolated fixture and CPU noise. - -Total CPU was roughly unchanged in the one-worktree cases. In this run it fell -13% with normal virtualization and 9% with expanded lineage; seven of eight -paired large-case CPU samples favored the change. These percentages are not -universal: a shorter three-variant ablation measured flat-list CPU at 89.0 ms/s before and -108.0 ms/s with the long cycle, while main-thread time still fell from 26.3 to -17.4 ms/s. The repeatable main-thread reduction is stronger evidence than a -single total-CPU percentage. - -Typing was similar in the small and flat-list cases. Expanded-lineage echo p90 -improved in the final run, but a shorter ablation had similar before/after -latencies. No general typing speedup or statistical non-regression guarantee -is established by these short experiments. - -### All CPU samples - -Values are rounded to one decimal and listed by round, with no outliers removed. -The first new small-case samples were higher than their paired baselines; they -remain included. CPU and typing were sampled separately. - -| Scenario | Version | Renderer + GPU CPU ms/s | Main-thread ms/s | -| ------------- | ------- | -------------------------- | -------------------------- | -| `one-agent` | Old | 37.8, 36.2, 26.2, 39.6 | 6.5, 5.2, 4.8, 5.7 | -| `one-agent` | New | 53.3, 35.8, 37.5, 39.0 | 6.7, 2.8, 3.0, 4.1 | -| `one-family` | Old | 46.3, 46.1, 47.9, 44.6 | 7.8, 7.6, 9.8, 7.7 | -| `one-family` | New | 53.8, 45.4, 42.5, 43.8 | 6.8, 4.5, 3.1, 4.3 | -| `200-flat` | Old | 142.4, 140.8, 147.0, 136.5 | 28.3, 28.1, 32.2, 27.0 | -| `200-flat` | New | 122.9, 97.2, 122.7, 126.7 | 19.2, 10.7, 16.6, 16.0 | -| `200-lineage` | Old | 317.9, 385.2, 315.9, 331.2 | 134.4, 159.6, 133.9, 147.3 | -| `200-lineage` | New | 318.6, 256.9, 296.5, 294.7 | 89.2, 60.8, 71.6, 68.3 | - -## Reproduce - -```sh -ORCA_BACKGROUND_LAUNCH=1 pnpm bench:spinners --sample-ms=5000 -ORCA_BACKGROUND_LAUNCH=1 pnpm bench:spinners --verify-only --scale-factor=1 -ORCA_BACKGROUND_LAUNCH=1 pnpm bench:spinners --verify-only --scale-factor=2 -ORCA_BACKGROUND_LAUNCH=1 ORCA_SPINNER_BENCH=1 ORCA_SPINNER_KEYS=64 \ - pnpm test:e2e spinner-workspace-perf.spec.ts --workers=1 -``` - -The full-app command rebuilds in `e2e` mode. For a fresh build already made with -`pnpm exec electron-vite build --mode e2e`, `SKIP_BUILD=1` reuses it. Do not reuse -an old launch-policy build. `ORCA_SPINNER_SAMPLE_MS`, `ORCA_SPINNER_ROUNDS`, -`ORCA_SPINNER_KEYS`, `ORCA_SPINNER_KEY_CADENCE_MS`, `ORCA_SPINNER_VARIANTS`, and -`ORCA_SPINNER_OUTPUT` control the experiment. `ORCA_SPINNER_CPU=0` repeats only -typing; `--grep one-agent` selects one scenario. Reports, native traces, typing -sidecars, and CDP screenshots are written under `.bench-fixtures/`. Run one -benchmark at a time, without concurrent builds or tests. - -The optional `contained` variant retains the rejected offscreen experiment for -ablation. It adds `content-visibility:auto` to the existing wrapper through -measurement-only styles. It is not enabled in production or the default -benchmark comparison. - -## Visual and behavioral checks - -Both 1x and 2x display-density checks passed 720 ring comparisons each: 6/8 px -rings, light/dark themes, supported zoom extremes, all 12 phases, long elapsed -times, and the daily wrap. The comparison pauses each animation and sets its -`currentTime`, so the long-elapsed and daily-wrap cases exercise the deterministic -style path rather than a running compositor animation. Against that path the -tolerance is one channel level for floating-point antialias rounding. A running -animation at multi-hour ages can differ by a few channels on the ring edge — a -fraction-of-a-pixel antialias difference at large accumulated angles, not a phase -or shape change. Checks also cover shared phase, reduced motion, initial offscreen -reveal, repeated scroll-away/reveal, and `display:none` restoration. - -## Limits and rejected approaches - -Adding `content-visibility:auto` to the existing stationary wrapper saved more -CPU at large mounted counts, but a 1x display check found a one-pixel shift at -the minimum UI zoom. That containment change is excluded. A previous -pseudo-element version also regressed typing latency in the virtualized list. -Neither prototype's CPU or typing numbers describe the final patch. - -An initial typing run used a 100 ms key cadence, which can repeatedly align with -200 ms status bursts. Follow-up runs use 113 ms, more keys, and two seconds of -warmup under status traffic. This reduces timing bias; it does not excuse a -regression. CPU measurements run separately and do not depend on key cadence. - -An early isolated test suggested a 31% process-CPU reduction that a longer audit -did not reproduce. The longer isolated audit measured original 104.04 versus -long-cycle 92.32 CPU ms/s, and main-thread 10.08 versus 0.24 ms/s. A fixture with -every ring far offscreen and containment enabled could also approach idle; that -is not representative of Orca with visible animations. Neither result justifies -claiming "free spinners" or a universal CPU percentage. Virtualized, unmounted -rows already cost nothing, and this patch does not add offscreen culling. - -All local measurements use an Apple M4 (10 cores), macOS, Electron 43.4.1 / -Chromium 150.0.7871.224. Native windows stay hidden and unfocused; -benchmark-only settings disable background throttling to exercise the frame -pipeline. These are not visible-window power measurements. No battery benefit -is established. Linux/Windows need their own runtime measurements. The -renderer-only change does not alter SSH execution, wire data, status semantics, -Git operations, or folder-workspace ownership. - -Animated PNGs, masks, layer promotion, CSS sprites, individual `rotate`, and -containment on the rotating element were also explored. Shared images added -raster work and regressed the single-ring case; sprites reintroduced per-frame -style work. They did not meet the appearance and responsiveness requirements. diff --git a/docs/reference/ssh-execution-boundary.md b/docs/reference/ssh-execution-boundary.md deleted file mode 100644 index 62ecb83838d7..000000000000 --- a/docs/reference/ssh-execution-boundary.md +++ /dev/null @@ -1,129 +0,0 @@ -# SSH Execution Boundary - -How Orca splits work between your machine and an SSH host, what survives a disconnect, and how to keep `unverifiable` distinct from `exited`. Nothing under `docs/` stated this before; agents and humans were inferring it from error strings and getting it wrong. - -## The rule - -**The execution host owns everything that touches execution** — tools, credentials, identity, environment, processes, and artifacts. The client owns the UI, transport, and Orca control-plane state, but is not authoritative for execution state. - -Two consequences, both non-negotiable: - -1. **No silent substitution.** An operation on a remote `repoPath` must never fall back to running on the client. A missing SSH provider is not permission to answer locally — a local run can answer for the _wrong repository_. -2. **No asserting what you cannot observe.** Loss of contact is not evidence of `exited`. Report `unverifiable`, never `exited`. - -The vocabulary is fixed: **`live` / `unverifiable` / `exited`**, taken from the incumbent `UnstoppedPtyVerdict`. Do not introduce synonyms, and never collapse `unverifiable` into either neighbour. `exited` requires positive evidence of absence from the host that owns the process; a transport failure can only ever produce `unverifiable`. - -Rule 1 is stated at `src/main/source-control/repo-default-branch.ts:76-78`, `src/main/repo-worktrees.ts:45-48`, `OrcaRuntimeService.probeWorktreeDrift` in `src/main/runtime/orca-runtime.ts`, and `src/renderer/src/lib/connection-context.ts:22-24`. It is enforced throughout `src/main/runtime/orca-runtime-git.ts` by `requireRuntimeGitProvider` in `src/main/runtime/runtime-git-command-target.ts`, and throughout the runtime filesystem commands by `requireRuntimeFileProvider` in `src/main/runtime/runtime-file-command-target.ts`. Both route on the target's resolved `executionHostId` rather than on a repo row's `connectionId`: they throw the provider-unavailable message when an SSH host has no registered provider, throw `ExecutionHostNotDispatchableError` for a `runtime:` host this process does not execute, and return `null` only for `local`. Grep those names for the current call sites rather than trusting a count. - -`src/main/runtime/unstopped-pty-verification.ts:12-16` is the reference implementation of rule 2: it keeps `live` / `unverifiable` / `exited` as three distinct verdicts, and treats "we could not ask" as its own answer. - -## What runs where - -| Concern | Executes on | Notes | -| -------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------- | -| PTYs, agent CLIs | **remote** | children of the detached relay daemon, not of the ssh channel | -| git (status, diff, log, fetch, push, commit, branch, worktree) | **remote** | via `src/relay/git-handler.ts` | -| filesystem, watching, search | **remote** | | -| repo setup hooks (`--setup`) | **remote** | identical policy to local | -| commit-message / PR-field AI generation | **remote** | uses the remote agent CLI and its auth | -| `gh` / GitHub API, `glab` / GitLab | **client** | inconsistent with the rule; PRs carry the client's identity | -| the `orca` CLI inside a remote terminal | **client runtime** | control plane only — your files and processes stay remote; see below | - -## Survival: what a disconnect does _not_ do - -By default, remote work survives your machine going away. The relay is a detached daemon (`nohup … .sock`, from `getDaemonSocketPath` in `src/main/daemon/daemon-spawner.ts`), every earlier protocol version stays attachable (`PROTOCOL_VERSION` in `src/main/daemon/daemon-protocol-version.ts`), and a daemon holding live sessions is preserved across a version change instead of replaced (`shouldPreserveDaemonWithLiveSessions` in `src/main/daemon/daemon-replacement-preflight.ts`). - -## Control plane - -On an SSH host, `orca` is a shim (`~/.orca-relay/bin/orca`) that proxies **back to the client's runtime** over the relay socket. Your repository, processes, and files remain remote — only the control plane is on the client. This is correct for an SSH target, but it has a consequence worth stating plainly: - -> When the client disconnects, every `orca …` command run on the SSH host fails with `No owning Orca client is connected to the relay`. The PTY stays `live`; its control plane does not. - -Orchestration state (Runs, Tasks, Dispatches, mailboxes) is client-resident for the same reason. An agent on an SSH host should not depend on `orca` for anything it must finish while you are away. **Commit and push early** — unpushed work on a remote box is unavailable to the client until it reconnects. - -## Distinguishing `unverifiable` from `exited` - -A verdict needs evidence from the host that owns the process. Apply these tests in order. - -**Was the signal produced by the owning host, or by the client's own bookkeeping?** Absence from a client-side set, a lookup that threw, a socket that closed, a command that timed out — none of these observe the process. They are `unverifiable` by construction, whatever the field is named. - -**Did every remote PTY on that target go quiet at once?** A transport drop takes them all together. Simultaneous silence across a host indicates a lost link, not simultaneous death. - -**Does the termination event match the current identity?** A host-delivered exit for the live PTY incarnation and provider generation, while its siblings still report, establishes `exited`. A stale event, an event for a superseded incarnation, or one quiet terminal with no host evidence does not. - -**Did the answer carry its evidence, or only the same wording?** `pty.attach` refuses with `PTY "" not found` both for a pid the relay probed and found gone and for an id its session map never had — which is every id minted before a relay restart, since ids carry a per-start mint epoch. Only the probed refusal carries `PTY_ATTACH_PROVEN_EXITED_MARKER` (`src/shared/pty-attach-absence-evidence.ts`) and reaches the client as `SshPtyProvenExitedOnRelayError`; the unmarked union arrives as `SshPtyAbsentFromRelayError`, which licenses retiring the client's own route to the PTY and nothing more. A missing marker is never evidence — an older relay omits it too. - -**Is a returned status actually a claim of success?** An operation that reports failure may have succeeded, and one that reports success may not have run — check the durable state it should have changed rather than trusting the return. - -Anything short of positive host evidence is `unverifiable`. Reporting it as `exited` is the error this document exists to prevent: it orphans live work and can cold-start a duplicate over the same worktree. - -## Host contact is a different question from process liveness - -The `live` / `unverifiable` / `exited` triple above answers one question: is this PTY running. It has -no synonyms, and nothing below adds any. - -A second, narrower question — can we currently reach the host at all, and what is its last answer -worth — is answered by `RuntimeHostContact` (`src/shared/runtime-host-contact.ts`), whose arms are -`live` / `unverifiable` / `refused` / `retired`. These are **not** extra process verdicts and must -never be mapped onto one: - -- `refused` is the host answering and turning us away — unauthorized, a protocol mismatch, a status - method it does not implement. That is positive evidence about the _connection_, and it says - nothing whatever about whether the host's PTYs are running. They almost certainly still are. -- `retired` is the pairing being ended by explicit user action. Same point: the client stops having - a route, the remote work is unaffected. - -Both are reasons to stop _trusting a cached answer_, never reasons to report a process `exited`. A -reader that needs a process verdict must still get it from the host that owns the process, by the -tests above. - -Why the extra arms exist at all: the renderer previously expressed every non-answer as one nullable -`status`, so a probe in flight, a probe that failed, a host that refused us and a retired pairing -all reached readers as the same `null` — and readers spent that `null` on decisions of very -different weight, including destructive ones. Folding `refused` and `retired` back into -`unverifiable` to match this document's triple would recreate exactly that collapse. The vocabularies -are deliberately separate because the questions are. - -## Deciding a remote pane is idle - -The orphan-PTY sweep is the one flow that turns an observation into a SIGKILL, so its idleness evidence has to be measured against the same thing the signal reaches. It is not the terminal. - -`forceKillPosixPtyProcessGroups` (`src/main/pty/posix-pty-process-groups.ts`) collects every process group on the pane's tty and `killpg`s each one. The blast radius is therefore _(process groups on the tty) × (members of those groups, wherever they are)_, and the second factor is not bounded by the terminal at all. Two facts make that gap reachable: - -- **Job control can be off.** With `set +m` a background job does not get its own process group — it keeps the shell's. `ps` then shows one process group on the tty, running a build. Nothing in a tty-shaped predicate can see it. -- **A group member can leave the terminal.** `ioctl(TIOCNOTTY)` without `setsid` drops the controlling terminal but keeps the pgid, so the process reports `tpgid == -1`, never appears in `ps -t `, and is still killed by `killpg(shellPgid)`. A double-forked grandchild similarly keeps the pgid while reparenting to pid 1, so no walk by `ppid` from the PTY root can name it either. - -So `shellOwnsEveryTtyProcessGroup` (`src/main/providers/agent-foreground-process-batch.ts`) requires both measurements: every process group on the tty is the shell's own with none stopped, **and** the shell's own process group has no other member anywhere in the host's process table. The name is tty-shaped for wire-compatibility reasons only. - -Two residuals remain, and neither is removable here. The capture is a snapshot, so work started between the `ps` and the signal is invisible — bounded by `RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS` on the reading side, not eliminated. And a process the host's own `ps` cannot enumerate (another PID namespace, `hidepid=2`, a table truncated by a permission boundary) is unobservable while `killpg` still reaches it. - -The general rule this instantiates: **evidence must be measured in the unit the destructive action operates on.** Evidence in a different unit is `unverifiable` no matter how precise it looks. - -## Reading artifacts instead of process state - -Artifacts are stronger evidence than liveness signals, but they answer a narrower question than they appear to. - -A matching commit from `git ls-remote --heads origin ` or a PR head lookup proves **that commit reached the remote** — not that the current run pushed it, and not that the latest work was included. An absent result proves nothing was found, not that nothing was pushed: the ref may have been deleted, the PR closed, or the query may simply have failed. - -A listing is only evidence about the hosts it actually covered. When a result does not name its scope, an empty answer is not evidence that nothing is running elsewhere. A clean **local** worktree says nothing at all about the remote one. - -## One host, one model - -An SSH host and a paired runtime (`orca environment`) imply opposite boundaries: the first is a dumb execution host driven by your client, the second is a peer that owns its own control plane. Registering the same machine both ways splits its worktrees across two identities, makes `terminal list` return different sets depending on `--environment`, and reliably confuses both humans and agents. Pick one per machine. - -For work that must continue while you are offline, use the peer/headless-runtime model on the remote host instead of the direct-SSH model. Its control plane is host-local, and its daemon-backed PTYs can stay `live` across a PID-scoped runtime restart so the runtime can reattach. A service manager that reaps the runtime's cgroup, or an explicit daemon shutdown, makes them `exited`; see [Running orcad](./orcad-operations.md#process-scoped-and-cgroup-wide-stops). Do not register the same machine through both models. A detached agent process outside Orca can also survive a control-plane outage, but it has no stdin, so its instructions cannot be amended mid-run. diff --git a/docs/reference/ssh-host-key-verification.md b/docs/reference/ssh-host-key-verification.md deleted file mode 100644 index 73955bdc7df5..000000000000 --- a/docs/reference/ssh-host-key-verification.md +++ /dev/null @@ -1,424 +0,0 @@ -# SSH host key verification (STA-4319) - -Revised after security and migration review. Where a first draft was wrong, the correction is kept -visible rather than quietly edited out — the reasoning matters for anyone changing this later. - -## The defect - -`src/main/ssh/ssh-connection.ts:1184` installs a `hostVerifier` that records a SHA-256 fingerprint -and then `return true`. Every ssh2 connection accepts every host key. There is no `known_hosts` -consult, no trust record, and no change detection anywhere in `src/main/ssh/`. There is exactly one -ssh2 `Client` construction site, so the fix has a single chokepoint. - -Scope is per-connection, not per-feature: one `SshConnection` per target serves exec, SFTP, port -forwarding, the filesystem watcher and relay deploy. - -### Threat model, corrected - -Traffic is still encrypted, so a passive observer gets nothing. The exposure is an **active** -attacker who can redirect the connection — ARP/DNS spoofing, hostile Wi-Fi, a hijacked internal name. - -Three corrections to the first draft: - -- **Jump hosts are NOT the worst case; they are already safe.** `shouldUseSystemSshTransport` - (`ssh-transport-selection.ts:71-91`) returns true for exactly the conditions under which - `resolveEffectiveProxy` (`ssh-proxy-command.ts:17-38`) returns a proxy — the two branch on the same - inputs in the same order — and `attemptConnect` returns unconditionally after the system probe - (`ssh-connection.ts:670-673`). So ProxyJump/ProxyCommand go through OpenSSH and are already - verified. The ssh2 proxy-spawn at `:697` is effectively unreachable. Good news for migration, and - the first draft's motivating example was simply wrong. -- **Agent forwarding was overstated.** `agentForward` is gated on the user's `ForwardAgent yes` - (`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent _auth_, whose - signatures bind the session id and cannot be replayed onward. The risk applies to users who opted - into `ForwardAgent`, not everyone. -- **Credential theft was understated, and the relay claim was backwards.** `isAgentFallbackError` - treats _any_ auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects - publickey walks the user to the password prompt (`ssh-connection.ts:844`) and the private-key - **passphrase** prompt (`:834`), and `cachedPassword` is replayed without prompting on every - reconnect (`:709`). Meanwhile the relay upload matters less than assumed — the attacker already - owns their machine. The real client-side impact is the **return** direction: the attacker becomes - the host our workspace trusts, driving relay protocol frames, landing SFTP content in local - worktrees, and feeding agent-hook payloads in. - -## Decisions - -### D1. Read the user's `known_hosts`; write only to our own store - -Consult the user's real `known_hosts` as a trust source — most developers already have their hosts -there from `ssh` and `git`, which is the entire migration story. Do **not** write to it: that file is -shared with every other SSH tool on the machine, and appending brings line-endings, permissions, -concurrent writers, and a corruption blast radius well beyond us. - -Two consequences to own rather than discover: - -- **Revocation does not propagate into our store.** `ssh-keygen -R host` clears `known_hosts` but not - our record. That is survivable for the ordinary rotation, because a `known_hosts` MATCH is now - decided before our store's mismatch — running the remedy we print and reconnecting works, which it - did not when the store was consulted first. What it does not cure is a host we only ever knew - ourselves, never written to `known_hosts`: there is no `ssh-keygen -R` for that one, so the - rejection names the store file directly. The "forget" action (D5) replaces that with a button; its - helper is deliberately absent until then, since an exported API nothing can reach is unverified in - production. Mismatch messaging must keep naming which source disagreed. -- **`ssh -G` on the HOME-divergent `-F` path suppresses `/etc/ssh/ssh_config`** - (`ssh-g-config-resolution.ts:44-52`), hiding site-wide `StrictHostKeyChecking yes` and - `GlobalKnownHostsFile`. On that path we must fail **strict**, never laxer than `ssh` would. - - There is no `ssh`-only way out of this: `-F /dev/null` does NOT invert the exclusion, it reports - built-in defaults, so a probe built on it looks permissive on every machine. Verified against - OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — _could_ - the site config be restricting host keys — where anything ambiguous (unreadable, an unresolvable - `Include`, the directive present at all) keeps the refusal. Only a site config that demonstrably - says nothing about host keys clears it, which is what stops the rule punishing every devcontainer, - `su` shell and Nix shell. - -### D2. Ask `ssh -G`, do not reimplement config resolution - -`ssh -G` reports `userknownhostsfile`, `globalknownhostsfile`, `stricthostkeychecking`, -`checkhostip`, `hostkeyalgorithms`, `fingerprinthash`, `hashknownhosts`, `updatehostkeys` and -**`hostkeyalias`** — with `Match` and `Include` already applied. `resolveWithSshG` exists and simply -does not read them yet. - -`userknownhostsfile` is a space-separated list on one line, may contain `~`, and may contain -double-quoted paths with spaces. When `ssh -G` is unavailable (no `ssh`, non-zero exit, >5s timeout) -fall back to `~/.ssh/known_hosts` + `known_hosts2` — never to accept. - -`HostKeyAlias` must be honoured: users tunnelling bastions through `localhost:port` depend on it and -would otherwise hit spurious mismatches. It appears nowhere in `src/main/ssh/` today. - -**Lookup key.** Config resolution uses `configHost || label` (`ssh-connection.ts:660`) while ssh2 -dials `effectiveHost` (`ssh-connection-utils.ts:188`). The `known_hosts` lookup must use -`HostKeyAlias` if set, else the **resolved hostname** — keying on the Orca label would miss every -existing entry. - -**Two ordered lookup passes, not one candidate set.** Verified against OpenSSH 10.2p1: a non-default -port looks up `[host]:port` first, and if that finds nothing it retries the **bare** host. Crucially, -on that second pass a wrong key is downgraded to `unknown` rather than reported as changed. So the -passes are `[['[host]:port'], ['host']]`, and the fallback pass can only yield `match` or `unknown`. -Collapsing them into one set would give a spurious first-contact prompt to anyone who has a bare -line and connects on a non-default port; treating the fallback as authoritative would raise a false -change-of-key alarm. - -**The entry condition to that second pass is the part that bites.** ssh runs it only when the -port-qualified lookup matched no plain entry of ANY key type — not "no match". Gating it on -"no match and no same-type mismatch" reaches the bare line when an off-port entry of another type -exists, and returns `match` where ssh prints `IDENTIFICATION HAS CHANGED`: an accept-a-changed-key -path, reproduced live. And the observations from each pass must not leak into the other, or an entry -found only on the fallback refuses a host ssh accepts as first contact. - -**`HostKeyAlias` suppresses the port entirely.** ssh looks the alias up bare and never brackets it, -so an alias gets ONE pass regardless of port. Combined with the rule above, a stale `[alias]:port` -line would otherwise block the bare lookup ssh actually performs — turning the bastion case this -feature cites `HostKeyAlias` for into a hard failure. - -**Hashed entries hash the candidate form, not the bare host** — `[example.com]:2222` is what gets -HMAC'd for a bracketed entry, so each candidate must be hashed separately. - -**Multiple files union.** Any exact hit in any file wins; a disagreeing entry in another file does -not make it a mismatch. Confirmed live in both orderings. - -**A `@cert-authority` line whose key equals the presented plain host key is not a match** — a CA line -only validates certificates. A normal line alongside it still decides. But ssh's verdict for a -CA-covered host presenting a plain key is `HOST_NEW`, not a failure: it connects. See D4. - -### D3. Six outcomes, and type scoping is only safe with algorithm ordering - -`match | mismatch | revoked | ca-only | unknown-type-known-host | unknown`. - -Mismatch is scoped to the same key type: a host with only an RSA entry that presents ed25519 is not -"changed". Without scoping we would false-alarm nearly every RSA-era user on their first upgraded -connect, training them to dismiss the one warning that matters. - -> **Corrected against a live client.** The premise above is wrong about OpenSSH, though the -> conclusion survives. `check_key_in_hostkeys` is not type-scoped at all: ANY non-marker entry for -> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — `known_hosts` -> holding only `ssh-rsa` against an ed25519-only server prints `IDENTIFICATION HAS CHANGED` and -> refuses. So ssh does not avoid the false alarm by scoping; it avoids the _situation_ via -> `order_hostkeyalgs`, and hard-fails when the situation arises anyway. Our split into `mismatch` -> and `unknown-type-known-host` therefore only chooses the wording — both refuse, which is ssh's -> action. What the ordering below buys us is what it buys ssh: the situation mostly never arises. - -**But scoping alone is a downgrade vector, and this is the correction that most changes the design.** -OpenSSH is safe here only because `order_hostkeyalgs()` reorders the client's proposed host-key -algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the _client's_ order -priority — so a server cannot choose a type the client deprioritised. ssh2 negotiates ed25519 first -regardless. An attacker who cannot forge the RSA key on file simply presents ed25519 and receives a -friendly first-contact prompt instead of a hard failure. - -Therefore: **set ssh2's `algorithms.serverHostKey` to lead with the key types already known for that -host.** Type scoping without algorithm ordering is not a safe design. - -And when the presented type is unknown _while other types are known for this host_, that is -`unknown-type-known-host` — never a plain TOFU prompt. It must say we already hold a different key -for this host. - -### D4. Outcomes - -- **match** → connect silently. -- **unknown** → trust-on-first-use (see the phasing below for whether that is silent or prompted). -- **mismatch** → hard fail, no override in the failure surface. -- **revoked** → hard fail, always. -- **ca-only** → ~~hard fail~~ **REVERSED: treated as first contact.** See below. -- **unknown-type-known-host** → treat as suspicious, not first contact. - -`StrictHostKeyChecking` is honoured: `no`/`off` accepts unknown but **never persists** and still -hard-fails changed and revoked; `accept-new` persists silently; `yes` denies unknown. - -> **`ssh -G` does not report the spelling the user wrote.** StrictHostKeyChecking is rendered through -> `fmt_multistate_int`, which prints the first entry of `multistate_strict_hostkey`, and that table -> lists true/false before yes/no. So `yes` arrives as `true`, `no` and `off` both as `false`; only -> `ask` and `accept-new` pass through unchanged. Matching on `yes`/`no`/`off` matches nothing a real -> config can produce. `UpdateHostKeys` has the same shape (`true`, not `yes`). - -**ca-only, reversed after review.** The rejection was stricter than ssh, and the blast radius was -mispriced. An SSH CA user holds ONE line — very often `@cert-authority *` — which matches every -candidate, so EVERY target failed, not just CA-signed ones, including on-demand runtime VMs, and -`StrictHostKeyChecking=no` did not help. `ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1` is read from the -process environment, which an Electron app launched from the Dock or Start Menu does not have, so -the documented escape was unreachable for exactly the people who needed it. And OpenSSH's own -verdict for a CA-covered host presenting a plain key is `HOST_NEW`: it connects. ssh2 cannot -validate certificates at all, so refusing conceded nothing ssh was not already conceding. - -The residual risk is accepted, not resolved: for a CA-protected host we take a plain key we cannot -tie to the CA. Certificate support is Phase 2 work. The `ca-only` outcome is still produced and -carried through the decision so the log shows a CA line was involved. - -**An unreadable known_hosts connects but records nothing.** A file that EXISTS and will not open is -the absence of evidence, and the common trigger is not exotic — a Windows OneDrive Known Folder Move -placeholder while offline fails with a cloud-file error, not ENOENT. Refusing there broke an -ordinary corporate laptop while blaming a config file that was fine, and was asymmetric with our own -store, which degrades to "nothing trusted" and connects. ssh warns and treats the host as unknown; -so do we — but we write no record, so a first contact we could not check never becomes durable -trust. An ABSENT file is not this case: that is the normal state for a fresh profile and genuinely -means nothing is known. - -### D5. Recovery must not live in the failure dialog - -A "forget this host key" button _in_ the mismatch dialog is D4's rejected "trust anyway" with one -extra click. Recovery lives in target settings: a separate, deliberate surface, no auto-retry, and it -shows the stored fingerprint so the user is choosing knowingly. - -Offer it only when **our** store is what disagreed; when `known_hosts` disagrees, forgetting our -record cannot unblock the connect. Messages, written to avoid naming internals: - -> **Ours disagreed** — "The host key for `build-01` changed since you last connected from Orca. If you -> rebuilt or reprovisioned this machine, this is expected." → _Forget the saved key_ / _Cancel_ - -> **`known_hosts` disagreed** — "The host key for `build-01` does not match the entry in -> `~/.ssh/known_hosts`. `ssh` and `git` will refuse this host too. Run `ssh-keygen -R build-01`." → -> no button, because a button would not help. - -### D6. Never prompt on a background reconnect - -A prompt only means something when a human initiated the connect. `userInitiated` does not exist on -the connect path today and must be threaded through `connect → attemptConnect → doSsh2Connect`, -defaulting **false**. - -Two traps: `useAutomationDispatchEvents.ts:203` and `pty-connection.ts:857` reach `ssh:connect` -without a human click — automation must pass `false`, but **terminal-pane focus reconnects must count -as user-initiated** or terminals die silently. And the denial string must avoid "authentication -failed"/"permission denied", or `isAgentFallbackError`/`isAuthError` -(`ssh-connection-utils.ts:46-61`) misclassifies it and the reconnect ladder retries a decision that -will never change. - -### D7. Fail closed — three known fail-open shapes - -1. The existing generation/disposed guard at `:1185` has the fail-open shape today: skip recording, - still `return true`. Post-fix that branch must **deny**. -2. A synchronous throw inside the verifier may not be caught by ssh2 — wrap and `verify(false)`. -3. Any non-`undefined` return accepts immediately (see Traps). - -Plus: no prompt channel registered → deny (the load-bearing default lives in `doSsh2Connect`, not in -IPC, so a caller that forgets to wire it cannot accidentally accept); no window → deny; timeout → -deny; dialog dismissed → deny. - -### D8. Store shape and scope - -Accepted keys are scoped to **host + port + key type**, not target id — aliases point at different -machines, two targets can name one machine, and a re-created target must not lose trust. - -The store is a **dedicated file**, not the main persistence blob (`persistence.ts:7088`): a settings -restore or rollback must not silently reset trust. Accept and mismatch events are logged. - -`hostKeyFingerprint` is now security-relevant _and_ wire-relevant — it is an isolation namespace sent -to the host (`ssh-relay-session.ts:1298`, `managed-hook-owner-identity.ts:187`). It is `undefined` on -the system transport, so **no trust logic may key off it**, and its format must not change (see -Traps). - -## Phasing — ship the defence before the dialog - -Review made the case that the riskiest part of this change is not the security model but the modal. -Startup restore fires eager connects for _all_ previously-active targets in parallel (`App.tsx:1041`) -with a 15s timeout, while a prompt would live 120s — N unknown hosts means N stacked dialogs -outliving the timeout that already deferred them. Runtime-owned ephemeral VMs -(`ephemeral-vm-runtime-ssh.ts:31`) dial a freshly provisioned host with a brand-new key on every -launch. Paired-web connects run on the _host desktop_ (`runtime/rpc/methods/ssh.ts:32`), so the -dialog would open on someone else's screen while the web user watches a spinner. - -**Phase 1 — no new modal.** Consult `known_hosts` + our store. `match` connects. `unknown` persists -silently with `accept-new` semantics and a passive notification naming the host and fingerprint. -`mismatch` (same type) and `revoked` hard-fail. This is the entire MITM defence with zero prompts, -zero startup storms and zero web hang. - -**Phase 2** — the TOFU dialog, `StrictHostKeyChecking` honouring, `ca-only`, `userInitiated` -plumbing, and the D5 settings surface. - -Carve-outs required before Phase 1 ships: - -- **Runtime-owned ephemeral targets are exempt from persistence** — a new key every launch is - expected, not suspicious, and recording one would accumulate a row per launch that eventually - reads as a spurious change. Implemented via `target.owner?.type === 'on-demand-runtime'`. -- **RPC-originated connects: NOT needed in Phase 1, required in Phase 2.** The review asked for - these to fail fast rather than leave a paired-web user watching a spinner for the 120s prompt - timeout. That hang is only reachable if a prompt exists, and Phase 1 has none — the decision - function is pinned by a test asserting it never returns `prompt`. An RPC connect therefore behaves - exactly like a local one: it accepts and records on first contact, or fails immediately with the - host-key reason. Adding a fail-fast path now would introduce a failure mode for a hang that cannot - occur. It becomes load-bearing the moment the dialog lands, and is listed in Phase 2. - - Worth noting for Phase 2: `runtime/rpc/methods/ssh.ts` already swallows the specific error and - rethrows `getPublicSshError(status)`, so a web client sees a generic failure rather than the - host-key reason. Pre-existing, but it means the Phase 2 message will not reach the web user - without a change there too. - -## Traps - -Each of these makes the fix silently do nothing. All confirmed in our tree. - -1. **An `async` verifier defeats it entirely.** ssh2 does - `const ret = hashCb(key, verify); if (ret !== undefined) verify(ret)`. An async function returns a - Promise — not `undefined`, and truthy — so ssh2 accepts before our callback settles. -2. **Do not set ssh2's `hostHash`.** It hands the callback a hex digest and discards the raw blob we - must compare — and it would change `hostKeyFingerprint`'s format, which is a cross-version state - break, not a local refactor. -3. **The existing test mock calls `hostVerifier(key)` with one argument** and ignores the return - (`ssh-connection.test.ts:86-91`). Under an async verifier every connect test there breaks. The - mock must change — flagged deliberately, not rewritten silently. -4. **Validate the blob**: embedded algorithm name must match the line's key-type field; reject empty - decodes, empty salts, and hashed entries whose hash is not 20 bytes. -5. **`ssh-relay-live-connect.test.ts:59`** constructs a connection with no credential callback — - headless with no prompt channel must deny, not hang. - -## Scope - -**In scope, corrected:** IPv6 literals and `[host]:port` bracket parsing. Review was right that this -is a _parser_ requirement, not a scope call — getting it wrong means hosts `ssh` knows come back -`unknown`, which is the prompt-training harm D3 exists to avoid. - -**Out of scope, with consequences stated:** - -- **`CheckHostIP`** — OpenSSH defaults it off; we form candidates from the hostname only. -- **WSL** — `src/main/ssh/` has no WSL awareness; a distro's `known_hosts` is unreachable, so WSL - users get first-contact treatment for hosts they already verified. -- **`UpdateHostKeys`** — we read it and use nothing, so we never learn a rotated key, which makes D5 - the routine path for key rotation rather than an exception. -- **Moving SFTP to the system transport** — correct direction, separate change. - -## Test plan - -**Parser** (against the file format, not our code's shape): plain lines, `host,host2` lists, -`[host]:port` used only when port ≠ 22, IPv6 literals, hashed `|1|salt|hash` with a real computable -vector, `@revoked`, `@cert-authority`, `*`/`?` globs, `!` negation vetoing a whole line, unrecognised -`@marker` skipping the line, malformed lines skipped not fatal, multiple keys per host, CRLF, blank -lines, comments, user file and global file disagreeing. - -**Decision function**: all six outcomes; type scoping; revocation resolved before match regardless of -line order; every `StrictHostKeyChecking` value; `no`/`off` never persists. - -**Algorithm ordering**: `algorithms.serverHostKey` leads with types on file — the test that makes D3 -safe rather than merely scoped. - -**Wiring**: unknown persists (Phase 1) without a prompt; match never notifies; mismatch fails with no -accept path; revoked fails; background reconnect denies; aborted connect settles pending verify -false; no prompt channel denies; runtime-owned targets are exempt; the denial string does not match -`isAuthError`; and — catching the worst regression — **the verifier returns nothing**, so a refactor -to `async` reddens a test rather than reaching a user. - -**Checked against a live client, not just the file format.** Two assumptions the design leans on were -verified by running an OpenSSH 10.2p1 client against a real `sshd` on `127.0.0.1:2222` and recording -its verdict: - -- **The bare-host fallback pass never reports a change.** With `StrictHostKeyChecking=accept-new`, a - bare line holding a _different_ key, dialed on a non-default port, made ssh connect and append a - new `[127.0.0.1]:2222` line — first contact, no `IDENTIFICATION HAS CHANGED`. Reporting `mismatch` - on that pass would refuse hosts ssh connects to happily, and would have looked like the cautious - choice. -- **`unknown-type-known-host` is ssh's own behaviour.** known_hosts holding `ssh-rsa` while the - server offers ed25519 makes ssh print `IDENTIFICATION HAS CHANGED` and refuse. So the rejection is - neither stricter nor laxer than ssh — and treating it as first contact, which a naive type-scoped - lookup does, is the laxer mistake. It also means `ssh-keygen -R` is the right remedy to name there. - -## What Phase 1 shipped, and what review changed - -The design above survived implementation. Every defect found afterwards was in the wiring, and the -pattern is worth recording because it repeats: **each one made us either blind or unusable, never -subtly wrong.** - -Fixed after review: - -1. **Our own store was type-downgradable.** The inline lookup filtered by key type first and could - only answer match/mismatch/unknown, so a record of a _different_ type read as `unknown`. D3's - downgrade, applied to the records we create ourselves. Stored types now also feed the algorithm - ordering — without that the guard is only half present. -2. **We keyed on the Orca label.** `ssh -G` echoes its own argument back as `hostname` when no Host - block matches, so for a manual target `resolved.hostname` _is_ the label — the one name D2 - forbids. We consulted no entries at all. -3. **A refused key still walked the credential ladder.** ssh2 reports a denial as a generic auth - failure, so we went on to prompt for the passphrase and hand it to the host we had just refused. - Rejections are now a typed error recognised before any fallback. -4. **Fail-closed nearly became fail-always.** "No readable `known_hosts`" counted a _missing_ file the - same as an unreadable one, so a profile that had never connected — everyone's first run — would - have been refused, and the suite passed only because dev machines have a `known_hosts`. -5. **Ephemeral runtimes were refused for a policy they cannot satisfy.** The carve-out sat below the - incomplete-sources check, so a HOME-divergent environment turned on-demand runtimes off entirely. - -A second review round, run against a live OpenSSH client and sshd rather than against the source, -found five more — and the pattern held: the two that mattered most were both cases where we refused -a host `ssh` connects to, and the worst single defect was that **`StrictHostKeyChecking` had never -been read correctly at all**, so a config saying `yes` was silently accepted AND persisted. See the -D2/D3/D4 corrections above. The lesson worth keeping: every one of these was invisible to unit tests -that fed the code the value a human writes, rather than the value the tool emits. - -## Action items (STA-4319) - -**Where the message actually lands.** Traced end to end, because a rejection the user cannot read is -a half-shipped feature. Fixed in this branch: the settings card clamped it to one line with no -tooltip, and the terminal reconnect overlay never asked for it at all. Still open: - -- **The "Remote Hosts" status bar shows only `Error`.** `SshTargetStatusRow` does not receive the - error, so the status bar is a dead end for the most likely place a user notices the failure. -- **"Connect again" is the wrong advice for a decision that will never change.** The terminal overlay - now prints the reason underneath, but its call to action still invites an action that cannot - succeed. Telling a permanent rejection from a transient fault in the renderer needs a typed reason - on the wire rather than a string — a remote-wire-compatibility decision, so deliberately deferred. -- **Toasts carry Electron's `Error invoking remote method 'ssh:connect':` prefix.** The repo has - strippers for exactly this; no SSH call site uses one. Also worth noting sonner auto-dismisses in - 4s, which is short for a message ending in a command the user is meant to copy. - -**Before Phase 2:** - -- **`UpdateHostKeys` (out of scope above, now the highest-value gap).** We read it and use nothing, - so a rotated key is a hard failure the user must resolve by hand. Combined with D5 this is the - routine path for key rotation, and it will be the most common way a legitimate user meets a - rejection. Decide whether Phase 2 honours it or D5's recovery surface absorbs it. -- **The web user never sees the reason.** `runtime/rpc/methods/ssh.ts` rethrows - `getPublicSshError(status)` on all three paths, and push events are redacted through - `getPublicSshState`, so a paired-web client always sees exactly `SSH connection unavailable`. - Pre-existing, but it makes the Phase 2 dialog message unreachable there without a change. Note the - redaction is not web-only: any target owned by a paired runtime environment is redacted, so a - _desktop_ user viewing a remote-Orca-server-owned host gets the same generic string. -- **RPC fail-fast** becomes load-bearing the moment the dialog exists (see Phasing). - -**Known gaps that Phase 1 accepts, listed so they are choices and not surprises:** - -- **WSL** — a distro's `known_hosts` is unreachable, so WSL users get first-contact treatment for - hosts they already verified through `ssh` inside the distro. -- **`CheckHostIP`** — candidates are formed from the hostname only. -- **Certificate validation** is still absent — a CA-covered host is now accepted on first contact - rather than refused (D4), so those users connect, but the CA itself verifies nothing for us. -- **`DEFAULT_SERVER_HOST_KEY_ALGORITHMS`** is a hand-copy of an ssh2 internal. A test pins it, so an - ssh2 upgrade that changes it fails CI rather than shipping — but the pin has to be honoured, not - deleted, because ssh2 throws `Unsupported algorithm` and every target stops connecting. - -**Rollout:** the first release carrying this is the first time Orca can refuse an SSH connection at -all. Worth a staged rollout or a kill switch: the failure modes we could not find are, by the shape -of the five above, far more likely to be "a legitimate host is refused" than "a bad key is accepted". diff --git a/docs/reference/ssh-reconnect-source-recovery.md b/docs/reference/ssh-reconnect-source-recovery.md deleted file mode 100644 index e8c12cf51895..000000000000 --- a/docs/reference/ssh-reconnect-source-recovery.md +++ /dev/null @@ -1,159 +0,0 @@ -# SSH reconnect: why the pane retry gets a byte tail, and what would actually change it - -Status: investigation result. The obvious follow-up to PR #14844 was traced and **rejected**, and -tracing it turned up the actual root cause: checkpointed source recovery has never run on an SSH -reconnect. Both are recorded here — the rejected shape so nobody re-proposes it, and the verified -cause with the fix it implies. - -## The shape of the problem - -A reconnect remounts the pane (`tab.generation` is its React key), so the xterm is disposed with its -buffer and something must repaint it. Today that is a **byte tail**: `reattachSshPtySession` sends -`requireReplay: true` and the relay returns `RecentPtyOutputBuffer.read()` — the last 100KB, read -non-destructively, with no notion of what this client already consumed. - -Two costs follow. Main's `@xterm/headless` model never sees those bytes (the tail bypasses -`onPtyData`), so it is stale by exactly the outage — which is what forces -`sshReconnectPaintsFromModel` to restrict the grid repaint to the alternate screen. And a shell loses -outage output past 100KB permanently. - -## The proposal that does not work - -"Make the pane-retry path request source recovery like `reattachKnownPtys` does." Mechanically this -is trivial — `sourceRecovery` is already an optional `pty.attach` param the relay parses, Path C -already calls the same `requestSshPtyAttach` helper and already parses the response field. The -required checkpoint state also survives a transport drop, in the module-level `recoveryByTarget` map -(`ssh-pty-consumer-recovery.ts:17`), reachable from `connectionId` because `connectionId === targetId`. - -It still fails, three ways: - -1. **The relay answers `'existing'` before it looks at the recovery argument.** - `relay-pty-source-publication.ts:99-109` short-circuits on a same-`clientId` attach, and a - reconnected client presents the same id — see the root-cause section below, where this turns out - to be the whole story rather than an obstacle specific to this proposal. -2. **A failed `reattachKnownPtys` deletes the checkpoint on purpose** (`ssh-relay-session.ts:3006-3007`) - and detaches the lease (`:3008`). The pane retry runs _after_ that, so it would present - `checkpointUnavailable`, which the relay converts to `restoreRequired` - (`relay-pty-source-publication.ts:124-130`) and the provider converts to - `SSH_SESSION_EXPIRED_ERROR` (`ssh-pty-provider.ts:103-107`). We would trade a blank-pane-with-tail - for a **killed session**. -3. **Wrong payload shape.** Recovery replays only the post-checkpoint delta - `(acceptedSourceEndSu → receivedEndSu]`. The byte tail is a screen snapshot for a _fresh, empty_ - xterm. Even a successful recovery returns roughly nothing in the common case, and the pane stays - blank. - -These two mechanisms answer different questions. Recovery keeps main's model whole; the tail repaints -a new terminal. Substituting one for the other is a category error. - -## A correction worth recording - -The motivating argument was "`requireReplay` is optional, so older relays ignore it and still show -blank panes." **That is wrong for the SSH relay.** The client deploys and launches its own relay -build into a version-scoped directory (`ssh-relay-deploy.ts:231`, `:594`), and `validateGrant` -rejects any grant whose `serverBuildId` differs from the expected one -(`ssh-pty-consumer-session.ts:58-65`, rationale in-code: _"client and relay ship in one build"_). -Client and SSH relay are version-locked; mixed versions do not occur on this channel. The -independent-update rule in `remote-wire-compatibility.md` still governs remote _runtime_ hosts — just -not this one. - -So there is no old-host population to rescue, and the urgency that argument created was false. - -## ANSWERED: checkpointed recovery never runs on an SSH reconnect - -The question above was "does the reconnecting client present a new `clientId`?" It does not, and the -consequence is that the whole checkpoint mechanism is dead on this path. Every link verified: - -1. **The client keeps its id.** A reconnect calls `Dispatcher.setWrite` - (`src/relay/dispatcher.ts:149-157`), which reuses `this.primaryClient` — including its `id` — and - replaces only the writer. The dispatcher refuses to detach the primary. This is already stated - in-repo at `src/relay/pty-handler.ts:1736-1742`. -2. **So `activate()` short-circuits.** `relay-pty-source-publication.ts:99` tests - `current?.clientId === context.clientId` and returns `'existing'` at `:108`. The `rotateDelivery` - recovery path at `:118-142` is reachable **only** when the ids differ — i.e. never, here. -3. **So the relay returns no `sourceRecovery`.** -4. **So the client abandons.** `finishSourceRecovery` (`ssh-relay-session.ts:2766-2785`) fails its - `!pendingRecovery` guard, calls `abandonPtySourceRecovery`, and returns false — which cancels the - delivery and deletes the checkpoint (`:3006-3008`). -5. **So the pane retry opens fresh and gets the byte tail**, via the `requireReplay` fix. - -The byte tail is therefore not a fallback. It is the only path that has ever run for an SSH -reconnect, and the flow-control/checkpoint machinery is inert on this path. - -That also explains the original blank-pane bug exactly: the relay concluded "this client already -holds the stream" because, by its own identity rule, it does. - -### The fix this implies - -Give a reconnected primary a distinguishable identity — a transport generation on the client record, -bumped in `setWrite` — and have `activate()` compare it alongside `clientId`, so a reconnect takes -`rotateDelivery` instead of `'existing'`. - -Why this is the tractable shape: - -- **No wire change.** `RequestContext`, `setWrite` and the publication are all relay-internal. -- **No compatibility exposure.** Client and relay ship in one build and are version-locked. -- **It does not disturb the invariant that broke three earlier attempts.** Deliveries still outlive - their clients; nothing retires on `onClientDetached`. The delivery is _rotated on re-attach_, - which is what the recovery design already intends and what its tests already cover. - -**UNVERIFIED and to be checked before implementing:** that `rotateDelivery`'s preconditions hold at -that moment (the checkpoint's `deliveryToken`, `clientGeneration`, `ownerGeneration` and -`ptyIncarnation` must match the live identity, `:124-128`); that `outputFlowControl` is granted on -the reconnected session; and what a rotation implies for the _renderer_, which still remounts with an -empty xterm and needs a screen, not a post-checkpoint delta. Recovery keeps main's model whole — it -does not by itself repaint a fresh terminal, so the tail may still be wanted for the pane even once -the model stops going stale. - -## Do not start at `onClientDetached` - -Three attempts failed there, each plausible until run: - -- Retiring the delivery on `dispatcher.onClientDetached` **breaks checkpoint recovery** (10 tests). - A delivery outliving its client is deliberate — it is what lets a client resume from a checkpoint. -- Retiring without `session.cancelDelivery()` orphans the credit ledger's one-upstream-owner slot; - the next open throws `PTY source delivery already has an upstream owner`. Seen live as a toast and - a blank pane. -- Comparing `record.identity.clientGeneration` to the request is impossible: that value is - client-supplied via `pty.openClient`, and `RequestContext` carries no generation of its own. - -## The lead that survives - -`reattachRejectedPty` (`ssh-relay-session.ts:1957-2004`) is an existing **single-PTY** entry point -into the `reattachKnownPtys` machinery, taking `(relayPtyId, mux, providerGeneration, mode)` and -driving recovery with `targetedDeliveryRecovery`. If per-pane recovery is wanted, that is the hook — -and it does not involve the pane-retry path at all. Unverified whether it is reachable at the moment -the renderer retries. - -## Preconditions, unchanged - -The SSH e2e lane must be green and triggering on **source** changes before any of this is attempted. -It was skipping for 15 specs; four regressions reached a user during that window. - -## Resolved: a disposed pane killed its successor's new shell - -A pane rebuilt during its first spawn uses the same reservation key, so main can return the -same PTY to both transports (#19386, #22578). The disposed transport must keep that shell -while its tab and layout leaf remain and its execution host's workspace is not being deleted. -A live transport refusing the id still retires it (#11003). This applies to local, WSL and SSH -IPC terminals; the remote-runtime transport has no corresponding kill. - -The remount trigger in the Scan-22 user report remains unknown. A retained shell can outlive -its tab if the tab closes before a successor binds it; keeping potentially owned work follows -the SSH execution boundary. - -## Open: the pane behind a preserved tab does not always rebind - -The merge now keeps a local tab the host has never been told about, so the tab and its title survive -a reconnect. The reattach behind it does not, reliably — measured at three runs in four against the -Docker-SSH lane. When it misses, the store holds the tab, the tab bar renders it, and the pane never -rebinds: the "frozen tab" shape the original report described, one layer down from the deletion that -used to cause it. - -Deliberately NOT asserted in `ssh-reconnect-tab-destruction.spec.ts`. A one-in-four flake in the lane -that exists to catch this class costs more than it proves — the lane stops being trusted, which is -exactly how the earlier silent-skip failure happened. Tab survival is asserted there and is -deterministic; the liveness gap is recorded here instead. - -Worth checking first, since it is the same shape as everything else in this file: the tab is absent -from the host snapshot, so whatever drives the per-tab reattach after an apply may simply not know to -reattach a tab the snapshot never mentioned. diff --git a/docs/reference/task-provider-identity-validation.md b/docs/reference/task-provider-identity-validation.md deleted file mode 100644 index 4dba0760aabe..000000000000 --- a/docs/reference/task-provider-identity-validation.md +++ /dev/null @@ -1,81 +0,0 @@ -# Task provider identity RPC validation - -The automation RPC identity schema follows `src/shared/task-provider-identity.ts`, -re-exported by `src/shared/task-source-context.ts`. Only GitHub requires fields beyond -`provider`: `owner` and `repo` are strings, and `host` is optional. GitLab, Linear, -and Jira fields are optional nullable strings. Requiring a GitLab project or a -Linear workspace/Jira site would contradict the domain type and account-wide scopes. - -The discriminated union validates these existing field types without trimming, -coercing, or stripping identity fields. Unknown fields pass through as they did under -`z.custom`, including fields from newer clients. Absent and explicit-null identities -remain distinct. The schema does not infer providers from owner/repo or require a -git worktree, repository slug, or local execution host for a source context. - -## Producer census - -Paths below are relative to the repository root. Searches covered production -`providerIdentity`, `TaskProviderIdentity`, `sourceContext`, and -`linkedTaskSourceContext` uses across desktop, shared code, mobile, and CLI. - -| Producer or forwarding path | Populated verdict | -| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/shared/project-host-setup-projection.ts`: `getProjectProviderIdentity` | GitHub owner/repo populated together, or no identity. Supplies project identities consumed by desktop and migration. | -| `src/renderer/src/components/task-page-source-context.tsx`: `getTaskPageRepoSourceContext` | GitHub fields populated through projection, or null. GitLab uses explicit provider and `buildGitLabProviderIdentity`; projectId/project/webUrl populated, namespace can be null. | -| Same file: `buildGitLabProviderIdentity` | GitLab fields come from project path/host; missing path components become null. No required GitLab field is invented. | -| `src/renderer/src/hooks/composer-state/source-context-state.ts` | Derived GitHub context carries a complete project identity or null. Jira folder/project-group context explicitly has null identity. Draft/linked contexts are forwarded. | -| `src/renderer/src/components/use-task-page-source-availability.ts` | Linear workspaceId/name and Jira siteId/URL can be null for account-wide selections; team/project fields are not populated. Valid under the existing optional-field contract. | -| `src/renderer/src/components/task-page-jira-item-source-context.ts` | Bound issue context populates siteId, siteUrl, projectKey. | -| `src/renderer/src/components/new-workspace/use-jira-url-source.ts` | Bound URL issue context populates siteId, siteUrl, projectKey. | -| `src/renderer/src/components/worktree-jump-palette-create-worktree.ts` | Linear teamId/key populated; workspaceId/name may be null. | -| `src/shared/task-source-context.ts`: normalize/build functions | GitHub missing owner/repo becomes null identity; other providers' missing fields become null. Provider mismatch becomes null, never an inferred provider. | -| `src/cli/handlers/automation-handler-flags.ts`, `src/cli/handlers/automations.ts` | Explicit JSON source-context input is normalized before create/update. GitHub fields populated or null identity; other fields nullable. Omitted/null context preserved by flag handling. | -| `src/main/persistence/scheduling-automations/automation-context-migration.ts` | Builds source context from projected complete GitHub identity, or null context. | -| Desktop automation save/scoped-list/host clients and web transport | Forward existing source contexts, not new identity constructors. `automation-orca-save.ts` forwards the current automation context or null. Legacy arbitrary malformed RPC input is deliberately rejected by the new schema. | -| Mobile | No task-provider identity/source-context constructor or sender found. `mobile/src/components/new-workspace-project-targets.ts` uses project identity solely for display. | - -## Compatibility evidence and limits - -Read `docs/reference/remote-wire-compatibility.md` before changing validation. -A source search against release tag `v1.4.199` also finds no mobile -`sourceContext`/`linkedTaskSourceContext` sender; its sole `providerIdentity` use -is the display-only project target above. The released CLI flag reader also calls -`normalizeTaskSourceContext`. This is source-level evidence for the checked release, -not a claim to have executed every historical mobile binary. - -No shipped mobile producer with a newly rejected payload was found. No new required -field was added to the domain contract. GitLab, Linear, and Jira discriminant-only -identities remain valid. Folder-workspace null/absent identities remain valid on -both local and SSH hosts. No execution/status logic or client-side parsing changed. - -## Regression evidence - -`src/main/runtime/rpc/methods/task-provider-identity.test.ts` checks unchanged valid -identities for all four providers, required GitHub fields, every declared field's -type, optional/null non-GitHub fields, unknown-field preservation, explicit GitLab -discrimination with owner/repo present, local/SSH folder contexts, and update patches. - -The focused run passed 81 tests. Temporarily replacing GitHub's field validators -with optional `z.unknown()` validators (discriminant-only acceptance) caused 17 -failures and 64 passes. The mutation was restored before running the gates. - -Counts re-measured at `cf4f77f` after the blank-field commit added seven tests; -the earlier 74/16/58 figures described the commit before it. - -## Gate results - -All commands ran with `ORCA_BACKGROUND_LAUNCH=1`. - -- `pnpm tc`: exit 0; completed the repository typecheck runner. -- `pnpm exec vitest run src/main/runtime/rpc`: exit 1; 277 files passed, - one failed; 2,462 tests passed, one failed, one skipped. The only failure was - the unrelated `structured-agent-session-adoption-replay.test.ts` hitting its - 5,000 ms timeout. All identity tests passed. -- `pnpm --dir mobile typecheck`: exit 0; `tsc --noEmit` passed. -- `pnpm run check:code-quality:changed`: exit 0; zero new code-quality, - type-aware, or React Doctor findings across the two changed code files. -- Isolated retry of `structured-agent-session-adoption-replay.test.ts`: exit 0; - one test passed, with the test body completing in 358 ms. -- Full RPC retry with `pnpm exec vitest run src/main/runtime/rpc --maxWorkers=4`: - exit 0; all 278 files passed, 2,470 tests passed, one skipped (97.24 seconds). - The bounded-concurrency rerun resolved the timeout without changing test code. diff --git a/docs/reference/terminal-artifact-grant-integrity.md b/docs/reference/terminal-artifact-grant-integrity.md deleted file mode 100644 index ea04d35deeff..000000000000 --- a/docs/reference/terminal-artifact-grant-integrity.md +++ /dev/null @@ -1,85 +0,0 @@ -# Terminal artifact grant integrity - -Clicking a path in terminal output mints a short-lived grant over one file in a -world-writable temp directory. Between minting the grant and using it, anything on the -host can replace that file. This page is the contract for the checks that catch such a -replacement — and, more importantly, for the windows they do **not** close. - -Read this before weakening a check in -`src/main/runtime/runtime-file-commands-terminal-artifact-access.ts`, before assuming -the sequence is atomic, or before extending the guarantee to remote hosts. - -## The stat identity is weaker than it looks - -A grant pins the file as `dev:ino:nlink:size:mtimeMs`. Four of those five fields -survive an unlink-and-recreate at the same path, measured on Linux (Node 24) by -replaying that exact sequence: - -| Field | After `rm` + recreate in the same directory | -| --------- | ------------------------------------------------------------------------ | -| `dev` | unchanged — same filesystem | -| `ino` | **reused 100% of the time** (3000/3000); ext4 hands back the freed inode | -| `nlink` | `1` before and after | -| `size` | unchanged whenever the replacement is the same length | -| `mtimeMs` | **quantised to 1 ms** — the kernel's coarse clock advances once a tick | - -So for a same-length replacement the whole identity string collapses to a 1 ms -timestamp race. The full string collided in 63.7% of back-to-back iterations, 19.8% -with a 0.5 ms gap, and 0% at ≥1 ms. The same probe on macOS collided 0 times in 2000 — -no inode reuse, nanosecond mtimes — which is why this only ever showed up on Linux CI. - -`ino` contributes no discriminating power against the exact case it is there to catch. -Do not add `ctimeMs` or `birthtimeMs` hoping to fix this: they come from the same -coarse clock and collide in the same window. `bigint: true` stats do not help either — -the precision loss is in the stored kernel timestamp, not in Node's `Number`. - -## The content digest sits alongside the identity, and cannot replace it - -Local grants also pin a sha256 of the artifact's bytes, read from the **same open -handle** as the stat so the two describe one inode with no gap between them. - -The identity string stays exactly as it is because it is a wire contract, not a -host-local detail: `src/relay/fs-handler-terminal-artifact.ts` recomputes that same -`dev:ino:nlink:size:mtimeMs` format field-for-field from its own stat and compares it -against the `expectedStatIdentity` the host sends. Changing the format would have a new -host publishing a string an older relay can never reproduce, failing every remote -artifact read as `terminal_file_grant_stale` — a break that reaches old peers with no -wire-schema change at all. See [remote wire compatibility](./remote-wire-compatibility.md). - -## What is still open - -The digest narrows these checks. It does not make any of them atomic. - -- **Read and preview — closed.** The bytes returned to the caller are the same - in-memory buffer that was digested, with no re-read in between, and the handle pins - the inode for the whole operation. A swap landing after the `open` leaves the handle - on the granted inode, so the granted content is what is served; a swap landing before - it fails the digest. -- **Write — a window survives.** Between the final pre-rename verification and the - `rename()` itself, the target path can still be swapped, and the rename clobbers - whatever is there. POSIX `rename()` has no "only if the target is still inode X" - form; Linux's `renameat2(RENAME_EXCHANGE)` would close it but is not portable and is - not exposed by Node. Narrowing this further means changing the commit strategy, not - adding another check before it. -- **Artifacts over 10 MB fall back to stat-only.** They digest to `null`, so only the - identity guards them. Nothing leaks: every read, preview, and write path rejects on - size before reading. The weakness is unreachable, not fixed — a later cap change - could expose it. -- **Remote and SSH grants are untouched.** They keep the stat-only check, with the full - 1 ms weakness on whatever filesystem the relay runs. Closing it needs a negotiated - capability so an older relay is never sent a digest it cannot verify. - -## What is proven, and what is inferred - -The filesystem numbers above are direct measurements. That identical stats plus changed -content previously returned the swapped bytes, and now do not, is pinned by -`orca-runtime-files-terminal-artifact-swap-detection.test.ts`, which replays the first -stat seen for a path so the collision is deterministic rather than a 1 ms coin flip. - -The join between the two is a chain, not a single observation. This defect surfaced as -an intermittent failure of `orca-runtime-files-terminal-artifact-io.test.ts` on -`rejects stale absolute terminal artifact previews before returning changed content`, -which swaps an 8-byte artifact for 8 different bytes. No one has instrumented a Linux -runner to prove that a specific CI failure was a same-tick mtime collision; the -conclusion rests on every ingredient being measured separately. Treat it accordingly if -a future failure does not fit. diff --git a/docs/reference/terminal-perf-latency-investigation.md b/docs/reference/terminal-perf-latency-investigation.md deleted file mode 100644 index 4e61ab3529c1..000000000000 --- a/docs/reference/terminal-perf-latency-investigation.md +++ /dev/null @@ -1,106 +0,0 @@ -# Terminal latency investigation (2026-09-21) - -The September 21 scheduled report has five latency violations: three restores -above 1,000 ms, a worst key around 2,091 ms, and timer drift around 2,034 ms. -These remain failures under the [historically calibrated budgets](terminal-perf-report-budgets.md). -Passing the looser Electron assertions does not establish that the report passed. - -## Historical boundary - -Slow restores predate September: the July 20 scheduled run measured a 1,226.6 ms -Latin restore; August 1 measured 1,492 ms. Earlier June/July logs did not contain -usable summary rows and their artifacts expired. There is no established good/bad -application revision boundary, and no completed git bisect. A newly visible report -failure is not, by itself, evidence of a newly introduced application regression. - -The scheduled workflow uses one Playwright worker. Parallel Electron workers do -not explain these particular failures. Repeated macOS runs did not reproduce the -Linux stalls (15 targeted samples: restore 136–236 ms, hidden worst key ≤23.6 ms). - -## Controlled Linux experiments - -All comparisons use the same application build within their run, one worker, -real PTYs, and the original terminal workload. Diagnostic tracing can perturb -measurements, so it identifies the mechanism rather than setting new budgets. - -| Comparison | Evidence | Finding | -| --- | --- | --- | -| Default versus disabled background throttling | [35657300611](https://github.com/stablyai/orca/actions/runs/35657300611) | 14/20 restores exceed 1 second; four typing measurements approach 1 second. `setBackgroundThrottling(false)` does not remove the stalls. | -| Native browser trace | [35658595456](https://github.com/stablyai/orca/actions/runs/35658595456) | Renderer waits roughly 960–1,010 ms in `LayerTreeHost::WaitForCommitCompletion`. Some typing samples contain consecutive waits. | -| Current flags versus flags preceding `c64777d1bcd` versus SwiftShader | [35659601151](https://github.com/stablyai/orca/actions/runs/35659601151) | All three configurations still trigger undrawn-frame throttling. Reverting the May 26 flags is not a demonstrated fix. | - -In the graphics comparison, current flags had one of six restores above 1 second; -the earlier flags had three and a 1,036 ms worst key. SwiftShader had six measured -restores between 301 and 396 ms, but still contained one-second native waits after -the restore measurement ended, and one 202 ms timer-drift violation. Its faster -restore numbers are insufficient evidence of a fix. - -## Native mechanism - -The trace shows the renderer blocked inside: - -``` -ProxyMain::BeginMainFrame - Commit - ProxyMain::BeginMainFrame::commit - LayerTreeHost::WaitForCommitCompletion -``` - -During the gap, Viz repeatedly emits `SendBeginFrameDecision` with -`reason: ThrottleUndrawnFrames` and `should_send: false`. The graphics comparison -recorded 346 such decisions with current flags, 626 with the earlier flags, and -401 with SwiftShader. Raster work was already ready before the wait ended. - -The [matching Chromium source](https://github.com/chromium/chromium/blob/150.0.7871.250/components/viz/service/frame_sinks/compositor_frame_sink_support.cc) -limits begin frames to once per second when too many submitted frames remain -undrawn. Input can block the renderer waiting for the next compositor commit; -this is not a one-second xterm parse or proof of a second of CPU consumption. -The same throttle is present in Chromium 148.0.7778.218 (Electron 42.3.3) and -146.0.7680.177. This source comparison does not prove identical runtime behavior. - -## Visibility control - -[Run 35660847455](https://github.com/stablyai/orca/actions/runs/35660847455) -compared ten hidden-window samples with ten visible-window samples on the same -isolated Xvfb runner, using SwiftShader in both modes. Actual window visibility -was recorded. The background terminal panes remained hidden in both modes. - -| Measurement | Hidden window | Visible window | -| --- | ---: | ---: | -| Undrawn-frame throttle decisions | 1,251 | 0 | -| Largest worst-key latency | 3,062.8 ms | 30.5 ms | -| Largest timer drift | 3,111.8 ms | 67.0 ms | -| Restore range | 213.8–1,862.2 ms (9 completed) | 223.4–734.3 ms (10 completed) | -| Electron tests passed | 9/10 | 10/10 | - -All ten visible-window samples satisfy the existing latency limits. This isolates -the never-presented Linux test window as the trigger for the reproduced native -stalls. It does not establish a newly introduced application-code regression or -prove that every historical outlier had the same cause. - -## Full scale validation - -[Run 35662787327](https://github.com/stablyai/orca/actions/runs/35662787327) -passed all 21 scenarios and all 32 strict report rows, with zero skipped, -unexpected, or retried tests. All 21 scenarios recorded successful isolated-display -presentation. This run restored the original graphics flags and removed profiling. - -| Metric | Largest measurement | Unchanged report limit | -| --- | ---: | ---: | -| Median typing | 15.5 ms | 25 ms | -| Worst key | 45.7 ms | 300 ms | -| Hidden-output restore | 395.7 ms | 1,000 ms | -| Worktree revisit | 50.3 ms | 300 ms | -| Scroll | 54.7 ms | 150 ms | - -Timer drift and queue/drop checks also passed. Coverage includes 100-pane -same-workspace and cross-workspace redraws, 50 real PTYs under held-ACK pressure, -and the original plain/Latin/title/rich-model hidden-output scenarios. No workload -or performance limit changed. - -The correction presents the benchmark window only when explicitly enabled inside -`xvfb-run` on a GitHub-hosted Linux runner. Ordinary local automation stays -windowless; production launch policy and hidden-terminal delivery remain unchanged. -For comparable Linux latency evidence, use the Terminal Perf workflow: a never- -presented local Linux window can still encounter the same compositor throttle. -Temporary profiling hooks and the comparison workflow were removed before the PR. diff --git a/docs/reference/terminal-perf-report-budgets.md b/docs/reference/terminal-perf-report-budgets.md deleted file mode 100644 index 54d26c3d2a51..000000000000 --- a/docs/reference/terminal-perf-report-budgets.md +++ /dev/null @@ -1,59 +0,0 @@ -# Terminal performance report budgets - -The saved-report gate is a performance regression gate, deliberately stricter than -some Electron test timeouts. Passing the Electron suite does not establish that a -slow sample is acceptable. CLI and HTML reports use the same policy in -`config/scripts/terminal-perf-report-budgets.mjs`. - -## Historical evidence (2026-09-21) - -Sample: 11 full scheduled Ubuntu runs, 32 annotation rows per run, August 1 through -September 21 (352 rows). Runs include failures, rather than selecting only green -runs. These are samples across different revisions and shared runners, not a -controlled A/B experiment or a statistical tail-latency estimate. Original June -logs returned HTTP 410 and could not establish an original baseline. - -Values below are milliseconds except peak queue chars (JavaScript character -counts, not process memory bytes). Maximum median spans every typing scenario; -peak queue spans the active/revisit ACK-pressure scenarios. - -| Date / run | Baseline median | Maximum typing median | Latin restore | Hidden 25-pane worst key | Peak queue chars | -| ----------------------------------------------------------------------- | --------------: | --------------------: | ------------: | -----------------------: | ---------------: | -| [2026-08-01](https://github.com/stablyai/orca/actions/runs/30693362353) | 11.7 | 12.7 | 1492.0 | 61.2 | 1867776 | -| [2026-08-03](https://github.com/stablyai/orca/actions/runs/30802935899) | 7.1 | 9.4 | 414.0 | 12.8 | 294912 | -| [2026-08-15](https://github.com/stablyai/orca/actions/runs/31875140053) | 8.9 | 13.8 | 1297.5 | 15.4 | 2523136 | -| [2026-08-24](https://github.com/stablyai/orca/actions/runs/32708128219) | 12.2 | 12.2 | 463.6 | 16.3 | 3227648 | -| [2026-09-01](https://github.com/stablyai/orca/actions/runs/33488510218) | 6.6 | 6.8 | 302.2 | 11.5 | 360448 | -| [2026-09-07](https://github.com/stablyai/orca/actions/runs/34102348134) | 7.5 | 11.3 | 328.2 | 269.6 | 2818048 | -| [2026-09-11](https://github.com/stablyai/orca/actions/runs/34580494139) | 9.5 | 10.4 | 233.5 | 186.2 | 2441216 | -| [2026-09-15](https://github.com/stablyai/orca/actions/runs/34948597774) | 10.3 | 12.6 | 282.2 | 1178.4 | 2818048 | -| [2026-09-17](https://github.com/stablyai/orca/actions/runs/35201162712) | 10.3 | 12.7 | 1182.2 | 15.7 | 2998272 | -| [2026-09-19](https://github.com/stablyai/orca/actions/runs/35432611564) | 7.6 | 10.3 | 236.7 | 1435.5 | 2588672 | -| [2026-09-21](https://github.com/stablyai/orca/actions/runs/35579708852) | 7.8 | 11.6 | 1640.7 | 2090.6 | 2523136 | - -## Decisions - -- Median typing: **25 ms**, tightened from 75 ms. The largest observed median was - 13.8 ms, leaving about 81% headroom without accepting a sustained 5x slowdown. -- Worst key: retain **300 ms**, including stress scenarios. Typical per-scenario - worst-key samples were tens of milliseconds; isolated 1–3 second samples are - failures to investigate, not a reason to adopt the e2e 3–3.5 second ceiling. -- Revisit: retain **300 ms**. Median of the 11 revisit samples was 160.7 ms; - the 1,030.2 ms outlier remains a failure. -- Restore: retain **1,000 ms**. Median restore per scenario ranged from 123 to - 493.9 ms. Observed outliers up to 1,706.4 ms do not justify a 4 second budget. -- Timer drift: retain **150 ms** and the pre-existing **3,500 ms** allowance only - for injected same/cross-workspace redraw scenarios. No new scenario receives - the broad allowance. This retains the CLI gate's existing policy in HTML too. -- Scroll: retain **150 ms**. Dropped backlogs: retain **zero**. -- Current queue: retain **2 Mi characters** everywhere. Only the transient peak - in active/revisit ACK-pressure scenarios gets **3.5 Mi characters** (3,670,016). - The maximum observed peak was 3,227,648 (3.08 Mi), leaving about 14% headroom. - The old 2 Mi peak budget rejects ordinary deliberately held-ACK bursts; the - proposed 5 Mi e2e ceiling was unnecessarily loose. Other scenarios keep 2 Mi. - -For the linked September 21 report, the two peak-queue failures are corrected; -the three slow restores, worst-key stall, and timer stall still fail. This change -does not claim to fix those stalls or make that run green. Re-evaluate future -budget changes against recorded measurements; do not set limits just above a new -failure or mirror relaxed test timeouts. diff --git a/docs/reference/terminal-startup-timing.md b/docs/reference/terminal-startup-timing.md deleted file mode 100644 index bf79200fdfcb..000000000000 --- a/docs/reference/terminal-startup-timing.md +++ /dev/null @@ -1,30 +0,0 @@ -# Terminal startup timing - -For #19333, enable the renderer's opt-in recorder in its DevTools console before opening a new terminal: - -```js -localStorage.setItem('orca:terminal-startup-timing', '1') -``` - -Remove the key to disable it. Existing sessions are unaffected. To capture the existing host spawn phases, start the host with `ORCA_PTY_SPAWN_TIMING=1`. Do not restart a host with active work just to enable diagnostics. - -The renderer emits one `terminal_startup_timing` breadcrumb per transport callback generation through the existing local diagnostic channel. In `main.trace.ndjson`, find the `renderer.breadcrumb` record whose `breadcrumb.name` matches. The host's existing console timing line also becomes a `pty.spawn.timing` trace record. Correlate available PTY IDs; renderer generation distinguishes retries. Compare elapsed durations within each process, not wall clocks across hosts. - -Renderer offsets are monotonic milliseconds from callback-generation creation immediately before a transport operation: - -| Field | Observation | -|---|---| -| connected | Transport connection callback accepted for the current generation | -| liveData | First nonempty live delivery, including control-only output | -| submitted | First live batch sent to the renderer output scheduler | -| writeStarted | Scheduler invokes the batch's pre-write callback | -| parsed | Xterm invokes that batch's completion callback | -| renderEvent | First public xterm render event after the batch starts writing | - -A render event can precede the parse callback. These observations do not establish the first printable glyph, physical screen presentation, React mount time or click-to-paint latency. Replay and synthetic reset writes do not claim the first live batch. A replay or resize can still contribute to a render event after a live write, so the event is temporal evidence rather than attribution to exact content. Hidden or restored panes may never submit a live batch; missing fields remain missing. A queue-cap warning can inherit the pre-write callback while discarding the original batch’s parse callback. In that case writeStarted/renderEvent describe incomplete pre-write activity, not successful delivery of the original batch; outcome cannot be observed without parsed. - -The recorder ends after connection, parse and render observations, or on replacement, disposal, error or a ten-second diagnostic deadline. It retains only phase numbers and identifiers, with one timer and at most one render listener while enabled. It does not retain terminal text, commands, credentials or transcript buffers. Disabled recording adds no listeners or timers. - -Host `phaseDurations` preserve the current phase boundaries: the timer starts after initial ownership lookups and logs before all commit/serializer work finishes. `totalMs` is that measured interval, not full IPC latency. `provider_spawn` includes provider call and surrounding reconciliation; it is not raw process creation time. The enclosing trace record is a diagnostic snapshot, not a span covering that interval. - -Reliability invariant: diagnostics must not change terminal output, delivery credits, provider ownership or spawn outcome. Failure source: Windows OMP first-paint report #19333. Oracle: opt-in recorder tests distinguish queued, parsed and render milestones; existing live-delivery and synchronized-output suites preserve output behavior. No matching startup diagnostic reliability gate exists; full click-to-physical-presentation remains an explicit validation gap. Native, daemon, WSL and SSH execution remain host-owned; this adds no wire fields or remote process queries. Mobile has no recorder change. macOS/Linux/Windows renderer timing uses the same public xterm events; physical-device timing requires a separate capture. diff --git a/docs/reference/windows-cmd-shim-resolution.md b/docs/reference/windows-cmd-shim-resolution.md deleted file mode 100644 index c17380e800d9..000000000000 --- a/docs/reference/windows-cmd-shim-resolution.md +++ /dev/null @@ -1,77 +0,0 @@ -# Resolving Windows `.cmd` shims past cmd.exe - -Node refuses to spawn a `.cmd`/`.bat` target without a shell (the -CVE-2024-27980 mitigation), so `resolveSpawn` has to make `cmd.exe` the program -and hand it `/d /v:off /s /c ""`. For an agent CLI that -means a long `cmd.exe /c` line whose caret-escaped payload is natural-language -prompt text — which Microsoft Defender for Endpoint's command-line model scores -as obfuscation. `codex.cmd` appeared in the spawn cluster of an MDE incident -against Orca for exactly this reason. - -`src/shared/child-process/windows-cmd-shim-resolution.ts` sidesteps it. npm's -`cmd-shim` and pnpm's `@zkochan/cmd-shim` generate files whose entire body is -"find a Node interpreter and run this script". Reading one lets `resolveSpawn` -spawn `node.exe ` would +// close the inline Q1 & Q2"}') + expect(directives).toEqual([{ file: 'a.html', title: 'Q1 & Q2' }]) + }) + + it('accepts CRLF line endings and up to three leading spaces', () => { + const { blocks } = blocksOf('intro\r\n ::orca-visual{file="a.html"}\r\nend') + expect(blocks.map((block) => block.type)).toEqual(['paragraph', 'visual', 'paragraph']) + }) + + it('leaves directives inside fenced code, quotes, list items and inline code as text', () => { + const fenced = blocksOf('```md\n::orca-visual{file="a.html"}\n```') + expect(fenced.directives).toEqual([]) + expect(fenced.blocks).toEqual([ + { type: 'code', text: '::orca-visual{file="a.html"}', language: 'md', closed: true } + ]) + expect(blocksOf('> ::orca-visual{file="a.html"}').directives).toEqual([]) + expect(blocksOf('- ::orca-visual{file="a.html"}').directives).toEqual([]) + expect(blocksOf('see `::orca-visual{file="a.html"}`').directives).toEqual([]) + expect(blocksOf(' ::orca-visual{file="a.html"}').directives).toEqual([]) + }) + + it('resumes recognizing directives after a fence closes', () => { + const { directives } = blocksOf('```\ncode\n```\n::orca-visual{file="after.html"}') + expect(directives).toEqual([{ file: 'after.html', title: null }]) + }) + + it('leaves malformed or path-like directives as literal text', () => { + for (const line of [ + '::orca-visual{file="../secret.html"}', + '::orca-visual{file="a.html" file="b.html"}', + '::orca-visual{file="a.htm"}', + '::orca-visual{file="a.html"', + '::orca-visual{file="a.html"} trailing words' + ]) { + const { directives, blocks } = blocksOf(line) + expect(directives, line).toEqual([]) + expect(blocks, line).toEqual([{ type: 'paragraph', text: line }]) + } + }) + + it('renders directives past the per-message cap as text', () => { + const lines = Array.from( + { length: NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE + 1 }, + (_, index) => `::orca-visual{file="v${index}.html"}` + ) + const { directives, blocks } = blocksOf(lines.join('\n')) + expect(directives).toHaveLength(NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE) + expect(blocks.at(-1)).toEqual({ type: 'paragraph', text: lines.at(-1) }) + }) + + it('renders no visuals for text that already spells a placeholder', () => { + const { directives } = blocksOf('\uE000ORCA_VISUAL_0\uE000\n::orca-visual{file="a.html"}') + expect(directives).toEqual([]) + }) + + it('never treats a placeholder-looking line as a visual when none were protected', () => { + expect(parseMobileMarkdown('\uE000ORCA_VISUAL_0\uE000')).toEqual([ + { type: 'paragraph', text: '\uE000ORCA_VISUAL_0\uE000' } + ]) + }) +}) diff --git a/mobile/src/components/mobile-markdown-visual-lines.ts b/mobile/src/components/mobile-markdown-visual-lines.ts new file mode 100644 index 000000000000..56ab8c14d900 --- /dev/null +++ b/mobile/src/components/mobile-markdown-visual-lines.ts @@ -0,0 +1,69 @@ +import { + NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE, + parseNativeChatVisualDirectiveLine, + type NativeChatVisualDirective +} from '../../../src/shared/native-chat-visual-directive' + +/** Same fence shapes the block parser opens and closes on, so a directive inside code stays code. */ +const CODE_FENCE_OPEN = /^```([A-Za-z0-9_-]+)?\s*$/ +const CODE_FENCE_CLOSE = /^```\s*$/ +// Private-use delimiters, as the preview normalizer's own code placeholders use. +const PLACEHOLDER_PREFIX = '\uE000ORCA_VISUAL_' +const PLACEHOLDER = /^\uE000ORCA_VISUAL_(\d+)\uE000$/ + +export type MobileMarkdownVisualLines = { + /** The content with each recognized directive line replaced by a placeholder line. */ + text: string + directives: NativeChatVisualDirective[] +} + +function placeholderFor(index: number): string { + return `${PLACEHOLDER_PREFIX}${index}\uE000` +} + +/** + * Lifts native-chat visual directive lines out of assistant prose before preview normalization, + * which decodes entities and strips tags and would otherwise rewrite a title. Only top-level lines + * outside fenced code count; quotes and list items never start with the marker. Past the per-message + * cap a directive stays literal text. + */ +export function protectMobileMarkdownVisualLines(content: string): MobileMarkdownVisualLines { + // Text that already spells a placeholder would alias a real one; it renders no visuals at all. + if (content.includes(PLACEHOLDER_PREFIX)) { + return { text: content, directives: [] } + } + const lines = content.replace(/\r\n?/g, '\n').split('\n') + const directives: NativeChatVisualDirective[] = [] + let inFence = false + const next = lines.map((line) => { + if (inFence) { + inFence = !CODE_FENCE_CLOSE.test(line) + return line + } + if (CODE_FENCE_OPEN.test(line)) { + inFence = true + return line + } + if (directives.length >= NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE) { + return line + } + const directive = parseNativeChatVisualDirectiveLine(line) + if (!directive) { + return line + } + directives.push(directive) + // Blank lines around it make the directive its own block, splitting any paragraph it was in. + return `\n${placeholderFor(directives.length - 1)}\n` + }) + return { text: next.join('\n'), directives } +} + +/** The directive index a parsed line stands for, or null for any other line. */ +export function mobileMarkdownVisualPlaceholderIndex(line: string, count: number): number | null { + const match = PLACEHOLDER.exec(line.trim()) + if (!match) { + return null + } + const index = Number(match[1]) + return index < count ? index : null +} diff --git a/mobile/src/components/pr-sidebar/MermaidDiagram.tsx b/mobile/src/components/pr-sidebar/MermaidDiagram.tsx index ac5dd9aa3b9a..b7dbe3c28355 100644 --- a/mobile/src/components/pr-sidebar/MermaidDiagram.tsx +++ b/mobile/src/components/pr-sidebar/MermaidDiagram.tsx @@ -4,6 +4,7 @@ import { WebView } from 'react-native-webview' import { colors, radii, spacing, typography } from '../../theme/mobile-theme' import { MERMAID_DIAGRAM_CONFIG } from './mermaid-diagram-config' import { MERMAID_ENGINE_JS } from './mermaid-webview-engine.generated' +import { encodeJsonForScript } from '../inline-script-json' export type MermaidDiagramProps = { source: string @@ -79,19 +80,6 @@ function MermaidFallback({ source, base }: MermaidDiagramProps) { ) } -// JSON.stringify escapes quotes and control chars but leaves `<`, `>`, `&`, and -// the U+2028/U+2029 line separators raw — so a value containing `` would -// close the inline ', + mode: 'inline' + }) + + it('puts the visual in an opaque frame that may only run scripts', () => { + const document = build('

hi

') + expect(document).toContain("frame.setAttribute('sandbox', 'allow-scripts')") + expect(document).not.toMatch(/allow-same-origin|allow-popups|allow-top-navigation|allow-forms/) + expect(document.indexOf('Content-Security-Policy')).toBeLessThan(document.indexOf('') + // One script element: the host's own. + expect(document.match(/ + +` +} diff --git a/mobile/src/session/mobile-native-chat-visual-read.test.ts b/mobile/src/session/mobile-native-chat-visual-read.test.ts new file mode 100644 index 000000000000..7ff33904ba0f --- /dev/null +++ b/mobile/src/session/mobile-native-chat-visual-read.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { + cachedMobileNativeChatVisual, + readMobileNativeChatVisual, + resetMobileNativeChatVisualCacheForTest, + type MobileNativeChatVisualSource +} from './mobile-native-chat-visual-read' + +const REVISION_A = 'a'.repeat(32) +const REVISION_B = 'b'.repeat(32) + +function success(result: unknown): RpcResponse { + return { id: '1', ok: true, result } +} + +function failure(code: string): RpcResponse { + return { id: '1', ok: false, error: { code, message: code } } +} + +function sourceWith(sendRequest: ReturnType): MobileNativeChatVisualSource { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader calls only sendRequest; the rest of RpcClient is connection lifecycle it never touches. + const client = { sendRequest } as unknown as RpcClient + return { client, sessionId: 'session-1' } +} + +afterEach(() => { + resetMobileNativeChatVisualCacheForTest() +}) + +describe('readMobileNativeChatVisual', () => { + it('reads the visual by session and bare file name, then caches it', async () => { + const sendRequest = vi + .fn() + .mockResolvedValue(success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '

' })) + const source = sourceWith(sendRequest) + + await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({ + kind: 'ready', + html: '

', + revision: REVISION_A + }) + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.readVisual', + { sessionId: 'session-1', file: 'chart.html' }, + expect.objectContaining({ timeoutMs: expect.any(Number) }) + ) + expect(cachedMobileNativeChatVisual(source, 'chart.html')).toEqual({ + html: '

', + revision: REVISION_A + }) + }) + + it('revalidates with the held revision and keeps the cached bytes on unchanged', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce(success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '

' })) + .mockResolvedValueOnce( + success({ ok: true, revision: REVISION_A, sizeBytes: 3, unchanged: true }) + ) + const source = sourceWith(sendRequest) + await readMobileNativeChatVisual(source, 'chart.html') + + await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({ + kind: 'ready', + html: '

', + revision: REVISION_A + }) + expect(sendRequest.mock.calls[1]?.[1]).toEqual({ + sessionId: 'session-1', + file: 'chart.html', + knownRevision: REVISION_A + }) + }) + + it('replaces the cached bytes when the host has a new revision', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce(success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '

' })) + .mockResolvedValueOnce( + success({ ok: true, revision: REVISION_B, sizeBytes: 4, html: '


' }) + ) + const source = sourceWith(sendRequest) + await readMobileNativeChatVisual(source, 'chart.html') + await readMobileNativeChatVisual(source, 'chart.html') + expect(cachedMobileNativeChatVisual(source, 'chart.html')?.revision).toBe(REVISION_B) + }) + + it('drops the cached bytes when the host refuses a visual it served before', async () => { + const source = sourceWith( + vi + .fn() + .mockResolvedValueOnce( + success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '

' }) + ) + .mockResolvedValueOnce(success({ ok: false, error: 'not_found' })) + ) + await readMobileNativeChatVisual(source, 'chart.html') + await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({ + kind: 'refused' + }) + expect(cachedMobileNativeChatVisual(source, 'chart.html')).toBeNull() + }) + + it('reads a host verdict and an older host as refused', async () => { + for (const response of [ + success({ ok: false, error: 'not_found' }), + success({ ok: false, error: 'some_future_error' }), + failure('method_not_found'), + failure('forbidden') + ]) { + resetMobileNativeChatVisualCacheForTest() + const source = sourceWith(vi.fn().mockResolvedValue(response)) + await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({ + kind: 'refused' + }) + } + }) + + it('reads an error reply and a malformed reply as no verdict, keeping the cache', async () => { + for (const response of [ + failure('runtime_error'), + success({ ok: true, revision: 'not-hex', html: '' }), + success('') + ]) { + resetMobileNativeChatVisualCacheForTest() + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '

' }) + ) + .mockResolvedValueOnce(response) + const source = sourceWith(sendRequest) + await readMobileNativeChatVisual(source, 'chart.html') + await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({ + kind: 'unreachable' + }) + expect(cachedMobileNativeChatVisual(source, 'chart.html')).not.toBeNull() + } + }) + + it('reads a transport failure as unreachable, never as refused', async () => { + const source = sourceWith(vi.fn().mockRejectedValue(new Error('socket closed'))) + await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({ + kind: 'unreachable' + }) + }) + + it('treats an unchanged answer for a revision it never sent as unreachable and drops the entry', async () => { + const source = sourceWith( + vi.fn().mockResolvedValue(success({ ok: true, revision: REVISION_A, unchanged: true })) + ) + await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({ + kind: 'unreachable' + }) + expect(cachedMobileNativeChatVisual(source, 'chart.html')).toBeNull() + }) + + it('shares one request between concurrent reads of the same visual', async () => { + let resolve: (response: RpcResponse) => void = () => {} + const sendRequest = vi.fn( + () => + new Promise((settle) => { + resolve = settle + }) + ) + const source = sourceWith(sendRequest) + const first = readMobileNativeChatVisual(source, 'chart.html') + const second = readMobileNativeChatVisual(source, 'chart.html') + resolve(success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '

' })) + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('keys the cache by client and session, so another host or chat never sees this one', async () => { + const source = sourceWith( + vi + .fn() + .mockResolvedValue(success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '

' })) + ) + await readMobileNativeChatVisual(source, 'chart.html') + expect(cachedMobileNativeChatVisual({ ...source, sessionId: 'other' }, 'chart.html')).toBeNull() + const otherHost = sourceWith(vi.fn()).client + expect(cachedMobileNativeChatVisual({ ...source, client: otherHost }, 'chart.html')).toBeNull() + }) + + it('evicts the oldest visuals past the entry bound', async () => { + const source = sourceWith( + vi + .fn() + .mockResolvedValue(success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '

' })) + ) + for (let index = 0; index < 17; index += 1) { + await readMobileNativeChatVisual(source, `v${index}.html`) + } + expect(cachedMobileNativeChatVisual(source, 'v0.html')).toBeNull() + expect(cachedMobileNativeChatVisual(source, 'v16.html')).not.toBeNull() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-visual-read.ts b/mobile/src/session/mobile-native-chat-visual-read.ts new file mode 100644 index 000000000000..7b46618a4f4e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-visual-read.ts @@ -0,0 +1,169 @@ +import { z } from 'zod' +import { NATIVE_CHAT_VISUAL_MAX_BYTES } from '../../../src/shared/native-chat-visual-directive' +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcResultVariant } from '../transport/rpc-operation-result-reader' +import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable' +import type { RpcClient } from '../transport/rpc-client' + +/** Where a chat's visuals are read from: the host that owns the structured session. */ +export type MobileNativeChatVisualSource = { + client: RpcClient + sessionId: string +} + +export type MobileNativeChatVisualRead = + | { kind: 'ready'; html: string; revision: string } + /** The host positively refused (missing, too large, outside the folder...) or cannot serve visuals. */ + | { kind: 'refused' } + /** No verdict: no answer, an error reply, an unreadable answer. Worth asking again. */ + | { kind: 'unreachable' } + +const Revision = z.string().regex(/^[0-9a-f]{16,64}$/) + +const visualReadReplySchema = z.union([ + z.object({ + ok: z.literal(true), + revision: Revision, + html: z.string().max(NATIVE_CHAT_VISUAL_MAX_BYTES) + }), + z.object({ ok: z.literal(true), revision: Revision, unchanged: z.literal(true) }), + // A newer host's error code still reads as a refusal. + z.object({ ok: z.literal(false), error: z.string() }) +]) + +/** An error reply and an unreadable reply both read as null. */ +const nativeChatVisualRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'agentSession.read-visual', + method: 'agentSession.readVisual', + acceptance: 'object-result-or-null', + barrier: 'after-caller-barrier', + read: rpcResultVariant('native-chat-visual', visualReadReplySchema) + }) +) + +const READ_TIMEOUT_MS = 15_000 +// Each entry is at most 512 KiB of UTF-8; the character bound is what caps memory. +const MAX_CACHE_ENTRIES = 16 +const MAX_CACHE_CHARS = 4 * 1024 * 1024 + +type CachedVisual = { html: string; revision: string } + +const cache = new Map() +const inFlight = new Map>() + +// The client stands for the host: each paired host has its own, so one host's visuals never +// answer for another's even if two session ids collided. +const clientKeys = new WeakMap() +let nextClientKey = 0 + +function cacheKey(source: MobileNativeChatVisualSource, file: string): string { + let clientKey = clientKeys.get(source.client) + if (clientKey === undefined) { + nextClientKey += 1 + clientKey = nextClientKey + clientKeys.set(source.client, clientKey) + } + return JSON.stringify([clientKey, source.sessionId, file]) +} + +function remember(key: string, entry: CachedVisual): void { + cache.delete(key) + cache.set(key, entry) + let chars = 0 + for (const value of cache.values()) { + chars += value.html.length + } + // Oldest first: Map iteration follows insertion order, and a hit re-inserts. + for (const [oldKey, value] of cache) { + if (cache.size <= MAX_CACHE_ENTRIES && chars <= MAX_CACHE_CHARS) { + break + } + if (oldKey === key) { + continue + } + cache.delete(oldKey) + chars -= value.html.length + } +} + +/** What this phone already holds for a visual, to paint before revalidating. */ +export function cachedMobileNativeChatVisual( + source: MobileNativeChatVisualSource, + file: string +): CachedVisual | null { + return cache.get(cacheKey(source, file)) ?? null +} + +async function readOnce( + source: MobileNativeChatVisualSource, + file: string, + key: string +): Promise { + const known = cache.get(key) + let response + try { + response = await nativeChatVisualRead.request( + source.client, + { + sessionId: source.sessionId, + file, + ...(known ? { knownRevision: known.revision } : {}) + }, + { timeoutMs: READ_TIMEOUT_MS, budgetSpansConnect: true } + ) + } catch { + return { kind: 'unreachable' } + } + if (!response.ok && isMobileMethodUnavailableError(response.error.code, response.error.message)) { + // An older host (its mobile allowlist answers `forbidden`): asking again will not help. + return { kind: 'refused' } + } + const reply = nativeChatVisualRead.interpret(response) + if (reply === null) { + // An error reply, an older host or an unreadable answer is not a verdict on the file. + return { kind: 'unreachable' } + } + if (!reply.ok) { + // The host no longer serves it (deleted, now too large...): stop painting the old bytes. + cache.delete(key) + return { kind: 'refused' } + } + if ('html' in reply) { + remember(key, { html: reply.html, revision: reply.revision }) + return { kind: 'ready', html: reply.html, revision: reply.revision } + } + // `unchanged` answers the revision this phone sent; anything else is a host that lost track. + if (known && known.revision === reply.revision) { + remember(key, known) + return { kind: 'ready', html: known.html, revision: known.revision } + } + cache.delete(key) + return { kind: 'unreachable' } +} + +/** + * Reads one visual from the chat's owning host, revalidating what the phone already holds by + * revision. Concurrent reads of the same visual share one request. + */ +export function readMobileNativeChatVisual( + source: MobileNativeChatVisualSource, + file: string +): Promise { + const key = cacheKey(source, file) + const pending = inFlight.get(key) + if (pending) { + return pending + } + const read = readOnce(source, file, key).finally(() => { + inFlight.delete(key) + }) + inFlight.set(key, read) + return read +} + +/** Test seam: forget every cached visual. */ +export function resetMobileNativeChatVisualCacheForTest(): void { + cache.clear() + inFlight.clear() +} diff --git a/mobile/src/session/mobile-native-chat-visual-theme.ts b/mobile/src/session/mobile-native-chat-visual-theme.ts new file mode 100644 index 000000000000..08f244887d52 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-visual-theme.ts @@ -0,0 +1,40 @@ +import type { NativeChatVisualTheme } from '../../../src/shared/native-chat-visual-shell' +import { colors, radii } from '../theme/mobile-theme' + +/** + * The phone's one (dark) theme, as the variables a visual styles against. The app has no theme + * switch, so a visual receives this before its content runs and never needs a live update. + * Chart colors are desktop's dark `--chart-1..5` (the blue 300-800 ramp), as hex. + */ +export const MOBILE_NATIVE_CHAT_VISUAL_THEME: NativeChatVisualTheme = { + colorScheme: 'dark', + tokens: { + '--background': colors.bgBase, + '--foreground': colors.textPrimary, + '--card': colors.bgPanel, + '--card-foreground': colors.textPrimary, + '--popover': colors.bgRaised, + '--popover-foreground': colors.textPrimary, + '--primary': colors.surfaceBright, + '--primary-foreground': colors.bgBase, + '--secondary': colors.bgRaised, + '--secondary-foreground': colors.textPrimary, + '--muted': colors.bgRaised, + '--muted-foreground': colors.textMuted, + '--accent': colors.bgRaised, + '--accent-foreground': colors.textPrimary, + '--destructive': colors.statusRed, + '--destructive-foreground': colors.onAccent, + '--border': colors.borderSubtle, + '--input': colors.borderSubtle, + '--ring': colors.textMuted, + '--radius': `${radii.row}px`, + '--chart-1': '#8ec5ff', + '--chart-2': '#2b7fff', + '--chart-3': '#155dfc', + '--chart-4': '#1447e6', + '--chart-5': '#193cb8', + '--font-sans': '-apple-system, system-ui, Roboto, sans-serif', + '--font-mono': 'Menlo, monospace' + } +} diff --git a/mobile/src/session/mobile-structured-agent-prompts-free-text.test.ts b/mobile/src/session/mobile-structured-agent-prompts-free-text.test.ts new file mode 100644 index 000000000000..5cf44a60c472 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-prompts-free-text.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalFreeTextInput } from '../../../src/shared/agent-session-journal-types' +import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question' +import { + projectStructuredQuestion, + structuredQuestionResponseTarget, + type StructuredQuestionItem +} from './mobile-structured-agent-prompts' + +function prompt(freeTextInput?: AgentJournalFreeTextInput): StructuredQuestionItem { + return { + itemId: 'item-1', + revision: 3, + sequence: 1, + observedAt: 1, + body: { + kind: 'question', + question: 'Edit the draft', + options: [], + freeTextQuestionId: 'q1', + ...(freeTextInput ? { freeTextInput } : {}), + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + } +} + +describe('mobile structured free-text questions', () => { + it('projects editor input metadata onto the existing question card', () => { + const freeTextInput = { + allowEmpty: true, + multiline: true, + initialValue: 'draft', + placeholder: 'Edit here' + } + expect(projectStructuredQuestion(prompt(freeTextInput))?.freeTextInput).toEqual(freeTextInput) + expect(projectStructuredQuestion(prompt())?.freeTextInput).toBeUndefined() + }) + + it('accepts empty and whitespace text only for the matching allowed prompt', () => { + const current = prompt({ allowEmpty: true }) + const projected = projectStructuredQuestion(current)! + for (const answer of ['', ' \n ']) { + const response = formatQuestionFreeTextAnswer(projected, answer) + expect(structuredQuestionResponseTarget(response, current)).toEqual({ + itemId: 'item-1', + expectedRevision: 3, + answer: { questionId: 'q1', optionIds: [], other: answer } + }) + expect(structuredQuestionResponseTarget(response, prompt())).toBeNull() + } + expect( + structuredQuestionResponseTarget(formatQuestionFreeTextAnswer(projected, ''), { + ...current, + revision: 4 + }) + ).toBeNull() + }) + + it('keeps legacy trimming for ordinary free-text answers', () => { + const current = prompt() + const projected = projectStructuredQuestion(current)! + expect( + structuredQuestionResponseTarget( + formatQuestionFreeTextAnswer(projected, ' draft '), + current + ) + ).toMatchObject({ answer: { other: 'draft' } }) + expect(structuredQuestionResponseTarget('', current)).toBeNull() + }) +}) diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts index b36ca43e8924..cd1b35d0102f 100644 --- a/mobile/src/session/mobile-structured-agent-prompts.ts +++ b/mobile/src/session/mobile-structured-agent-prompts.ts @@ -192,6 +192,7 @@ export function projectStructuredQuestion( ...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}), multiSelect: false, allowOther: Boolean(prompt.body.freeTextQuestionId), + ...(prompt.body.freeTextInput ? { freeTextInput: prompt.body.freeTextInput } : {}), optionTokens: prompt.body.options.map((option) => encodePromptToken({ kind: 'question-option', @@ -258,8 +259,13 @@ export function structuredQuestionResponseTarget( } const freeText = decodeQuestionFreeTextAnswer(response) if (freeText) { - const answer = freeText.answer.trim() - return answer.length > 0 + const allowEmpty = + currentPrompt?.itemId === freeText.payload.itemId && + currentPrompt.revision === freeText.payload.revision && + currentPrompt.body.freeTextQuestionId === freeText.payload.questionId && + currentPrompt.body.freeTextInput?.allowEmpty === true + const answer = allowEmpty ? freeText.answer : freeText.answer.trim() + return answer.length > 0 || allowEmpty ? { itemId: freeText.payload.itemId, expectedRevision: freeText.payload.revision, @@ -272,6 +278,7 @@ export function structuredQuestionResponseTarget( } const [question] = agentSessionPromptQuestions(currentPrompt.body) const trimmed = response.trim() + const allowEmpty = currentPrompt.body.freeTextInput?.allowEmpty === true const option = currentPrompt.body.options.find( (candidate) => candidate.id === response || candidate.label === trimmed ) @@ -282,11 +289,11 @@ export function structuredQuestionResponseTarget( answer: { questionId: question.id, optionIds: [option.id] } } } - return question && currentPrompt.body.freeTextQuestionId && trimmed + return question && currentPrompt.body.freeTextQuestionId && (trimmed || allowEmpty) ? { itemId: currentPrompt.itemId, expectedRevision: currentPrompt.revision, - answer: { questionId: question.id, optionIds: [], other: trimmed } + answer: { questionId: question.id, optionIds: [], other: allowEmpty ? response : trimmed } } : null } diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index b5d7959eb460..7b2da967d56c 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -27,10 +27,7 @@ export const STRUCTURED_SEND_TIMEOUT_MS = 15_000 export type StructuredAgentSessionMutationCallResult = | { status: 'accepted'; value: TValue } | { status: 'refused'; code: AgentSessionWireRefusalCode; message: string } - /** `hostRejectedByRequestSchema`: the host's schema turned this request away before running - * it, so the same request can never be accepted there. An auth refusal does not set it: - * it says nothing about an earlier delivery of the same id. */ - | { status: 'failed'; message: string; hostRejectedByRequestSchema?: true } + | { status: 'failed'; message: string } | { status: 'unknown' } export type StructuredAgentSessionMutationResult = @@ -188,10 +185,7 @@ export async function requestStructuredAgentSessionMutation(args: { status: 'failed', message: agentSessionWriteNoticeEnglish( agentSessionWriteNoticeParts(answered, phoneWriteKind(fingerprintMethod, fields)) - ), - ...(error instanceof AgentSessionRpcResponseError && error.code === 'invalid_argument' - ? { hostRejectedByRequestSchema: true } - : {}) + ) } } if ( diff --git a/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts deleted file mode 100644 index 2652c041b525..000000000000 --- a/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -// A resend past a saved record storage would not clear goes out under a fresh id. A retry of that -// text in the same app run must replay that id, never mint another: if the first resend's answer -// was lost, a second fresh id could deliver the message twice. - -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { RpcClient } from '../transport/rpc-client' -import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' -import type { RpcResponse } from '../transport/types' -import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' -import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' -import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' - -const asyncStorage = vi.hoisted(() => ({ - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn() -})) - -vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) - -function ok(result: unknown): RpcResponse { - return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } -} - -/** Its own submission, which a Stop took back before the agent started it. */ -function stoppedAnswer(clientMessageId: string): RpcResponse { - return ok({ - ok: true, - replayed: false, - fence: 3, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { - clientMessageId, - submission: { - clientMessageId, - fence: 3, - payloadFingerprint: 'fingerprint', - dispatchState: 'rejected', - providerItemId: null, - reason: DISPATCH_REJECTED_CANCELLED, - submittedAt: 10, - resolvedAt: 10 - } - } - }) -} - -function queuedAnswer(clientMessageId: string, state: 'waiting' | 'withdrawn'): RpcResponse { - return ok({ - ok: true, - replayed: false, - fence: 3, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { clientMessageId, queued: { messageId: clientMessageId, position: 1, state } } - }) -} - -/** Each `agentSession.send` answered in turn: a lost answer, a queued draft in that state, or its - * submission stopped. */ -function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn' | 'stopped')[]) { - const ids: string[] = [] - const deliveries: unknown[] = [] - const sendRequest = vi.fn(async (_method, params) => { - const envelope = Object(Object(params).envelope) - const id = String(envelope.clientOperationId) - const answer = answers[ids.length] - ids.push(id) - deliveries.push(Object(params).delivery) - if (answer === 'lost' || answer === undefined) { - throw markRpcDeliveryUnknown(new Error('Connection closed')) - } - return answer === 'stopped' ? stoppedAnswer(id) : queuedAnswer(id, answer) - }) - const client: RpcClient = { - sendRequest, - subscribe: vi.fn(() => vi.fn()), - updateTerminalSubscriptionViewport: () => {}, - getState: () => 'connected', - getReconnectAttempt: () => 0, - getLastConnectedAt: () => null, - onStateChange: () => () => {}, - notifyForeground: () => {}, - close: () => {} - } - return { client, ids, deliveries } -} - -function sendAgain(client: RpcClient, onError: (message: string) => void, queue = true) { - return sendMobileStructuredAgentSessionMessage({ - client, - sessionId: 'session-1', - sessionKey: 'host-a:session-1', - callerIdentity: 'device-a', - expectedRuntimeFence: 3, - text: 'again', - attachments: [], - ...(queue ? { delivery: 'queue-if-active' as const } : {}), - onError - }) -} - -describe('a resend past a saved record storage would not clear', () => { - let stored: Map - - beforeEach(() => { - vi.clearAllMocks() - resetMobileStructuredSendOperationJournalForTests() - stored = new Map() - asyncStorage.getItem.mockImplementation(async (key: string) => stored.get(key) ?? null) - asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { - stored.set(key, value) - }) - asyncStorage.removeItem.mockImplementation(async (key: string) => { - stored.delete(key) - }) - }) - - it('replays its own id once storage recovers, even without the queue capability now', async () => { - const { client, ids, deliveries } = hostAnswering([ - 'lost', - 'withdrawn', - 'lost', - 'withdrawn', - 'waiting' - ]) - const onError = vi.fn() - expect(await sendAgain(client, onError)).toBe('unknown') - asyncStorage.setItem.mockRejectedValue(new Error('disk full')) - asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) - expect(await sendAgain(client, onError)).toBe('unknown') - // Storage recovers: the lost send's record now clears, and the queue capability is gone. - asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { - stored.set(key, value) - }) - asyncStorage.removeItem.mockImplementation(async (key: string) => { - stored.delete(key) - }) - expect(await sendAgain(client, onError, false)).toBe('queued') - expect(ids).toHaveLength(5) - // The resend's id is replayed, as first sent, never replaced by a fresh one. - expect(ids[4]).toBe(ids[2]) - expect(deliveries[4]).toBe('queue-if-active') - }) - - it('replays its own id on a retry after a lost answer, and reports the record only once sent', async () => { - // Lost first send; its withdrawn replay; the fresh resend's answer lost; the retry's - // withdrawn replay; then the resend answered. - const { client, ids } = hostAnswering(['lost', 'withdrawn', 'lost', 'withdrawn', 'waiting']) - const onError = vi.fn() - const send = () => - sendMobileStructuredAgentSessionMessage({ - client, - sessionId: 'session-1', - sessionKey: 'host-a:session-1', - callerIdentity: 'device-a', - expectedRuntimeFence: 3, - text: 'again', - attachments: [], - delivery: 'queue-if-active', - onError - }) - expect(await send()).toBe('unknown') - // From here the lost send's record can never be cleared. - asyncStorage.setItem.mockRejectedValue(new Error('disk full')) - asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) - - expect(await send()).toBe('unknown') - // An unconfirmed resend may not have gone out, so nothing says it was sent. - expect(onError).not.toHaveBeenCalled() - - expect(await send()).toBe('queued') - expect(ids).toHaveLength(5) - expect(ids[1]).toBe(ids[0]) - expect(ids[3]).toBe(ids[0]) - expect(ids[2]).not.toBe(ids[0]) - // The retry replays the resend's id rather than minting one that could deliver twice. - expect(ids[4]).toBe(ids[2]) - expect(onError).toHaveBeenCalledWith( - "Sent, but this phone couldn't update its record of sent messages." - ) - }) - - // Lost first send; its withdrawn replay; the fresh resend's answer lost; the retry's withdrawn - // replay; then that resend's own replay withdrawn too. A resend is sent once, never again. - it('sends a withdrawn replay once more at most, then hands a queued draft back', async () => { - const { client, ids } = hostAnswering(['lost', 'withdrawn', 'lost', 'withdrawn', 'withdrawn']) - const onError = vi.fn() - expect(await sendAgain(client, onError)).toBe('unknown') - asyncStorage.setItem.mockRejectedValue(new Error('disk full')) - asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) - expect(await sendAgain(client, onError)).toBe('unknown') - - expect(await sendAgain(client, onError)).toBe('rejected') - expect(ids).toHaveLength(5) - expect(ids[4]).toBe(ids[2]) - expect(onError).toHaveBeenCalledWith('Message not sent') - }) - - it('reads a resend a Stop took back again as sent, since the chat draws it, not as not sent', async () => { - const { client, ids } = hostAnswering(['lost', 'stopped', 'lost', 'stopped', 'stopped']) - const onError = vi.fn() - expect(await sendAgain(client, onError, false)).toBe('unknown') - asyncStorage.setItem.mockRejectedValue(new Error('disk full')) - asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) - expect(await sendAgain(client, onError, false)).toBe('unknown') - - expect(await sendAgain(client, onError, false)).toBe('accepted') - expect(ids).toHaveLength(5) - expect(ids[4]).toBe(ids[2]) - // Only the storage failure is reported; nothing says the message was not sent. - expect(onError.mock.calls).toEqual([ - ["Sent, but this phone couldn't update its record of sent messages."] - ]) - }) -}) diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index c7a2aa356128..41c6e109a30f 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -2,249 +2,47 @@ import type { AgentSessionSendResult } from '../../../src/shared/agent-session-w import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment -} from '../../../src/shared/structured-agent-session-outbox' -import { - structuredAgentSessionDomainFingerprint, - structuredAgentSessionPayloadFingerprint -} from '../../../src/shared/structured-agent-session-mutation' +} from '../../../src/shared/structured-agent-session-send-mutation' import type { RpcClient } from '../transport/rpc-client' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { requestStructuredAgentSessionMutation, timeoutForDeadline } from './mobile-structured-agent-session-rpc' -import { structuredSessionOperationId } from './structured-session-operation-id' -import { - mobileStructuredSendDelivery, - mobileStructuredSendWithdrawnBeforeStart -} from './mobile-structured-send-delivery' -import { - bypassedMobileStructuredSendOperationId, - clearMobileStructuredSendOperation, - forgetBypassedMobileStructuredSendOperation, - getOrCreateMobileStructuredSendOperation, - mobileStructuredSendOperationKey, - rememberBypassedMobileStructuredSendOperation -} from './mobile-structured-send-operation-journal' +import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' export async function sendMobileStructuredAgentSessionMessage(input: { client: RpcClient sessionId: string - sessionKey: string - callerIdentity: string expectedRuntimeFence: number text: string - attachments: readonly (StructuredAgentSessionAttachment & { contentFingerprint?: string })[] + attachments: readonly StructuredAgentSessionAttachment[] /** Sent only when the host advertises `agent-session.queued-messages.v1`. */ delivery?: 'queue-if-active' deadline?: number onError: (message: string) => void - /** Internal: the one fresh-id resend after a withdrawn replay. */ - resendingAfterWithdrawal?: true - /** Internal: that resend when the withdrawn id's record could not be cleared. It bypasses the - * record, so failing storage never blocks the send; its id is kept in memory for this app run, - * so a retry after a lost answer replays it rather than sending again. */ - bypassRetainedRecord?: true - /** Internal: on that resend, the key the withdrawn record matched. The remembered id is keyed - * by it, so a capability change since the lost answer never mints another id. */ - resendOperationKey?: string }): Promise { const timeoutMs = timeoutForDeadline(input.deadline) if (timeoutMs === null) { input.onError('Message not sent') return 'rejected' } - const requestedBody = structuredAgentSessionSendBody(input.text, input.attachments) - const requestedPayloadFingerprint = structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: input.sessionId, - fields: { body: requestedBody } - }) - const intentFields = { - text: input.text.trimEnd(), - attachments: input.attachments.map( - (attachment) => - attachment.contentFingerprint ?? - structuredAgentSessionDomainFingerprint({ - domain: 'mobile.nativeChat.image.preview', - sessionId: '', - fields: { previewUri: attachment.previewUri } - }) - ) - } - // `delivery` is part of the intent key, never a stored journal field: the - // immediate key is exactly today's, so an older build still reads the journal. - const operationKeyFor = (delivery: 'queue-if-active' | undefined): string => - mobileStructuredSendOperationKey({ - sessionKey: input.sessionKey, - intentFingerprint: structuredAgentSessionDomainFingerprint({ - domain: 'mobile.agentSession.send.intent', - sessionId: input.sessionKey, - fields: delivery ? { ...intentFields, delivery } : intentFields - }) - }) - const queuedOperationKey = operationKeyFor('queue-if-active') - const immediateOperationKey = operationKeyFor(undefined) - const requestedOperationKey = input.delivery ? queuedOperationKey : immediateOperationKey - const attachmentPaths = input.attachments.map((attachment) => attachment.path) - const resendKey = input.resendOperationKey ?? requestedOperationKey - let operation: Awaited> - try { - if (input.bypassRetainedRecord) { - operation = bypassOperation(resendKey, requestedPayloadFingerprint, attachmentPaths) - } else if ( - input.resendOperationKey !== undefined && - bypassedMobileStructuredSendOperationId(resendKey) !== undefined - ) { - // Storage recovered after a bypassed resend's answer was lost: that id is replayed, never - // replaced by a fresh one that could deliver the message twice. - operation = await adoptBypassedOperation({ - operationKey: resendKey, - callerIdentity: input.callerIdentity, - payloadFingerprint: requestedPayloadFingerprint, - attachmentPaths - }) - } else { - operation = await getOrCreateMobileStructuredSendOperation({ - operationKey: requestedOperationKey, - // A retained id replays exactly as first sent, whatever the capability says now; - // a host that refuses that request shape retires it, so the next send goes out fresh. - alternateOperationKey: input.delivery ? immediateOperationKey : queuedOperationKey, - callerIdentity: input.callerIdentity, - payloadFingerprint: requestedPayloadFingerprint, - attachmentPaths, - createOperationId: structuredSessionOperationId - }) - } - } catch { - input.onError('Message not sent') - return 'rejected' - } - const operationKey = operation.operationKey - const delivery = operationKey === queuedOperationKey ? 'queue-if-active' : undefined - const body = structuredAgentSessionSendBody( - input.text, - operation.attachmentPaths.map((path) => ({ path, previewUri: '' })) - ) - const payloadFingerprint = structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: input.sessionId, - fields: { body } - }) - if (payloadFingerprint !== operation.payloadFingerprint) { - input.onError('Message not sent') - return 'rejected' - } + // Each Send is a new action; the shared mutation sender mints its operation id once. const result = await requestStructuredAgentSessionMutation({ client: input.client, method: 'agentSession.send', fingerprintMethod: 'agentSession.send', sessionId: input.sessionId, expectedRuntimeFence: input.expectedRuntimeFence, - // `delivery` joins the wire fields — and so the operation fingerprint — but - // never the journal's body-only fingerprint the submission echo recomputes. - fields: { body, ...(delivery ? { delivery } : {}) }, - clientOperationId: operation.operationId, + fields: { + body: structuredAgentSessionSendBody(input.text, input.attachments), + ...(input.delivery ? { delivery: input.delivery } : {}) + }, timeoutMs }) - const outcome = mobileStructuredSendDelivery(result, operation.retained) - if (input.bypassRetainedRecord && outcome.operationIdSpent) { - forgetBypassedMobileStructuredSendOperation(operationKey, operation.operationId) - } - let released = false - if (outcome.operationIdSpent) { - try { - await clearMobileStructuredSendOperation({ - operationKey, - operationId: operation.operationId - }) - released = true - } catch { - // A retained settled id can suppress a later identical send, never - // duplicate this one; the next replay gets another clear chance. - } - } - const withdrawnReplay = - (result.status === 'accepted' && - 'queued' in result.value && - result.value.queued?.state === 'withdrawn') || - (operation.retained && mobileStructuredSendWithdrawnBeforeStart(result)) - if (withdrawnReplay && operation.retained && !input.resendingAfterWithdrawal) { - // The retained id's draft or submission was withdrawn, so it never reached the agent: - // this identical message is a new one, not a replay to swallow. A record - // storage would not clear is bookkeeping: it is reported, never allowed to - // block the send. - const resent = await sendMobileStructuredAgentSessionMessage({ - ...input, - resendingAfterWithdrawal: true, - resendOperationKey: operationKey, - ...(released ? {} : { bypassRetainedRecord: true as const }) - }) - // Only when the resend is known to have gone out; an unconfirmed one may not have. - if (!released && (resent === 'accepted' || resent === 'queued')) { - input.onError("Sent, but this phone couldn't update its record of sent messages.") - } - return resent - } - if (withdrawnReplay && mobileStructuredSendWithdrawnBeforeStart(result)) { - // Not resent, but the chat draws it with its stop row: handing it back too would show it twice. - return 'accepted' - } - if (withdrawnReplay) { - // Not resent: no card and no bubble holds the text, so it goes back to the - // composer rather than vanishing. - input.onError('Message not sent') - return 'rejected' - } + const outcome = mobileStructuredSendDelivery(result) if (outcome.error !== null) { input.onError(outcome.error) } return outcome.outcome } - -/** The id a resend past an uncleared record goes out under: the one this app run already used - * for this text, replayed, or a fresh one remembered for the next retry. */ -function bypassOperation( - operationKey: string, - payloadFingerprint: string, - attachmentPaths: string[] -): Awaited> { - const bypassed = bypassedMobileStructuredSendOperationId(operationKey) - const operationId = bypassed ?? structuredSessionOperationId() - if (bypassed === undefined) { - rememberBypassedMobileStructuredSendOperation(operationKey, operationId) - } - return { - operationKey, - operationId, - retained: bypassed !== undefined, - payloadFingerprint, - attachmentPaths - } -} - -/** A remembered bypassed id handed back to the saved record once storage works again: the record - * replays it, and memory lets it go. Should storage fail again, memory keeps replaying it. */ -async function adoptBypassedOperation(input: { - operationKey: string - callerIdentity: string - payloadFingerprint: string - attachmentPaths: string[] -}): Promise>> { - const operationId = bypassedMobileStructuredSendOperationId(input.operationKey) - if (operationId === undefined) { - throw new Error('No bypassed send id to adopt') - } - try { - const recorded = await getOrCreateMobileStructuredSendOperation({ - ...input, - createOperationId: () => operationId - }) - if (recorded.operationId === operationId) { - forgetBypassedMobileStructuredSendOperation(input.operationKey, operationId) - } - return { ...recorded, retained: true } - } catch { - return bypassOperation(input.operationKey, input.payloadFingerprint, input.attachmentPaths) - } -} diff --git a/mobile/src/session/mobile-structured-grouped-question.test.ts b/mobile/src/session/mobile-structured-grouped-question.test.ts index 37a4dc125c27..83b1e50e9c1e 100644 --- a/mobile/src/session/mobile-structured-grouped-question.test.ts +++ b/mobile/src/session/mobile-structured-grouped-question.test.ts @@ -123,6 +123,43 @@ describe('mobile structured grouped questions', () => { ]) }) + it('projects editor metadata and preserves allowed empty and whitespace answers', () => { + const editor = question({ + options: [], + freeTextInput: { + allowEmpty: true, + multiline: true, + initialValue: 'draft', + placeholder: 'Edit response' + } + }) + const projected = projectGroupedQuestion([editor], null, PROMPT_KEY)! + expect(projected.freeTextInput).toEqual(editor.freeTextInput) + + for (const answer of ['', ' \n ']) { + const result = advanceGroupedQuestion({ + response: formatQuestionFreeTextAnswer(projected, answer), + questions: [editor], + draft: null, + promptKey: PROMPT_KEY + }) + expect(result).toEqual({ + kind: 'submit', + answers: [{ questionId: 'q1', optionIds: [], other: answer }] + }) + } + + const legacy = question({ options: [] }) + expect( + advanceGroupedQuestion({ + response: formatQuestionFreeTextAnswer(projected, ''), + questions: [legacy], + draft: null, + promptKey: PROMPT_KEY + }) + ).toBeNull() + }) + it('keeps selected options and other text for grouped multi-select answers', () => { const questions = [SECOND] const only = projectGroupedQuestion(questions, null, PROMPT_KEY)! diff --git a/mobile/src/session/mobile-structured-grouped-question.ts b/mobile/src/session/mobile-structured-grouped-question.ts index d111b4de95aa..7fa5c41b95d7 100644 --- a/mobile/src/session/mobile-structured-grouped-question.ts +++ b/mobile/src/session/mobile-structured-grouped-question.ts @@ -122,6 +122,7 @@ export function projectGroupedQuestion( ...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}), multiSelect: question.multiSelect, allowOther: Boolean(question.freeTextQuestionId), + ...(question.freeTextInput ? { freeTextInput: question.freeTextInput } : {}), optionTokens: question.options.map((option) => encodeGroupedToken({ kind: 'option', @@ -155,11 +156,12 @@ function answerFromResponse( const trimmed = part.trim() const freeText = decodeGroupedFreeTextAnswer(trimmed) if (freeText) { - const answer = freeText.answer.trim() + const allowEmpty = question.freeTextInput?.allowEmpty === true + const answer = allowEmpty ? freeText.answer : freeText.answer.trim() if ( freeText.promptKey !== promptKey || freeText.questionId !== question.id || - answer.length === 0 || + (answer.length === 0 && !allowEmpty) || other !== undefined ) { return null @@ -182,14 +184,14 @@ function answerFromResponse( if (optionIds.some((optionId) => !offered.has(optionId))) { return null } - if (other && !question.freeTextQuestionId) { + if (other !== undefined && !question.freeTextQuestionId) { return null } - const answerCount = optionIds.length + (other ? 1 : 0) + const answerCount = optionIds.length + (other !== undefined ? 1 : 0) if (answerCount === 0 || (!question.multiSelect && answerCount !== 1)) { return null } - return { questionId: question.id, optionIds, ...(other ? { other } : {}) } + return { questionId: question.id, optionIds, ...(other !== undefined ? { other } : {}) } } /** diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 3341b7a80d9c..8ead70b20275 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../src/shared/structured-agent-session-dispatch-rejection' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - QUEUED_MESSAGE_PAUSED_SEND_FAILED -} from '../../../src/shared/agent-session-wire' +import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../src/shared/agent-session-wire' import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' import { mobileQueueHasResumableCard, @@ -103,35 +100,12 @@ describe('mobileQueuedMessageCards', () => { expect(resumable([failed])).toBe(false) expect(resumable([failed, waiting])).toBe(true) expect(resumable([waiting, { ...returned, position: 3 }])).toBe(true) - // A kept card is held on its own, like a failed one: the drain goes past it. - const kept = draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }) - expect(resumable([kept])).toBe(false) - expect(resumable([kept, waiting])).toBe(true) - }) - - // The host kept it unsent across a restart or a close; the cards behind it are not held by it. - it('captions a kept card as not sent yet, and leaves the cards behind it plainly queued', () => { - const cards = mobileQueuedMessageCards( - [ - draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }), - draft({ messageId: 'b', position: 2 }) - ], - [], - { pendingPrompt: false } - ) - expect(cards.map(({ caption, needsAttention }) => ({ caption, needsAttention }))).toEqual([ - { caption: 'Not sent yet — tap Send to send it', needsAttention: false }, - { caption: null, needsAttention: false } - ]) }) it('words the paused queue by reason, and one this build does not know as a plain pause', () => { expect(mobileQueuePauseLabel({ reason: 'stopped' })).toBe( 'Queue paused because you interrupted' ) - expect(mobileQueuePauseLabel({ reason: 'cleared' })).toBe( - 'Queue paused after you cleared the conversation' - ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a reason newer than this build's union, as a newer host would send it. const newer = { reason: 'later_reason' } as never expect(mobileQueuePauseLabel(newer)).toBe('Queue paused') diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index 6388efbf4123..d65a73388c3d 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -6,9 +6,8 @@ import { readWholeAgentSessionFailureFact } from '../../../src/shared/agent-sess import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' -import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-disposition' +import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-rejection-words' import { - QUEUED_MESSAGE_PAUSED_KEPT, QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage, type AgentSessionQueuePause @@ -53,22 +52,17 @@ function returnedCaption( ) } -/** One card's own hold: a failed conversion, or a send the host kept; the queue's pause is the - * list's first row. */ +/** One card's own hold: a failed conversion; the queue's pause is the list's first row. */ function pausedCaption(reason: string | undefined): string { if (reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { return "Couldn't send — tap Send to retry" } - if (reason === QUEUED_MESSAGE_PAUSED_KEPT) { - return 'Not sent yet — tap Send to send it' - } // Absent or unknown (newer host) marker: a plain pause, promising no release rule. return 'Paused' } const QUEUE_PAUSE_LABELS: Readonly> = { - stopped: 'Queue paused because you interrupted', - cleared: 'Queue paused after you cleared the conversation' + stopped: 'Queue paused because you interrupted' } /** Whether Resume would send anything: a waiting card with no hold of its own, ahead of any diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index b7e457c7569d..ef0e7a520d9a 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -6,8 +6,6 @@ import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' -const WITHDRAWN_SENTENCE = 'This message was withdrawn before the agent started it.' - function accepted( dispatchState: AgentJournalDispatchState, reason: string | null = null @@ -16,291 +14,147 @@ function accepted( } describe('mobileStructuredSendDelivery', () => { - it('keeps the operation id for every unknown, host-recorded or ack-lost', () => { - // The one answer that may be a delivery. Spending the id here turns the next - // identical send into a second copy in front of the model. + it('reports transport and host uncertainty on this send', () => { expect(mobileStructuredSendDelivery({ status: 'unknown' })).toEqual({ outcome: 'unknown', - operationIdSpent: false, error: null }) expect(mobileStructuredSendDelivery(accepted('unknown'))).toEqual({ outcome: 'unknown', - operationIdSpent: false, error: null }) }) - it('reports a written send as sent and spends its id', () => { - // `pending` is written and awaiting the provider's acknowledgement — not doubt. - for (const dispatchState of ['accepted', 'pending'] as const) { - expect(mobileStructuredSendDelivery(accepted(dispatchState))).toEqual({ - outcome: 'accepted', - operationIdSpent: true, - error: null - }) - } + it.each(['accepted', 'pending'] as const)('reports a written %s send as accepted', (state) => { + expect(mobileStructuredSendDelivery(accepted(state))).toEqual({ + outcome: 'accepted', + error: null + }) }) - it('classifies a queued draft answer as spent, card-rendered, never a bubble', () => { - // The host holds the message now; a later identical send is a new message. - // A dispatched draft answers as `queued` only when the host lost its - // submission, so no echo would retire a bubble: it too shows nothing. - for (const state of ['waiting', 'dispatched', 'returned', 'withdrawn'] as const) { - const queued: StructuredAgentSessionMutationCallResult = { + it.each(['waiting', 'dispatched', 'returned'] as const)( + 'lets the host own its %s queued message without an optimistic bubble', + (state) => { + expect( + mobileStructuredSendDelivery({ + status: 'accepted', + value: { + clientMessageId: 'client-1', + queued: { messageId: 'client-1', position: 1, state } + } + }) + ).toEqual({ outcome: 'queued', error: null }) + } + ) + + it('hands back a withdrawn card instead of silently sending again', () => { + expect( + mobileStructuredSendDelivery({ status: 'accepted', value: { clientMessageId: 'client-1', - queued: { messageId: 'client-1', position: 1, state } + queued: { messageId: 'client-1', position: 1, state: 'withdrawn' } } - } - const outcome = 'queued' - expect(mobileStructuredSendDelivery(queued)).toEqual({ - outcome, - operationIdSpent: true, - error: null }) - expect(mobileStructuredSendDelivery(queued, true)).toEqual({ - outcome, - operationIdSpent: true, - error: null - }) - } + ).toEqual({ outcome: 'rejected', error: 'Message not sent' }) }) - it("reads a replay answered by its draft's hand-off as unconfirmed, and spends the id", () => { - // The hand-off names the replayed id as its draft: the host's answer states the link, so the - // id is spent without waiting for a stream that may never carry the hand-off. - const handedOff = structuredSendResultFixture('accepted') - if (!('submission' in handedOff)) { + it('keeps a handed-off queued message unconfirmed until the stream identifies it', () => { + const value = structuredSendResultFixture('accepted') + if (!('submission' in value)) { throw new Error('expected a submission answer') } - const replay: StructuredAgentSessionMutationCallResult = { - status: 'accepted', - value: { - clientMessageId: 'retained-draft-id', - submission: { - ...handedOff.submission, - clientMessageId: 'fresh-id', - queuedMessageId: 'retained-draft-id' + expect( + mobileStructuredSendDelivery({ + status: 'accepted', + value: { + clientMessageId: 'draft-id', + submission: { + ...value.submission, + clientMessageId: 'fresh-id', + queuedMessageId: 'draft-id' + } } - } - } - expect(mobileStructuredSendDelivery(replay, true)).toEqual({ - outcome: 'unknown', - operationIdSpent: true, - error: null - }) + }) + ).toEqual({ outcome: 'unknown', error: null }) }) - it('never spends a retained id on a malformed answer with no submission and no id', () => { - const malformed: StructuredAgentSessionMutationCallResult = { - status: 'accepted', - value: JSON.parse('{}') - } - expect(mobileStructuredSendDelivery(malformed, true)).toEqual({ + it('fails closed when a malformed answer has no submission', () => { + expect(mobileStructuredSendDelivery({ status: 'accepted', value: JSON.parse('{}') })).toEqual({ outcome: 'unknown', - operationIdSpent: false, error: null }) }) - it('does not report a retained payload replay as a new accepted send', () => { - for (const dispatchState of ['accepted', 'pending'] as const) { - expect(mobileStructuredSendDelivery(accepted(dispatchState), true)).toEqual({ - outcome: 'unknown', - operationIdSpent: false, - error: null - }) - } - }) - - it('spends the id of a rejection and withholds its internal reason', () => { - // Provably undelivered and terminal, so the id can only replay it: spending the - // id makes the retry a first delivery. The marker itself names nothing a person - // can act on, so it must not reach the screen. + it('withholds internal provider write reasons', () => { expect( mobileStructuredSendDelivery(accepted('rejected', 'provider_write_failed: broken pipe')) ).toEqual({ outcome: 'rejected', - operationIdSpent: true, error: "Orca couldn't reach the agent. Your message was not sent. Send it again." }) }) - it('answers a send the host kept as a card like a queued one, first send or replay', () => { - // The card shows the text, so neither an error nor a composer hand-back may repeat it. - const kept: StructuredAgentSessionMutationCallResult = { - status: 'accepted', - value: { - clientMessageId: 'msg-1', - submission: { - clientMessageId: 'msg-1', - fence: 3, - payloadFingerprint: 'fingerprint', - dispatchState: 'rejected', - providerItemId: null, - reason: 'Orca restarted before this was sent.', - submittedAt: 10, - resolvedAt: 10, - keptAsQueuedMessageId: 'msg-1' - } - } - } - for (const retained of [false, true]) { - expect(mobileStructuredSendDelivery(kept, retained)).toEqual({ - outcome: 'queued', - operationIdSpent: true, - error: null - }) + it('lets a kept queued card hold the text after a provider rejection', () => { + const value = structuredSendResultFixture('rejected', 'Orca restarted before this was sent.') + if (!('submission' in value)) { + throw new Error('expected a submission answer') } + value.submission.keptAsQueuedMessageId = 'card-id' + expect(mobileStructuredSendDelivery({ status: 'accepted', value })).toEqual({ + outcome: 'queued', + error: null + }) }) - // The chat draws a send a Stop took back with its stop row, so it never goes back to the draft. A - // retained replay is resent by the caller, which needs the id spent and no words of its own. it.each([ - { retained: false, reason: DISPATCH_REJECTED_CANCELLED, fact: false, outcome: 'accepted' }, - { retained: false, reason: WITHDRAWN_SENTENCE, fact: true, outcome: 'accepted' }, - { retained: true, reason: DISPATCH_REJECTED_CANCELLED, fact: false, outcome: 'rejected' }, - { retained: true, reason: WITHDRAWN_SENTENCE, fact: true, outcome: 'rejected' } - ] as const)( - 'reads a send a Stop took back (retained $retained, typed fact $fact) as $outcome, spent, wordless', - ({ retained, reason, fact, outcome }) => { - const value = structuredSendResultFixture('rejected', reason) - if (fact && 'submission' in value) { - value.submission.rejection = { kind: 'cancelled' } - } - expect(mobileStructuredSendDelivery({ status: 'accepted', value }, retained)).toEqual({ - outcome, - operationIdSpent: true, - error: null - }) + { reason: DISPATCH_REJECTED_CANCELLED, typed: false }, + { reason: 'This message was withdrawn before the agent started it.', typed: true } + ])('leaves a stopped submission in its existing chat row (typed $typed)', ({ reason, typed }) => { + const value = structuredSendResultFixture('rejected', reason) + if (typed && 'submission' in value) { + value.submission.rejection = { kind: 'cancelled' } } - ) + expect(mobileStructuredSendDelivery({ status: 'accepted', value })).toEqual({ + outcome: 'accepted', + error: null + }) + }) - it('shows a provider content rejection verbatim', () => { + it('shows an actionable content rejection', () => { expect( mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp')) - ).toEqual({ + ).toEqual({ outcome: 'rejected', error: 'Claude does not support .bmp' }) + }) + + it.each([ + ['agent_session_operation_invalid', 'Invalid operation'], + ['agent_session_checkpoint_stale', 'Fence moved'], + ['agent_session_operation_expired', 'Operation expired'], + ['agent_session_operation_conflict', 'Operation conflict'] + ] as const)('reports the current %s refusal', (code, message) => { + expect(mobileStructuredSendDelivery({ status: 'refused', code, message })).toEqual({ outcome: 'rejected', - operationIdSpent: true, - error: 'Claude does not support .bmp' + error: message }) }) - it('spends only refusals that prove the operation is settled', () => { - expect( - mobileStructuredSendDelivery({ - status: 'refused', - code: 'agent_session_operation_invalid', - message: 'Invalid operation' - }) - ).toEqual({ outcome: 'rejected', operationIdSpent: true, error: 'Invalid operation' }) - expect( - mobileStructuredSendDelivery({ - status: 'refused', - code: 'agent_session_checkpoint_stale', - message: 'Fence moved' - }) - ).toEqual({ outcome: 'rejected', operationIdSpent: false, error: 'Fence moved' }) + it('preserves an unknown operation refusal as uncertainty', () => { expect( mobileStructuredSendDelivery({ status: 'refused', code: 'agent_session_operation_unknown', message: 'Outcome unknown' }) - ).toEqual({ outcome: 'unknown', operationIdSpent: false, error: null }) + ).toEqual({ outcome: 'unknown', error: null }) + }) + + it('reports a request failure without consulting earlier sends', () => { expect( mobileStructuredSendDelivery({ status: 'failed', message: 'Your message was not sent. Send it again.' }) - ).toEqual({ - outcome: 'rejected', - operationIdSpent: true, - error: 'Your message was not sent. Send it again.' - }) - }) - - it('spends an ambiguous id the host has expired, and says to check the chat', () => { - // The host refuses an expired id on every replay; keeping it would refuse this text forever. - // The earlier attempt may still be in the chat, so the words never say it was not sent. - expect( - mobileStructuredSendDelivery( - { - status: 'refused', - code: 'agent_session_operation_expired', - message: 'Operation expired' - }, - true - ) - ).toEqual({ - outcome: 'rejected', - operationIdSpent: true, - error: - "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed." - }) - }) - - it('never releases an ambiguous id on any other later RPC refusal or failure', () => { - expect( - mobileStructuredSendDelivery( - { - status: 'refused', - code: 'agent_session_operation_conflict', - message: 'Operation conflict' - }, - true - ) - ).toEqual({ outcome: 'rejected', operationIdSpent: false, error: 'Operation conflict' }) - expect( - mobileStructuredSendDelivery( - { status: 'failed', message: 'Your message was not sent. Send it again.' }, - true - ) - ).toEqual({ - outcome: 'rejected', - operationIdSpent: false, - error: 'Your message was not sent. Send it again.' - }) - }) - - it('releases a replay only when the host refuses its request shape itself', () => { - // An older host's strict schema refuses `delivery` before it runs anything: - // that replay can never be accepted, so keeping the id refuses the text forever. - expect( - mobileStructuredSendDelivery( - { - status: 'failed', - message: 'Your message was not sent. Send it again.', - hostRejectedByRequestSchema: true - }, - true - ) - ).toEqual({ - outcome: 'rejected', - operationIdSpent: true, - error: 'Your message was not sent. Send it again.' - }) - // Any other refusal (an auth failure, a host without the method) proves nothing - // about an earlier delivery of this id. - expect( - mobileStructuredSendDelivery( - { status: 'failed', message: 'Your message was not sent.' }, - true - ) - ).toMatchObject({ operationIdSpent: false }) - }) - - it('fails closed when an invalid host response omits the required submission', () => { - const result = { - status: 'accepted', - value: { clientMessageId: 'msg-1' } - } as unknown as StructuredAgentSessionMutationCallResult - expect(mobileStructuredSendDelivery(result)).toEqual({ - outcome: 'unknown', - operationIdSpent: false, - error: null - }) + ).toEqual({ outcome: 'rejected', error: 'Your message was not sent. Send it again.' }) }) }) diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index 2d48d1d2a9e0..744a561cabb1 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -1,144 +1,53 @@ -// What one `agentSession.send` answer means to a client with no outbox. -// -// The desktop reads the same four dispatch states through -// `disposeStructuredAgentSessionSendResult`; mobile has no queue to move, so it -// needs only two facts: the outcome to report, and whether the operation id it -// sent under is spent. -// -// The id is the whole safety mechanism here. Mobile keys its retained ids by -// message body, so re-sending the same text reuses the id — and one id is one -// delivery: `performSend` answers a second request under a recorded id from the -// ledger and never puts it back on the wire. Releasing the id turns that replay -// into a genuine second delivery, which is why only a settled answer releases it: -// -// accepted/pending — the send happened. The id is spent; a later identical -// message is a new message and must carry a new id. -// withdrawn — a submission a Stop took back before the agent started it is -// drawn in the chat with its stop row, so it spends the id and goes back to no -// draft. Answering a first send, it is that message: sent, then stopped. As a -// retained replay it cannot be told from a new send of the same text, and it -// provably never ran, so the caller sends it again under a fresh id. -// rejected — a terminal refusal or rejected submission spends a fresh id. A -// pending-admission refusal, or any refusal after earlier transport doubt, -// keeps it because neither proves a retained delivery did not happen. Two -// exceptions spend it anyway, because the host can never accept the replay -// and keeping the id would only refuse every later send of the same text: -// a host that refuses the replay's request shape itself (an older host's -// strict schema turning `delivery` away), and an id the host has expired. -// A rejection the host kept as a card answers as `queued`: the card holds the text. -// unknown — the one answer that KEEPS its id, whether it came from the host or -// from an ack-loss on the way back. The message may be with the provider, so -// the retry has to stay a replay. Rotating here is what sent one message to a -// model five times. - -import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry' -import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' -import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition' +import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-rejection-words' import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' export type MobileStructuredSendDelivery = { outcome: MobileNativeChatSendOutcome - /** True when a retry is safe under a fresh operation id. */ - operationIdSpent: boolean - /** Copy for the user, or null when the outcome needs none. */ error: string | null } -/** Whether a send answer is its own submission, which a Stop took back before the agent started it. */ -export function mobileStructuredSendWithdrawnBeforeStart( - result: StructuredAgentSessionMutationCallResult -): boolean { - if (result.status !== 'accepted' || !('submission' in result.value)) { - return false - } - const { submission } = result.value - return ( - submission.queuedMessageId === undefined && - submission.dispatchState === 'rejected' && - dispatchWasWithdrawn(submission) - ) -} - export function mobileStructuredSendDelivery( - result: StructuredAgentSessionMutationCallResult, - retained = false + result: StructuredAgentSessionMutationCallResult ): MobileStructuredSendDelivery { if (result.status === 'unknown') { - return { outcome: 'unknown', operationIdSpent: false, error: null } + return { outcome: 'unknown', error: null } } if (result.status === 'refused') { - const refusalState = agentSessionRefusalOperationState(result.code) - if (refusalState === 'unknown') { - return { outcome: 'unknown', operationIdSpent: false, error: null } - } - if (retained && result.code === 'agent_session_operation_expired') { - // The host refuses this id for good once its day is up, so keeping it would refuse this text - // forever. The earlier attempt may already be in the chat, so the words say to check first. - return { - outcome: 'rejected', - operationIdSpent: true, - error: agentSessionWriteNoticeEnglish(['sendOutcomeLost']) - } - } - return { - outcome: 'rejected', - operationIdSpent: refusalState === 'settled-rejected' && !retained, - error: result.message - } + return agentSessionRefusalOperationState(result.code) === 'unknown' + ? { outcome: 'unknown', error: null } + : { outcome: 'rejected', error: result.message } } if (result.status !== 'accepted') { - return { - outcome: 'rejected', - operationIdSpent: !retained || result.hostRejectedByRequestSchema === true, - error: result.message - } + return { outcome: 'rejected', error: result.message } } if ('queued' in result.value && result.value.queued) { - // The host holds (or already settled) the draft: the send is spent — a - // later identical message is a new message. A withdrawn replay is spent - // too, never unknown: its card was deleted or carried by a /clear, and the - // caller resends it or hands the text back. A dispatched draft answers here - // only when the host could not find the submission it became (a live one - // answers with that submission), so no echo would retire an optimistic - // bubble: it shows nothing, and the transcript or the card owns the text. - return { outcome: 'queued', operationIdSpent: true, error: null } - } - const submission: AgentJournalSubmission | undefined = - 'submission' in result.value ? result.value.submission : undefined - if (submission !== undefined && submission.queuedMessageId === result.value.clientMessageId) { - // The host says this id's queued draft was handed off as that submission: the send reached - // it, so the id is spent now, not when a stream that may never carry the hand-off shows it. - // Which send the phone meant stays unconfirmed, as for any retained replay of a live send. - return { outcome: 'unknown', operationIdSpent: true, error: null } + return result.value.queued.state === 'withdrawn' + ? { outcome: 'rejected', error: 'Message not sent' } + : { outcome: 'queued', error: null } } + const submission = 'submission' in result.value ? result.value.submission : undefined if (!submission || submission.dispatchState === 'unknown') { - return { outcome: 'unknown', operationIdSpent: false, error: null } + return { outcome: 'unknown', error: null } } - if (submission.dispatchState === 'rejected' && submission.keptAsQueuedMessageId !== undefined) { - // The host kept it as a card, which holds the text: no error, and nothing handed back to the - // composer, so the words never show twice. - return { outcome: 'queued', operationIdSpent: true, error: null } - } - if (mobileStructuredSendWithdrawnBeforeStart(result)) { - // The chat draws it with its stop row, so it is never handed back to the composer as well. A - // retained replay is resent by the caller. - return { outcome: retained ? 'rejected' : 'accepted', operationIdSpent: true, error: null } + if (submission.queuedMessageId === result.value.clientMessageId) { + return { outcome: 'unknown', error: null } } if (submission.dispatchState === 'rejected') { + if (submission.keptAsQueuedMessageId !== undefined) { + return { outcome: 'queued', error: null } + } + if (submission.queuedMessageId === undefined && dispatchWasWithdrawn(submission)) { + // The host transcript already owns this send's stopped row. + return { outcome: 'accepted', error: null } + } return { outcome: 'rejected', - operationIdSpent: true, error: structuredAgentSessionRejectionNotice(submission.reason, 'composer-send') } } - if (retained) { - // A payload match cannot distinguish retrying the ambiguous action from a - // later identical intent. Wait for the stream to settle and release it. - return { outcome: 'unknown', operationIdSpent: false, error: null } - } - return { outcome: 'accepted', operationIdSpent: true, error: null } + return { outcome: 'accepted', error: null } } diff --git a/mobile/src/session/mobile-structured-send-new-action.test.ts b/mobile/src/session/mobile-structured-send-new-action.test.ts new file mode 100644 index 000000000000..71827af8640c --- /dev/null +++ b/mobile/src/session/mobile-structured-send-new-action.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { fieldsOf } from './use-mobile-structured-agent-session-queued.test-fixture' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' + +const storage = vi.hoisted(() => ({ getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() })) +vi.mock('@react-native-async-storage/async-storage', () => ({ default: storage })) + +describe('a new phone send after an acknowledgement was lost', () => { + beforeEach(() => { + vi.clearAllMocks() + + const saved = new Map() + storage.getItem.mockImplementation(async (key: string) => saved.get(key) ?? null) + storage.setItem.mockImplementation(async (key: string, value: string) => saved.set(key, value)) + storage.removeItem.mockImplementation(async (key: string) => saved.delete(key)) + }) + + it.each([false, true])( + 'delivers identical text as a new action while the old host entry stays unknown (relaunch %s)', + async (relaunch) => { + const ids: string[] = [] + const delivered: string[] = [] + const ledger = new Set() + const sendRequest = vi.fn(async (_method, params) => { + const id = String(fieldsOf(fieldsOf(params).envelope).clientOperationId) + const replayed = ledger.has(id) + ids.push(id) + if (!replayed) { + ledger.add(id) + delivered.push(id) + if (delivered.length === 1) { + throw markRpcDeliveryUnknown(new Error('Connection closed after dispatch')) + } + } + return { + id: 'response', + ok: true, + result: { + ok: true, + replayed, + fence: 3, + cursor: { epoch: 'epoch', sequence: 1 }, + value: { + clientMessageId: id, + submission: { + clientMessageId: id, + fence: 3, + payloadFingerprint: String(fieldsOf(fieldsOf(params).envelope).payloadFingerprint), + dispatchState: id === delivered[0] ? 'unknown' : 'accepted', + providerItemId: null, + reason: null, + submittedAt: Date.now(), + resolvedAt: null + } + } + } + } + }) + const clientFor = (): RpcClient => ({ + sendRequest, + subscribe: vi.fn(() => vi.fn()), + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + }) + let client = clientFor() + const onError = vi.fn() + const message = { + sessionId: 'session', + sessionKey: 'remote-host:folder:session', + callerIdentity: 'phone', + expectedRuntimeFence: 3, + text: 'please continue', + attachments: [], + onError + } + const send = () => sendMobileStructuredAgentSessionMessage({ ...message, client }) + + expect(await send()).toBe('unknown') + if (relaunch) { + client = clientFor() + } + expect(await send()).toBe('accepted') + expect(delivered).toHaveLength(2) + expect(new Set(ids).size).toBe(2) + expect(onError).not.toHaveBeenCalled() + expect(storage.getItem).not.toHaveBeenCalled() + expect(storage.setItem).not.toHaveBeenCalled() + } + ) +}) diff --git a/mobile/src/session/mobile-structured-send-operation-journal.test.ts b/mobile/src/session/mobile-structured-send-operation-journal.test.ts deleted file mode 100644 index 3c76e1d1c3fc..000000000000 --- a/mobile/src/session/mobile-structured-send-operation-journal.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS } from '../../../src/shared/agent-session-host-authority' -import { AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT } from '../../../src/shared/agent-session-operation-ledger' - -const asyncStorage = vi.hoisted(() => ({ - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn() -})) - -vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) - -import { - clearMobileStructuredSendOperation, - clearMobileStructuredSettledSendOperations, - getOrCreateMobileStructuredSendOperation as getOrCreatePersistedOperation, - mobileStructuredSendCallerFingerprint, - mobileStructuredSendOperationKey, - resetMobileStructuredSendOperationJournalForTests -} from './mobile-structured-send-operation-journal' -import { readMirroredStorage } from '../storage/mirrored-storage-keys' - -const NOW = 1_900_000_000_000 -/** The key the journal persists under, which the hybrid shell mirrors into every `init`. */ -const JOURNAL_KEY = 'orca:mobileStructuredSendOperations:v1' -const OPERATION_KEY = 'a'.repeat(64) -const CALLER_IDENTITY = 'mobile-device-a' - -function getOrCreateMobileStructuredSendOperation( - input: Omit< - Parameters[0], - 'callerIdentity' | 'payloadFingerprint' | 'attachmentPaths' - > & { payloadFingerprint?: string; attachmentPaths?: readonly string[] } -) { - const { payloadFingerprint = 'b'.repeat(64), attachmentPaths = [], ...operation } = input - return getOrCreatePersistedOperation({ - ...operation, - callerIdentity: CALLER_IDENTITY, - payloadFingerprint, - attachmentPaths - }).then(({ operationId, retained }) => ({ operationId, retained })) -} - -function operationIdAt(timestamp: number, entropy: string): string { - return `${timestamp}-${entropy.repeat(32).slice(0, 32)}` -} - -describe('mobile structured send operation journal', () => { - let values: Map - - beforeEach(() => { - vi.clearAllMocks() - resetMobileStructuredSendOperationJournalForTests() - values = new Map() - asyncStorage.getItem.mockImplementation(async (key: string) => values.get(key) ?? null) - asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { - values.set(key, value) - }) - asyncStorage.removeItem.mockImplementation(async (key: string) => { - values.delete(key) - }) - }) - - it('persists an ambiguous id before dispatch and reuses it after remount', async () => { - const firstId = operationIdAt(NOW, '1') - await expect( - getOrCreateMobileStructuredSendOperation({ - operationKey: OPERATION_KEY, - createOperationId: () => firstId, - now: NOW - }) - ).resolves.toEqual({ operationId: firstId, retained: false }) - - resetMobileStructuredSendOperationJournalForTests() - const createAfterRemount = vi.fn(() => operationIdAt(NOW, '2')) - await expect( - getOrCreateMobileStructuredSendOperation({ - operationKey: OPERATION_KEY, - createOperationId: createAfterRemount, - now: NOW - }) - ).resolves.toEqual({ operationId: firstId, retained: true }) - expect(createAfterRemount).not.toHaveBeenCalled() - }) - - /** - * A mirror the page reads is not allowed to run ahead of the store (round 4, CodeRabbit). - * - * The hybrid shell builds `init` from the mirror synchronously, so the page is handed whatever - * was noted here. Noting the write before it is persisted is what keeps an `init` in the same - * turn current; keeping the note after the persist was refused publishes a journal that does - * not exist, and the page resumes operations the device never wrote down. - */ - it('rolls the mirror back when persisting an added entry fails', async () => { - await getOrCreateMobileStructuredSendOperation({ - operationKey: OPERATION_KEY, - createOperationId: () => operationIdAt(NOW, '8'), - now: NOW - }) - const held = readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY] - asyncStorage.setItem.mockRejectedValueOnce(new Error('the store is full')) - await expect( - getOrCreateMobileStructuredSendOperation({ - operationKey: 'c'.repeat(64), - createOperationId: () => operationIdAt(NOW, '9'), - now: NOW - }) - ).rejects.toThrow('the store is full') - expect(readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY]).toBe(held) - }) - - it('rolls the mirror back when persisting the last clear fails', async () => { - const firstId = operationIdAt(NOW, 'a') - await getOrCreateMobileStructuredSendOperation({ - operationKey: OPERATION_KEY, - createOperationId: () => firstId, - now: NOW - }) - const held = readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY] - asyncStorage.removeItem.mockRejectedValueOnce(new Error('the store is full')) - await expect( - clearMobileStructuredSendOperation({ operationKey: OPERATION_KEY, operationId: firstId }) - ).rejects.toThrow('the store is full') - expect(readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY]).toBe(held) - }) - - it('clears only the exact settled operation', async () => { - const firstId = operationIdAt(NOW, '3') - await getOrCreateMobileStructuredSendOperation({ - operationKey: OPERATION_KEY, - createOperationId: () => firstId, - now: NOW - }) - - await expect( - clearMobileStructuredSendOperation({ - operationKey: OPERATION_KEY, - operationId: operationIdAt(NOW, '4') - }) - ).rejects.toThrow('identity changed') - await clearMobileStructuredSendOperation({ - operationKey: OPERATION_KEY, - operationId: firstId - }) - - const secondId = operationIdAt(NOW, '5') - await expect( - getOrCreateMobileStructuredSendOperation({ - operationKey: OPERATION_KEY, - createOperationId: () => secondId, - now: NOW - }) - ).resolves.toEqual({ operationId: secondId, retained: false }) - }) - - it('clears an ack-lost id only when its journal submission settles', async () => { - const sessionKey = 'host-a:session-a' - const payloadFingerprint = 'c'.repeat(64) - const operationKey = mobileStructuredSendOperationKey({ - sessionKey, - intentFingerprint: payloadFingerprint - }) - const operationId = operationIdAt(NOW, 'd') - await getOrCreateMobileStructuredSendOperation({ - operationKey, - payloadFingerprint, - createOperationId: () => operationId, - now: NOW - }) - const submission = { - clientMessageId: operationId, - fence: 1, - payloadFingerprint, - dispatchState: 'unknown' as const, - providerItemId: null, - reason: 'ack lost', - submittedAt: NOW, - resolvedAt: NOW - } - - await clearMobileStructuredSettledSendOperations({ submissions: [submission] }) - expect(values.size).toBe(1) - - await clearMobileStructuredSettledSendOperations({ - submissions: [{ ...submission, dispatchState: 'accepted', reason: null }] - }) - expect(values.size).toBe(0) - }) - - it("clears an id a draft hand-off names, whatever that hand-off's state", async () => { - const sessionKey = 'host-a:session-a' - const payloadFingerprint = '9'.repeat(64) - const operationKey = mobileStructuredSendOperationKey({ - sessionKey, - intentFingerprint: payloadFingerprint - }) - const operationId = operationIdAt(NOW, '7') - await getOrCreateMobileStructuredSendOperation({ - operationKey, - payloadFingerprint, - createOperationId: () => operationId, - now: NOW - }) - // The drain handed the draft off under a fresh id; only the link names this send. - await clearMobileStructuredSettledSendOperations({ - submissions: [ - { - clientMessageId: operationIdAt(NOW + 1, '8'), - queuedMessageId: operationId, - fence: 1, - payloadFingerprint, - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: NOW + 1, - resolvedAt: null - } - ] - }) - expect(values.size).toBe(0) - }) - - it('does not clear a newer id for an older matching-payload submission', async () => { - const sessionKey = 'host-a:session-a' - const payloadFingerprint = 'e'.repeat(64) - const operationKey = mobileStructuredSendOperationKey({ - sessionKey, - intentFingerprint: payloadFingerprint - }) - const operationId = operationIdAt(NOW, 'f') - await getOrCreateMobileStructuredSendOperation({ - operationKey, - payloadFingerprint, - createOperationId: () => operationId, - now: NOW - }) - - await clearMobileStructuredSettledSendOperations({ - submissions: [ - { - clientMessageId: operationIdAt(NOW - 1, '1'), - fence: 1, - payloadFingerprint, - dispatchState: 'accepted', - providerItemId: null, - reason: null, - submittedAt: NOW - 1, - resolvedAt: NOW - 1 - } - ] - }) - - await expect( - getOrCreateMobileStructuredSendOperation({ - operationKey, - createOperationId: () => operationIdAt(NOW, '2'), - now: NOW - }) - ).resolves.toEqual({ operationId, retained: true }) - }) - - it('reuses the original attachment paths for an ambiguous repeat upload', async () => { - const operationId = operationIdAt(NOW, 'e') - const payloadFingerprint = 'd'.repeat(64) - await getOrCreatePersistedOperation({ - operationKey: OPERATION_KEY, - callerIdentity: CALLER_IDENTITY, - payloadFingerprint, - attachmentPaths: ['/tmp/original.png'], - createOperationId: () => operationId, - now: NOW - }) - - await expect( - getOrCreatePersistedOperation({ - operationKey: OPERATION_KEY, - callerIdentity: CALLER_IDENTITY, - payloadFingerprint: 'e'.repeat(64), - attachmentPaths: ['/tmp/reuploaded.png'], - createOperationId: () => operationIdAt(NOW, 'f'), - now: NOW - }) - ).resolves.toEqual({ - operationKey: OPERATION_KEY, - operationId, - retained: true, - payloadFingerprint, - attachmentPaths: ['/tmp/original.png'] - }) - }) - - it('replays an entry retained under the alternate key, and says which key matched', async () => { - const operationId = operationIdAt(NOW, 'e') - const alternateKey = 'b'.repeat(64) - await getOrCreatePersistedOperation({ - operationKey: alternateKey, - callerIdentity: CALLER_IDENTITY, - payloadFingerprint: 'd'.repeat(64), - attachmentPaths: [], - createOperationId: () => operationId, - now: NOW - }) - await expect( - getOrCreatePersistedOperation({ - operationKey: OPERATION_KEY, - alternateOperationKey: alternateKey, - callerIdentity: CALLER_IDENTITY, - payloadFingerprint: 'd'.repeat(64), - attachmentPaths: [], - createOperationId: () => operationIdAt(NOW, 'f'), - now: NOW - }) - ).resolves.toMatchObject({ operationKey: alternateKey, operationId, retained: true }) - }) - - it('keeps an ambiguous id after the host replay window closes', async () => { - const expired = operationIdAt(NOW - AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS - 1, '6') - asyncStorage.getItem.mockResolvedValueOnce( - JSON.stringify({ - v: 1, - entries: [ - { - operationKey: OPERATION_KEY, - operationId: expired, - callerFingerprint: mobileStructuredSendCallerFingerprint(CALLER_IDENTITY), - payloadFingerprint: 'b'.repeat(64), - attachmentPaths: [] - } - ] - }) - ) - - await expect( - getOrCreateMobileStructuredSendOperation({ - operationKey: OPERATION_KEY, - createOperationId: () => operationIdAt(NOW, '7'), - now: NOW - }) - ).resolves.toEqual({ operationId: expired, retained: true }) - }) - - it('fails closed when a retained operation id is malformed', async () => { - asyncStorage.getItem.mockResolvedValueOnce( - JSON.stringify({ - v: 1, - entries: [ - { - operationKey: OPERATION_KEY, - operationId: 'not-an-operation-id', - callerFingerprint: mobileStructuredSendCallerFingerprint(CALLER_IDENTITY), - payloadFingerprint: 'b'.repeat(64), - attachmentPaths: [] - } - ] - }) - ) - - await expect( - getOrCreateMobileStructuredSendOperation({ - operationKey: OPERATION_KEY, - createOperationId: () => operationIdAt(NOW, '8'), - now: NOW - }) - ).rejects.toThrow('unreadable') - }) - - it('fails closed when durable identity cannot be read or written', async () => { - asyncStorage.getItem.mockRejectedValueOnce(new Error('storage unavailable')) - await expect( - getOrCreateMobileStructuredSendOperation({ - operationKey: OPERATION_KEY, - createOperationId: () => operationIdAt(NOW, '9'), - now: NOW - }) - ).rejects.toThrow('storage unavailable') - - asyncStorage.setItem.mockRejectedValueOnce(new Error('disk full')) - await expect( - getOrCreateMobileStructuredSendOperation({ - operationKey: OPERATION_KEY, - createOperationId: () => operationIdAt(NOW, 'a'), - now: NOW - }) - ).rejects.toThrow('disk full') - expect(values.size).toBe(0) - }) - - it('refuses new sends rather than evicting an ambiguous id at capacity', async () => { - asyncStorage.getItem.mockResolvedValueOnce( - JSON.stringify({ - v: 1, - entries: Array.from( - { length: AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT }, - (_, index) => ({ - operationKey: index.toString(16).padStart(64, '0'), - operationId: operationIdAt(NOW, index.toString(16).padStart(32, '0')), - callerFingerprint: mobileStructuredSendCallerFingerprint(CALLER_IDENTITY), - payloadFingerprint: 'b'.repeat(64), - attachmentPaths: [] - }) - ) - }) - ) - - await expect( - getOrCreateMobileStructuredSendOperation({ - operationKey: OPERATION_KEY, - createOperationId: () => operationIdAt(NOW, 'b'), - now: NOW - }) - ).rejects.toThrow('journal is full') - }) - - it('hashes the session scope and payload instead of retaining message bodies', () => { - const first = mobileStructuredSendOperationKey({ - sessionKey: 'host-a:session-a', - intentFingerprint: 'message-body-fingerprint' - }) - const second = mobileStructuredSendOperationKey({ - sessionKey: 'host-b:session-a', - intentFingerprint: 'message-body-fingerprint' - }) - - expect(first).toMatch(/^[0-9a-f]{64}$/) - expect(second).not.toBe(first) - expect(first).not.toContain('message-body') - }) - - it('fails closed when a retained id crosses authenticated caller identities', async () => { - await getOrCreatePersistedOperation({ - operationKey: OPERATION_KEY, - callerIdentity: 'mobile-device-before-repair', - payloadFingerprint: 'b'.repeat(64), - attachmentPaths: [], - createOperationId: () => operationIdAt(NOW, '3'), - now: NOW - }) - - await expect( - getOrCreatePersistedOperation({ - operationKey: OPERATION_KEY, - callerIdentity: 'mobile-device-after-repair', - payloadFingerprint: 'b'.repeat(64), - attachmentPaths: [], - createOperationId: () => operationIdAt(NOW, '4'), - now: NOW - }) - ).rejects.toThrow('caller identity changed') - }) -}) diff --git a/mobile/src/session/mobile-structured-send-operation-journal.ts b/mobile/src/session/mobile-structured-send-operation-journal.ts deleted file mode 100644 index 98937ad1bbbb..000000000000 --- a/mobile/src/session/mobile-structured-send-operation-journal.ts +++ /dev/null @@ -1,281 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { z } from 'zod' -import { persistMirrored } from '../storage/mirrored-storage-keys' -import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' -import { - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, - AGENT_SESSION_OPERATION_FUTURE_SKEW_MS, - parseAgentSessionOperationTimestamp -} from '../../../src/shared/agent-session-host-authority' -import { AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT } from '../../../src/shared/agent-session-operation-ledger' -import { structuredAgentSessionDomainFingerprint } from '../../../src/shared/structured-agent-session-mutation' - -const STORAGE_KEY = 'orca:mobileStructuredSendOperations:v1' -const OperationEntrySchema = z - .object({ - operationKey: z.string().regex(/^[0-9a-f]{64}$/), - operationId: z.string().max(128), - callerFingerprint: z.string().regex(/^[0-9a-f]{64}$/), - payloadFingerprint: z.string().regex(/^[0-9a-f]{64}$/), - attachmentPaths: z.array(z.string().max(4096)).max(128) - }) - .strict() -const OperationJournalSchema = z - .object({ - v: z.literal(1), - entries: z.array(OperationEntrySchema).max(AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT) - }) - .strict() - -type OperationEntry = z.infer -type OperationJournal = z.infer - -const mutations: { tail: Promise } = { tail: Promise.resolve() } - -export function mobileStructuredSendOperationKey(input: { - sessionKey: string - intentFingerprint: string -}): string { - return structuredAgentSessionDomainFingerprint({ - domain: 'mobile.agentSession.send.operation', - sessionId: input.sessionKey, - fields: { intentFingerprint: input.intentFingerprint } - }) -} - -export function mobileStructuredSendCallerFingerprint(callerIdentity: string): string { - return structuredAgentSessionDomainFingerprint({ - domain: 'mobile.agentSession.send.caller', - sessionId: callerIdentity, - fields: {} - }) -} - -function newOperationIdIsAdmissible(operationId: string, now: number): boolean { - const timestamp = parseAgentSessionOperationTimestamp(operationId) - return ( - timestamp !== null && - timestamp <= now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS && - now - timestamp <= AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - ) -} - -function parseJournal(raw: string | null): OperationJournal { - if (raw === null) { - return { v: 1, entries: [] } - } - let value: unknown - try { - value = JSON.parse(raw) - } catch { - throw new Error('Structured send operation journal is unreadable') - } - const parsed = OperationJournalSchema.safeParse(value) - if (!parsed.success) { - throw new Error('Structured send operation journal is unreadable') - } - if ( - new Set(parsed.data.entries.map((entry) => entry.operationKey)).size !== - parsed.data.entries.length || - parsed.data.entries.some( - (entry) => parseAgentSessionOperationTimestamp(entry.operationId) === null - ) - ) { - throw new Error('Structured send operation journal is unreadable') - } - return parsed.data -} - -async function writeEntries(entries: OperationEntry[]): Promise { - // Through the one write path, which notes the mirror on an accepted write and on nothing else - // (ruling 35). The rejection this can raise is the point of the key: a journal the device never - // wrote must not reach the page, and the composer above catches it as "Message not sent". - await persistMirrored( - STORAGE_KEY, - entries.length === 0 ? null : JSON.stringify({ v: 1, entries }) - ) -} - -async function serialize(action: () => Promise): Promise { - const operation = mutations.tail.then(action, action) - mutations.tail = operation.then( - () => undefined, - () => undefined - ) - return operation -} - -export async function getOrCreateMobileStructuredSendOperation(input: { - operationKey: string - /** The same intent under its other delivery: a retained entry there is replayed - * as-is, so the key that matched — never a stored field — says how it was sent. */ - alternateOperationKey?: string - callerIdentity: string - payloadFingerprint: string - attachmentPaths: readonly string[] - createOperationId: () => string - now?: number -}): Promise<{ - operationKey: string - operationId: string - retained: boolean - payloadFingerprint: string - attachmentPaths: string[] -}> { - return serialize(async () => { - const now = input.now ?? Date.now() - const callerFingerprint = mobileStructuredSendCallerFingerprint(input.callerIdentity) - const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY)) - const entries = journal.entries - const existing = - entries.find((entry) => entry.operationKey === input.operationKey) ?? - entries.find( - (entry) => - input.alternateOperationKey !== undefined && - entry.operationKey === input.alternateOperationKey - ) - if (existing) { - if (existing.callerFingerprint !== callerFingerprint) { - throw new Error('Structured send caller identity changed') - } - return { - operationKey: existing.operationKey, - operationId: existing.operationId, - retained: true, - payloadFingerprint: existing.payloadFingerprint, - attachmentPaths: [...existing.attachmentPaths] - } - } - // Ambiguity has no TTL. At the fixed capacity, refusing a new send is safer - // than evicting an id whose message may already be in provider context. - if (entries.length >= AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT) { - throw new Error('Structured send operation journal is full') - } - const operationId = input.createOperationId() - if (!newOperationIdIsAdmissible(operationId, now)) { - throw new Error('Structured send operation id is invalid') - } - const entry = OperationEntrySchema.parse({ - operationKey: input.operationKey, - operationId, - callerFingerprint, - payloadFingerprint: input.payloadFingerprint, - attachmentPaths: [...input.attachmentPaths] - }) - await writeEntries([...entries, entry]) - return { - operationKey: input.operationKey, - operationId, - retained: false, - payloadFingerprint: input.payloadFingerprint, - attachmentPaths: [...input.attachmentPaths] - } - }) -} - -export async function clearMobileStructuredSendOperation(input: { - operationKey: string - operationId: string -}): Promise { - return serialize(async () => { - const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY)) - const entries = journal.entries - const existing = entries.find((entry) => entry.operationKey === input.operationKey) - if (!existing) { - return - } - if (existing.operationId !== input.operationId) { - throw new Error('Structured send operation identity changed') - } - await writeEntries(entries.filter((entry) => entry !== existing)) - }) -} - -/** Reconcile an ack-lost operation once the authoritative journal settles it, or hands it off: - * a submission naming the operation's id as its `queuedMessageId` is that send's queued draft - * going out, in whatever state, so the send reached the host and its id is spent. */ -export async function clearMobileStructuredSettledSendOperations(input: { - submissions: readonly AgentJournalSubmission[] -}): Promise { - const settled = new Set( - input.submissions.flatMap((submission) => - submission.dispatchState === 'accepted' || submission.dispatchState === 'rejected' - ? [`${submission.payloadFingerprint}\u0000${submission.clientMessageId}`] - : [] - ) - ) - const handedOff = new Set( - input.submissions.flatMap((submission) => - submission.queuedMessageId !== undefined ? [submission.queuedMessageId] : [] - ) - ) - if (settled.size === 0 && handedOff.size === 0) { - return - } - return serialize(async () => { - const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY)) - const entries = journal.entries.filter( - (entry) => - !settled.has(`${entry.payloadFingerprint}\u0000${entry.operationId}`) && - !handedOff.has(entry.operationId) - ) - if (entries.length !== journal.entries.length) { - await writeEntries(entries) - } - }) -} - -/** - * Spend the ids the host publishes as queued drafts. A draft is named by the - * operation id of the send that created it, so seeing one is the host's own - * receipt of that send — the `queued` answer a lost acknowledgement never - * brought. Without this, a draft another device later withdraws leaves an entry - * that no submission will ever settle. - */ -export async function clearMobileStructuredQueuedSendOperations(input: { - queuedMessageIds: readonly string[] -}): Promise { - if (input.queuedMessageIds.length === 0) { - return - } - const held = new Set(input.queuedMessageIds) - return serialize(async () => { - const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY)) - const entries = journal.entries.filter((entry) => !held.has(entry.operationId)) - if (entries.length !== journal.entries.length) { - await writeEntries(entries) - } - }) -} - -/** Ids sent past a saved record storage would not clear, keyed by the operation key that record - * matched, for this app run only: a retry of that text replays the same id instead of minting - * another, which could deliver it twice. An entry dies at app restart, when the host answers the - * id as spent, or when a saved record takes the id over; a replay the host keeps keeps it. */ -const bypassedOperationIds = new Map() - -export function bypassedMobileStructuredSendOperationId(operationKey: string): string | undefined { - return bypassedOperationIds.get(operationKey) -} - -export function rememberBypassedMobileStructuredSendOperation( - operationKey: string, - operationId: string -): void { - bypassedOperationIds.set(operationKey, operationId) -} - -export function forgetBypassedMobileStructuredSendOperation( - operationKey: string, - operationId: string -): void { - if (bypassedOperationIds.get(operationKey) === operationId) { - bypassedOperationIds.delete(operationKey) - } -} - -/** Test-only: drain in-memory serialization and bypassed ids while preserving durable storage. */ -export function resetMobileStructuredSendOperationJournalForTests(): void { - mutations.tail = Promise.resolve() - bypassedOperationIds.clear() -} diff --git a/mobile/src/session/mobile-structured-send-page-storage-refusal.test.ts b/mobile/src/session/mobile-structured-send-page-storage-refusal.test.ts deleted file mode 100644 index b33d992ddb23..000000000000 --- a/mobile/src/session/mobile-structured-send-page-storage-refusal.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -/** - * The page's own AsyncStorage under the durable send journal, which is the one allowlisted value - * that outgrows what the bridge will carry. - * - * Measured on this tree: a journal entry with no attachment costs 343 characters in the array — - * 342 of its own plus the comma that joins it — and the schema admits 4,096 of them, so 47 - * unsettled sends measure 16,140 and 48 measure 16,483, past `PAGE_STORAGE_MAX_VALUE_CHARS`. Every - * page write goes through `page-async-storage`, which the - * bundler aliases over the real module, so this is the module the journal actually writes to - * inside the page — and the reason the refusal must be a rejection rather than a dropped write. - * - * What a resolved refusal would cost, which is more than a lost preference: the journal would - * answer with an operation id no store holds, the mutation would go out carrying it, and a retry - * after a crash would send the same message again. Ruling 7's "nothing silently no-ops", at the - * one key where silence is a duplicate message rather than a default. - * - * The other half of the chain — that the rejection reaches the composer as "Message not sent" — is - * in `use-mobile-structured-agent-session-send.test.tsx`, which already drives the real send with - * a client double; asserting it here would mean building a second one. - */ -vi.mock('@react-native-async-storage/async-storage', async () => ({ - default: (await import('../mobile-web-shell/bridge/page-async-storage')).default -})) - -const { publishPageStorage } = await import('../mobile-web-shell/bridge/page-async-storage') -const { PAGE_STORAGE_MAX_VALUE_CHARS, pageStorageEntriesForInit } = - await import('../mobile-web-shell/page-storage-keys') -const { - getOrCreateMobileStructuredSendOperation, - resetMobileStructuredSendOperationJournalForTests -} = await import('./mobile-structured-send-operation-journal') - -const HOST_ID = 'host-1' -const SESSION_ROUTE = '/h/host-1/session/wt-1' -const JOURNAL = 'orca:mobileStructuredSendOperations:v1' - -const hex = (fill: string) => fill.repeat(64) - -/** - * A journal the module itself reads back, built past the cap out of real entries rather than - * filler: a value the parser refuses reads as "unreadable" and never reaches the write at all. - * `ENTRIES_OVER_THE_CAP` is the measured number — one entry costs 343 characters in the array. - */ -const ENTRIES_OVER_THE_CAP = 48 - -function storedJournal(count: number): string { - return JSON.stringify({ - v: 1, - entries: Array.from({ length: count }, (_, index) => ({ - operationKey: index.toString(16).padStart(64, '0'), - operationId: `17584320${String(index).padStart(5, '0')}-${'b'.repeat(32)}`, - callerFingerprint: hex('c'), - payloadFingerprint: hex('d'), - attachmentPaths: [] - })) - }) -} - -const posted: { key: string; value: string | null }[] = [] - -/** - * The page seated the way the shell seats it, rather than from a hand-written record. - * - * `pageStorageEntriesForInit` is the split the shell runs before `init` is built, so driving the - * page through it is what makes these states ones production can reach: a journal over the cap - * never arrives as a value, it arrives as a name on the oversize list. - */ -function publishAsTheShellWould(held: Record): void { - posted.length = 0 - const { entries, oversize } = pageStorageEntriesForInit(held) - publishPageStorage( - entries, - (key, value) => { - posted.push({ key, value }) - return true - }, - HOST_ID, - SESSION_ROUTE, - oversize - ) -} - -function publish(entries: Record): void { - publishAsTheShellWould(entries) -} - -function claim() { - return getOrCreateMobileStructuredSendOperation({ - operationKey: hex('a'), - callerIdentity: 'caller-1', - payloadFingerprint: hex('d'), - attachmentPaths: [], - createOperationId: () => `${String(Date.now())}-${hex('b').slice(0, 32)}` - }) -} - -beforeEach(() => { - resetMobileStructuredSendOperationJournalForTests() - publish({}) -}) - -describe('the durable send journal against the page store', () => { - it('rejects the send that would take the journal past what the page may write', async () => { - // The real precondition, seated through the shell's own split: 47 entries fit, so `init` - // carries them and the page holds a journal it can read. The 48th is the one that does not. - const held = storedJournal(ENTRIES_OVER_THE_CAP - 1) - expect(held.length).toBeLessThanOrEqual(PAGE_STORAGE_MAX_VALUE_CHARS) - expect(storedJournal(ENTRIES_OVER_THE_CAP).length).toBeGreaterThan(PAGE_STORAGE_MAX_VALUE_CHARS) - publishAsTheShellWould({ [JOURNAL]: held }) - await expect(claim()).rejects.toThrow(/could not save/) - // Nothing posted either: the value the wire would have dropped never left the page. - expect(posted).toEqual([]) - }) - - /** - * The destructive one (ruling 33.6). - * - * A native journal past the cap is dropped from `init` for size, and the key stays in - * `pageStorageKeysForRoute`. Without the oversize list the page reads `null`, `parseJournal` - * answers an empty journal, and the first send writes a one-entry value over the device's — the - * native entries gone and a fresh `operationId` for an operation native already holds, which is - * the duplicate send ruling 7 exists to prevent. - */ - it('refuses a send when init could not carry the journal, instead of replacing it', async () => { - const held = storedJournal(ENTRIES_OVER_THE_CAP) - const { entries, oversize } = pageStorageEntriesForInit({ [JOURNAL]: held }) - // The precondition itself: the shell hands no value for this key and names it instead. - expect(entries).toEqual({}) - expect(oversize).toEqual([JOURNAL]) - publishAsTheShellWould({ [JOURNAL]: held }) - await expect(claim()).rejects.toThrow(/could not save/) - // The half that makes it destructive: nothing was posted, so the native journal is untouched. - expect(posted).toEqual([]) - }) - - it('claims an id when the journal fits, so the refusal above is the size and not the path', async () => { - publish({}) - await expect(claim()).resolves.toEqual(expect.objectContaining({ retained: false })) - expect(posted.map((write) => write.key)).toEqual([JOURNAL]) - }) -}) diff --git a/mobile/src/session/use-mobile-native-chat-ask-auto-advance.ts b/mobile/src/session/use-mobile-native-chat-ask-auto-advance.ts new file mode 100644 index 000000000000..ef801a965dad --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-ask-auto-advance.ts @@ -0,0 +1,17 @@ +import { useEffect, useRef } from 'react' +import { NATIVE_CHAT_QUESTION_AUTO_ADVANCE_MS } from '../../../src/shared/native-chat-question-auto-advance' + +/** Mirrors desktop's useNativeChatQuestionAutoAdvance; shared code cannot import this app's React. */ +export function useMobileNativeChatAskAutoAdvance(): { + schedule: (step: () => void) => void + cancel: () => void +} { + const timerRef = useRef>(undefined) + const cancel = (): void => clearTimeout(timerRef.current) + const schedule = (step: () => void): void => { + cancel() + timerRef.current = setTimeout(step, NATIVE_CHAT_QUESTION_AUTO_ADVANCE_MS) + } + useEffect(() => () => clearTimeout(timerRef.current), []) + return { schedule, cancel } +} diff --git a/mobile/src/session/use-mobile-native-chat-composer-tray.test.tsx b/mobile/src/session/use-mobile-native-chat-composer-tray.test.tsx new file mode 100644 index 000000000000..eae7cbb8bec5 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-composer-tray.test.tsx @@ -0,0 +1,197 @@ +// The composer tray keeps one card list and one child-work strip per conversation, so an action +// still in flight in one never disables the same control in another, and the strip's open list +// outlives a roster that empties between sequential children. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentChildWorkView } from '../../../src/shared/agent-status-child-work-view' +import type { AgentSessionBackgroundTaskState } from '../../../src/shared/agent-session-wire' +import { agentChildRowContextForSessionStream } from '../../../src/shared/agent-child-row-stream-context' +import { structuredSessionBackgroundTasksView } from '../../../src/shared/structured-session-background-tasks-view' +import { useMobileNativeChatComposerTray } from './use-mobile-native-chat-composer-tray' +import type { MobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks' + +vi.mock('react-native', () => ({ + Dimensions: { get: () => ({ width: 430, height: 900 }) }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + Activity: 'Activity', + AlertCircle: 'AlertCircle', + Bot: 'Bot', + ChevronDown: 'ChevronDown', + CircleHelp: 'CircleHelp', + SquareTerminal: 'SquareTerminal', + Workflow: 'Workflow', + CornerDownRight: 'CornerDownRight', + ListEnd: 'ListEnd', + MoreHorizontal: 'MoreHorizontal', + Pause: 'Pause', + Pencil: 'Pencil', + Play: 'Play', + Send: 'Send', + Trash2: 'Trash2' +})) + +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('../components/AgentStateDot', () => ({ + AGENT_WORKING_COLOR: '#eab308', + AgentStateDot: 'AgentStateDot' +})) +vi.mock('../hooks/use-now', () => ({ useNow: () => 100_000 })) + +const CARD = { + messageId: 'waiting-1', + text: 'next', + state: 'waiting' as const, + paused: false, + needsAttention: false, + caption: null, + attribution: null +} + +const CHILD: AgentChildWorkView = { + id: 'a', + providerId: 'task-a', + kind: 'agent', + description: 'review a', + state: 'working', + membership: 'live', + firstObservedAt: 90_000, + observedAt: 99_000, + stoppable: true, + invocation: { invocationId: 'spawn-a', generation: 1 } +} +const RUNNING: AgentSessionBackgroundTaskState = { state: 'monitoring', children: [CHILD] } + +function tasks( + sessionKey: string, + state: AgentSessionBackgroundTaskState | null +): MobileStructuredBackgroundTasks { + return { + sessionKey, + view: structuredSessionBackgroundTasksView(state, null), + rowContext: agentChildRowContextForSessionStream(true, 0), + stop: vi.fn(async () => undefined) + } +} + +const NEVER_RESUMES = (): Promise => new Promise(() => undefined) + +function Slot({ + sessionKey, + onResume = NEVER_RESUMES, + backgroundTasks = null +}: { + sessionKey: string + onResume?: () => Promise + backgroundTasks?: MobileStructuredBackgroundTasks | null +}): React.JSX.Element { + const tray = useMobileNativeChatComposerTray({ + queued: { cards: [CARD], pause: { reason: 'stopped' }, onResume, sessionKey }, + backgroundTasks + }) + return createElement('View', null, tray.content) +} + +describe('useMobileNativeChatComposerTray', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it("never lets one conversation's pending Resume disable another's", async () => { + const pending = vi.fn(() => new Promise(() => undefined)) + const mounted = create(createElement('View')) + renderer = mounted + const resume = () => + mounted.root.findByProps({ accessibilityLabel: 'Resume sending the queued messages' }) + await act(async () => { + mounted.update(createElement(Slot, { sessionKey: 'session-a', onResume: pending })) + }) + await act(async () => resume().props.onPress()) + expect(resume().props.disabled).toBe(true) + await act(async () => { + mounted.update(createElement(Slot, { sessionKey: 'session-b', onResume: pending })) + }) + expect(resume().props.disabled).toBe(false) + }) + + function stripRows(mounted: ReactTestRenderer) { + return mounted.root.findAll((node) => node.props.testID === 'background-task-row') + } + + function openStrip(mounted: ReactTestRenderer): void { + const header = mounted.root.find( + (node) => + String(node.type) === 'Pressable' && node.props.accessibilityState?.expanded !== undefined + ) + act(() => header.props.onPress()) + } + + it('keeps the strip open across a moment with nothing running', async () => { + const mounted = create(createElement('View')) + renderer = mounted + await act(async () => { + mounted.update( + createElement(Slot, { sessionKey: 's-a', backgroundTasks: tasks('s-a', RUNNING) }) + ) + }) + openStrip(mounted) + expect(stripRows(mounted)).toHaveLength(1) + await act(async () => { + mounted.update( + createElement(Slot, { sessionKey: 's-a', backgroundTasks: tasks('s-a', null) }) + ) + }) + expect(stripRows(mounted)).toHaveLength(0) + await act(async () => { + mounted.update( + createElement(Slot, { sessionKey: 's-a', backgroundTasks: tasks('s-a', RUNNING) }) + ) + }) + expect(stripRows(mounted)).toHaveLength(1) + }) + + it("starts another conversation's strip closed", async () => { + const mounted = create(createElement('View')) + renderer = mounted + await act(async () => { + mounted.update( + createElement(Slot, { sessionKey: 's-a', backgroundTasks: tasks('s-a', RUNNING) }) + ) + }) + openStrip(mounted) + expect(stripRows(mounted)).toHaveLength(1) + await act(async () => { + mounted.update( + createElement(Slot, { sessionKey: 's-b', backgroundTasks: tasks('s-b', RUNNING) }) + ) + }) + expect(stripRows(mounted)).toHaveLength(0) + }) + + it('gives the strip and the cards of one conversation their own keys', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const mounted = create(createElement('View')) + renderer = mounted + await act(async () => { + mounted.update( + createElement(Slot, { sessionKey: 's-a', backgroundTasks: tasks('s-a', RUNNING) }) + ) + }) + const duplicateKeys = consoleError.mock.calls.filter((call) => + String(call[0]).includes('same key') + ) + consoleError.mockRestore() + expect(duplicateKeys).toEqual([]) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-composer-tray.ts b/mobile/src/session/use-mobile-native-chat-composer-tray.ts new file mode 100644 index 000000000000..17e0570c57a4 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-composer-tray.ts @@ -0,0 +1,80 @@ +// The region between the transcript and the composer, built outside the chat view: the session's +// running child work, then its queued-draft cards, and the composer ref a card's Edit focuses. + +import { createElement, Fragment, useMemo, type ReactNode, type RefObject } from 'react' +import type { TextInput } from 'react-native' +import { MobileNativeChatBackgroundTasks } from './MobileNativeChatBackgroundTasks' +import { + MobileNativeChatQueuedMessages, + type MobileNativeChatQueuedMessagesProps +} from './MobileNativeChatQueuedMessages' +import type { MobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks' +import { useMobileNativeChatQueuedEditFocus } from './use-mobile-native-chat-queued-edit-focus' + +export type MobileNativeChatComposerTray = { + content?: ReactNode + composerInputRef?: RefObject +} + +export type ComposerTrayProps = { + /** Running child work and host-held queued drafts, drawn between transcript and composer. */ + composerTray?: MobileNativeChatComposerTray +} + +/** A view given no tray draws nothing there; one stable object, so the default never re-renders. */ +export const NO_COMPOSER_TRAY: MobileNativeChatComposerTray = {} + +export function useMobileNativeChatComposerTray({ + queued, + backgroundTasks +}: { + queued: MobileNativeChatQueuedMessagesProps & { + /** One card list per conversation: a pending action in one never disables another's. */ + sessionKey: string + } + /** Null off the structured lane, which publishes no child work. */ + backgroundTasks: MobileStructuredBackgroundTasks | null +}): MobileNativeChatComposerTray { + const { composerInputRef, editQueuedMessage } = useMobileNativeChatQueuedEditFocus(queued.onEdit) + const { cards, onSend, onDelete, pause, onResume, sessionKey, steerHeld } = queued + return useMemo( + () => ({ + content: createElement( + Fragment, + null, + backgroundTasks + ? // Keyed per conversation: one chat's open list or pending Stop never shows in another. + // Its own suffix: the two never share a key, so each stays mounted as the other comes + // and goes. + createElement(MobileNativeChatBackgroundTasks, { + key: `${backgroundTasks.sessionKey}:background-tasks`, + tasks: backgroundTasks + }) + : null, + createElement(MobileNativeChatQueuedMessages, { + key: sessionKey, + cards, + onSend, + onDelete, + onEdit: editQueuedMessage, + pause, + onResume, + steerHeld + }) + ), + composerInputRef + }), + [ + backgroundTasks, + cards, + composerInputRef, + editQueuedMessage, + onDelete, + onResume, + onSend, + pause, + sessionKey, + steerHeld + ] + ) +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index c44001a45737..7e0c29ecb1ea 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -97,7 +97,6 @@ export function useMobileNativeChatController(args: { transcriptPath: activeChatResolution?.transcriptPath ?? null, sessionId: activeChatSessionId, sourceIdentity, - callerIdentity: deviceTokenRef.current ?? '', enabled: showNativeChat, connState, hostSupport: agentSessionHostSupport, @@ -197,10 +196,7 @@ export function useMobileNativeChatController(args: { onSendError }) - const { nativeChatFilePaths, loadNativeChatFiles } = useMobileNativeChatFileSearch({ - client, - worktreeId - }) + const nativeChatFiles = useMobileNativeChatFileSearch({ client, worktreeId }) // Why: the send seam reports outgoing catalog commands to session-option // tracking, but the options hook needs the seam's dispatcher — a ref breaks @@ -298,6 +294,7 @@ export function useMobileNativeChatController(args: { nativeChatSession, /** Structured lane: drives the per-turn status row and live tool progress. */ nativeChatStructured: activeChatStructured, + nativeChatVisualSource: activeChatStructured ? structuredNativeChat.visualSource : null, nativeChatAgentWorking, nativeChatTurnIndicator: activeChatStructured ? structuredNativeChat.turnIndicator : null, nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null, @@ -323,8 +320,8 @@ export function useMobileNativeChatController(args: { handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop, // The inactive lane's session is starved of identity, so its cards stay empty. nativeChatQueued: structuredNativeChat.queued, - nativeChatFilePaths, - loadNativeChatFiles, + nativeChatBackgroundTasks: activeChatStructured ? structuredNativeChat.backgroundTasks : null, + ...nativeChatFiles, handleNativeChatSend: activeChatStructured ? structuredNativeChatSend.send : handleNativeChatSend, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index d1ed7b9b77f1..8f1e59bd0cfc 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -126,8 +126,7 @@ describe('useMobileNativeChatImageAttachments', () => { { id: 'img-1', path: '/tmp/a.png', - previewUri: 'file:///a.jpg', - contentFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/) + previewUri: 'file:///a.jpg' } ]) expect(client.calls.some((c) => c.method === 'terminal.send')).toBe(false) diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx deleted file mode 100644 index d623953c0e9b..000000000000 --- a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -// The queued-card slot keeps one card list per conversation, so an action still in flight in one -// never disables the same control in another. - -import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { useMobileNativeChatQueuedSlot } from './use-mobile-native-chat-queued-slot' - -vi.mock('react-native', () => ({ - Pressable: 'Pressable', - StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, - Text: 'Text', - View: 'View' -})) - -vi.mock('lucide-react-native', () => ({ - AlertCircle: 'AlertCircle', - CornerDownRight: 'CornerDownRight', - ListEnd: 'ListEnd', - MoreHorizontal: 'MoreHorizontal', - Pause: 'Pause', - Pencil: 'Pencil', - Play: 'Play', - Send: 'Send', - Trash2: 'Trash2' -})) - -vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) - -const CARD = { - messageId: 'waiting-1', - text: 'next', - state: 'waiting' as const, - paused: false, - needsAttention: false, - caption: null, - attribution: null -} - -function Slot({ - sessionKey, - onResume -}: { - sessionKey: string - onResume: () => Promise -}): React.JSX.Element { - const slot = useMobileNativeChatQueuedSlot({ - cards: [CARD], - pause: { reason: 'stopped' }, - onResume, - sessionKey - }) - return createElement('View', null, slot.cards) -} - -describe('useMobileNativeChatQueuedSlot', () => { - let renderer: ReactTestRenderer | null = null - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - }) - - it("never lets one conversation's pending Resume disable another's", async () => { - const pending = vi.fn(() => new Promise(() => undefined)) - const mounted = create(createElement('View')) - renderer = mounted - const resume = () => - mounted.root.findByProps({ accessibilityLabel: 'Resume sending the queued messages' }) - await act(async () => { - mounted.update(createElement(Slot, { sessionKey: 'session-a', onResume: pending })) - }) - await act(async () => resume().props.onPress()) - expect(resume().props.disabled).toBe(true) - await act(async () => { - mounted.update(createElement(Slot, { sessionKey: 'session-b', onResume: pending })) - }) - expect(resume().props.disabled).toBe(false) - }) -}) diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.ts b/mobile/src/session/use-mobile-native-chat-queued-slot.ts deleted file mode 100644 index 41b3eb48c8cc..000000000000 --- a/mobile/src/session/use-mobile-native-chat-queued-slot.ts +++ /dev/null @@ -1,61 +0,0 @@ -// The queued-draft cards' place in the chat view, built outside it: the cards to show between the -// transcript and the composer, and the composer ref their Edit focuses. - -import { createElement, useMemo, type ReactNode, type RefObject } from 'react' -import type { TextInput } from 'react-native' -import { - MobileNativeChatQueuedMessages, - type MobileNativeChatQueuedMessagesProps -} from './MobileNativeChatQueuedMessages' -import { useMobileNativeChatQueuedEditFocus } from './use-mobile-native-chat-queued-edit-focus' - -export type MobileNativeChatQueuedSlot = { - cards?: ReactNode - composerInputRef?: RefObject -} - -export type MobileQueuedSlotProps = { - /** Host-held queued drafts, rendered as cards between transcript and composer. */ - queuedSlot?: MobileNativeChatQueuedSlot -} - -/** A view given no slot shows no cards; one stable object, so the default never re-renders. */ -export const NO_QUEUED_SLOT: MobileNativeChatQueuedSlot = {} - -export function useMobileNativeChatQueuedSlot( - queuedMessages: MobileNativeChatQueuedMessagesProps & { - /** One card list per conversation: a pending action in one never disables another's. */ - sessionKey: string - } -): MobileNativeChatQueuedSlot { - const { composerInputRef, editQueuedMessage } = useMobileNativeChatQueuedEditFocus( - queuedMessages.onEdit - ) - const { cards, onSend, onDelete, pause, onResume, sessionKey, steerHeld } = queuedMessages - return useMemo( - () => ({ - cards: createElement(MobileNativeChatQueuedMessages, { - key: sessionKey, - cards, - onSend, - onDelete, - onEdit: editQueuedMessage, - pause, - onResume, - steerHeld - }), - composerInputRef - }), - [ - cards, - composerInputRef, - editQueuedMessage, - onDelete, - onResume, - onSend, - pause, - sessionKey, - steerHeld - ] - ) -} diff --git a/mobile/src/session/use-mobile-native-chat-session-lane.ts b/mobile/src/session/use-mobile-native-chat-session-lane.ts index c34f1995190c..15f68f0758b3 100644 --- a/mobile/src/session/use-mobile-native-chat-session-lane.ts +++ b/mobile/src/session/use-mobile-native-chat-session-lane.ts @@ -16,7 +16,6 @@ export function useMobileNativeChatSessionLane({ transcriptPath, sessionId, sourceIdentity, - callerIdentity, hostSupport, appendComposerTextRef, enabled, @@ -33,7 +32,6 @@ export function useMobileNativeChatSessionLane({ transcriptPath: string | null sessionId: string | null sourceIdentity: Parameters[0]['sourceIdentity'] - callerIdentity: string hostSupport: StructuredAgentSessionHostSupport | null /** The active pane's live composer; a queued card's Edit copies through it. * A ref because the drafts (and their append) mount after this lane. */ @@ -62,7 +60,6 @@ export function useMobileNativeChatSessionLane({ client, sessionId: structured ? sessionId : null, sourceIdentity, - callerIdentity, hostSupport, appendComposerText, enabled, diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure-growing-row.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure-growing-row.test.tsx new file mode 100644 index 000000000000..90fda385bcc0 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure-growing-row.test.tsx @@ -0,0 +1,49 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { Harness, Result, userMessage } from './use-mobile-native-chat-turn-disclosure.test-fixture' + +function assistantMessage(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: null, + source: 'transcript' + } +} + +describe('useMobileNativeChatTurnDisclosure growing row', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('marks no row while a prompt is open', () => { + const messages = [userMessage('u1'), assistantMessage('a1')] + act(() => { + renderer = create(createElement(Harness, { messages, enabled: true, lineYields: true })) + }) + const { disclosure } = renderer!.root.findByType(Result).props + expect(disclosure.resolveRow(1, messages[1]).mayStillGrow).toBe(false) + }) + + it('marks only the newest assistant row, even when a user row follows it', () => { + const messages = [ + userMessage('u1'), + assistantMessage('a1'), + assistantMessage('a2'), + userMessage('u2') + ] + act(() => { + renderer = create(createElement(Harness, { messages, enabled: true })) + }) + const { disclosure } = renderer!.root.findByType(Result).props + expect( + messages.map((message, index) => disclosure.resolveRow(index, message).mayStillGrow) + ).toEqual([false, false, true, false]) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index 28bd7a7d4fa8..4da463207657 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from 'react' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isSubagentGroupBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { nativeChatReasoningDisclosureKey } from '../../../src/shared/native-chat-reasoning-row' import { nativeChatLiveLine, @@ -33,11 +33,17 @@ export type MobileNativeChatTurnRow = { /** Set only on a settled turn — the one row that has activity to disclose. */ turnKey?: string activeTurnIsWorking: boolean + /** The newest assistant row of a live turn with no prompt open: its last text may still grow. */ + mayStillGrow: boolean /** The live activity line discloses this open reasoning block, so its row draws nothing. */ reasoningIsLive: boolean /** A reasoning row's disclosure, keyed like the live line's so an opened block stays open. */ reasoningExpanded: boolean onToggleReasoning: (key: string) => void + /** The roster groups the reader opened, by group id; set only on a row that carries one, so a + * toggle re-renders roster rows alone. */ + subagentGroupsOpen?: ReadonlySet + onToggleSubagentGroup: (groupId: string) => void } /** The live activity line, when it draws, and whether the reader opened the block it discloses. */ @@ -163,6 +169,7 @@ export function useMobileNativeChatTurnDisclosure({ }) const [expandedTurnIds, toggleExpandedTurn] = useScopedOpenKeys(scopeKey) const [expandedReasoning, toggleReasoning] = useScopedOpenKeys(scopeKey) + const [openSubagentGroups, toggleSubagentGroup] = useScopedOpenKeys(scopeKey) const bars = useMemo(() => nativeChatTurnBarRows(rows, turnKeys), [rows, turnKeys]) const { active, activeTurnKey, completedByTurn } = turnStatuses @@ -195,6 +202,16 @@ export function useMobileNativeChatTurnDisclosure({ }, [expandedReasoning, line] ) + // Why a reverse loop: Hermes lacks the ES2023 last-match array helper (fork ES2023 ban). + const latestAssistantId = useMemo(() => { + const rows = waiting.listMessages + for (let i = rows.length - 1; i >= 0; i--) { + if (rows[i].role === 'assistant') { + return rows[i].id + } + } + return null + }, [waiting.listMessages]) const resolveRow = useCallback( (listIndex: number, message: NativeChatMessage): MobileNativeChatTurnRow => { const index = waiting.indexById?.get(message.id) ?? listIndex @@ -219,11 +236,16 @@ export function useMobileNativeChatTurnDisclosure({ turnKey: turnKey && turnStatus?.workedSeconds != null ? turnKey : undefined, // With no user boundary at all, the session's working state stays authoritative. activeTurnIsWorking: inLiveWorkingTurn(index), + mayStillGrow: !lineYields && message.id === latestAssistantId, reasoningIsLive: message.id === liveReasoningId, reasoningExpanded: message.role === 'reasoning' && expandedReasoning.has(nativeChatReasoningDisclosureKey(message.id)), - onToggleReasoning: toggleReasoning + onToggleReasoning: toggleReasoning, + ...(message.blocks.some(isSubagentGroupBlock) + ? { subagentGroupsOpen: openSubagentGroups } + : {}), + onToggleSubagentGroup: toggleSubagentGroup } }, [ @@ -236,9 +258,13 @@ export function useMobileNativeChatTurnDisclosure({ completedByTurn, expandedTurnIds, inLiveWorkingTurn, + latestAssistantId, + lineYields, liveReasoningId, expandedReasoning, - toggleReasoning + toggleReasoning, + openSubagentGroups, + toggleSubagentGroup ] ) diff --git a/mobile/src/session/use-mobile-native-chat-visual.test.tsx b/mobile/src/session/use-mobile-native-chat-visual.test.tsx new file mode 100644 index 000000000000..21e8cde258cc --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-visual.test.tsx @@ -0,0 +1,92 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { MobileNativeChatVisualRead } from './mobile-native-chat-visual-read' + +const reads = vi.hoisted(() => { + const next: MobileNativeChatVisualRead[] = [] + return { next, calls: 0 } +}) + +vi.mock('./mobile-native-chat-visual-read', () => ({ + cachedMobileNativeChatVisual: () => null, + readMobileNativeChatVisual: () => { + reads.calls += 1 + return Promise.resolve(reads.next.shift() ?? { kind: 'unreachable' }) + } +})) + +import { + useMobileNativeChatVisual, + type MobileNativeChatVisualState +} from './use-mobile-native-chat-visual' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the read module is mocked; the client is only an identity here. +const source = { client: {} as RpcClient, sessionId: 'session-1' } + +let latest: { state: MobileNativeChatVisualState; retry: () => void } | null = null +function Probe() { + latest = useMobileNativeChatVisual(source, 'chart.html') + return null +} + +describe('useMobileNativeChatVisual', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + vi.useFakeTimers() + reads.next = [] + reads.calls = 0 + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + latest = null + vi.useRealTimers() + }) + + async function mount(): Promise { + await act(async () => { + renderer = create(createElement(Probe)) + }) + } + + it('re-asks a bounded number of times when the host does not answer, then shows unavailable', async () => { + await mount() + expect(reads.calls).toBe(1) + for (const delay of [1_500, 5_000]) { + await act(async () => { + await vi.advanceTimersByTimeAsync(delay) + }) + } + expect(reads.calls).toBe(3) + expect(latest?.state).toEqual({ kind: 'unavailable' }) + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(reads.calls).toBe(3) + }) + + it('does not latch a failure: a retry asks again and can succeed', async () => { + reads.next = [{ kind: 'refused' }, { kind: 'ready', html: '

', revision: 'a'.repeat(32) }] + await mount() + expect(latest?.state).toEqual({ kind: 'unavailable' }) + await act(async () => { + latest?.retry() + }) + expect(reads.calls).toBe(2) + expect(latest?.state).toEqual({ kind: 'ready', html: '

', revision: 'a'.repeat(32) }) + }) + + it('stops re-asking after unmount', async () => { + await mount() + act(() => renderer?.unmount()) + renderer = null + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(reads.calls).toBe(1) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-visual.ts b/mobile/src/session/use-mobile-native-chat-visual.ts new file mode 100644 index 000000000000..cb06138d5d05 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-visual.ts @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useState } from 'react' +import { + cachedMobileNativeChatVisual, + readMobileNativeChatVisual, + type MobileNativeChatVisualSource +} from './mobile-native-chat-visual-read' + +export type MobileNativeChatVisualState = + | { kind: 'loading' } + | { kind: 'ready'; html: string; revision: string } + | { kind: 'unavailable' } + +/** Automatic re-asks after a read that got no answer; the reader's tap starts a fresh round. */ +const AUTO_RETRY_DELAYS_MS = [1_500, 5_000] as const + +function initialState( + source: MobileNativeChatVisualSource, + file: string +): MobileNativeChatVisualState { + const cached = cachedMobileNativeChatVisual(source, file) + return cached ? { kind: 'ready', ...cached } : { kind: 'loading' } +} + +/** + * One visual's content: painted from the phone's cache when it has one, revalidated against the + * owning host on every mount, and retried a bounded number of times when the host does not answer. + * Nothing latches: `retry` (or a new client after a reconnect) asks again. + */ +export function useMobileNativeChatVisual( + source: MobileNativeChatVisualSource, + file: string +): { state: MobileNativeChatVisualState; retry: () => void } { + const [state, setState] = useState(() => initialState(source, file)) + // `round` is a reader's retry; `retriesUsed` counts automatic re-asks within it. + const [request, setRequest] = useState({ round: 0, retriesUsed: 0 }) + const [retryInMs, setRetryInMs] = useState(null) + + useEffect(() => { + let disposed = false + void readMobileNativeChatVisual(source, file).then((read) => { + if (disposed) { + return + } + if (read.kind === 'ready') { + // Same revision keeps the same state object, so the frame does not reload its document. + setState((current) => + current.kind === 'ready' && current.revision === read.revision + ? current + : { kind: 'ready', html: read.html, revision: read.revision } + ) + return + } + const delay = + read.kind === 'unreachable' ? AUTO_RETRY_DELAYS_MS[request.retriesUsed] : undefined + if (delay !== undefined) { + setRetryInMs(delay) + return + } + // A host that does not answer leaves a visual on screen; one that refuses it takes it down. + setState((current) => + current.kind === 'ready' && read.kind === 'unreachable' ? current : { kind: 'unavailable' } + ) + }) + return () => { + disposed = true + } + }, [source, file, request]) + + useEffect(() => { + if (retryInMs === null) { + return + } + const timer = setTimeout(() => { + setRetryInMs(null) + setRequest((current) => ({ ...current, retriesUsed: current.retriesUsed + 1 })) + }, retryInMs) + return () => clearTimeout(timer) + }, [retryInMs]) + + const retry = useCallback(() => { + setState((current) => (current.kind === 'unavailable' ? { kind: 'loading' } : current)) + setRetryInMs(null) + setRequest((current) => ({ round: current.round + 1, retriesUsed: 0 })) + }, []) + + return { state, retry } +} diff --git a/mobile/src/session/use-mobile-session-close-actions.ts b/mobile/src/session/use-mobile-session-close-actions.ts index 22fdcc58f825..442d514cc77d 100644 --- a/mobile/src/session/use-mobile-session-close-actions.ts +++ b/mobile/src/session/use-mobile-session-close-actions.ts @@ -16,6 +16,7 @@ export function useMobileSessionCloseActions(scope: MobileSessionContentCreateAc terminalsRef, setSessionTabs, setFileDocs, + setMarkdownDocs, sessionTabsRef, reconcileBufferedDraftsRef, closedTabTombstonesRef, @@ -119,6 +120,17 @@ export function useMobileSessionCloseActions(scope: MobileSessionContentCreateAc }) ) if (response.accepted) { + if (tab.type === 'markdown') { + setMarkdownDocs((prev) => { + const doc = prev.get(tab.id) + if (!doc || (doc.status === 'ready' && doc.isDirty)) { + return prev + } + const next = new Map(prev) + next.delete(tab.id) + return next + }) + } if (tab.type === 'file') { setFileDocs((prev) => { if (!prev.has(tab.id)) { diff --git a/mobile/src/session/use-mobile-session-document-readers.ts b/mobile/src/session/use-mobile-session-document-readers.ts index a1e7d64a3049..be9f4d74f6de 100644 --- a/mobile/src/session/use-mobile-session-document-readers.ts +++ b/mobile/src/session/use-mobile-session-document-readers.ts @@ -17,7 +17,8 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati if (!client) { return } - setMarkdownDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' })) + const loading = { status: 'loading' } as const + setMarkdownDocs((prev) => new Map(prev).set(tab.id, loading)) try { const response = await markdownTabRead.request(client, { worktree: `id:${worktreeId}`, @@ -26,19 +27,21 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati if (response.ok) { const result = markdownTabRead.interpret(response) setMarkdownDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - content: result.content, - localContent: result.content, - baseVersion: result.version, - isDirty: false, - editable: result.editable === true, - stale: result.isDirty, - readOnlyReason: result.readOnlyReason, - ...(result.truncated === true - ? { truncated: true, byteLength: result.byteLength } - : {}) - }) + prev.get(tab.id) === loading + ? new Map(prev).set(tab.id, { + status: 'ready', + content: result.content, + localContent: result.content, + baseVersion: result.version, + isDirty: false, + editable: result.editable === true, + stale: result.isDirty, + readOnlyReason: result.readOnlyReason, + ...(result.truncated === true + ? { truncated: true, byteLength: result.byteLength } + : {}) + }) + : prev ) return } @@ -57,22 +60,26 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati } const fileResult = fallback.value setMarkdownDocs((prev) => - new Map(prev).set( - tab.id, - buildMarkdownDiskFallbackDoc({ - content: fileResult.content, - truncated: fileResult.truncated, - byteLength: fileResult.byteLength, - tabIsDirty: tab.isDirty - }) - ) + prev.get(tab.id) === loading + ? new Map(prev).set( + tab.id, + buildMarkdownDiskFallbackDoc({ + content: fileResult.content, + truncated: fileResult.truncated, + byteLength: fileResult.byteLength, + tabIsDirty: tab.isDirty + }) + ) + : prev ) } catch (err) { setMarkdownDocs((prev) => - new Map(prev).set(tab.id, { - status: 'error', - message: documentReadErrorMessage(err, "Couldn't load markdown") - }) + prev.get(tab.id) === loading + ? new Map(prev).set(tab.id, { + status: 'error', + message: documentReadErrorMessage(err, "Couldn't load markdown") + }) + : prev ) } }, diff --git a/mobile/src/session/use-mobile-session-image-attachments.ts b/mobile/src/session/use-mobile-session-image-attachments.ts index 92a2b52c844d..ec53d196a4c5 100644 --- a/mobile/src/session/use-mobile-session-image-attachments.ts +++ b/mobile/src/session/use-mobile-session-image-attachments.ts @@ -35,7 +35,6 @@ type Args = { id: string path: string previewUri: string - contentFingerprint?: string }[] ) => Promise /** Structured agent sessions do not have a terminal paste path. */ diff --git a/mobile/src/session/use-mobile-structured-agent-session-background-tasks.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-background-tasks.test.tsx new file mode 100644 index 000000000000..aef38e82e6e9 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-background-tasks.test.tsx @@ -0,0 +1,184 @@ +// The running child work a structured session publishes reaches the phone's strip: desktop's view +// of the same roster, rows that stop claiming live work once the stream is lost, and a Stop that +// reaches the host's background-task cancel. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentChildWorkView } from '../../../src/shared/agent-status-child-work-view' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { + backgroundTaskRowMeta, + buildBackgroundTaskGroupsFromViews +} from '../../../src/shared/background-task-roster' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { + batchEvent, + CAPABLE, + fieldsOf, + mutationOk, + ok, + snapshotEvent +} from './use-mobile-structured-agent-session-queued.test-fixture' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() } +})) + +function childView(id: string): AgentChildWorkView { + return { + id, + providerId: `task-${id}`, + kind: 'agent', + description: `review ${id}`, + state: 'working', + membership: 'live', + firstObservedAt: 1_000, + observedAt: 2_000, + stoppable: true, + invocation: { invocationId: `spawn-${id}`, generation: 1 } + } +} + +function withChildren(hostNow?: number): AgentSessionSubscribeEvent { + const snapshot = snapshotEvent() + return snapshot.type === 'snapshot' + ? { + ...snapshot, + ...(hostNow !== undefined ? { hostNow } : {}), + backgroundTasks: { + state: 'monitoring', + supportsTaskStop: true, + children: [childView('a'), childView('b')] + } + } + : snapshot +} + +describe('mobile structured session background tasks', () => { + let renderer: ReactTestRenderer | null = null + let hook: ReturnType | null = null + let listener: ((value: unknown) => void) | null = null + const sendRequest = vi.fn() + const onSendError = vi.fn() + const client: RpcClient = { + sendRequest, + subscribe: (_method, _params, onData) => { + listener = onData + return vi.fn() + }, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + + function Harness({ connected }: { connected: boolean }): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected, + agent: 'claude', + hostSupport: CAPABLE, + onSendError + }) + return null + } + + async function mount(event: AgentSessionSubscribeEvent): Promise { + act(() => { + renderer = create(createElement(Harness, { connected: true })) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(event)) + } + + beforeEach(() => { + vi.clearAllMocks() + + sendRequest.mockImplementation(async (method) => + method === 'agentSession.cancel' + ? mutationOk({}) + : method === 'agentSession.options' + ? ok({ models: [], current: {} }) + : ok({}) + ) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + listener = null + }) + + it('returns the running children the host publishes, with live rows while the stream is', async () => { + await mount(withChildren()) + const tasks = hook?.backgroundTasks + expect(tasks?.view.show).toBe(true) + expect(tasks?.view.supportsStop).toBe(true) + expect(tasks?.view.children?.map((view) => view.id)).toEqual(['a', 'b']) + expect(tasks?.rowContext.transportObservation).toBe('live') + }) + + it('shows no strip for a host that reports nothing running', async () => { + await mount(snapshotEvent()) + expect(hook?.backgroundTasks.view.show).toBe(false) + }) + + it('keeps the roster but stops claiming live work once the connection drops', async () => { + await mount(withChildren()) + act(() => renderer?.update(createElement(Harness, { connected: false }))) + expect(hook?.backgroundTasks.view.children).toHaveLength(2) + expect(hook?.backgroundTasks.rowContext.transportObservation).toBe('unverifiable') + }) + + function stripElapsed(): string { + const tasks = hook!.backgroundTasks + const [group] = buildBackgroundTaskGroupsFromViews(tasks.view.children ?? [], tasks.rowContext) + // The strip reads host-stamped clocks on the host's clock: the phone's now less the offset. + return backgroundTaskRowMeta( + group!.tasks[0]!.row, + Date.now() - tasks.rowContext.hostClockOffsetMs + ) + } + + it('keeps the rows on a jitter-only frame and re-derives a shifted host clock', async () => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(100_000) + await mount(withChildren(40_000)) + const first = hook?.backgroundTasks + expect(first?.rowContext.hostClockOffsetMs).toBe(60_000) + // Child first seen at host time 1_000; host now is 40_000. + expect(stripElapsed()).toBe('39s') + + // A later sample whose delivery took 300 ms longer: the same offset once rounded. + const jitter = batchEvent() + act(() => listener?.(jitter.type === 'batch' ? { ...jitter, hostNow: 39_700 } : jitter)) + expect(hook?.backgroundTasks).toBe(first) + + // The host's clock jumped forward 20 s (a restart or correction): every elapsed moves with it. + act(() => listener?.(withChildren(60_000))) + expect(hook?.backgroundTasks.rowContext.hostClockOffsetMs).toBe(40_000) + expect(stripElapsed()).toBe('59s') + clock.mockRestore() + }) + + it("stops one child through the host's background-task cancel", async () => { + await mount(withChildren()) + await act(async () => { + await hook?.backgroundTasks.stop('task-a') + }) + const cancel = sendRequest.mock.calls.find(([method]) => method === 'agentSession.cancel') + expect(fieldsOf(cancel?.[1])).toMatchObject({ + turnId: 'background-tasks', + scope: 'background-tasks', + taskId: 'task-a' + }) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx index 9ed32c49579d..2d7e8f07462c 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx @@ -7,7 +7,6 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' import type { RpcClient } from '../transport/rpc-client' -import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' import { CAPABLE, @@ -98,7 +97,7 @@ async function sentDelivery(items: AgentJournalRenderItem[]): Promise { beforeEach(() => { vi.clearAllMocks() - resetMobileStructuredSendOperationJournalForTests() + sendRequest.mockImplementation(async (method) => method === 'agentSession.send' ? mutationOk({ clientMessageId: 'client-1' }) diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 4c419108c7c1..328a1effdd30 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -34,9 +34,6 @@ vi.mock('./use-mobile-structured-agent-options', () => ({ vi.mock('./use-mobile-structured-prompt-responses', () => ({ useMobileStructuredPromptResponses: mocks.promptResponses })) -vi.mock('./use-mobile-structured-send-operation-reconciliation', () => ({ - useMobileStructuredSendOperationReconciliation: vi.fn() -})) import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index c14e823ab401..0268fece2c65 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -15,7 +15,6 @@ import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-sessi import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse } from '../transport/types' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' -import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' import { @@ -124,7 +123,7 @@ describe('mobile structured queued messages', () => { beforeEach(() => { vi.clearAllMocks() - resetMobileStructuredSendOperationJournalForTests() + stored = new Map() asyncStorage.getItem.mockImplementation(async (key: string) => stored.get(key) ?? null) asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { @@ -167,10 +166,7 @@ describe('mobile structured queued messages', () => { fields: { body: params.body, delivery: 'queue-if-active' } }) ) - // Spent at `queued`: the durable send-operation entry is released. - await vi.waitFor(() => - expect(stored.has('orca:mobileStructuredSendOperations:v1')).toBe(false) - ) + expect(asyncStorage.setItem).not.toHaveBeenCalled() }) it('keeps today’s request exactly against an incapable host', async () => { @@ -207,317 +203,61 @@ describe('mobile structured queued messages', () => { ) }) - it('replays an ack-lost delivery send under one id and the delivery it was sent with', async () => { + it('a new send follows the current host capability after acknowledgement loss', async () => { let attempts = 0 - sendRequest.mockImplementation(async (method) => { - if (method === 'agentSession.send') { - attempts += 1 + sendRequest.mockImplementation(async (method, params) => { + if (method !== 'agentSession.send') { + return ok({ models: [], current: {} }) + } + if (++attempts === 1) { throw markRpcDeliveryUnknown(new Error('Connection closed')) } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + const id = String(fieldsOf(fieldsOf(params).envelope).clientOperationId) + return mutationOk({ clientMessageId: id, submission: acceptedSubmission(id) }) }) await mountSession(CAPABLE) await act(async () => { - expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') + expect(await hook!.sendWithOutcome('again')).toBe('unknown') }) unmountSession() - // The capability probe has not answered after the reload, but the recorded - // operation must replay bit-for-bit — content-derived key, same id, same - // delivery field — or the host would refuse it as a fingerprint conflict. await mountSession(LEGACY) await act(async () => { - expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') + expect(await hook!.sendWithOutcome('again')).toBe('accepted') }) - expect(attempts).toBe(2) const first = requestOf('agentSession.send', 0) const second = requestOf('agentSession.send', 1) - expect(second.params.delivery).toBe('queue-if-active') - expect(second.envelope.clientOperationId).toBe(first.envelope.clientOperationId) - // Nothing new is persisted: an older build still reads the send journal. - const journal = stored.get('orca:mobileStructuredSendOperations:v1') ?? '' - expect(journal).not.toContain('delivery') - }) - - it('retires an ack-lost delivery send an older host refuses, so the next send goes out plain', async () => { - const journalKey = 'orca:mobileStructuredSendOperations:v1' - let attempts = 0 - sendRequest.mockImplementation(async (method, params) => { - if (method === 'agentSession.send') { - attempts += 1 - if (attempts <= 2) { - throw markRpcDeliveryUnknown(new Error('Connection closed')) - } - // The downgraded host's strict schema turns `delivery` away before running anything. - if ('delivery' in fieldsOf(params)) { - return { - id: 'request-1', - ok: false, - error: { code: 'invalid_argument', message: 'Unrecognized key: "delivery"' } - } - } - return mutationOk({ - clientMessageId: 'client-plain', - submission: { - clientMessageId: 'client-plain', - fence: 3, - payloadFingerprint: 'fp', - dispatchState: 'accepted', - providerItemId: null, - reason: null, - submittedAt: 10, - resolvedAt: 10 - } - }) - } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) - }) - await mountSession(CAPABLE) - await act(async () => { - expect(await hook!.sendWithOutcome('downgraded')).toBe('unknown') - }) - unmountSession() - await mountSession(LEGACY) - // A lost answer is still doubt: the replay keeps the id and its delivery. - await act(async () => { - expect(await hook!.sendWithOutcome('downgraded')).toBe('unknown') - }) - const first = requestOf('agentSession.send', 0) - expect(requestOf('agentSession.send', 1).envelope.clientOperationId).toBe( - first.envelope.clientOperationId - ) - expect(stored.get(journalKey)).toContain(String(first.envelope.clientOperationId)) - // The host answering that it cannot take the request retires the entry, once. - await act(async () => { - expect(await hook!.sendWithOutcome('downgraded')).toBe('rejected') - }) - const refused = requestOf('agentSession.send', 2) - expect(refused.params.delivery).toBe('queue-if-active') - expect(refused.envelope.clientOperationId).toBe(first.envelope.clientOperationId) - expect(onSendError).toHaveBeenCalledTimes(1) - await act(async () => { - expect(await hook!.sendWithOutcome('downgraded')).toBe('accepted') - }) - const plain = requestOf('agentSession.send', 3) - expect('delivery' in plain.params).toBe(false) - expect(plain.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) - expect(attempts).toBe(4) - }) - - it('keeps an ack-lost send when the host refuses its replay as unauthorized', async () => { - let attempts = 0 - sendRequest.mockImplementation(async (method) => { - if (method === 'agentSession.send') { - attempts += 1 - if (attempts === 1) { - throw markRpcDeliveryUnknown(new Error('Connection closed')) - } - // An auth refusal says nothing about whether the first attempt was delivered. - return { - id: 'request-1', - ok: false, - error: { code: 'unauthorized', message: 'Pairing revoked' } - } - } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) - }) - await mountSession(CAPABLE) - await act(async () => { - expect(await hook!.sendWithOutcome('in doubt')).toBe('unknown') - }) - await act(async () => { - expect(await hook!.sendWithOutcome('in doubt')).toBe('rejected') - }) - await act(async () => { - await hook!.sendWithOutcome('in doubt') - }) - const first = requestOf('agentSession.send', 0) - expect(requestOf('agentSession.send', 2).envelope.clientOperationId).toBe( - first.envelope.clientOperationId - ) + expect(first.params.delivery).toBe('queue-if-active') + expect('delivery' in second.params).toBe(false) + expect(second.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) }) }) - it('an ack-lost send is spent once the host publishes it as a draft, even one later withdrawn', async () => { - const journalKey = 'orca:mobileStructuredSendOperations:v1' + it('an ack-lost queued send never absorbs the next identical message', async () => { let attempts = 0 - sendRequest.mockImplementation(async (method) => { - if (method === 'agentSession.send') { - attempts += 1 - if (attempts === 1) { - throw markRpcDeliveryUnknown(new Error('Connection closed')) - } - return mutationOk({ - clientMessageId: `client-${attempts}`, - queued: { messageId: `client-${attempts}`, position: 1, state: 'waiting' } - }) + sendRequest.mockImplementation(async (method, params) => { + if (method !== 'agentSession.send') { + return ok({ models: [], current: {} }) } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + if (++attempts === 1) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + const id = String(fieldsOf(fieldsOf(params).envelope).clientOperationId) + return mutationOk({ + clientMessageId: id, + queued: { messageId: id, position: 1, state: 'waiting' } + }) }) await mountSession(CAPABLE, snapshotEvent({ runningTurn: true })) await act(async () => { expect(await hook!.sendWithOutcome('held')).toBe('unknown') }) - const operationId = String(requestOf('agentSession.send').envelope.clientOperationId) - expect(stored.get(journalKey)).toContain(operationId) - // Someone else's draft proves nothing about this send. - act(() => listener?.(batchEvent([queuedDraft({ messageId: 'other-device' })]))) - await act(async () => {}) - expect(stored.get(journalKey)).toContain(operationId) - // The host names the draft by this send's operation id: that is its receipt. - act(() => listener?.(batchEvent([queuedDraft({ messageId: operationId })]))) - await vi.waitFor(() => expect(stored.has(journalKey)).toBe(false)) - // Deleted elsewhere: no submission will ever settle it, and nothing has to. - act(() => listener?.(batchEvent(null))) + const firstId = String(requestOf('agentSession.send').envelope.clientOperationId) + act(() => listener?.(batchEvent([queuedDraft({ messageId: firstId })]))) await act(async () => { expect(await hook!.sendWithOutcome('held')).toBe('queued') }) - expect(requestOf('agentSession.send', 1).envelope.clientOperationId).not.toBe(operationId) - }) - - it('an identical send whose retained id replays as withdrawn goes out fresh', async () => { - let attempts = 0 - sendRequest.mockImplementation(async (method) => { - if (method === 'agentSession.send') { - attempts += 1 - if (attempts === 1) { - throw markRpcDeliveryUnknown(new Error('Connection closed')) - } - const state = attempts === 2 ? 'withdrawn' : 'waiting' - // A pruned deleted card's receipt names no place in the queue: position 0. - return mutationOk({ - clientMessageId: `client-${attempts}`, - queued: { - messageId: `client-${attempts}`, - position: state === 'withdrawn' ? 0 : 1, - state - } - }) - } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) - }) - await mountSession(CAPABLE) - await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('unknown') - }) - // A Delete spent the ack-lost draft before it reached the agent; typing the - // same words again is a new message, not a replay to swallow. - await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('queued') - }) - expect(attempts).toBe(3) - const ids = [0, 1, 2].map( - (index) => requestOf('agentSession.send', index).envelope.clientOperationId - ) - expect(ids[1]).toBe(ids[0]) - expect(ids[2]).not.toBe(ids[0]) - }) - - it('a withdrawn replay whose record storage will not clear still goes out fresh, and says so', async () => { - let attempts = 0 - sendRequest.mockImplementation(async (method) => { - if (method === 'agentSession.send') { - attempts += 1 - if (attempts === 1) { - throw markRpcDeliveryUnknown(new Error('Connection closed')) - } - const state = attempts === 2 ? 'withdrawn' : 'waiting' - return mutationOk({ - clientMessageId: `client-${attempts}`, - queued: { messageId: `client-${attempts}`, position: 1, state } - }) - } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) - }) - await mountSession(CAPABLE) - await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('unknown') - }) - // The retained record cannot be cleared; that must not keep this text from being sent. - asyncStorage.setItem.mockRejectedValue(new Error('disk full')) - asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) - await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('queued') - }) - expect(attempts).toBe(3) - const ids = [0, 1, 2].map( - (index) => requestOf('agentSession.send', index).envelope.clientOperationId - ) - expect(ids[1]).toBe(ids[0]) - expect(ids[2]).not.toBe(ids[0]) - expect(onSendError).toHaveBeenCalledWith( - "Sent, but this phone couldn't update its record of sent messages." - ) - }) - - describe('a lost answer, then Stop, then the drain under a fresh id', () => { - const journalKey = 'orca:mobileStructuredSendOperations:v1' - let attempts = 0 - let lostId = '' - - beforeEach(() => { - attempts = 0 - lostId = '' - sendRequest.mockImplementation(async (method, params) => { - if (method === 'agentSession.send') { - attempts += 1 - const id = String(fieldsOf(fieldsOf(params).envelope).clientOperationId) - if (attempts === 1) { - lostId = id - throw markRpcDeliveryUnknown(new Error('Connection closed')) - } - if (id === lostId) { - // The replay answers with the hand-off, which names the replayed id as its draft. - return mutationOk({ - clientMessageId: id, - submission: acceptedSubmission('fresh-hand-off', id) - }) - } - return mutationOk({ - clientMessageId: id, - queued: { messageId: id, position: 1, state: 'waiting' } - }) - } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) - }) - }) - - it('spends the record once the stream carries the hand-off naming it; a direct send does not', async () => { - await mountSession(CAPABLE) - await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('unknown') - }) - expect(stored.get(journalKey)).toContain(lostId) - // A direct send's submission names no draft, so it settles nothing here. - act(() => listener?.(batchEvent(undefined, [acceptedSubmission('someone-else')]))) - await act(async () => {}) - expect(stored.get(journalKey)).toContain(lostId) - // The drain's hand-off went out under a fresh id and names the lost send's draft. - act(() => listener?.(batchEvent(undefined, [acceptedSubmission('fresh-hand-off', lostId)]))) - await vi.waitFor(() => expect(stored.has(journalKey)).toBe(false)) - await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('queued') - }) - expect(attempts).toBe(2) - expect(requestOf('agentSession.send', 1).envelope.clientOperationId).not.toBe(lostId) - }) - - it('spends the record from a replay answered by the hand-off, which no page carries', async () => { - await mountSession(CAPABLE) - await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('unknown') - }) - // The host holds that message now; the phone paints no bubble for it. The stream never - // carries the hand-off, so the answer's link is what spends the record. - await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('unknown') - }) - expect(requestOf('agentSession.send', 1).envelope.clientOperationId).toBe(lostId) - await vi.waitFor(() => expect(stored.has(journalKey)).toBe(false)) - await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('queued') - }) - expect(requestOf('agentSession.send', 2).envelope.clientOperationId).not.toBe(lostId) - }) + expect(attempts).toBe(2) + expect(requestOf('agentSession.send', 1).envelope.clientOperationId).not.toBe(firstId) }) describe('cards from the published list', () => { @@ -577,21 +317,20 @@ describe('mobile structured queued messages', () => { listener?.( batchEvent( [ - queuedDraft({ messageId: 'kept-1', paused: true, pausedReason: 'kept' }), + queuedDraft({ messageId: 'kept-1' }), queuedDraft({ messageId: 'behind', position: 2 }) ], [], - // This host publishes no restart pause; a Stop's stands in for any queue-wide one. - { reason: 'stopped' } + null ) ) ) + // Plain waiting cards: the host holds them until the chat's next turn, and shows no row. expect(hook!.queued.cards.map(({ messageId, caption }) => ({ messageId, caption }))).toEqual([ - { messageId: 'kept-1', caption: 'Not sent yet — tap Send to send it' }, + { messageId: 'kept-1', caption: null }, { messageId: 'behind', caption: null } ]) - // The kept card is held on its own, so Resume would send the card behind it. - expect(hook!.queued.pause).toEqual({ reason: 'stopped' }) + expect(hook!.queued.pause).toBeNull() }) }) diff --git a/mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx index 0c1c1a7f7115..a59a5f699276 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx @@ -5,7 +5,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' -import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' import { CAPABLE, SESSION_ID, ok } from './use-mobile-structured-agent-session-queued.test-fixture' @@ -72,7 +71,7 @@ async function readFailedWith(reason: string) { beforeEach(() => { vi.clearAllMocks() - resetMobileStructuredSendOperationJournalForTests() + sendRequest.mockImplementation(async (method) => method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) ) diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 4fa1d5bd13c0..3a6c351c6302 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -5,8 +5,8 @@ import type { AgentJournalDispatchState } from '../../../src/shared/agent-sessio import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' +import { fieldsOf, ok } from './use-mobile-structured-agent-session-queued.test-fixture' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' -import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' @@ -18,10 +18,6 @@ const asyncStorage = vi.hoisted(() => ({ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) -function ok(result: unknown) { - return { ok: true, result, _meta: { runtimeId: 'runtime-1' } } -} - function sendResult(dispatchState: AgentJournalDispatchState, reason: string | null = null) { return ok({ ok: true, @@ -32,7 +28,7 @@ function sendResult(dispatchState: AgentJournalDispatchState, reason: string | n }) } -function snapshotEvent(): AgentSessionSubscribeEvent { +function snapshotEvent(): Extract { return { type: 'snapshot', sessionId: 'session-1', @@ -57,18 +53,28 @@ function snapshotEvent(): AgentSessionSubscribeEvent { } } -describe('mobile structured send retries', () => { +describe('mobile structured send actions', () => { let renderer: ReactTestRenderer | null = null let hook: ReturnType | null = null let listener: ((value: unknown) => void) | null = null let storedOperations: Map const onSendError = vi.fn() - const sendRequest = vi.fn() - const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + const sendRequest = vi.fn() + const subscribe = vi.fn((_method, _params, onData) => { listener = onData return vi.fn() }) - const client = { sendRequest, subscribe } as unknown as RpcClient + const client: RpcClient = { + sendRequest, + subscribe, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } function Harness(): null { hook = useMobileStructuredAgentSession({ @@ -78,8 +84,9 @@ describe('mobile structured send retries', () => { enabled: true, connected: true, agent: 'codex', + hostSupport: null, onSendError - } as never) + }) return null } @@ -96,15 +103,14 @@ describe('mobile structured send retries', () => { } function sentIds(): string[] { - return calls().map( - ([, params]) => - (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + return calls().map(([, params]) => + String(fieldsOf(fieldsOf(params).envelope).clientOperationId) ) } beforeEach(() => { vi.clearAllMocks() - resetMobileStructuredSendOperationJournalForTests() + storedOperations = new Map() asyncStorage.getItem.mockImplementation( async (key: string) => storedOperations.get(key) ?? null @@ -127,137 +133,59 @@ describe('mobile structured send retries', () => { listener = null }) - it('keeps one id across acknowledgement loss and host unknown replays', async () => { - let attempts = 0 - sendRequest.mockImplementation(async (method) => { - if (method !== 'agentSession.send') { - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) - } - attempts += 1 - if (attempts === 1) { - throw markRpcDeliveryUnknown(new Error('Connection closed')) - } - return sendResult('unknown') - }) - await mountSession() - - await act(async () => { - expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') - expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') - expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') - }) - - expect(calls()).toHaveLength(3) - expect(new Set(sentIds()).size).toBe(1) - expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true) - }) + it.each(['unknown', 'pending', 'accepted'] as const)( + 'a later Send owns a new id even when the earlier host answer was %s', + async (state) => { + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' ? sendResult(state) : ok({ models: [], current: {} }) + ) + await mountSession() + await act(async () => { + const outcome = state === 'unknown' ? 'unknown' : 'accepted' + expect(await hook!.sendWithOutcome('same text')).toBe(outcome) + expect(await hook!.sendWithOutcome('same text')).toBe(outcome) + }) + expect(new Set(sentIds()).size).toBe(2) + } + ) - // A replay of a retained id cannot be told from a new send of the same text, and a Stop took the - // first one back before the agent started it, so this press goes out as a new message. - it('sends a replay a Stop took back once more, under a fresh id, saying nothing', async () => { - let attempts = 0 - sendRequest.mockImplementation(async (method) => { - if (method !== 'agentSession.send') { - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) - } - attempts += 1 - if (attempts === 1) { - throw markRpcDeliveryUnknown(new Error('Connection closed')) + it('keeps the original unknown host row while allowing another Send', async () => { + const original = structuredSendResultFixture('unknown') + if (!('submission' in original)) { + throw new Error('expected a submission answer') + } + const event = snapshotEvent() + event.page.submissions = [original.submission] + event.page.items = [ + { + itemId: 'original-message', + revision: 1, + sequence: 1, + observedAt: 10, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'same text' }] } } - return attempts === 2 - ? sendResult('rejected', DISPATCH_REJECTED_CANCELLED) - : sendResult('pending') - }) - await mountSession() - - await act(async () => { - expect(await hook!.sendWithOutcome('stopped one')).toBe('unknown') - expect(await hook!.sendWithOutcome('stopped one')).toBe('accepted') - }) - - expect(onSendError).not.toHaveBeenCalled() - // The lost first send, its replay, and exactly one resend under a new id. - const ids = sentIds() - expect(ids).toHaveLength(3) - expect(ids[1]).toBe(ids[0]) - expect(ids[2]).not.toBe(ids[0]) - }) - - it('reads a first answer a Stop took back as sent, saying nothing, and spends its id', async () => { + ] sendRequest.mockImplementation(async (method) => - method === 'agentSession.send' - ? sendResult('rejected', DISPATCH_REJECTED_CANCELLED) - : method === 'agentSession.options' - ? ok({ models: [], current: {} }) - : ok({}) + method === 'agentSession.send' ? sendResult('accepted') : ok({ models: [], current: {} }) ) await mountSession() - + act(() => listener?.(event)) + const messages = hook!.session.messages + expect(messages).toHaveLength(1) await act(async () => { - expect(await hook!.sendWithOutcome('stopped first')).toBe('accepted') - expect(await hook!.sendWithOutcome('stopped first')).toBe('accepted') + expect(await hook!.sendWithOutcome('same text')).toBe('accepted') }) - - expect(onSendError).not.toHaveBeenCalled() - const ids = sentIds() - expect(ids).toHaveLength(2) - expect(ids[1]).not.toBe(ids[0]) + expect(hook!.session.messages).toEqual(messages) + expect(event.page.submissions[0]?.dispatchState).toBe('unknown') + expect(calls()).toHaveLength(1) }) - it('releases an ack-lost id after the journal accepts it for a later identical intent', async () => { - let attempts = 0 + it('does not replay an old action after remount', async () => { sendRequest.mockImplementation(async (method) => { - if (method !== 'agentSession.send') { - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) - } - attempts += 1 - return attempts === 1 - ? Promise.reject(markRpcDeliveryUnknown(new Error('Connection closed'))) - : sendResult('accepted') - }) - await mountSession() - await act(async () => { - expect(await hook!.sendWithOutcome('same text, later intent')).toBe('unknown') - }) - const firstRequest = calls()[0]![1] as { - envelope: { clientOperationId: string; payloadFingerprint: string } - } - const event = snapshotEvent() - act(() => - listener?.({ - ...event, - page: { - ...event.page, - submissions: [ - { - ...structuredSendResultFixture('accepted').submission, - clientMessageId: firstRequest.envelope.clientOperationId, - payloadFingerprint: firstRequest.envelope.payloadFingerprint - } - ] - } - }) - ) - await vi.waitFor(() => expect(storedOperations.size).toBe(0)) - - await act(async () => { - expect(await hook!.sendWithOutcome('same text, later intent')).toBe('accepted') - }) - - expect(sentIds()).toHaveLength(2) - expect(new Set(sentIds()).size).toBe(2) - }) - - it('reuses an ambiguous id after the session hook remounts', async () => { - let attempts = 0 - sendRequest.mockImplementation(async (method) => { - if (method !== 'agentSession.send') { - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + if (method === 'agentSession.send') { + throw markRpcDeliveryUnknown(new Error('Connection closed')) } - attempts += 1 - return attempts === 1 - ? Promise.reject(markRpcDeliveryUnknown(new Error('Connection closed'))) - : sendResult('unknown') + return ok({ models: [], current: {} }) }) await mountSession() await act(async () => { @@ -265,131 +193,87 @@ describe('mobile structured send retries', () => { }) act(() => renderer?.unmount()) renderer = null - hook = null listener = null - await mountSession() await act(async () => { expect(await hook!.sendWithOutcome('survive remount')).toBe('unknown') }) - - expect(new Set(sentIds()).size).toBe(1) - expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true) - expect(asyncStorage.setItem.mock.invocationCallOrder[0]).toBeLessThan( - sendRequest.mock.invocationCallOrder.find( - (_, index) => sendRequest.mock.calls[index]?.[0] === 'agentSession.send' - )! - ) + expect(new Set(sentIds()).size).toBe(2) }) - it('keeps the id when the host fails after provider dispatch', async () => { - let attempts = 0 - sendRequest.mockImplementation(async (method) => { - if (method !== 'agentSession.send') { - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) - } - attempts += 1 - return attempts === 1 - ? { - id: 'request-1', - ok: false as const, - error: { code: 'runtime_error', message: 'journal resolve failed' }, - _meta: { runtimeId: 'runtime-1' } - } - : sendResult('unknown') - }) + it('uses newly uploaded attachment paths for a new action with the same image', async () => { + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' ? sendResult('unknown') : ok({ models: [], current: {} }) + ) await mountSession() - await act(async () => { - expect(await hook!.sendWithOutcome('possibly delivered')).toBe('unknown') - expect(await hook!.sendWithOutcome('possibly delivered')).toBe('unknown') - }) - - expect(calls()).toHaveLength(2) - expect(new Set(sentIds()).size).toBe(1) - expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true) - }) - - it('reuses the original uploaded attachment identity after acknowledgement loss', async () => { - let attempts = 0 - sendRequest.mockImplementation(async (method) => { - if (method !== 'agentSession.send') { - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + for (const path of ['/tmp/original.png', '/tmp/reuploaded.png']) { + expect( + await hook!.sendWithOutcome('describe', undefined, undefined, [ + { + path, + previewUri: 'file:///photo.jpg' + } + ]) + ).toBe('unknown') } - attempts += 1 - return attempts === 1 - ? Promise.reject(markRpcDeliveryUnknown(new Error('Connection closed'))) - : sendResult('unknown') - }) - await mountSession() - const contentFingerprint = 'f'.repeat(64) - - await act(async () => { - expect( - await hook!.sendWithOutcome('describe', undefined, undefined, [ - { - path: '/tmp/original.png', - previewUri: 'file:///photo.jpg', - contentFingerprint - } - ]) - ).toBe('unknown') - expect( - await hook!.sendWithOutcome('describe', undefined, undefined, [ - { - path: '/tmp/reuploaded.png', - previewUri: 'file:///photo.jpg', - contentFingerprint - } - ]) - ).toBe('unknown') }) - - expect(new Set(sentIds()).size).toBe(1) + expect(new Set(sentIds()).size).toBe(2) expect(calls()[1]?.[1]).toMatchObject({ body: { - blocks: expect.arrayContaining([{ type: 'image-ref', path: '/tmp/original.png' }]) + blocks: expect.arrayContaining([{ type: 'image-ref', path: '/tmp/reuploaded.png' }]) } }) }) + it('reports a send stopped before the agent started it without sending it twice', async () => { + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' + ? sendResult('rejected', DISPATCH_REJECTED_CANCELLED) + : ok({ models: [], current: {} }) + ) + await mountSession() + await act(async () => { + expect(await hook!.sendWithOutcome('stopped')).toBe('accepted') + expect(await hook!.sendWithOutcome('stopped')).toBe('accepted') + }) + expect(calls()).toHaveLength(2) + expect(new Set(sentIds()).size).toBe(2) + expect(onSendError).not.toHaveBeenCalled() + }) + it.each(['invalid_argument', 'unauthorized'])( - 'rotates after a %s pre-handler RPC refusal that proves the send did not run', + 'a later Send is independent after a %s refusal', async (code) => { let attempts = 0 sendRequest.mockImplementation(async (method) => { if (method !== 'agentSession.send') { - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + return ok({ models: [], current: {} }) } - attempts += 1 - return attempts === 1 + return ++attempts === 1 ? { + id: 'request-1', ok: false as const, - error: { code, message: 'Message is not authorized' }, - _meta: { runtimeId: 'runtime-1' } + error: { code, message: 'Message is not authorized' } } : sendResult('accepted') }) await mountSession() - await act(async () => { - expect(await hook!.sendWithOutcome('never reached the handler')).toBe('rejected') - expect(await hook!.sendWithOutcome('never reached the handler')).toBe('accepted') + expect(await hook!.sendWithOutcome('again')).toBe('rejected') + expect(await hook!.sendWithOutcome('again')).toBe('accepted') }) - - expect(sentIds()).toHaveLength(2) expect(new Set(sentIds()).size).toBe(2) } ) - it('keeps the send id after a pending-admission refusal', async () => { + it('a new Send goes through after a stale-fence refusal', async () => { let attempts = 0 sendRequest.mockImplementation(async (method) => { if (method !== 'agentSession.send') { - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + return ok({ models: [], current: {} }) } - attempts += 1 - return attempts === 1 + return ++attempts === 1 ? ok({ ok: false, refusal: { @@ -401,112 +285,33 @@ describe('mobile structured send retries', () => { : sendResult('accepted') }) await mountSession() - - await act(async () => { - expect(await hook!.sendWithOutcome('retry at the current fence')).toBe('rejected') - expect(await hook!.sendWithOutcome('retry at the current fence')).toBe('unknown') - }) - - expect(sentIds()).toHaveLength(2) - expect(new Set(sentIds()).size).toBe(1) - }) - - it('keeps the id when an older host refuses an unknown replay', async () => { - let attempts = 0 - sendRequest.mockImplementation(async (method) => { - if (method !== 'agentSession.send') { - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) - } - attempts += 1 - if (attempts === 1) { - throw markRpcDeliveryUnknown(new Error('Connection closed')) - } - return attempts === 2 - ? ok({ - ok: false, - refusal: { - code: 'agent_session_operation_unknown', - message: 'The outcome is unknown.' - } - }) - : sendResult('unknown') - }) - await mountSession() - await act(async () => { - expect(await hook!.sendWithOutcome('old host replay')).toBe('unknown') - expect(await hook!.sendWithOutcome('old host replay')).toBe('unknown') - expect(await hook!.sendWithOutcome('old host replay')).toBe('unknown') + expect(await hook!.sendWithOutcome('again')).toBe('rejected') + expect(await hook!.sendWithOutcome('again')).toBe('accepted') }) - - expect(new Set(sentIds()).size).toBe(1) - expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true) + expect(new Set(sentIds()).size).toBe(2) }) - it('sends under a new id once the host has expired an ambiguous one', async () => { - let attempts = 0 - sendRequest.mockImplementation(async (method) => { - if (method !== 'agentSession.send') { - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) - } - attempts += 1 - if (attempts === 1) { - throw markRpcDeliveryUnknown(new Error('Connection closed')) - } - if (attempts === 3) { - return sendResult('accepted') - } - return ok({ - ok: false, - refusal: { - code: 'agent_session_operation_expired', - message: 'Operation expired.' - } - }) - }) - await mountSession() - - await act(async () => { - expect(await hook!.sendWithOutcome('old ambiguity')).toBe('unknown') - expect(await hook!.sendWithOutcome('old ambiguity')).toBe('rejected') - expect(await hook!.sendWithOutcome('old ambiguity')).toBe('accepted') - }) - - expect(calls()).toHaveLength(3) - const [first, replay, fresh] = sentIds() - expect(replay).toBe(first) - expect(fresh).not.toBe(first) - expect(onSendError).toHaveBeenCalledWith( - "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed." + it('never reads an old phone journal, even when storage is full or unreadable', async () => { + asyncStorage.getItem.mockRejectedValue(new Error('unreadable old journal')) + asyncStorage.setItem.mockRejectedValue(new Error('disk full')) + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' ? sendResult('accepted') : ok({ models: [], current: {} }) ) - }) - - it('does not retain an id when the action budget expires before dispatch', async () => { await mountSession() - await act(async () => { - expect(await hook!.sendWithOutcome('never attempted', undefined, 0)).toBe('rejected') + expect(await hook!.sendWithOutcome('new action')).toBe('accepted') }) - - expect(calls()).toHaveLength(0) + expect(asyncStorage.getItem).not.toHaveBeenCalled() expect(asyncStorage.setItem).not.toHaveBeenCalled() + expect(onSendError).not.toHaveBeenCalled() }) - it('puts a store that would not take the journal on screen, and sends nothing', async () => { - // Inside the page the store is the app's, reached over the `storage` grant, and it rejects a - // journal past `PAGE_STORAGE_MAX_VALUE_CHARS` — 48 unsettled sends, measured. A refusal that - // resolved instead would put a mutation on the wire carrying an operation id nothing holds, - // and a retry after a crash would send this message twice (rulings-ota-c7.md ruling 7). - asyncStorage.setItem.mockImplementation(async () => { - throw new Error('Orca could not save orca:mobileStructuredSendOperations:v1') - }) + it('sends nothing once the action budget expires', async () => { await mountSession() - await act(async () => { - expect(await hook!.sendWithOutcome('the journal will not take this')).toBe('rejected') + expect(await hook!.sendWithOutcome('never attempted', undefined, 0)).toBe('rejected') }) - - expect(onSendError).toHaveBeenCalledWith('Message not sent') expect(calls()).toHaveLength(0) }) }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx index 04b8e87304a2..db9657a4dd8b 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -22,8 +22,6 @@ const asyncStorage = vi.hoisted(() => ({ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) -import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' - function ok(result: unknown) { return { ok: true, result, _meta: { runtimeId: 'runtime-1' } } } @@ -271,7 +269,7 @@ describe('useMobileStructuredAgentSession', () => { beforeEach(() => { vi.clearAllMocks() - resetMobileStructuredSendOperationJournalForTests() + storedOperations = new Map() asyncStorage.getItem.mockImplementation( async (key: string) => storedOperations.get(key) ?? null diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 6bcc502aaf2b..b5f17dbbfa49 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -18,6 +18,7 @@ import { projectStructuredQuestion } from './mobile-structured-agent-prompts' import type { RpcClient } from '../transport/rpc-client' +import type { MobileNativeChatVisualSource } from './mobile-native-chat-visual-read' import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatSession } from './use-mobile-native-chat-session' @@ -29,7 +30,6 @@ import { useMobileStructuredPromptResponses } from './use-mobile-structured-prom import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing' -import { useMobileStructuredSendOperationReconciliation } from './use-mobile-structured-send-operation-reconciliation' import { pendingStructuredPromptIdentity, requestMobileStructuredAgentSessionCancel @@ -45,6 +45,10 @@ import { useMobileStructuredQueuedMessageControls, type MobileStructuredQueuedMessageControls } from './use-mobile-structured-queued-message-controls' +import { + useMobileStructuredBackgroundTasks, + type MobileStructuredBackgroundTasks +} from './use-mobile-structured-background-tasks' type StructuredMobileSession = ReturnType & ReturnType & { @@ -67,6 +71,10 @@ type StructuredMobileSession = ReturnType Promise /** The queued-draft cards and their actions, from any host that publishes them. */ queued: MobileStructuredQueuedMessageControls + /** Running child work for the strip above the composer, as desktop shows it. */ + backgroundTasks: MobileStructuredBackgroundTasks + /** Where this chat's `::orca-visual` lines read their HTML from; null without a client. */ + visualSource: MobileNativeChatVisualSource | null } export function useMobileStructuredAgentSession(args: { @@ -74,10 +82,8 @@ export function useMobileStructuredAgentSession(args: { sessionId: string | null /** Host/workspace scope used to keep same provider ids isolated. */ sourceIdentity?: string - /** Authenticated identity the host keys mutation admission under. */ - callerIdentity?: string enabled: boolean - /** Live transport only; gates the connection-scoped hold, nothing else. */ + /** Live transport only; gates the connection-scoped hold, and whether child rows may read live. */ connected: boolean /** Capability facts from the shared runtime status probe; null follows the legacy wire. */ hostSupport: StructuredAgentSessionHostSupport | null @@ -91,7 +97,6 @@ export function useMobileStructuredAgentSession(args: { const { agent, appendComposerText, - callerIdentity = '', client, connected, sessionId, @@ -112,7 +117,6 @@ export function useMobileStructuredAgentSession(args: { const stateArgs = { client, sessionId, sessionKey, enabled, connected } const { state, stateRef, queuedMessages, queuePause, loadingOlder, loadEarlier } = useMobileStructuredAgentState(stateArgs) - useMobileStructuredSendOperationReconciliation(state.submissions, queuedMessages) const mutate = useMobileStructuredAgentMutate({ client, @@ -145,10 +149,8 @@ export function useMobileStructuredAgentSession(args: { const sendWithOutcome = useMobileStructuredSendWithOutcome({ agent, - callerIdentity, client, sessionId, - sessionKey, enabled, queueCapable, stateRef, @@ -190,6 +192,13 @@ export function useMobileStructuredAgentSession(args: { selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state) const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) + const backgroundTasks = useMobileStructuredBackgroundTasks({ + sessionKey, + state, + turnId, + connected, + mutate + }) const hostStopping = useMobileStructuredSessionHostStopping({ client, sessionId, @@ -266,8 +275,14 @@ export function useMobileStructuredAgentSession(args: { ] ) + const visualSource = useMemo( + () => (client && sessionId ? { client, sessionId } : null), + [client, sessionId] + ) + return { ...options, + visualSource, session: { messages, status, @@ -292,6 +307,7 @@ export function useMobileStructuredAgentSession(args: { question: projectStructuredQuestion(questionPrompt, groupedDraft), respondPermission, respondQuestion, - queued + queued, + backgroundTasks } } diff --git a/mobile/src/session/use-mobile-structured-background-tasks.ts b/mobile/src/session/use-mobile-structured-background-tasks.ts new file mode 100644 index 000000000000..5b32d82a7391 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-background-tasks.ts @@ -0,0 +1,67 @@ +// The running child work a structured session's stream publishes, for the strip above the composer: +// desktop's view of the same roster, the verdict its rows read, and the Stop that reaches them. + +import { useCallback, useMemo } from 'react' +import type { AgentChildRowContext } from '../../../src/shared/agent-child-row-model' +import { agentChildRowContextForSessionStream } from '../../../src/shared/agent-child-row-stream-context' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import { + structuredSessionBackgroundTasksView, + type StructuredSessionBackgroundTasksView +} from '../../../src/shared/structured-session-background-tasks-view' +import type { MobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' + +export type MobileStructuredBackgroundTasks = { + /** The conversation these tasks belong to; the strip's open and stopping state is kept per one. */ + sessionKey: string + view: StructuredSessionBackgroundTasksView + /** The verdict every child row reads: unverifiable once this phone stops hearing the stream. Its + * host clock offset also moves the rows' host-stamped clocks onto the phone's. */ + rowContext: AgentChildRowContext + /** One child by its provider id, or, with none, every background task the host can stop. */ + stop: (taskId?: string) => Promise +} + +/** Coarser than one frame's delivery latency, finer than any clock a row shows. */ +const OFFSET_GRAIN_MS = 1_000 + +export function useMobileStructuredBackgroundTasks(args: { + sessionKey: string + state: StructuredAgentSessionState + turnId: string | null + /** Live transport; without it the kept roster is the last thing heard, not what runs now. */ + connected: boolean + mutate: MobileStructuredAgentMutate +}): MobileStructuredBackgroundTasks { + const { connected, mutate, sessionKey, state, turnId } = args + const view = useMemo( + () => structuredSessionBackgroundTasksView(state.backgroundTasks, turnId), + [state.backgroundTasks, turnId] + ) + // A disconnected or failed stream keeps the last roster on screen; its live claims then stand + // for nothing, so they read unverifiable rather than working. + const streamLive = connected && state.status === 'ready' + // Re-derived from every frame's host sample, so a host restart or clock correction shows at once; + // rounded so a sample's own delivery jitter never rebuilds the rows (they memoize on the value). + const hostClock = state.hostClock + const offsetMs = hostClock + ? Math.round((hostClock.receivedAt - hostClock.hostNow) / OFFSET_GRAIN_MS) * OFFSET_GRAIN_MS + : 0 + const rowContext = useMemo( + () => agentChildRowContextForSessionStream(streamLive, offsetMs), + [streamLive, offsetMs] + ) + const stop = useCallback( + (taskId?: string) => + mutate('agentSession.cancel', 'agentSession.cancel', { + turnId: 'background-tasks', + scope: 'background-tasks', + ...(taskId ? { taskId } : {}) + }), + [mutate] + ) + return useMemo( + () => ({ sessionKey, view, rowContext, stop }), + [sessionKey, view, rowContext, stop] + ) +} diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts index 5abe42b39e60..274390eca6ba 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts @@ -8,7 +8,6 @@ type StructuredNativeChatAttachment = { id?: string path: string previewUri: string - contentFingerprint?: string } export function useMobileStructuredNativeChatSendBridge(args: { diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index f65ecf2d46e3..a786eeacbe09 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -1,8 +1,8 @@ // The queued-draft surface the structured session exposes: cards derived from // the published list and the Send-now / Delete / Edit actions. Shown whatever // the queue capability says: a host that does not queue sends still publishes a -// message it kept across a restart or a close, and only queueing a new send is -// gated. A host older than the queue publishes no list, so shows no cards. +// message it kept as a card across a restart or a close, and only queueing a new +// send is gated. A host older than the queue publishes no list, so shows no cards. // Nothing here is durable: the host owns the queue, and the published list is // the only truth a card action ever needs. diff --git a/mobile/src/session/use-mobile-structured-send-operation-reconciliation.ts b/mobile/src/session/use-mobile-structured-send-operation-reconciliation.ts deleted file mode 100644 index 65d0f3184668..000000000000 --- a/mobile/src/session/use-mobile-structured-send-operation-reconciliation.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect } from 'react' -import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' -import type { MobileQueuedMessageFeed } from './mobile-structured-queued-message-feed' -import { - clearMobileStructuredQueuedSendOperations, - clearMobileStructuredSettledSendOperations -} from './mobile-structured-send-operation-journal' - -export function useMobileStructuredSendOperationReconciliation( - submissions: readonly AgentJournalSubmission[], - queuedMessages: MobileQueuedMessageFeed = null -): void { - useEffect(() => { - void clearMobileStructuredSettledSendOperations({ submissions }).catch(() => undefined) - }, [submissions]) - useEffect(() => { - if (!queuedMessages || queuedMessages.length === 0) { - return - } - void clearMobileStructuredQueuedSendOperations({ - queuedMessageIds: queuedMessages.map((draft) => draft.messageId) - }).catch(() => undefined) - }, [queuedMessages]) -} diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index e1759ac4fca8..4a7b1e67470c 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -9,7 +9,7 @@ import { pendingPromptsAllUnanswerableHere } from '../../../src/shared/agent-ses import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment -} from '../../../src/shared/structured-agent-session-outbox' +} from '../../../src/shared/structured-agent-session-send-mutation' import type { StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer' import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' import type { RpcClient } from '../transport/rpc-client' @@ -33,15 +33,12 @@ export function mobileStructuredSendQueues( export type StructuredMobileSendAttachment = StructuredAgentSessionAttachment & { id?: string - contentFingerprint?: string } export function useMobileStructuredSendWithOutcome(args: { agent: string | null - callerIdentity: string client: RpcClient | null sessionId: string | null - sessionKey: string enabled: boolean queueCapable: boolean stateRef: { readonly current: StructuredAgentSessionState } @@ -59,7 +56,6 @@ export function useMobileStructuredSendWithOutcome(args: { ) => Promise { const { agent, - callerIdentity, client, commandPending, controller, @@ -67,7 +63,6 @@ export function useMobileStructuredSendWithOutcome(args: { onSendError, queueCapable, sessionId, - sessionKey, stateRef } = args return useCallback( @@ -121,8 +116,6 @@ export function useMobileStructuredSendWithOutcome(args: { return sendMobileStructuredAgentSessionMessage({ client, sessionId, - sessionKey, - callerIdentity, expectedRuntimeFence: currentFence, text, attachments: sendAttachments, @@ -135,7 +128,6 @@ export function useMobileStructuredSendWithOutcome(args: { }, [ agent, - callerIdentity, client, commandPending, controller, @@ -143,7 +135,6 @@ export function useMobileStructuredSendWithOutcome(args: { onSendError, queueCapable, sessionId, - sessionKey, stateRef ] ) diff --git a/mobile/src/source-control/MobileSourceControlContent.test.tsx b/mobile/src/source-control/MobileSourceControlContent.test.tsx new file mode 100644 index 000000000000..e04dc4c041af --- /dev/null +++ b/mobile/src/source-control/MobileSourceControlContent.test.tsx @@ -0,0 +1,314 @@ +import { createElement, useState, type ReactElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileSourceControlContent } from './MobileSourceControlContent' +import * as branchFormat from './mobile-branch-entry-format' +import type { MobileSourceControlState } from './use-mobile-source-control-state' + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + ScrollView: 'ScrollView', + SectionList: ({ ListFooterComponent }: { ListFooterComponent: ReactElement }) => + createElement('SectionList', null, ListFooterComponent), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' +})) +vi.mock('expo-clipboard', () => ({ setStringAsync: async () => {} })) +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ArrowDownUp: 'ArrowDownUp', + ArrowUp: 'ArrowUp', + Check: 'Check', + ChevronRight: 'ChevronRight', + CloudUpload: 'CloudUpload', + FileText: 'FileText', + GitBranch: 'GitBranch', + GitPullRequestArrow: 'GitPullRequestArrow', + History: 'History', + Minus: 'Minus', + MoreHorizontal: 'MoreHorizontal', + Plus: 'Plus', + RefreshCw: 'RefreshCw', + Sparkles: 'Sparkles', + Trash2: 'Trash2' +})) + +const noop = () => {} +const unusedAsync = async (): Promise => { + throw new Error('unused action') +} + +function initialState(): MobileSourceControlState { + const branchEntries = Array.from({ length: 20 }, (_, index) => ({ + path: `src/file-${index}.ts`, + status: 'modified' as const, + added: 3, + removed: 1, + canOpen: true + })) + const branchCompareResult = { + entries: branchEntries, + summary: { + status: 'ready' as const, + baseRef: 'main', + changedFiles: 20, + commitsAhead: undefined, + errorMessage: undefined + } + } + return { + client: null, + connState: 'connected', + forceReconnect: null, + insets: { top: 0, bottom: 0, left: 0, right: 0 }, + router: { + back: noop, + push: noop, + replace: noop, + navigate: noop, + dismiss: noop, + dismissAll: noop, + dismissTo: noop, + prefetch: noop, + canGoBack: () => false, + canDismiss: () => false, + setParams: noop, + reload: noop + }, + setRootRef: noop, + worktreeLabel: 'fixture', + screenState: { kind: 'loading' }, + branchCompareState: { kind: 'ready', result: branchCompareResult }, + branchDiffPreview: null, + setBranchDiffPreview: noop, + busyAction: null, + commitMessage: '', + setCommitMessage: noop, + generatingMessage: false, + showBranchPicker: false, + setShowBranchPicker: noop, + localBranches: null, + createdPrUrl: null, + setCreatedPrUrl: noop, + createdPrWarning: null, + setCreatedPrWarning: noop, + discardTarget: null, + setDiscardTarget: noop, + showActionSheet: false, + setShowActionSheet: noop, + actionError: null, + commitFailureRecovery: null, + commitFailureRecoveryAction: { + summary: null, + hasDetails: false, + launching: false, + availability: 'available', + launchError: null, + launchWarning: null, + launchSuccess: null, + undeliveredPrompt: null, + launch: unusedAsync + }, + keyboardLift: 0, + openingPath: null, + openingBranchPath: null, + status: null, + sections: [], + branchCompareResult, + branchCompareSummaryText: '20 files vs main', + branchEntries, + shouldShowBranchCompareSection: true, + hasVisibleChanges: true, + stageablePaths: [], + unstageablePaths: [], + stagedCount: 1, + unstagedCount: 0, + branchLabel: 'fixture', + upstream: undefined, + upstreamKnown: false, + syncLabel: null, + primaryAction: { + kind: 'commit', + label: 'Commit', + accessibilityLabel: 'Commit', + accessibilityHint: '', + disabled: false, + loading: false, + onPress: noop + }, + createPrAction: { + visible: false, + label: 'Create Pull Request', + disabled: true, + loading: false, + pushFirst: false, + onPress: noop + }, + loadStatus: unusedAsync, + openFile: unusedAsync, + openBranchDiff: unusedAsync, + runGitAction: unusedAsync, + stageAll: unusedAsync, + unstageAll: unusedAsync, + commit: unusedAsync, + generateCommitMessage: unusedAsync, + cancelGenerateCommitMessage: noop, + createPr: unusedAsync, + openBranchPicker: noop, + openHistory: noop, + checkoutBranch: unusedAsync, + abortConflictOperation: unusedAsync, + runActionSheetCommit: unusedAsync, + runActionSheetCommitSequence: unusedAsync, + runActionSheetCommitSync: unusedAsync, + runActionSheetGitSequence: unusedAsync, + runActionSheetGitSync: unusedAsync, + runActionSheetRebase: unusedAsync + } +} + +let renderer: ReactTestRenderer | null = null +const controls: { patch: ((patch: Partial) => void) | null } = { + patch: null +} + +function Fixture({ initial }: { initial: MobileSourceControlState }) { + const [state, setState] = useState(initial) + controls.patch = (patch) => setState((previous) => ({ ...previous, ...patch })) + return createElement(MobileSourceControlContent, { + state: { + ...state, + setCommitMessage: (next) => + setState((previous) => ({ + ...previous, + commitMessage: typeof next === 'function' ? next(previous.commitMessage) : next + })) + } + }) +} + +function render(initial = initialState()) { + act(() => { + renderer = create(createElement(Fixture, { initial })) + }) +} +function patch(values: Partial) { + act(() => { + controls.patch?.(values) + }) +} +function rows() { + return ( + renderer?.root.findAll( + (node) => + String(node.type) === 'Pressable' && + typeof node.props.accessibilityLabel === 'string' && + node.props.accessibilityLabel.startsWith('Open committed change') + ) ?? [] + ) +} +function text() { + return JSON.stringify(renderer?.toJSON()) +} + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + controls.patch = null + vi.restoreAllMocks() +}) + +describe('committed branch files during commit-message typing', () => { + it.each(['scroll', 'sections'])('keeps the footer without revisiting rows in %s', (layout) => { + const initial = initialState() + if (layout === 'sections') { + initial.sections = [{ area: 'staged', title: 'Staged Changes', data: [] }] + } + const format = vi.spyOn(branchFormat, 'formatMobileBranchEntryMeta') + render(initial) + expect(format).toHaveBeenCalledTimes(20) + format.mockClear() + for (let index = 0; index < 50; index++) { + act(() => { + const input = renderer?.root.find((node) => String(node.type) === 'TextInput') + if (typeof input?.props.onChangeText !== 'function') { + throw new Error('missing input handler') + } + input.props.onChangeText(`message ${index}`) + }) + } + expect(renderer?.root.find((node) => String(node.type) === 'TextInput').props.value).toBe( + 'message 49' + ) + expect(rows()).toHaveLength(20) + expect(format).not.toHaveBeenCalled() + }) + + it('updates the footer when each of its inputs changes independently', () => { + const initial = initialState() + const format = vi.spyOn(branchFormat, 'formatMobileBranchEntryMeta') + render(initial) + patch({ branchCompareSummaryText: 'changed summary' }) + expect(text()).toContain('changed summary') + const first = initial.branchEntries[0] + if (!first) { + throw new Error('missing fixture entry') + } + patch({ branchEntries: [{ ...first, path: 'changed.ts' }] }) + expect(rows()).toHaveLength(1) + expect(text()).toContain('changed.ts') + patch({ busyAction: 'stage-all' }) + expect(rows()[0]?.props.disabled).toBe(true) + patch({ busyAction: null }) + expect(rows()[0]?.props.disabled).toBe(false) + patch({ openingPath: 'local.ts' }) + expect(rows()[0]?.props.disabled).toBe(true) + patch({ openingPath: null }) + patch({ openingBranchPath: 'changed.ts' }) + expect(rows()[0]?.props.disabled).toBe(true) + expect(rows()[0]?.findAll((node) => String(node.type) === 'ActivityIndicator')).toHaveLength(1) + patch({ openingBranchPath: null }) + const open = vi.fn(async () => {}) + patch({ openBranchDiff: open }) + act(() => { + const onPress = rows()[0]?.props.onPress + if (typeof onPress !== 'function') { + throw new Error('missing branch opener') + } + onPress() + }) + expect(open).toHaveBeenCalledWith(expect.objectContaining({ path: 'changed.ts' })) + patch({ branchCompareState: { kind: 'error', message: 'compare failed' } }) + expect(text()).toContain('compare failed') + expect(rows()).toHaveLength(0) + patch({ branchCompareState: initial.branchCompareState }) + patch({ + branchCompareResult: { + ...initial.branchCompareResult, + summary: { + status: 'error', + baseRef: 'main', + changedFiles: 0, + commitsAhead: undefined, + errorMessage: 'unavailable' + } + } + }) + expect(text()).toContain('unavailable') + expect(rows()).toHaveLength(0) + patch({ branchCompareResult: initial.branchCompareResult }) + expect(rows()).toHaveLength(1) + patch({ shouldShowBranchCompareSection: false }) + expect(text()).not.toContain('Committed on Branch') + patch({ shouldShowBranchCompareSection: true }) + expect(rows()).toHaveLength(1) + format.mockClear() + patch({ connState: 'disconnected', actionError: 'offline', keyboardLift: 100 }) + expect(text()).toContain('Reconnecting to desktop') + expect(text()).toContain('offline') + expect(format).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/source-control/MobileSourceControlContent.tsx b/mobile/src/source-control/MobileSourceControlContent.tsx index 885d8bf0c4da..6c0a8ca3f338 100644 --- a/mobile/src/source-control/MobileSourceControlContent.tsx +++ b/mobile/src/source-control/MobileSourceControlContent.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { ActivityIndicator, Pressable, @@ -58,20 +59,33 @@ export function MobileSourceControlContent({ state }: Props) { const shouldShowGenerateButton = stagedCount > 0 || generatingMessage const createPrHeroActive = createPrAction.visible && !createPrAction.disabled && !createPrAction.pushFirst - const branchCompareFooter = ( - + const branchCompareFooter = useMemo( + () => ( + + ), + [ + state.shouldShowBranchCompareSection, + state.branchCompareSummaryText, + state.branchEntries, + state.branchCompareState, + state.branchCompareResult, + busyAction, + openBranchDiff, + openingBranchPath, + openingPath + ] ) return ( diff --git a/mobile/src/storage/mirrored-storage-keys.ts b/mobile/src/storage/mirrored-storage-keys.ts index 590c99a77405..c8eac0c40346 100644 --- a/mobile/src/storage/mirrored-storage-keys.ts +++ b/mobile/src/storage/mirrored-storage-keys.ts @@ -78,8 +78,7 @@ function note(key: string, value: string | null): void { * refused page write left this map holding a value no store had taken and the next `init` handed * the page exactly that. There is nothing to undo, because nothing is written until the answer. * - * Returns the store's own promise, so a caller that has something to say about a refusal — the - * durable send journal is the one — still hears it, and a caller that has not is unchanged. + * Returns the store's own promise so callers can report refusals. */ export function persistMirrored(key: string, value: string | null): Promise { const write = value === null ? AsyncStorage.removeItem(key) : AsyncStorage.setItem(key, value) diff --git a/mobile/src/tasks/mobile-task-detail-hydration.test-fixture.tsx b/mobile/src/tasks/mobile-task-detail-hydration.test-fixture.tsx new file mode 100644 index 000000000000..afa07de3799e --- /dev/null +++ b/mobile/src/tasks/mobile-task-detail-hydration.test-fixture.tsx @@ -0,0 +1,188 @@ +import { createElement, useState } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { createFakeRpcClient, type SentRequest } from '../mobile-web-shell/bridge-host-test-fakes' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import type { ActionableTaskItem, TaskItem } from './mobile-tasks-project-workspace-types' +import type { DetailPayload, GitLabWorkItem } from './mobile-tasks-provider-detail-types' +import { useMobileTasksItemDetailLoading } from './use-mobile-tasks-item-detail-loading' + +export const GITLAB_DETAILS = { + body: 'Merge request body', + comments: [], + item: { labels: ['performance'], mergeable: 'MERGEABLE' }, + assignees: ['reviewer'], + pipelineJobs: [{ id: 1, name: 'test', stage: 'test', status: 'success' }], + reviewers: [{ username: 'reviewer' }], + approvalState: { approvalsRequired: 1, approvalsLeft: 0 } +} + +export const SUCCESS_CHECKS = { + state: 'success', + total: 1, + passed: 1, + failed: 0, + pending: 0, + neutral: 0 +} as const + +export function gitlabItem( + source: Partial = {} +): Extract { + return { + key: 'gitlab:repo:mr:12', + provider: 'gitlab', + title: 'A merge request', + subtitle: 'Repository !12', + status: 'Open', + updatedAt: '2026-10-07T00:00:00Z', + source: { + id: 'mr:12', + type: 'mr', + number: 12, + title: 'A merge request', + state: 'opened', + url: 'https://gitlab.example/group/repo/-/merge_requests/12', + labels: [], + author: null, + updatedAt: '2026-10-07T00:00:00Z', + repoId: 'repo', + repoName: 'Repository', + projectRef: { host: 'gitlab.example', path: 'group/repo' }, + ...source + } + } +} + +export function hydratedItem(source: Partial = {}) { + return gitlabItem({ + checksSummary: SUCCESS_CHECKS, + mergeable: 'MERGEABLE', + reviewDecision: 'approved', + reviewerCount: 1, + ...source + }) +} + +export function success(result: unknown): RpcResponse { + return { id: 'detail', ok: true, result } +} + +export function detailClient() { + const client = createFakeRpcClient({ getLastConnectedAt: () => 1 }) + const settled = new Set() + const pendingRequests = () => client.requests.filter((request) => !settled.has(request)) + return { + client, + get requests() { + return client.requests.map((request) => ({ + method: request.method, + params: request.args[1], + options: request.args[2] + })) + }, + pending: pendingRequests, + async answer( + reply: RpcResponse | ((request: SentRequest) => RpcResponse) = success( + structuredClone(GITLAB_DETAILS) + ), + pending = pendingRequests() + ) { + await act(async () => { + for (const request of pending) { + settled.add(request) + request.resolve(typeof reply === 'function' ? reply(request) : reply) + } + }) + }, + async reject(error: Error) { + const pending = pendingRequests() + await act(async () => { + for (const request of pending) { + settled.add(request) + request.reject(error) + } + }) + } + } +} + +type DetailState = { + actionItem: ActionableTaskItem | null + items: TaskItem[] + payload: DetailPayload | null + loading: boolean + error: string +} + +export async function mountDetail( + initial: ActionableTaskItem = gitlabItem(), + initialItems: TaskItem[] = [initial] +) { + const transport = detailClient() + const observation: { state?: DetailState } = {} + let renderer: ReactTestRenderer | undefined + const commands = { + select: (_item: ActionableTaskItem | null): void => {}, + refresh: (): void => {}, + client: (_client: RpcClient): void => {}, + rerender: (): void => {} + } + function Probe(): null { + const [actionItem, setActionItem] = useState(initial) + const [items, setItems] = useState(initialItems) + const [payload, setDetailPayload] = useState(null) + const [loading, setDetailLoading] = useState(false) + const [error, setDetailError] = useState('') + const [detailRefreshSeq, setRefreshSeq] = useState(0) + const [client, setClient] = useState(transport.client) + const [, setUnrelated] = useState(0) + commands.select = setActionItem + commands.refresh = () => setRefreshSeq((value) => value + 1) + commands.client = setClient + commands.rerender = () => setUnrelated((value) => value + 1) + observation.state = { actionItem, items, payload, loading, error } + useMobileTasksItemDetailLoading({ + actionItem, + client, + detailRefreshSeq, + setActionItem, + setItems, + setDetailPayload, + setDetailLoading, + setDetailError, + tasksSupported: true + }) + return null + } + await act(async () => { + renderer = create(createElement(Probe)) + }) + return { + transport, + state() { + if (!observation.state) { + throw new Error('Detail hook did not mount') + } + return observation.state + }, + async select(item: ActionableTaskItem | null) { + await act(async () => commands.select(item)) + }, + async refresh() { + await act(async () => commands.refresh()) + }, + async replaceClient(client: RpcClient) { + await act(async () => commands.client(client)) + }, + async rerender() { + await act(async () => commands.rerender()) + }, + async dispose() { + await act(async () => { + renderer?.unmount() + renderer = undefined + }) + } + } +} diff --git a/mobile/src/tasks/use-mobile-tasks-item-detail-lifecycle.test.tsx b/mobile/src/tasks/use-mobile-tasks-item-detail-lifecycle.test.tsx new file mode 100644 index 000000000000..e29da4016d82 --- /dev/null +++ b/mobile/src/tasks/use-mobile-tasks-item-detail-lifecycle.test.tsx @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ActionableTaskItem } from './mobile-tasks-project-workspace-types' +import { + detailClient, + GITLAB_DETAILS, + gitlabItem, + hydratedItem, + mountDetail, + success +} from './mobile-task-detail-hydration.test-fixture' + +vi.mock('./mobile-tasks-dependencies', async () => { + const react = await import('react') + const { colors } = await import('../theme/mobile-theme') + const { buildGitLabCheckSummary } = await import('./gitlab-check-summary') + return { useEffect: react.useEffect, colors, buildGitLabCheckSummary } +}) + +vi.mock('./mobile-tasks-legacy-foundation', async () => import('./mobile-tasks-item-mapping')) + +const mounts: Awaited>[] = [] +async function mount(...args: Parameters) { + const fixture = await mountDetail(...args) + mounts.push(fixture) + return fixture +} +afterEach(async () => { + for (const fixture of mounts.splice(0)) { + await fixture.dispose() + } +}) + +describe('task detail request lifetime', () => { + it('ignores results after another client becomes authoritative', async () => { + const fixture = await mount(hydratedItem()) + const next = detailClient() + await fixture.replaceClient(next.client) + await fixture.transport.answer(success({ ...GITLAB_DETAILS, body: 'Old client' })) + expect(fixture.state().payload).toBeNull() + await next.answer(success({ ...GITLAB_DETAILS, body: 'Current client' })) + expect(fixture.state()).toMatchObject({ loading: false, payload: { body: 'Current client' } }) + expect(next.requests).toHaveLength(1) + }) + + it('ignores an old item reply after selecting another item', async () => { + const fixture = await mount(hydratedItem()) + const next = hydratedItem({ id: 'mr:99', number: 99 }) + const old = fixture.transport.pending() + await fixture.select(next) + const current = fixture.transport.requests[1] + expect(current?.params).toMatchObject({ iid: 99 }) + await fixture.transport.answer(success({ ...GITLAB_DETAILS, body: 'Retired item' }), old) + expect(fixture.state().payload).toBeNull() + await fixture.transport.answer(success({ ...GITLAB_DETAILS, body: 'Current item' })) + expect(fixture.state().payload).toMatchObject({ body: 'Current item' }) + expect(fixture.state().actionItem).toBe(next) + expect(fixture.transport.requests).toHaveLength(2) + }) + + it('stops requests and ignores an outstanding reply on close', async () => { + const fixture = await mount() + await fixture.select(null) + await fixture.transport.answer() + expect(fixture.state()).toMatchObject({ actionItem: null, payload: null, loading: false }) + expect(fixture.transport.requests).toHaveLength(1) + }) + + it('ignores a reply after unmount', async () => { + const fixture = await mount() + const state = fixture.state() + await fixture.dispose() + await fixture.transport.answer() + expect(fixture.state()).toBe(state) + expect(fixture.transport.requests).toHaveLength(1) + }) + + it.each([ + success(null), + success('invalid'), + { id: 'detail', ok: false as const, error: { code: 'refused', message: 'Access refused' } } + ])('does not retry refused or unreadable details', async (reply) => { + const fixture = await mount() + await fixture.transport.answer(reply) + expect(fixture.transport.requests).toHaveLength(1) + expect(fixture.transport.pending()).toHaveLength(0) + expect(fixture.state().loading).toBe(false) + expect(fixture.state().error).not.toBe('') + }) + + it('does not retry a transport failure', async () => { + const fixture = await mount() + await fixture.transport.reject(new Error('Transport lost')) + expect(fixture.state()).toMatchObject({ loading: false, error: 'Transport lost' }) + expect(fixture.transport.requests).toHaveLength(1) + }) +}) + +describe('other detail providers', () => { + it('keeps GitHub and Linear reads stable after successful replies', async () => { + const gitlab = gitlabItem() + const github: ActionableTaskItem = { + ...gitlab, + provider: 'github', + source: { ...gitlab.source, type: 'pr', state: 'open' } + } + const fixture = await mount(github) + await fixture.transport.answer(success({ body: 'GitHub body' })) + expect(fixture.transport.requests).toHaveLength(1) + expect(fixture.state()).toMatchObject({ loading: false, payload: { provider: 'github' } }) + const issue = { + id: 'linear-1', + identifier: 'ENG-1', + title: 'Issue', + url: 'https://linear.example/ENG-1', + updatedAt: '', + priority: 0, + labels: [], + state: { name: 'Todo', type: 'unstarted', color: '' }, + team: { id: 'team', name: 'Team', key: 'ENG' } + } + const linear: ActionableTaskItem = { ...gitlab, provider: 'linear', source: issue } + await fixture.select(linear) + expect(fixture.transport.requests.slice(1).map((request) => request.method)).toEqual([ + 'linear.getIssue', + 'linear.issueComments' + ]) + await fixture.transport.answer((request) => + success(request.method === 'linear.issueComments' ? [] : issue) + ) + expect(fixture.transport.requests).toHaveLength(3) + expect(fixture.state()).toMatchObject({ loading: false, payload: { provider: 'linear' } }) + expect(fixture.state().actionItem).toBe(linear) + }) +}) diff --git a/mobile/src/tasks/use-mobile-tasks-item-detail-loading.test.tsx b/mobile/src/tasks/use-mobile-tasks-item-detail-loading.test.tsx new file mode 100644 index 000000000000..f2e526f46338 --- /dev/null +++ b/mobile/src/tasks/use-mobile-tasks-item-detail-loading.test.tsx @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProviderCheckSummary } from '../../../src/shared/github/pull-request-types' +import type { GitLabWorkItem } from './mobile-tasks-provider-detail-types' +import { + GITLAB_DETAILS, + gitlabItem, + hydratedItem, + mountDetail, + SUCCESS_CHECKS, + success +} from './mobile-task-detail-hydration.test-fixture' + +vi.mock('./mobile-tasks-dependencies', async () => { + const react = await import('react') + const { colors } = await import('../theme/mobile-theme') + const { buildGitLabCheckSummary } = await import('./gitlab-check-summary') + return { useEffect: react.useEffect, colors, buildGitLabCheckSummary } +}) + +vi.mock('./mobile-tasks-legacy-foundation', async () => import('./mobile-tasks-item-mapping')) + +const mounts: Awaited>[] = [] +async function mount(...args: Parameters) { + const fixture = await mountDetail(...args) + mounts.push(fixture) + return fixture +} +afterEach(async () => { + for (const fixture of mounts.splice(0)) { + await fixture.dispose() + } +}) + +const summaryChanges: { field: keyof ProviderCheckSummary; summary: ProviderCheckSummary }[] = [ + { field: 'state', summary: { ...SUCCESS_CHECKS, state: 'failure' } }, + { field: 'total', summary: { ...SUCCESS_CHECKS, total: 9 } }, + { field: 'passed', summary: { ...SUCCESS_CHECKS, passed: 9 } }, + { field: 'failed', summary: { ...SUCCESS_CHECKS, failed: 9 } }, + { field: 'pending', summary: { ...SUCCESS_CHECKS, pending: 9 } }, + { field: 'neutral', summary: { ...SUCCESS_CHECKS, neutral: 9 } } +] +const statusChanges: { field: string; source: Partial }[] = [ + { field: 'mergeable', source: { mergeable: 'CONFLICTING' } }, + { field: 'reviewDecision', source: { reviewDecision: 'review_required' } }, + { field: 'reviewerCount', source: { reviewerCount: 9 } } +] + +describe('GitLab task detail hydration', () => { + it('settles repeated identical replies and keeps the loaded sheet readable', async () => { + const fixture = await mount() + for (let round = 0; round < 6; round += 1) { + await fixture.transport.answer() + } + expect(fixture.transport.requests).toHaveLength(2) + expect(fixture.transport.pending()).toHaveLength(0) + expect(fixture.state()).toMatchObject({ + loading: false, + error: '', + payload: { provider: 'gitlab', body: GITLAB_DETAILS.body } + }) + }) + + it('retains already hydrated selected and list references after an unrelated render', async () => { + const initial = hydratedItem() + const other = gitlabItem({ id: 'mr:99', number: 99 }) + const items = [initial, other] + const fixture = await mount(initial, items) + await fixture.rerender() + expect(fixture.transport.requests).toHaveLength(1) + await fixture.transport.answer() + expect(fixture.transport.requests).toHaveLength(1) + expect(fixture.state().actionItem).toBe(initial) + expect(fixture.state().items).toBe(items) + expect(fixture.state().items[1]).toBe(other) + expect(fixture.transport.requests[0]).toMatchObject({ + method: 'gitlab.workItemDetails', + params: { repo: 'id:repo', iid: 12, type: 'mr', projectRef: initial.source.projectRef }, + options: { timeoutMs: 30_000 } + }) + }) + + it.each(summaryChanges)( + 'hydrates a changed checks $field before settling', + async ({ summary }) => { + const initial = hydratedItem({ checksSummary: summary }) + const other = gitlabItem({ id: 'mr:99' }) + const fixture = await mount(initial, [initial, other]) + await fixture.transport.answer() + expect(fixture.state().actionItem?.source).toMatchObject({ checksSummary: SUCCESS_CHECKS }) + expect(fixture.state().items[0]?.source).toMatchObject({ checksSummary: SUCCESS_CHECKS }) + expect(fixture.state().items[1]).toBe(other) + const selected = fixture.state().actionItem + const items = fixture.state().items + await fixture.transport.answer() + expect(fixture.transport.requests).toHaveLength(2) + expect(fixture.transport.pending()).toHaveLength(0) + expect(fixture.state().actionItem).toBe(selected) + expect(fixture.state().items).toBe(items) + } + ) + + it.each(statusChanges)('hydrates provided $field before settling', async ({ source }) => { + const fixture = await mount(hydratedItem(source)) + await fixture.transport.answer() + expect(fixture.state().actionItem?.source).toMatchObject({ + mergeable: 'MERGEABLE', + reviewDecision: 'approved', + reviewerCount: 1 + }) + await fixture.transport.answer() + expect(fixture.transport.requests).toHaveLength(2) + expect(fixture.transport.pending()).toHaveLength(0) + }) + + it('preserves absent optional status fields while accepting empty checks', async () => { + const initial = hydratedItem({ + mergeable: 'CONFLICTING', + reviewDecision: 'review_required', + reviewerCount: 9 + }) + const fixture = await mount(initial) + const reply = success({ body: 'No status fields', comments: [], pipelineJobs: [] }) + await fixture.transport.answer(reply) + expect(fixture.state().actionItem?.source).toMatchObject({ + checksSummary: { state: 'none', total: 0, passed: 0, failed: 0, pending: 0, neutral: 0 }, + mergeable: 'CONFLICTING', + reviewDecision: 'review_required', + reviewerCount: 9 + }) + await fixture.transport.answer(reply) + expect(fixture.transport.requests).toHaveLength(2) + expect(fixture.state()).toMatchObject({ loading: false, payload: { body: 'No status fields' } }) + }) + + it('still reads explicit refreshes and changed details', async () => { + const fixture = await mount(hydratedItem()) + await fixture.transport.answer() + await fixture.refresh() + await fixture.transport.answer(success({ ...GITLAB_DETAILS, body: 'Updated body' })) + expect(fixture.transport.requests).toHaveLength(2) + expect(fixture.state()).toMatchObject({ loading: false, payload: { body: 'Updated body' } }) + }) +}) diff --git a/mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx b/mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx index 4ffafce06e74..6f96124fd36e 100644 --- a/mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx +++ b/mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx @@ -1,3 +1,5 @@ +import type { ProviderCheckSummary } from '../../../src/shared/github/pull-request-types' +import type { GitLabWorkItem } from './mobile-tasks-provider-detail-types' import type { ItemDetailMetadataEffectsModel } from './use-mobile-tasks-item-detail-metadata-effects' import { type HostedReviewDecision, @@ -12,7 +14,43 @@ import { linearIssueRead } from './mobile-task-item-detail-operations' -export function useMobileTasksItemDetailLoading(model: ItemDetailMetadataEffectsModel) { +type GitLabHydratedStatus = Pick + +function gitLabHydrationUnchanged( + source: GitLabWorkItem, + summary: ProviderCheckSummary, + status: GitLabHydratedStatus +): boolean { + const current = source.checksSummary + return ( + current?.state === summary.state && + current.total === summary.total && + current.passed === summary.passed && + current.failed === summary.failed && + current.pending === summary.pending && + current.neutral === summary.neutral && + (status.mergeable === undefined || source.mergeable === status.mergeable) && + (status.reviewDecision === undefined || source.reviewDecision === status.reviewDecision) && + (status.reviewerCount === undefined || source.reviewerCount === status.reviewerCount) + ) +} + +export type ItemDetailLoadingInput = Pick< + ItemDetailMetadataEffectsModel, + | 'actionItem' + | 'client' + | 'detailRefreshSeq' + | 'setActionItem' + | 'setDetailError' + | 'setDetailLoading' + | 'setDetailPayload' + | 'setItems' + | 'tasksSupported' +> + +export function useMobileTasksItemDetailLoading( + model: Model +) { const { actionItem, client, @@ -109,7 +147,9 @@ export function useMobileTasksItemDetailLoading(model: ItemDetailMetadataEffects ...(details.reviewers !== undefined ? { reviewerCount: details.reviewers.length } : {}) } setActionItem((current) => - current?.provider === 'gitlab' && current.source.id === actionItem.source.id + current?.provider === 'gitlab' && + current.source.id === actionItem.source.id && + !gitLabHydrationUnchanged(current.source, checksSummary, hydratedStatus) ? { ...current, source: { @@ -120,20 +160,24 @@ export function useMobileTasksItemDetailLoading(model: ItemDetailMetadataEffects } : current ) - setItems((current) => - current.map((candidate) => - candidate.provider === 'gitlab' && candidate.source.id === actionItem.source.id - ? { - ...candidate, - source: { - ...candidate.source, - checksSummary, - ...hydratedStatus - } - } - : candidate - ) - ) + setItems((current) => { + let changed = false + const next = current.map((candidate) => { + if ( + candidate.provider !== 'gitlab' || + candidate.source.id !== actionItem.source.id || + gitLabHydrationUnchanged(candidate.source, checksSummary, hydratedStatus) + ) { + return candidate + } + changed = true + return { + ...candidate, + source: { ...candidate.source, checksSummary, ...hydratedStatus } + } + }) + return changed ? next : current + }) } return } @@ -211,4 +255,4 @@ export function useMobileTasksItemDetailLoading(model: ItemDetailMetadataEffects return model } -export type ItemDetailLoadingModel = ReturnType +export type ItemDetailLoadingModel = ItemDetailMetadataEffectsModel diff --git a/mobile/src/transport/base64-byte-codec.test.ts b/mobile/src/transport/base64-byte-codec.test.ts index 9dcd84bcf571..641f10433ceb 100644 --- a/mobile/src/transport/base64-byte-codec.test.ts +++ b/mobile/src/transport/base64-byte-codec.test.ts @@ -1,7 +1,37 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { decodeBase64Bytes, encodeBase64Bytes } from './base64-byte-codec' describe('base64 byte codec', () => { + it('uses native encoding on the original byte view without building binary strings', () => { + class NativeBase64Bytes extends Uint8Array { + toBase64(): string { + return Buffer.from(this).toString('base64') + } + } + const bytes = new NativeBase64Bytes(new Uint8Array([9, 255, 254, 8]).buffer, 1, 2) + const nativeEncoder = vi.spyOn(bytes, 'toBase64') + const binaryEncoder = vi.spyOn(globalThis, 'btoa') + try { + expect(encodeBase64Bytes(bytes)).toBe('//4=') + expect(nativeEncoder).toHaveBeenCalledExactlyOnceWith() + expect(binaryEncoder).not.toHaveBeenCalled() + } finally { + binaryEncoder.mockRestore() + } + }) + + it('keeps the fallback when the native property is not callable', () => { + const bytes = new Uint8Array([255, 254]) + Object.defineProperty(bytes, 'toBase64', { value: undefined }) + expect(encodeBase64Bytes(bytes)).toBe('//4=') + }) + + it('preserves empty encoding for a detached byte view', () => { + const bytes = new Uint8Array([1, 2, 3]) + structuredClone(bytes.buffer, { transfer: [bytes.buffer] }) + expect(encodeBase64Bytes(bytes)).toBe('') + }) + it.each([0, 1, 2, 3, 4, 8190 - 1, 8190, 8190 + 1, 8190 + 2, 8190 * 2, 256 * 1024 + 1])( 'preserves all bytes and padding at size %i', (length) => { diff --git a/mobile/src/transport/base64-byte-codec.ts b/mobile/src/transport/base64-byte-codec.ts index 2786bb5abbe1..9958e0cf8ea8 100644 --- a/mobile/src/transport/base64-byte-codec.ts +++ b/mobile/src/transport/base64-byte-codec.ts @@ -2,6 +2,12 @@ const BASE64_BINARY_CHUNK_BYTES = 8190 export function encodeBase64Bytes(bytes: Uint8Array): string { + if (bytes.byteLength === 0) { + return '' + } + if ('toBase64' in bytes && typeof bytes.toBase64 === 'function') { + return bytes.toBase64() + } const encoded: string[] = [] for (let offset = 0; offset < bytes.byteLength; offset += BASE64_BINARY_CHUNK_BYTES) { const end = Math.min(offset + BASE64_BINARY_CHUNK_BYTES, bytes.byteLength) diff --git a/mobile/tests-typecheck-baseline.txt b/mobile/tests-typecheck-baseline.txt index 91b1fd6bae8c..e8fa6ca1b48d 100644 --- a/mobile/tests-typecheck-baseline.txt +++ b/mobile/tests-typecheck-baseline.txt @@ -84,7 +84,6 @@ src/session/use-mobile-native-chat-image-attachments.test.ts src/session/use-mobile-pr-branch-context.test.ts src/session/use-mobile-pr-sidebar-controller.test.ts src/session/use-mobile-session-terminal-create-actions.test.ts -src/session/use-mobile-structured-agent-session-send.test.tsx src/session/use-mobile-structured-agent-session.test.tsx src/session/use-mobile-structured-prompt-responses.test.tsx src/session/use-mobile-terminal-inventory-recovery.test.ts diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index ed26d89319f8..22cc58fe0b1e 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -165,6 +165,10 @@ "file": "src/components/AuthFailedBannerActions.web.tsx", "reason": "The auth-failed banner offers Retry, Re-pair and Remove, and on the page only Re-pair works. /pair-scan sits outside the page's route root of app/h (config/scripts/mobile-web-app-route-manifest.mjs:6), so route-handoff.web.ts:207 hands the push to the shell, which opens the native scan screen and returns to the same page document on Back. Retry and Remove are inert here: forceReconnect is null (client-context.web.tsx:57, read through host-client-hooks.ts:88) and removal refuses (page-host-removal-refusal.ts). The banner is reachable on the page because the shell forwards the native client's state (mobile-web-shell/bridge-host.ts:301) and auth-failed is in the wire enum (bridge/bridge-envelope.ts:44). So this one renders Re-pair as native does and one line naming the app for reconnect and removal." }, + { + "file": "src/session/MobileNativeChatVisualFrame.web.tsx", + "reason": "The native frame runs a chat visual's scripts in a sandboxed child of a trusted host document inside a react-native-webview, which throws at import in a browser. The page's response-header policy (script-src 'self', frame-src 'none') is inherited by a srcdoc frame, so a visual's inline scripts cannot run here without loosening the page policy. This one renders the visual sealed, as MobileHtmlPreview.web.tsx does: an empty sandbox, no script, a fixed-height frame the visual scrolls inside." + }, { "file": "src/components/MobileHtmlPreview.web.tsx", "reason": "The native preview renders an agent-produced HTML artifact inside a sandboxed WebView with navigation locked to the initial inline document, and react-native-webview throws at import in a browser for the reason above. This one renders the artifact in a sandboxed iframe with no allow-scripts and no allow-same-origin, keeping the Preview/Source toggle. srcdoc rather than a blob: URL and no CSP change at all: a srcdoc frame has no URL for frame-src to match and inherits its embedder's policy instead, so it is admitted under the shipped frame-src 'none' on Chromium and WebKit alike, while a blob: frame is refused by frame-src and refused again in WebKit by the frame-ancestors 'none' it inherits. The inherited policy is also what seals it -- script-src 'self' refuses the artifact's inline script, img-src bounds its images, font-src 'none' its fonts -- and allow-top-navigation-by-user-activation is the one capability granted, so a tapped link becomes a top-frame navigation the shell opens externally (ruling 29) while a meta refresh, a form submit, target=_blank and any script-initiated navigation produce none. That token is granted only when the shell says it can open such a navigation: without the externalNavigation grant this file renders the artifact with its links as text and seals the frame with an empty sandbox instead (C8.1, ruling 37.2)." diff --git a/resources/native-chat-visuals/.claude-plugin/plugin.json b/resources/native-chat-visuals/.claude-plugin/plugin.json new file mode 100644 index 000000000000..855f314a7f13 --- /dev/null +++ b/resources/native-chat-visuals/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "orca-native-chat", + "version": "1.0.0", + "description": "Teaches an Orca chat agent to show charts, diagrams and mockups inline in the chat." +} diff --git a/resources/native-chat-visuals/skills/orca-chat-visuals/SKILL.md b/resources/native-chat-visuals/skills/orca-chat-visuals/SKILL.md new file mode 100644 index 000000000000..dc63d3c8d206 --- /dev/null +++ b/resources/native-chat-visuals/skills/orca-chat-visuals/SKILL.md @@ -0,0 +1,33 @@ +--- +name: orca-chat-visuals +description: Show a chart, diagram, table, report or mockup inline in this Orca chat as an HTML + page. Use when a visual would help the user understand your answer. Only available when the + ORCA_CHAT_VISUALS_DIR environment variable is set. +user-invocable: false +--- + +# Inline visuals in an Orca chat + +Orca can show an HTML page inside your reply. Use one whenever a visual would make the answer +clearer. + +## Writing the page + +- Save it in the folder named by `ORCA_CHAT_VISUALS_DIR` (`printenv ORCA_CHAT_VISUALS_DIR`; in + PowerShell `$env:ORCA_CHAT_VISUALS_DIR`). If it's unset or you can't write there, skip the visual. +- One complete HTML file per visual, directly in that folder, under 512 KB, with a new file name + each time (e.g. `latency-by-region-7c1e.html`): letters, digits, `.`, `_`, `-`, ending in `.html`. +- Inline your styles, scripts and data. Libraries and fonts may load only from cdn.jsdelivr.net, + unpkg.com, cdnjs.cloudflare.com, esm.sh, fonts.googleapis.com and fonts.gstatic.com. The page + can't make network requests; use inline SVG or `data:` URLs for images. +- Don't size anything to the viewport height (`100vh`); Orca fits the frame to your content. +- Optional: Orca's theme colors are available as CSS variables (`--background`, `--foreground`, + `--muted-foreground`, `--border`, `--primary`, `--chart-1` to `--chart-5`). + +## Showing it + +After the file exists, put this on its own line in your reply, outside any code block: + +::orca-visual{file="latency-by-region-7c1e.html" title="Latency by region"} + +`file` is the bare file name, never a path; `title` is optional. At most 8 per reply. diff --git a/src/cli/handlers/account-list-format.ts b/src/cli/handlers/account-list-format.ts index 66da6d4b1b71..59df0c6b3a48 100644 --- a/src/cli/handlers/account-list-format.ts +++ b/src/cli/handlers/account-list-format.ts @@ -3,7 +3,7 @@ import type { ManagedDataAccountsState } from '../../shared/managed-account-type // Why: Claude and Codex managed-account summaries both carry id+email+active id, // so one formatter renders either provider's block. type AccountsBlock = { - accounts: readonly { id: string; email: string }[] + accounts: readonly { id: string; email: string; needsSignIn?: true }[] activeAccountId: string | null activeAccountIdsByRuntime?: { host: string | null @@ -35,7 +35,10 @@ export function formatAccountsBlock(label: string, block: AccountsBlock): string ...Object.values(block.activeAccountIdsByRuntime?.wsl ?? {}) ]) const lines = block.accounts.map( - (account) => ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}` + (account) => + ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}${ + account.needsSignIn ? ' (sign in again in Orca Settings > AI Provider Accounts)' : '' + }` ) return `Managed ${label} accounts (${block.accounts.length}):\n${lines.join('\n')}` } diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts index 8b2fe9d5ae7e..e19fd4a8ad29 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -1,6 +1,5 @@ import type * as NodeCliCommandResolutionModule from '../../shared/node-cli-command-resolution' import { EventEmitter } from 'node:events' -import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' import { delimiter } from 'node:path' import type * as NodeFs from 'node:fs' @@ -63,7 +62,10 @@ import { WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../../shared/windows-batch-spawn' -import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { + ACCOUNT_IMPORT_RUNTIME_CAPABILITY, + CLAUDE_SIGN_IN_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' function successfulChild(): EventEmitter { const child = new EventEmitter() @@ -131,8 +133,14 @@ describe('account CLI handlers', () => { ok: true, result: method === 'status.get' - ? { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] } - : accountState(method.includes('Claude') ? 'claude@example.com' : 'codex@example.com'), + ? { + capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CLAUDE_SIGN_IN_RUNTIME_CAPABILITY] + } + : method === 'accounts.beginClaudeSignIn' + ? { accountId: 'draft', configDir: '/fake/final-profile', runtime: 'host' } + : accountState( + method.includes('Claude') ? 'claude@example.com' : 'codex@example.com' + ), _meta: { runtimeId: 'test-runtime' } }) ) @@ -306,37 +314,17 @@ describe('account CLI handlers', () => { expect(pathValues).toContain(`${nodeBin}${delimiter}${effectivePathBefore}`) }) - it('removes scoped Claude credentials and restores the legacy Keychain item', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - readKeychainMock.mockResolvedValue('legacy-credentials') - + it('runs login in the final profile and never reads, writes or cleans up Keychain credentials', async () => { await ACCOUNT_HANDLERS['account add'](context('claude')) - - const configDir = spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR - expect(deleteKeychainMock).toHaveBeenCalledWith(configDir) - expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') - expect(callMock).toHaveBeenCalledWith('accounts.addClaudeFromConfigDir', { - configDir, - previousLegacyCredentialsSha256: createHash('sha256') - .update('legacy-credentials') - .digest('hex') - }) - expect(existsSync(configDir)).toBe(false) - }) - - it('attributes a Claude account added through the WSL bridge to the cwd distro', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - - await ACCOUNT_HANDLERS['account add']( - context('claude', false, String.raw`\\wsl.localhost\Ubuntu-22.04\home\user\project`) + expect(spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR).toBe('/fake/final-profile') + expect(callMock).toHaveBeenCalledWith( + 'accounts.finishClaudeSignIn', + { accountId: 'draft', runtime: 'host', wslDistro: undefined }, + { timeoutMs: 300000 } ) - - const configDir = spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR - expect(callMock).toHaveBeenCalledWith('accounts.addClaudeFromConfigDir', { - configDir, - runtime: 'wsl', - wslDistro: 'Ubuntu-22.04' - }) + expect(readKeychainMock).not.toHaveBeenCalled() + expect(writeKeychainMock).not.toHaveBeenCalled() + expect(deleteKeychainMock).not.toHaveBeenCalled() }) it('attributes a Codex account added through the WSL bridge to the cwd distro', async () => { @@ -364,8 +352,9 @@ describe('account CLI handlers', () => { ) expect(callMock).toHaveBeenCalledWith( - 'accounts.addClaudeFromConfigDir', - expect.objectContaining({ runtime: 'wsl', wslDistro: 'Debian' }) + 'accounts.beginClaudeSignIn', + expect.objectContaining({ runtime: 'wsl', wslDistro: 'Debian' }), + { timeoutMs: 300000 } ) }) @@ -375,10 +364,14 @@ describe('account CLI handlers', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) process.env.ORCA_CLI_WSL_DISTRO = 'Ubuntu Work' await ACCOUNT_HANDLERS['account add'](context(agent, false, String.raw`C:\work with spaces`)) - expect(callMock).toHaveBeenCalledWith( - agent === 'claude' ? 'accounts.addClaudeFromConfigDir' : 'accounts.addCodexFromHome', - expect.objectContaining({ runtime: 'wsl', wslDistro: 'Ubuntu Work' }) - ) + expect( + callMock.mock.calls.some( + ([method, params]) => + method === + (agent === 'claude' ? 'accounts.beginClaudeSignIn' : 'accounts.addCodexFromHome') && + params.wslDistro === 'Ubuntu Work' + ) + ).toBe(true) } ) @@ -470,62 +463,6 @@ describe('account CLI handlers', () => { exitSpy.mockRestore() }) - it('waits for in-flight cleanup when a second signal arrives', async () => { - // Why: a boolean latch lets the second signal's process.exit fire while the - // first cleanup is still inside a Keychain call, stranding the credentials. - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - readKeychainMock.mockResolvedValue('legacy-credentials') - let releaseKeychainDelete: (() => void) | undefined - deleteKeychainMock.mockImplementationOnce( - () => - new Promise((resolvePromise) => { - releaseKeychainDelete = () => resolvePromise() - }) - ) - const child = Object.assign(new EventEmitter(), { kill: vi.fn() }) - let configDir = '' - spawnMock.mockImplementation((_command, _args, options: { env: Record }) => { - configDir = options.env.CLAUDE_CONFIG_DIR - return child - }) - const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) - const sigintBefore = process.listeners('SIGINT') - const sigtermBefore = process.listeners('SIGTERM') - - const pending = ACCOUNT_HANDLERS['account add'](context('claude')).catch(() => {}) - await vi.waitFor(() => expect(configDir).not.toBe('')) - - const onSigint = newSignalListener('SIGINT', sigintBefore) - const onSigterm = newSignalListener('SIGTERM', sigtermBefore) - // Why: `rawListeners` exposes the `once` wrapper, so this fails if the handler - // is registered with `once` — where a second Ctrl-C falls through to Node's - // default and kills the process mid-cleanup. - expect(process.rawListeners('SIGINT')).toContain(onSigint) - - onSigint('SIGINT') - await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGINT')) - child.emit('exit', 1) - child.emit('close', 1) - await vi.waitFor(() => expect(deleteKeychainMock).toHaveBeenCalledWith(configDir)) - onSigterm('SIGTERM') - await new Promise((resolvePromise) => { - setImmediate(resolvePromise) - }) - - expect(exitSpy).not.toHaveBeenCalled() - expect(writeKeychainMock).not.toHaveBeenCalled() - expect(existsSync(configDir)).toBe(true) - - releaseKeychainDelete?.() - await vi.waitFor(() => expect(exitSpy).toHaveBeenCalled()) - expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') - expect(existsSync(configDir)).toBe(false) - expect(child.kill).toHaveBeenCalledOnce() - - await pending - exitSpy.mockRestore() - }) - it('warns that the account may already be registered when interrupted mid-RPC', async () => { // Why: the runtime finishes the add independently of this process, so an // interrupt after sign-in cannot honestly be reported as "not added". @@ -540,7 +477,9 @@ describe('account CLI handlers', () => { ? Promise.resolve({ id: 'test', ok: true, - result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, + result: { + capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CLAUDE_SIGN_IN_RUNTIME_CAPABILITY] + }, _meta: { runtimeId: 'test-runtime' } }) : method === 'accounts.list' @@ -569,32 +508,6 @@ describe('account CLI handlers', () => { exitSpy.mockRestore() }) - it('stays armed for signals until post-success cleanup finishes', async () => { - // Why: detaching the handlers before cleanup leaves the multi-second Keychain - // calls covered only by Node's default handling, which kills mid-cleanup. - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - readKeychainMock.mockResolvedValue('legacy-credentials') - let releaseKeychainDelete: (() => void) | undefined - deleteKeychainMock.mockImplementationOnce( - () => - new Promise((resolvePromise) => { - releaseKeychainDelete = () => resolvePromise() - }) - ) - - const listenersBefore = process.listeners('SIGINT') - - const pending = ACCOUNT_HANDLERS['account add'](context('claude')) - await vi.waitFor(() => expect(deleteKeychainMock).toHaveBeenCalled()) - - // Why: cleanup is still in flight here, so this add's guard must still be installed. - const handler = newSignalListener('SIGINT', listenersBefore) - - releaseKeychainDelete?.() - await pending - expect(process.listeners('SIGINT')).not.toContain(handler) - }) - it('fails before the login when the runtime is unreachable', async () => { // Why: discovering a dead runtime after sign-in wastes a full OAuth round trip. callMock.mockRejectedValue(new Error('runtime not running')) @@ -606,22 +519,40 @@ describe('account CLI handlers', () => { expect(spawnMock).not.toHaveBeenCalled() }) - it('fails before login when the running runtime predates account imports', async () => { + it('still adds Codex through an older host that advertises only the retired import capability', async () => { callMock.mockResolvedValue({ id: 'test', ok: true, - result: { capabilities: [] }, + result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, _meta: { runtimeId: 'test-runtime' } }) + spawnMock.mockImplementation(() => { + throw new Error('stop after the capability gate') + }) - await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow( - 'runtime is too old' - ) - expect(callMock).toHaveBeenCalledOnce() - expect(callMock).toHaveBeenCalledWith('status.get') - expect(spawnMock).not.toHaveBeenCalled() + await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow() + expect(spawnMock).toHaveBeenCalled() }) + it.each(['claude', 'codex'] as const)( + 'refuses %s login before spawning when its capability is absent', + async (agent) => { + callMock.mockResolvedValue({ + id: 'test', + ok: true, + result: { capabilities: [] }, + _meta: { runtimeId: 'test-runtime' } + }) + + await expect(ACCOUNT_HANDLERS['account add'](context(agent))).rejects.toThrow( + 'The running Orca runtime is too old to add accounts from the CLI.' + ) + expect(callMock).toHaveBeenCalledOnce() + expect(callMock).toHaveBeenCalledWith('status.get') + expect(spawnMock).not.toHaveBeenCalled() + } + ) + it.each(['environment', 'pairing-code'])( 'rejects --%s instead of silently ignoring it', async (flag) => { @@ -667,7 +598,9 @@ describe('account CLI handlers', () => { ? Promise.resolve({ id: 'test', ok: true, - result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, + result: { + capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CLAUDE_SIGN_IN_RUNTIME_CAPABILITY] + }, _meta: { runtimeId: 'test-runtime' } }) : method === 'accounts.list' @@ -699,15 +632,14 @@ describe('account CLI handlers', () => { expect(logSpy).not.toHaveBeenCalled() }) - it('fails a successful Claude add when Keychain cleanup fails', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - readKeychainMock.mockResolvedValue('legacy-credentials') - deleteKeychainMock.mockRejectedValueOnce(new Error('Keychain denied cleanup')) - - await expect(ACCOUNT_HANDLERS['account add'](context('claude'))).rejects.toThrow( - 'Failed to clean up Claude login artifacts' - ) - expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') + it('does not touch a locked Keychain while the CLI signs in', async () => { + readKeychainMock.mockRejectedValue(new Error('locked')) + deleteKeychainMock.mockRejectedValue(new Error('locked')) + writeKeychainMock.mockRejectedValue(new Error('locked')) + await ACCOUNT_HANDLERS['account add'](context('claude')) + expect(readKeychainMock).not.toHaveBeenCalled() + expect(deleteKeychainMock).not.toHaveBeenCalled() + expect(writeKeychainMock).not.toHaveBeenCalled() }) it('rejects `--agent` with no value instead of defaulting to Claude', async () => { @@ -741,6 +673,32 @@ describe('account CLI handlers', () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('claude@example.com (active)')) }) + it('tells the user which saved Claude accounts need a fresh sign-in', async () => { + callMock.mockResolvedValue({ + id: 'test', + ok: true, + result: { + claude: { + accounts: [ + { id: 'old', email: 'old@example.com', needsSignIn: true }, + { id: 'ok', email: 'ok@example.com' } + ], + activeAccountId: 'old' + }, + codex: { accounts: [], activeAccountId: null } + }, + _meta: { runtimeId: 'test-runtime' } + }) + + await ACCOUNT_HANDLERS['account list']({ ...context('claude'), flags: new Map() }) + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining( + ' old@example.com (active) (sign in again in Orca Settings > AI Provider Accounts)\n ok@example.com\n' + ) + ) + }) + it('lists accounts without forcing a provider usage refresh', async () => { // Why: the forced lane bypasses the poll throttle and costs one serial // round-trip per managed account, and this output shows no usage numbers. diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index d4081c3c5da1..db2cd31c8b8a 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -1,5 +1,4 @@ import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' @@ -9,10 +8,10 @@ import { RuntimeClientError } from '../runtime-client' import { stripElectronRunAsNode } from '../runtime/launch' import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentialsStrict, - writeActiveClaudeKeychainCredentials -} from '../../main/claude-accounts/keychain' + buildWslExecArgs, + buildWslLoginShellCommand, + quotePosixShell +} from '../../shared/wsl-login-shell-command' import { getVersionManagerBinPaths, resolveCliCommand, @@ -24,9 +23,13 @@ import { WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../../shared/windows-batch-spawn' import { stdioForWindowsInteractiveChild } from '../../shared/windows-console-input' -import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { + ACCOUNT_IMPORT_RUNTIME_CAPABILITY, + CLAUDE_SIGN_IN_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' import type { RuntimeStatus } from '../../shared/runtime-types' import type { + ClaudeAccountSignIn, ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, ManagedDataAccountsState @@ -140,79 +143,53 @@ async function runAgentLoginInTerminal( }) } -async function cleanupClaudeLoginArtifacts( - configDir: string, - legacyCredentials: string | null, - restoreLegacyCredentials: boolean -): Promise { - const errors: unknown[] = [] - if (process.platform === 'darwin') { - try { - await deleteActiveClaudeKeychainCredentialsStrict(configDir) - } catch (error) { - errors.push(error) - } - if (restoreLegacyCredentials) { - try { - await (legacyCredentials - ? writeActiveClaudeKeychainCredentials(legacyCredentials) - : deleteActiveClaudeKeychainCredentialsStrict()) - } catch (error) { - errors.push(error) - } - } - } - try { - rmSync(configDir, { recursive: true, force: true }) - } catch (error) { - errors.push(error) - } - if (errors.length > 0) { - throw new AggregateError(errors, 'Failed to clean up Claude login artifacts.') - } -} - -/** Logs into a Claude account in a temp config dir, then registers it with the local runtime. */ +/** Signs in to a new account folder the host created, then registers it (superset AddAccountDialog). */ async function addClaudeAccount({ client, cwd, json }: HandlerContext): Promise { - const configDir = mkdtempSync(join(tmpdir(), 'orca-account-add-claude-')) + const { result: signIn } = await client.call( + 'accounts.beginClaudeSignIn', + getWslAccountTarget(cwd) ?? {}, + { timeoutMs: 300_000 } + ) const session: InteractiveLoginSession = { child: null, registering: false, terminationPromise: null } - let legacyCredentials: string | null = null - let restoreLegacyCredentials = false const result = await withInteractiveLoginCleanup( session, + // Why always: the host keeps a folder that became an account, and deletes an abandoned one. + // Never thrown: an older running Orca lacks the method, and the add itself already finished. async () => { - await cleanupClaudeLoginArtifacts(configDir, legacyCredentials, restoreLegacyCredentials) + const { accountId, runtime, wslDistro } = signIn + await client + .call('accounts.cancelClaudeSignIn', { accountId, runtime, wslDistro }) + .catch((error: unknown) => console.warn('[account] Could not clean up sign-in:', error)) }, async () => { - if (process.platform === 'darwin') { - legacyCredentials = await readActiveClaudeKeychainCredentialsStrict() - restoreLegacyCredentials = true + if (signIn.runtime === 'wsl' && signIn.wslDistro) { + const login = `exec env CLAUDE_CONFIG_DIR=${quotePosixShell(signIn.configDir)} claude auth login` + await runAgentLoginInTerminal( + 'wsl.exe', + buildWslExecArgs(signIn.wslDistro, ['/bin/sh', '-c', buildWslLoginShellCommand(login)]), + {}, + json, + session + ) + } else { + await runAgentLoginInTerminal( + 'claude', + ['auth', 'login'], + { CLAUDE_CONFIG_DIR: signIn.configDir }, + json, + session + ) } - await runAgentLoginInTerminal( - 'claude', - ['auth', 'login', '--claudeai'], - { - CLAUDE_CONFIG_DIR: configDir - }, - json, - session - ) session.registering = true - return client.call('accounts.addClaudeFromConfigDir', { - configDir, - ...getWslAccountTarget(cwd), - ...(process.platform === 'darwin' - ? { - previousLegacyCredentialsSha256: legacyCredentials - ? createHash('sha256').update(legacyCredentials).digest('hex') - : null - } - : {}) - }) + return client.call( + 'accounts.finishClaudeSignIn', + { accountId: signIn.accountId, runtime: signIn.runtime, wslDistro: signIn.wslDistro }, + { timeoutMs: 300_000 } + ) } ) printResult(result, json, (state) => formatAccountsBlock('Claude', state)) @@ -265,9 +242,14 @@ function rejectAccountRemoteSelectionFlags(ctx: HandlerContext, command: string) ) } -async function assertAccountImportSupported({ client }: HandlerContext): Promise { +async function assertAccountImportSupported( + { client }: HandlerContext, + agent: 'claude' | 'codex' +): Promise { const status = await client.call('status.get') - if (!status.result.capabilities?.includes(ACCOUNT_IMPORT_RUNTIME_CAPABILITY)) { + const capability = + agent === 'claude' ? CLAUDE_SIGN_IN_RUNTIME_CAPABILITY : ACCOUNT_IMPORT_RUNTIME_CAPABILITY + if (!status.result.capabilities?.includes(capability)) { throw new RuntimeClientError( 'incompatible_runtime', 'The running Orca runtime is too old to add accounts from the CLI. Update or restart Orca and try again.' @@ -300,7 +282,7 @@ export const ACCOUNT_HANDLERS: Record = { return } // Why: fail on runtime version skew before burning a full OAuth round trip. - await assertAccountImportSupported(ctx) + await assertAccountImportSupported(ctx, agent) await ctx.client.call('accounts.list', { refreshUsage: false }) await (agent === 'claude' ? addClaudeAccount(ctx) : addCodexAccount(ctx)) }, diff --git a/src/cli/handlers/terminal-originating-pane.test.ts b/src/cli/handlers/terminal-originating-pane.test.ts index c10e40875e4a..0d61c5096f98 100644 --- a/src/cli/handlers/terminal-originating-pane.test.ts +++ b/src/cli/handlers/terminal-originating-pane.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { terminalSwitchOriginHandler } from './terminal-originating-pane' import type { HandlerContext } from '../dispatch' -import { TERMINAL_ORIGINATING_PANE_NAVIGATION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { TERMINAL_ORIGINATING_PANE_NAVIGATION_RUNTIME_CAPABILITY } from '../../shared/terminal-navigation-runtime-capabilities' import { CORE_COMMAND_SPECS } from '../specs/core' import { parseArgs, validateCommandAndFlags } from '../args' diff --git a/src/cli/handlers/terminal-originating-pane.ts b/src/cli/handlers/terminal-originating-pane.ts index aab7eea7ed5f..8fd11049d327 100644 --- a/src/cli/handlers/terminal-originating-pane.ts +++ b/src/cli/handlers/terminal-originating-pane.ts @@ -2,7 +2,7 @@ import type { CommandHandler } from '../dispatch' import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' import { printResult } from '../format' import { RuntimeClientError } from '../runtime-client' -import { TERMINAL_ORIGINATING_PANE_NAVIGATION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { TERMINAL_ORIGINATING_PANE_NAVIGATION_RUNTIME_CAPABILITY } from '../../shared/terminal-navigation-runtime-capabilities' import { TerminalSwitchOrigin } from '../../shared/rpc-contract/terminal-originating-pane-params' export const terminalSwitchOriginHandler: CommandHandler = async ({ flags, client, json }) => { diff --git a/src/cli/handlers/terminal-question-answer.test.ts b/src/cli/handlers/terminal-question-answer.test.ts index 19372eda76f9..6d724b17c35a 100644 --- a/src/cli/handlers/terminal-question-answer.test.ts +++ b/src/cli/handlers/terminal-question-answer.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { terminalQuestionAnswerHandler } from './terminal-question-answer' import type { HandlerContext } from '../dispatch' -import { TERMINAL_QUESTION_ANSWER_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { TERMINAL_QUESTION_ANSWER_RUNTIME_CAPABILITY } from '../../shared/terminal-navigation-runtime-capabilities' vi.mock('../format', () => ({ printResult: vi.fn() })) diff --git a/src/cli/handlers/terminal-question-answer.ts b/src/cli/handlers/terminal-question-answer.ts index 370222b58db6..f5143d621c7b 100644 --- a/src/cli/handlers/terminal-question-answer.ts +++ b/src/cli/handlers/terminal-question-answer.ts @@ -2,7 +2,7 @@ import type { CommandHandler } from '../dispatch' import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' import { printResult } from '../format' import { RuntimeClientError } from '../runtime-client' -import { TERMINAL_QUESTION_ANSWER_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { TERMINAL_QUESTION_ANSWER_RUNTIME_CAPABILITY } from '../../shared/terminal-navigation-runtime-capabilities' export const terminalQuestionAnswerHandler: CommandHandler = async ({ flags, client, json }) => { const provider = getRequiredStringFlag(flags, 'provider') diff --git a/src/cli/handlers/terminal-session-navigation.ts b/src/cli/handlers/terminal-session-navigation.ts index 0fdf79b21153..ff8741413626 100644 --- a/src/cli/handlers/terminal-session-navigation.ts +++ b/src/cli/handlers/terminal-session-navigation.ts @@ -2,7 +2,7 @@ import type { CommandHandler } from '../dispatch' import { getRequiredStringFlag } from '../flags' import { printResult } from '../format' import { RuntimeClientError } from '../runtime-client' -import { TERMINAL_SESSION_NAVIGATION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { TERMINAL_SESSION_NAVIGATION_RUNTIME_CAPABILITY } from '../../shared/terminal-navigation-runtime-capabilities' function handler(method: string): CommandHandler { return async ({ flags, client, json }) => { diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 32559f305e11..5c814f91fc63 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -1,3 +1,4 @@ +import { SKILL_INSTALL_RESULT_V2_CAPABILITY } from '../../shared/skill-install-capability' import { createServer, type Server } from 'node:http' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -28,7 +29,6 @@ import { RUNTIME_PROTOCOL_VERSION, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, - SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index c8de61a9fdac..eb280f1900bf 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -138,6 +138,30 @@ if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then } fi unset __orca_codex_binary +__orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" +if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } +fi +unset __orca_claude_binary # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent # status when the foreground command exits — mirrors the zsh daemon wrapper. diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt index ff24125e2c00..42fb21032ecc 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt @@ -17,4 +17,34 @@ if test "$__orca_codex_type" = file command codex $argv end end -set -e __orca_codex_type \ No newline at end of file +set -e __orca_codex_type +set -l __orca_claude_type (type -t claude 2>/dev/null) +if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file + function claude + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + set -l pointer (string replace -r '^~/' "$HOME/" -- "$ORCA_CLAUDE_PROFILE_POINTER") + set -l profile (cat "$pointer" 2>/dev/null) + if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$ORCA_CLAUDE_INJECTED_CONFIG_DIR" + command claude $argv + return $status + end + if test -z "$profile"; and test -n "$CLAUDE_CONFIG_DIR"; and test -n "$ORCA_CLAUDE_USER_CONFIG_DIR" + env -u ORCA_CLAUDE_INJECTED_CONFIG_DIR CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR" claude $argv + return $status + end + if test -z "$profile" + env -u CLAUDE_CONFIG_DIR -u ORCA_CLAUDE_INJECTED_CONFIG_DIR claude $argv + return $status + end + if not string match -qr '^(/|[A-Za-z]:)' -- "$profile" + return 1 + end + test -d "$profile"; or mkdir -p -m 700 -- "$profile" 2>/dev/null + set -l headers + if string match -irq 'authorization|x-api-key|api-key|bearer' -- "$ANTHROPIC_CUSTOM_HEADERS" + set headers -u ANTHROPIC_CUSTOM_HEADERS + end + env $headers -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN -u CLAUDE_CODE_OAUTH_TOKEN -u AWS_BEARER_TOKEN_BEDROCK CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv + end +end +set -e __orca_claude_type diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 18850b4a0efc..22c221bb9434 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -220,6 +220,30 @@ __orca_deferred_init() { } fi unset __orca_codex_binary + __orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" + if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } + fi + unset __orca_claude_binary if [[ -n "${_orca_histfile:-}" ]]; then HISTFILE="$_orca_histfile" fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index 05f3da594e03..a43c7bf2c396 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -148,6 +148,30 @@ if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then } fi unset __orca_codex_binary +__orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" +if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } +fi +unset __orca_claude_binary # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent # status when the foreground command (e.g. an interrupted Claude/Codex CLI) diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt index ff24125e2c00..42fb21032ecc 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt @@ -17,4 +17,34 @@ if test "$__orca_codex_type" = file command codex $argv end end -set -e __orca_codex_type \ No newline at end of file +set -e __orca_codex_type +set -l __orca_claude_type (type -t claude 2>/dev/null) +if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file + function claude + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + set -l pointer (string replace -r '^~/' "$HOME/" -- "$ORCA_CLAUDE_PROFILE_POINTER") + set -l profile (cat "$pointer" 2>/dev/null) + if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$ORCA_CLAUDE_INJECTED_CONFIG_DIR" + command claude $argv + return $status + end + if test -z "$profile"; and test -n "$CLAUDE_CONFIG_DIR"; and test -n "$ORCA_CLAUDE_USER_CONFIG_DIR" + env -u ORCA_CLAUDE_INJECTED_CONFIG_DIR CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR" claude $argv + return $status + end + if test -z "$profile" + env -u CLAUDE_CONFIG_DIR -u ORCA_CLAUDE_INJECTED_CONFIG_DIR claude $argv + return $status + end + if not string match -qr '^(/|[A-Za-z]:)' -- "$profile" + return 1 + end + test -d "$profile"; or mkdir -p -m 700 -- "$profile" 2>/dev/null + set -l headers + if string match -irq 'authorization|x-api-key|api-key|bearer' -- "$ANTHROPIC_CUSTOM_HEADERS" + set headers -u ANTHROPIC_CUSTOM_HEADERS + end + env $headers -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN -u CLAUDE_CODE_OAUTH_TOKEN -u AWS_BEARER_TOKEN_BEDROCK CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv + end +end +set -e __orca_claude_type diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index b3c8a92869ca..e6e492c48bcf 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -227,6 +227,30 @@ __orca_deferred_init() { } fi unset __orca_codex_binary + __orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" + if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } + fi + unset __orca_claude_binary if [[ -n "${_orca_histfile:-}" ]]; then HISTFILE="$_orca_histfile" fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 21dce8480e63..1fcf476a1a0b 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -190,6 +190,30 @@ __orca_deferred_init() { } fi unset __orca_codex_binary + __orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" + if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } + fi + unset __orca_claude_binary if [[ -n "${_orca_histfile:-}" ]]; then HISTFILE="$_orca_histfile" fi diff --git a/src/main/acp/acp-account-binding.ts b/src/main/acp/acp-account-binding.ts new file mode 100644 index 000000000000..4adddaa48822 --- /dev/null +++ b/src/main/acp/acp-account-binding.ts @@ -0,0 +1,40 @@ +// How an ACP agent's chats pin their account: what a new chat records, and how a launch points the +// agent at the account its record pinned. Both run on the machine that runs the agent. + +import { + isLegacyAgentSessionAccountHome, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' +import { resolveStructuredEnvAccountHomePath } from '../runtime/structured-agent-account-home' + +type AccountPin = { accountHomeVariable: string } | { accountLocatorKind: 'opencode' } + +export type AcpAccountBinding = { + /** What the agent's definition says its records pin. */ + pin: AccountPin + /** The account a new chat pins: the current selection, read without side effects. */ + resolve(input: { launchEnv: NodeJS.ProcessEnv }): Promise + /** `env` pointed at the pinned account; throws for an account of another kind. */ + environment(home: AgentSessionAccountHome, env: Record): Record +} + +/** An agent whose account is one config directory named by `variable`, with a default under the + * user's home. */ +export function directoryAccountBinding( + variable: string, + defaultHome: (homePath: string) => string +): AcpAccountBinding { + return { + pin: { accountHomeVariable: variable }, + resolve: async ({ launchEnv }) => ({ + variable, + path: resolveStructuredEnvAccountHomePath({ launchEnv, variable, defaultPath: defaultHome }) + }), + environment: (home, env) => { + if (!isLegacyAgentSessionAccountHome(home) || home.variable !== variable) { + throw new Error(`sessions of this agent pin ${variable}`) + } + return { ...env, [variable]: home.path } + } + } +} diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 596ddcb4049a..1defc82f7644 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -41,6 +41,8 @@ export type AcpDialectNotification = /** Hooks interpret extensions; lifecycle and row identity stay shared. */ export type AcpDialect = { injectedPromptIdentity?: true + /** A tool update in the shared shape (`rawOutput.stdout`, `rawOutput.exitCode`), read first. */ + normalizeToolUpdate?(update: ToolCallUpdate): ToolCallUpdate toolName?(update: ToolCallUpdate): string | undefined toolBackgroundTasks?( update: ToolCallUpdate, diff --git a/src/main/acp/acp-dialects/omp-dialect.test.ts b/src/main/acp/acp-dialects/omp-dialect.test.ts new file mode 100644 index 000000000000..5c6b3cb059b7 --- /dev/null +++ b/src/main/acp/acp-dialects/omp-dialect.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import type { ToolCallUpdate } from '../generated/acp-protocol.generated' +import { OMP_ACP_DIALECT } from './omp-dialect' + +const text = (value: string) => ({ + type: 'content' as const, + content: { type: 'text' as const, text: value } +}) +const normalize = (update: ToolCallUpdate) => OMP_ACP_DIALECT.normalizeToolUpdate!(update) + +describe('OMP tool updates', () => { + it('leaves a tool without a command echo as it came', () => { + const update: ToolCallUpdate = { + toolCallId: 'call-1', + status: 'completed', + rawOutput: { content: [{ type: 'text', text: 'file body' }], details: {} }, + content: [text('file body')] + } + expect(normalize(update)).toBe(update) + }) + + it('keeps a result that itself starts with "$ "', () => { + const update: ToolCallUpdate = { + toolCallId: 'call-1', + status: 'completed', + rawOutput: { content: [{ type: 'text', text: '$ 5.00' }], details: {} }, + content: [text('$ 5.00')] + } + expect(normalize(update)).toBe(update) + }) + + it('keeps output that only looks like a notice when OMP sent no matching detail', () => { + const update = normalize({ + toolCallId: 'call-1', + status: 'completed', + rawOutput: { + content: [{ type: 'text', text: 'done\n\nCommand exited with code 3' }], + details: {} + }, + content: [text('$ ./run'), text('done\n\nCommand exited with code 3')] + }) + expect(update).toMatchObject({ + content: [], + rawOutput: { stdout: 'done\n\nCommand exited with code 3' } + }) + expect(update.rawOutput).not.toHaveProperty('exitCode') + }) +}) diff --git a/src/main/acp/acp-dialects/omp-dialect.ts b/src/main/acp/acp-dialects/omp-dialect.ts new file mode 100644 index 000000000000..cb7e321d8228 --- /dev/null +++ b/src/main/acp/acp-dialects/omp-dialect.ts @@ -0,0 +1,66 @@ +import { z } from 'zod' +import type { ToolCallContent, ToolCallUpdate } from '../generated/acp-protocol.generated' +import type { AcpDialect } from './acp-dialect' + +// OMP sends a tool's result as `rawOutput: {content: [{type: 'text', text}], details}`, with a +// command's non-zero exit at `details.exitCode`, and repeats the text as content behind a +// `$ ` echo. The shared reader takes content text first, so that echo became the output. +const textBlockSchema = z.looseObject({ type: z.literal('text'), text: z.string() }) +const toolResultSchema = z.looseObject({ + content: z.array(z.unknown()), + details: z + .looseObject({ + exitCode: z.number().int().safe().optional(), + wallTimeMs: z.number().optional() + }) + .optional() +}) + +function contentText(block: ToolCallContent | undefined): string | undefined { + return block?.type === 'content' && block.content.type === 'text' ? block.content.text : undefined +} + +/** OMP appends these notice lines to a command's output; the row shows exit and timing itself. */ +function commandOutput(text: string, exitCode: number | undefined, timed: boolean): string { + let output = text + const exitNotice = `\n\nCommand exited with code ${exitCode}` + if (exitCode !== undefined && output.endsWith(exitNotice)) { + output = output.slice(0, -exitNotice.length) + } + return timed ? output.replace(/\n\nWall time: \d+(?:\.\d+)? seconds$/u, '') : output +} + +function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { + const echo = contentText(update.content?.[0]) + if (!echo?.startsWith('$ ')) { + return update + } + const parsed = toolResultSchema.safeParse(update.rawOutput) + const texts = parsed.success + ? parsed.data.content.flatMap((block) => textBlockSchema.safeParse(block).data?.text ?? []) + : [] + if (texts.includes(echo)) { + return update + } + // The result's own text moves to `stdout`; content keeps anything else it carried. + const content = (update.content ?? []).slice(1).filter((block) => { + const text = contentText(block) + return text === undefined || !texts.includes(text) + }) + if (!parsed.success) { + return { ...update, content } + } + const exitCode = parsed.data.details?.exitCode + const stdout = commandOutput( + texts.join('\n'), + exitCode, + parsed.data.details?.wallTimeMs !== undefined + ) + return { + ...update, + content, + rawOutput: { ...parsed.data, stdout, ...(exitCode === undefined ? {} : { exitCode }) } + } +} + +export const OMP_ACP_DIALECT: AcpDialect = { normalizeToolUpdate } diff --git a/src/main/acp/acp-dialects/opencode-dialect.ts b/src/main/acp/acp-dialects/opencode-dialect.ts new file mode 100644 index 000000000000..98566568f160 --- /dev/null +++ b/src/main/acp/acp-dialects/opencode-dialect.ts @@ -0,0 +1,45 @@ +import { z } from 'zod' +import type { ToolCallUpdate } from '../generated/acp-protocol.generated' +import type { AcpDialect } from './acp-dialect' + +// OpenCode reports a command's output and exit code under `rawOutput.metadata`; the shared shape +// is `stdout` and `exitCode`. +const commandRawOutputSchema = z.looseObject({ + output: z.unknown().optional(), + metadata: z + .looseObject({ + exit: z.number().int().nullable().optional(), + output: z.string().optional() + }) + .optional() +}) + +function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { + const parsed = commandRawOutputSchema.safeParse(update.rawOutput) + if (!parsed.success) { + return update + } + const rawOutput = parsed.data + const output = + typeof rawOutput.output === 'string' ? rawOutput.output : rawOutput.metadata?.output + const hasSharedOutput = ['stdout', 'stderr', 'output_for_prompt'].some( + (key) => rawOutput[key] !== undefined + ) + const hasSharedExitCode = rawOutput.exitCode !== undefined || rawOutput.exit_code !== undefined + const exitCode = rawOutput.metadata?.exit ?? undefined + if ((output === undefined || hasSharedOutput) && (exitCode === undefined || hasSharedExitCode)) { + return update + } + return { + ...update, + rawOutput: { + ...rawOutput, + ...(output === undefined || hasSharedOutput ? {} : { stdout: output }), + ...(exitCode === undefined || hasSharedExitCode ? {} : { exitCode }) + } + } +} + +export const OPENCODE_ACP_DIALECT: AcpDialect = { + normalizeToolUpdate +} diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts index 3d74a9c1c7cc..858bac2d6703 100644 --- a/src/main/acp/acp-json-rpc-peer.test.ts +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -293,6 +293,22 @@ describe('ACP JSON-RPC peer', () => { output.destroy() }) + it('closes when a request times out mid-write, so a cancel never waits behind it', async () => { + vi.useFakeTimers() + const input = new PassThrough() + const output = new Writable({ write() {} }) + const peer = new AcpJsonRpcPeer(input, output, {}, { requestTimeoutMs: 20 }) + peers.push(peer) + const timedOut = expect(peer.request('stuck', {})).rejects.toBeInstanceOf( + AcpRequestTimeoutError + ) + await vi.advanceTimersByTimeAsync(20) + await timedOut + expect(peer.closed).toBe(true) + await expect(peer.notify('session/cancel', {})).rejects.toBeInstanceOf(AcpRequestTimeoutError) + input.destroy() + }) + it('fails pending requests on an output stream error', async () => { const { peer, agent } = fixture() const rejected = expect(peer.request('wait', {})).rejects.toThrow('Broken pipe') diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 2b705aa0dd7f..0cc9feae43e0 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -9,15 +9,27 @@ import { import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' import type { AcpDialect } from './acp-dialects/acp-dialect' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' +import { OMP_ACP_DIALECT } from './acp-dialects/omp-dialect' +import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect' +import { directoryAccountBinding, type AcpAccountBinding } from './acp-account-binding' +import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' +import { scrubOpenCodeAcpEnvironment } from '../opencode/opencode-acp-environment' +import { openCodeStoredUserMessagesReader } from '../opencode/opencode-acp-stored-messages' +import type { AcpStoredUserMessagesReader } from './acp-recovery-history' +import { isStableCliVersionFrom, isStableCliVersionOnLine } from '../agent-cli-version-probe' +import type { TuiAgent } from '../../shared/tui-agent' export type AcpLaunchSpec = { - /** The Orca agent id (a `TuiAgent`), which names the agent's records and its catalog label. */ - agent: string + /** The Orca agent id, which names the agent's records, its catalog label and its settings. */ + agent: TuiAgent command: string /** Built per launch: `fullAccess` is the Agent Permissions setting's bypass posture. */ args(input: { fullAccess: boolean }): string[] - /** Overlaid on the child's environment. */ + /** Laid over the child's environment last, after the account and the user's own variables. */ env: Readonly> + /** Rewrites what the child would inherit from Orca's own plumbing; returns the keys it must not + * inherit at all. `inherited` is the environment the child process starts from. */ + scrubEnvironment?(env: Record, inherited: NodeJS.ProcessEnv): string[] dialect: AcpDialect /** The agent's own sign-in command, for a person to run when it reports auth required. */ loginCommand: readonly string[] @@ -27,12 +39,17 @@ export type AcpLaunchSpec = { advertised: readonly string[] env: Readonly> }): string | undefined - /** Variable naming the agent's config directory, pinned as each record's account home. */ - accountHomeVariable: string - /** The config directory the agent uses when the variable is unset, under the user's home. */ - defaultAccountHome(homePath: string): string + /** The account each chat pins, and how a launch points the agent at it. */ + account: AcpAccountBinding + /** The `--version` releases a structured chat runs on, asked before a create and again at every + * launch; any other release keeps the terminal chat. Absent runs whatever is installed. */ + supportsVersion?(version: string): boolean /** Where the agent installs its own binary, searched after PATH. */ - installDirectories(accountHomePath: string): string[] + installDirectories(input: { env: Readonly>; homePath: string }): string[] + /** Images go to the agent when it also advertises them; off sends text prompts only. */ + imagePrompts?: true + /** The agent's own store of a session's user messages, read for restart recovery only. */ + readStoredUserMessages?: AcpStoredUserMessagesReader } const GROK_LAUNCH_SPEC: AcpLaunchSpec = { @@ -50,12 +67,63 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { : advertised.includes('cached_token') ? 'cached_token' : undefined, - accountHomeVariable: 'GROK_HOME', - defaultAccountHome: (homePath) => join(homePath, '.grok'), - installDirectories: (accountHomePath) => [join(accountHomePath, 'bin')] + account: directoryAccountBinding('GROK_HOME', (homePath) => join(homePath, '.grok')), + installDirectories: ({ env }) => (env.GROK_HOME ? [join(env.GROK_HOME, 'bin')] : []) } -export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [GROK_LAUNCH_SPEC] +// `opencode acp` on 1.x serves in-process; on 2.x it starts a private `opencode serve --stdio` child +// with this environment and ends it with stdin, so both lines run the chat's own account. +const OPENCODE_ACP_RELEASE_LINES = [ + // The release the recorded sessions capture. + { major: 1, floor: '1.18.31' }, + // The 2.x release verified to start that private child. + { major: 2, floor: '2.0.14' } +] as const + +const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { + agent: 'opencode', + command: 'opencode', + // OpenCode has no bypass flag: full access answers each permission request yes. + args: () => ['acp'], + // Applied last: the client name ACP sessions report, and no question tool, which ACP cannot + // answer (it would wait forever). + env: { OPENCODE_CLIENT: 'acp', OPENCODE_ENABLE_QUESTION_TOOL: 'false' }, + scrubEnvironment: scrubOpenCodeAcpEnvironment, + dialect: OPENCODE_ACP_DIALECT, + loginCommand: ['opencode', 'auth', 'login'], + account: openCodeAcpAccountBinding(), + installDirectories: ({ homePath }) => [join(homePath, '.opencode', 'bin')], + supportsVersion: (version) => + OPENCODE_ACP_RELEASE_LINES.some((line) => isStableCliVersionOnLine(version, line)), + imagePrompts: true, + readStoredUserMessages: openCodeStoredUserMessagesReader() +} + +// OMP serves ACP through `omp acp`; its environment reaches it as the user set it. +const OMP_LAUNCH_SPEC: AcpLaunchSpec = { + agent: 'omp', + command: 'omp', + // `omp acp` takes no flags: full access answers each permission request yes. + args: () => ['acp'], + env: {}, + dialect: OMP_ACP_DIALECT, + // OMP signs in from its own `/login`. + loginCommand: ['omp'], + // The directory OMP's terminal chats read too; its default is OMP's own. + account: directoryAccountBinding('PI_CODING_AGENT_DIR', (homePath) => + join(homePath, '.omp', 'agent') + ), + // OMP's installers use directories the shared resolver already searches after PATH. + installDirectories: () => [], + // Stable releases from 17.0.5, the release verified to serve `omp acp`. + supportsVersion: (version) => isStableCliVersionFrom(version, '17.0.5') +} + +export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [ + GROK_LAUNCH_SPEC, + OPENCODE_LAUNCH_SPEC, + OMP_LAUNCH_SPEC +] export function acpLaunchSpecFor(agent: string): AcpLaunchSpec | null { return ACP_LAUNCH_SPECS.find((spec) => spec.agent === agent) ?? null diff --git a/src/main/acp/acp-prompt-content.test.ts b/src/main/acp/acp-prompt-content.test.ts new file mode 100644 index 000000000000..63b7363ca5f9 --- /dev/null +++ b/src/main/acp/acp-prompt-content.test.ts @@ -0,0 +1,109 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import { + ACP_PROMPT_IMAGE_MAX_BYTES, + AcpPromptContentError, + acpPromptBlocks +} from './acp-prompt-content' + +const PNG = Buffer.from('89504e470d0a1a0a', 'hex') +const dir = mkdtempSync(join(tmpdir(), 'acp-prompt-content-')) +const pngPath = join(dir, 'shot.png') +writeFileSync(pngPath, PNG) + +function message(...blocks: NativeChatBlock[]): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks } +} + +async function refusal(body: AgentJournalMessageItem, images = true) { + const error = await acpPromptBlocks(body, images).catch((caught: unknown) => caught) + if (!(error instanceof AcpPromptContentError)) { + throw new Error(`expected a refusal, got ${String(error)}`) + } + return error.failure +} + +describe('ACP prompt content', () => { + it('sends text as text blocks', async () => { + await expect(acpPromptBlocks(message({ type: 'text', text: 'hi' }), false)).resolves.toEqual([ + { type: 'text', text: 'hi' } + ]) + }) + + it('sends a file or data image as an ACP image block, in order', async () => { + const data = PNG.toString('base64') + await expect( + acpPromptBlocks( + message( + { type: 'image-ref', path: pngPath }, + { type: 'text', text: 'what is this?' }, + { type: 'image-ref', url: pathToFileURL(pngPath).href }, + { type: 'image-ref', url: `data:image/png;base64,${data}` } + ), + true + ) + ).resolves.toEqual([ + { type: 'image', mimeType: 'image/png', data }, + { type: 'text', text: 'what is this?' }, + { type: 'image', mimeType: 'image/png', data }, + { type: 'image', mimeType: 'image/png', data } + ]) + }) + + it('refuses an image when the agent takes none', async () => { + expect(await refusal(message({ type: 'image-ref', path: pngPath }), false)).toMatchObject({ + kind: 'attachmentInvalid', + attachment: { reason: 'unsupportedType' } + }) + }) + + it('refuses what it cannot read or send before anything leaves Orca', async () => { + expect(await refusal(message({ type: 'image-ref', path: 'relative.png' }))).toMatchObject({ + attachment: { reason: 'noSource' } + }) + expect(await refusal(message({ type: 'image-ref', path: join(dir, 'a.bmp') }))).toMatchObject({ + attachment: { reason: 'unsupportedType' } + }) + expect(await refusal(message({ type: 'image-ref', path: `${dir}.png` }))).toMatchObject({ + kind: 'attachmentUnreadable' + }) + expect( + await refusal(message({ type: 'image-ref', url: 'data:image/svg+xml;base64,PHN2Zz4=' })) + ).toMatchObject({ attachment: { reason: 'unsupportedType' } }) + }) + + it('keeps a message with images within one protocol line', async () => { + // The images fit their raw limit, but with this much text the prompt would pass the line. + const text = 'x'.repeat(8 * 1024 * 1024) + const image = { + type: 'image-ref' as const, + url: `data:image/png;base64,${PNG.toString('base64')}` + } + expect( + await refusal(message({ type: 'text', text }, { type: 'text', text }, image)) + ).toMatchObject({ + attachment: { reason: 'totalTooLarge' } + }) + expect(ACP_PROMPT_IMAGE_MAX_BYTES * (4 / 3)).toBeLessThan(16 * 1024 * 1024) + }) + + it('leaves a text-only message to the agent, however long', async () => { + const text = 'x'.repeat(9 * 1024 * 1024) + await expect( + acpPromptBlocks(message({ type: 'text', text }, { type: 'text', text }), true) + ).resolves.toHaveLength(2) + }) + + it("bounds one message's images together", async () => { + const big = Buffer.alloc(ACP_PROMPT_IMAGE_MAX_BYTES / 2 + 1).toString('base64') + const image = { type: 'image-ref' as const, url: `data:image/png;base64,${big}` } + expect(await refusal(message(image, image))).toMatchObject({ + attachment: { reason: 'totalTooLarge', limit: ACP_PROMPT_IMAGE_MAX_BYTES } + }) + }) +}) diff --git a/src/main/acp/acp-prompt-content.ts b/src/main/acp/acp-prompt-content.ts new file mode 100644 index 000000000000..2d1c6e336b1d --- /dev/null +++ b/src/main/acp/acp-prompt-content.ts @@ -0,0 +1,161 @@ +// A person's message as an ACP prompt. Images go as the protocol's image blocks, and only to an +// agent that advertised them; each is checked and read before anything reaches the agent. + +import { extname, isAbsolute } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + agentSessionFailureFact, + type AgentSessionAttachmentProblem, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { IMAGE_FILE_MIME_TYPES } from '../../shared/image-file-extensions' +import type { NativeChatImageRefBlock } from '../../shared/native-chat-types' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../../shared/node-bounded-file-reader' +import type { AcpLaunchSpec } from './acp-launch-specs' +import { resolveAcpPeerOptions } from './acp-peer-limits' +import { acpAgentName } from './acp-structured-acquire' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { ContentBlock } from './generated/acp-protocol.generated' + +const ACP_IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) +/** A prompt is one JSON-RPC line; this leaves room for the request around its blocks. */ +const ACP_PROMPT_LINE_BUDGET = resolveAcpPeerOptions().maxLineBytes - 64 * 1024 +/** One message's images together, raw: base64 makes each 4 bytes of them 3 more. */ +export const ACP_PROMPT_IMAGE_MAX_BYTES = Math.floor((ACP_PROMPT_LINE_BUDGET * 3) / 4) - 1024 * 1024 +const MAX_IMAGE_COUNT = 20 +const READ_TIMEOUT_MS = 15_000 + +/** Why Orca did not send a message; never the agent's refusal. */ +export class AcpPromptContentError extends Error { + constructor(readonly failure: SubmissionRejectionFact) { + super(`ACP prompt refused: ${failure.kind}`) + this.name = 'AcpPromptContentError' + } +} + +function attachmentProblem(attachment: AgentSessionAttachmentProblem): AcpPromptContentError { + return new AcpPromptContentError(agentSessionFailureFact('attachmentInvalid', { attachment })) +} + +async function imageBlock( + block: NativeChatImageRefBlock, + remainingBytes: number, + signal: AbortSignal +): Promise<{ block: ContentBlock; bytes: number }> { + const tooLarge = (): AcpPromptContentError => + attachmentProblem({ + reason: remainingBytes < ACP_PROMPT_IMAGE_MAX_BYTES ? 'totalTooLarge' : 'tooLarge', + limit: ACP_PROMPT_IMAGE_MAX_BYTES + }) + if (block.url?.startsWith('data:')) { + const match = /^data:(image\/[a-z]+);base64,([A-Za-z0-9+/]*={0,2})$/.exec(block.url) + if (!match || !ACP_IMAGE_MIME_TYPES.has(match[1]) || match[2].length % 4 !== 0) { + throw attachmentProblem({ reason: 'unsupportedType' }) + } + const bytes = Buffer.byteLength(match[2], 'base64') + if (bytes === 0) { + throw attachmentProblem({ reason: 'empty' }) + } + if (bytes > remainingBytes) { + throw tooLarge() + } + return { block: { type: 'image', mimeType: match[1], data: match[2] }, bytes } + } + let path = block.path + if (!path && block.url?.startsWith('file:')) { + try { + path = fileURLToPath(block.url) + } catch { + throw attachmentProblem({ reason: 'noSource' }) + } + } + if (!path || !isAbsolute(path)) { + throw attachmentProblem({ reason: 'noSource' }) + } + const mimeType = IMAGE_FILE_MIME_TYPES[extname(path).toLowerCase()] + if (!mimeType || !ACP_IMAGE_MIME_TYPES.has(mimeType)) { + throw attachmentProblem({ reason: 'unsupportedType' }) + } + let data: Buffer + try { + data = (await readNodeFileWithinLimit(path, remainingBytes, { regularFileOnly: true, signal })) + .buffer + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw tooLarge() + } + if (error instanceof Error && error.message === 'Expected a regular file') { + throw attachmentProblem({ reason: 'notAFile' }) + } + throw new AcpPromptContentError(agentSessionFailureFact('attachmentUnreadable')) + } + if (data.byteLength === 0) { + throw attachmentProblem({ reason: 'empty' }) + } + return { block: { type: 'image', mimeType, data: data.toString('base64') }, bytes: data.length } +} + +/** The prompt for `body`; throws `AcpPromptContentError` for what Orca will not send. */ +export async function acpPromptBlocks( + body: AgentJournalMessageItem, + acceptsImages: boolean +): Promise { + const blocks: ContentBlock[] = [] + const signal = AbortSignal.timeout(READ_TIMEOUT_MS) + let imageCount = 0 + let imageBytes = 0 + for (const block of body.blocks) { + if (block.type === 'text') { + blocks.push({ type: 'text', text: block.text }) + continue + } + if (block.type !== 'image-ref' || !acceptsImages) { + throw attachmentProblem({ reason: 'unsupportedType' }) + } + imageCount += 1 + if (imageCount > MAX_IMAGE_COUNT) { + throw attachmentProblem({ reason: 'tooMany', limit: MAX_IMAGE_COUNT }) + } + const image = await imageBlock(block, ACP_PROMPT_IMAGE_MAX_BYTES - imageBytes, signal) + imageBytes += image.bytes + blocks.push(image.block) + } + // Only images can carry a message past one line here; their words are the refusal's. + if (imageCount > 0 && Buffer.byteLength(JSON.stringify(blocks)) > ACP_PROMPT_LINE_BUDGET) { + throw attachmentProblem({ reason: 'totalTooLarge', limit: ACP_PROMPT_IMAGE_MAX_BYTES }) + } + return blocks +} + +/** The prompt for `body` to `session`'s agent, or Orca's refusal of it in words that name the + * agent. Images go only where the agent's row offers them and its start advertised them. */ +export async function acpDispatchPrompt( + body: AgentJournalMessageItem, + session: { + spec: Pick + connection: Pick + } +): Promise { + try { + const advertised = + session.spec.imagePrompts === true && + (await session.connection.initialize()).agentCapabilities?.promptCapabilities?.image === true + return await acpPromptBlocks(body, advertised) + } catch (error) { + if (error instanceof AcpPromptContentError) { + return agentSessionFailureWords(error.failure, { + surface: 'rejection', + agentName: acpAgentName(session.spec.agent) + }) + } + throw error + } +} diff --git a/src/main/acp/acp-recovery-history.test.ts b/src/main/acp/acp-recovery-history.test.ts new file mode 100644 index 000000000000..402b9e2f76d1 --- /dev/null +++ b/src/main/acp/acp-recovery-history.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' +import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' +import { reconcileSubmissions } from '../native-chat/agent-session-journal/journal-submission-reconciler' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { readAcpRecoveryHistory, type AcpStoredUserMessage } from './acp-recovery-history' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' + +const SESSION = 'session-alpha-1' +const identity = { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'opencode', + providerHandle: null +} + +function fingerprint(text: string): string { + return computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } + }) +} + +function submission( + clientMessageId: string, + text: string, + dispatchState: AgentJournalSubmission['dispatchState'], + submittedAt: number, + resolvedAt: number | null = null +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: fingerprint(text), + dispatchState, + providerItemId: null, + reason: null, + submittedAt, + resolvedAt + } +} + +function journal(...submissions: AgentJournalSubmission[]): JournalLoad { + const state = createJournalReducerState(SESSION, 'epoch-1') + for (const entry of submissions) { + state.submissions.set(entry.clientMessageId, entry) + } + return { state, newer: null, damage: null } +} + +const launch: AcpStructuredLaunch = { + spec: acpLaunchSpecFor('opencode')!, + command: '/resolved/opencode', + args: ['acp'], + cwd: '/repo', + env: { XDG_DATA_HOME: '/data' }, + envToDelete: [], + fullAccess: false, + resume: { + sessionId: 'ses_1', + key: 'acp:opencode:ses_1', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } +} + +function stored(id: string, text: string, createdAt: number): AcpStoredUserMessage { + return { id, blocks: [{ type: 'text', text }], createdAt } +} + +async function windowFor(load: JournalLoad | null, messages: AcpStoredUserMessage[] | null) { + const readStoredUserMessages = vi.fn(async () => messages) + const window = await readAcpRecoveryHistory( + { + spec: { readStoredUserMessages }, + resolveLaunch: async () => launch, + readJournal: () => load + }, + identity + ) + return { window, readStoredUserMessages } +} + +describe('ACP restart recovery from the agent store', () => { + it('settles a message the agent recorded after the crash as sent, under its own row', async () => { + const load = journal( + submission('first', 'hello', 'accepted', 100, 110), + submission('held', 'run the tests', 'unknown', 200) + ) + const { window, readStoredUserMessages } = await windowFor(load, [ + stored('msg_1', 'hello', 105), + stored('msg_2', 'run the tests', 205) + ]) + expect(readStoredUserMessages).toHaveBeenCalledWith( + expect.objectContaining({ env: launch.env, providerSessionId: 'ses_1' }) + ) + expect(window).toMatchObject({ boundaryConsistent: false, turnInFlight: true }) + expect( + reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! }) + ).toEqual([ + { + clientMessageId: 'held', + outcome: 'accepted', + providerItemId: 'msg_2', + identity: { provider: 'orca', clientMessageId: 'held' } + } + ]) + }) + + it('never offers an older identical message as the one in doubt', async () => { + const load = journal( + submission('first', 'continue', 'accepted', 100, 110), + submission('held', 'continue', 'unknown', 200) + ) + const { window } = await windowFor(load, [stored('msg_1', 'continue', 105)]) + expect(window?.items).toEqual([]) + expect( + reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! }) + ).toMatchObject([{ clientMessageId: 'held', outcome: 'unknown' }]) + }) + + it('lets an accepted send claim its own copy even when the agent stored it after the next send', async () => { + // Accepted at dispatch (r=500) and stored at 505; the identical send behind it was lost. + const load = journal( + submission('first', 'continue', 'accepted', 100, 500), + submission('held', 'continue', 'unknown', 110) + ) + const { window } = await windowFor(load, [stored('msg_1', 'continue', 505)]) + expect(window?.items).toEqual([]) + }) + + it('keeps an older unconfirmed send from widening the window over a delivered copy', async () => { + const load = journal( + submission('old', 'yes', 'unknown', 50), + submission('first', 'yes', 'accepted', 300, 300), + submission('held', 'yes', 'unknown', 400) + ) + const { window } = await windowFor(load, [stored('msg_1', 'yes', 303)]) + expect(window?.items).toEqual([]) + }) + + it('confirms the doubted send when both identical copies are stored', async () => { + const load = journal( + submission('first', 'yes', 'accepted', 300, 300), + submission('held', 'yes', 'unknown', 400) + ) + const { window } = await windowFor(load, [ + stored('msg_1', 'yes', 303), + stored('msg_2', 'yes', 405) + ]) + expect( + reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! }) + ).toMatchObject([{ clientMessageId: 'held', outcome: 'accepted', providerItemId: 'msg_2' }]) + }) + + it('reads nothing for a queued card the agent was never handed', async () => { + const queued = { ...submission('queued', 'x', 'pending', 1), handoverRecorded: true as const } + const { window, readStoredUserMessages } = await windowFor(journal(queued), []) + expect(window).toBeNull() + expect(readStoredUserMessages).not.toHaveBeenCalled() + }) + + it('reads nothing with no message in doubt, a damaged journal, or no resumable session', async () => { + expect((await windowFor(journal(submission('a', 'x', 'accepted', 1, 2)), [])).window).toBeNull() + const damaged = journal(submission('held', 'x', 'unknown', 1)) + expect( + (await windowFor({ ...damaged, damage: { sequence: 3, cause: 'sequence-gap' } }, [])).window + ).toBeNull() + const readStoredUserMessages = vi.fn(async () => []) + await expect( + readAcpRecoveryHistory( + { + spec: { readStoredUserMessages }, + resolveLaunch: async () => ({ ...launch, resume: null }), + readJournal: () => journal(submission('held', 'x', 'unknown', 1)) + }, + identity + ) + ).resolves.toBeNull() + expect(readStoredUserMessages).not.toHaveBeenCalled() + }) + + it('reads nothing for an agent whose store Orca does not read', async () => { + await expect( + readAcpRecoveryHistory( + { + spec: {}, + resolveLaunch: async () => launch, + readJournal: () => journal(submission('held', 'x', 'unknown', 1)) + }, + identity + ) + ).resolves.toBeNull() + }) + + it('answers null, never throws, when the store cannot be read', async () => { + const warn = vi.fn() + await expect( + readAcpRecoveryHistory( + { + spec: { + readStoredUserMessages: async () => { + throw new Error('database is locked') + } + }, + resolveLaunch: async () => launch, + readJournal: () => journal(submission('held', 'x', 'unknown', 1)), + logger: { warn, error: vi.fn() } + }, + identity + ) + ).resolves.toBeNull() + expect(warn).toHaveBeenCalled() + }) +}) diff --git a/src/main/acp/acp-recovery-history.ts b/src/main/acp/acp-recovery-history.ts new file mode 100644 index 000000000000..d00a21a6d863 --- /dev/null +++ b/src/main/acp/acp-recovery-history.ts @@ -0,0 +1,145 @@ +// Restart recovery for an ACP agent that keeps its own store: whether a message a crash left +// unconfirmed reached the agent. Presence only: a match settles it as sent, while absence proves +// nothing, so nothing is ever called undelivered. Bounded, and null on any failure; the user can +// always resend or discard, so this never stands between them and the chat. + +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { agentSessionSendBodyFingerprint } from '../../shared/structured-agent-session-send-mutation' +import type { + AgentJournalSubmission, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' +import type { + ProviderHistoryItem, + ProviderHistoryWindow +} from '../native-chat/agent-session-journal/journal-submission-reconciler' +import { isQueuedAgentJournalSubmission } from '../../shared/agent-session-queued-submission' +import type { AcpLaunchSpec } from './acp-launch-specs' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +const RECOVERY_READ_MS = 5_000 + +/** A person's message as the agent's own store holds it. */ +export type AcpStoredUserMessage = { + /** The store's id for it, claimed at most once. */ + id: string + blocks: NativeChatBlock[] + /** Epoch ms the agent recorded it, on this machine's clock. */ + createdAt: number +} + +/** Reads the user messages of one provider session from the agent's own store; null when it cannot. */ +export type AcpStoredUserMessagesReader = (input: { + env: Record + providerSessionId: string + signal: AbortSignal +}) => Promise + +/** Sends a crash may have left unconfirmed; a queued card was never handed over, so the agent + * cannot hold it. */ +function sendsInDoubt(load: JournalLoad): AgentJournalSubmission[] { + return [...load.state.submissions.values()].filter( + (submission) => + (submission.dispatchState === 'pending' || submission.dispatchState === 'unknown') && + !isQueuedAgentJournalSubmission(submission) + ) +} + +function fingerprintOf(sessionId: string, message: AcpStoredUserMessage): string { + return agentSessionSendBodyFingerprint(sessionId, { + kind: 'message', + role: 'user', + blocks: message.blocks + }) +} + +/** + * The agent's store keeps no id of Orca's, so a stored message can only match a send by content. + * Each send the journal already accepted first claims the earliest stored copy of its text written + * no earlier than it was sent, so an identical message that did reach the agent is never offered as + * the one in doubt; what is left, from the first send in doubt on, is the window. Claiming more than + * a send's own copy only leaves a send in doubt unconfirmed, never wrongly confirmed. + */ +function recoveryWindow( + load: JournalLoad, + inDoubt: readonly AgentJournalSubmission[], + messages: readonly AcpStoredUserMessage[] +): ProviderHistoryItem[] { + const submissions = [...load.state.submissions.values()] + const from = Math.min(...inDoubt.map((submission) => submission.submittedAt)) + const stored = messages + .map((message) => ({ message, fingerprint: fingerprintOf(load.state.sessionId, message) })) + .toSorted((left, right) => left.message.createdAt - right.message.createdAt) + const claimed = new Set() + const accepted = submissions + .filter((submission) => submission.dispatchState === 'accepted') + .toSorted((left, right) => left.submittedAt - right.submittedAt) + for (const submission of accepted) { + const index = stored.findIndex( + ({ message, fingerprint }, at) => + !claimed.has(at) && + fingerprint === submission.payloadFingerprint && + message.createdAt >= submission.submittedAt + ) + if (index !== -1) { + claimed.add(index) + } + } + return stored.flatMap(({ message, fingerprint }, at) => + claimed.has(at) || message.createdAt < from + ? [] + : [{ providerItemId: message.id, clientMessageId: null, payloadFingerprint: fingerprint }] + ) +} + +/** The window for one chat; null for an agent whose store Orca does not read. */ +export async function readAcpRecoveryHistory( + input: Pick & { + spec: Pick + }, + identity: AgentSessionJournalIdentity +): Promise { + const { readStoredUserMessages } = input.spec + if (!readStoredUserMessages || !input.readJournal) { + return null + } + const sessionId = identity.sessionId + const signal = AbortSignal.timeout(RECOVERY_READ_MS) + try { + const load = input.readJournal(sessionId) + const inDoubt = load && !load.damage && !load.newer ? sendsInDoubt(load) : [] + if (!load || inDoubt.length === 0) { + return null + } + const launch = await waitForPromiseWithSignal(input.resolveLaunch({ identity }), signal) + if (!launch.resume) { + return null + } + const messages = await waitForPromiseWithSignal( + readStoredUserMessages({ + env: launch.env, + providerSessionId: launch.resume.sessionId, + signal + }), + signal + ) + if (!messages) { + return null + } + return { + // Absence proves nothing: neither the start of the read nor the end of the agent's work is known. + boundaryConsistent: false, + turnInFlight: true, + items: recoveryWindow(load, inDoubt, messages) + } + } catch (error) { + input.logger?.warn('ACP recovery history could not be read', { + scope: 'acp-recovery-history', + sessionId, + error + }) + return null + } +} diff --git a/src/main/acp/acp-stdio-error-boundary.ts b/src/main/acp/acp-stdio-error-boundary.ts index 67854b6f95a2..6dd0fe81b55b 100644 --- a/src/main/acp/acp-stdio-error-boundary.ts +++ b/src/main/acp/acp-stdio-error-boundary.ts @@ -1,16 +1 @@ -import type { Readable, Writable } from 'node:stream' - -function ignoreLateError(): void {} - -export function detachAcpStreamErrorHandler( - stream: Readable | Writable, - handler: (error: Error) => void -): void { - stream.removeListener('error', handler) - if (stream.closed) { - return - } - // Node may emit the write error after its callback has already closed the peer. - stream.on('error', ignoreLateError) - stream.once('close', () => stream.removeListener('error', ignoreLateError)) -} +export { detachProviderStreamErrorHandler as detachAcpStreamErrorHandler } from '../provider-process/provider-stdio-error-boundary' diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index cf7466f27d8f..b9c8b41d209f 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -124,7 +124,7 @@ export async function acquireAcpStructuredSession(input: { ...structuredSessionChildIdentityEnv(sessionId, launch.env), [PROVIDER_SPAWN_TOKEN_ENV]: acquire.spawnToken }, - envToDelete: ACP_CHILD_ENV_TO_DELETE + envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete] }, { clientInfo: { name: 'orca', version: '1' }, diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index e39ade6c7b97..a81fb2c8198e 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -11,7 +11,7 @@ import { SESSION, type ProviderTimelineRig } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { acpLaunchSpecFor } from './acp-launch-specs' +import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' import { AcpScriptedAgent, tick, type FakeFrame } from './acp-scripted-agent.test-support' import type { AcpAgentConnectionOptions } from './acp-agent-connection' import { AcpConnectionClosedError } from './acp-errors' @@ -156,6 +156,8 @@ export type AcpAdapterRig = { export async function openAcpAdapterRig( options: { + /** The agent's row; Grok's unless a test drives another agent. */ + spec?: AcpLaunchSpec launch?: Partial initialize?: Record script?: (agent: AcpScriptedAgent) => void @@ -167,14 +169,16 @@ export async function openAcpAdapterRig( const spawned: string[] = [] const lifecycle: StructuredAgentSessionLifecycleEvent[] = [] const settled: AcpAdapterRig['settled'] = [] + const spec = options.spec ?? GROK const adapter = new AcpStructuredSessionAdapter({ - spec: GROK, + spec, resolveLaunch: async () => ({ - spec: GROK, - command: '/opt/grok/bin/grok', - args: GROK.args({ fullAccess: false }), + spec, + command: `/opt/bin/${spec.command}`, + args: spec.args({ fullAccess: false }), cwd: '/workspace/project', env: { PATH: '/usr/bin', ORCA_PANE_KEY: 'tab-1:pane-1', ORCA_AGENT_HOOK_PORT: '1234' }, + envToDelete: [], fullAccess: false, resume: null, ...options.launch @@ -226,7 +230,7 @@ export async function openAcpAdapterRig( sessionId: SESSION, workspaceId: 'workspace-1', hostId: 'local', - agent: 'grok', + agent: spec.agent, providerHandle: null }, fence: acquireOptions.fence ?? 1, diff --git a/src/main/acp/acp-structured-agent-definitions.ts b/src/main/acp/acp-structured-agent-definitions.ts index d51816db450e..c82d5483ad88 100644 --- a/src/main/acp/acp-structured-agent-definitions.ts +++ b/src/main/acp/acp-structured-agent-definitions.ts @@ -16,15 +16,15 @@ export function acpStructuredAgentDefinition(spec: AcpLaunchSpec): StructuredAge return { agent: spec.agent, handleTransport: ACP_HANDLE_TRANSPORT, - accountHomeVariable: spec.accountHomeVariable, + ...spec.account.pin, capabilities: { // ACP has no stable rewind, compact or goal method; a command arrives through `/` instead. rewind: false, compact: false, threadGoal: false, contextUsage: true, - // Off: Orca sends text prompts only until ACP image prompts have a path. - imagePrompts: false, + // An agent that does not also advertise images at its start has a message with one refused. + imagePrompts: spec.imagePrompts === true, steering: 'queue', // Orca answers every permission request the agent sends; the agent decides when it asks. approvalEnforcement: 'orca' diff --git a/src/main/acp/acp-structured-electron-free.test.ts b/src/main/acp/acp-structured-electron-free.test.ts index 0a63542a1997..dfc3ceb45eb4 100644 --- a/src/main/acp/acp-structured-electron-free.test.ts +++ b/src/main/acp/acp-structured-electron-free.test.ts @@ -6,12 +6,13 @@ import { build } from 'esbuild' import { describe, expect, it } from 'vitest' describe('ACP structured lane', () => { - it('reaches no Electron import from the adapter or its launch resolution', async () => { + it('reaches no Electron import from the adapter, its launch resolution or any agent row', async () => { const result = await build({ entryPoints: [ resolve(__dirname, 'acp-structured-session-adapter.ts'), resolve(__dirname, 'acp-structured-launch-resolution.ts'), - resolve(__dirname, 'acp-agent-connection.ts') + resolve(__dirname, 'acp-agent-connection.ts'), + resolve(__dirname, 'acp-launch-specs.ts') ], bundle: true, write: false, diff --git a/src/main/acp/acp-structured-fixture-replay.test-support.ts b/src/main/acp/acp-structured-fixture-replay.test-support.ts index c4cf1605c797..51555fe9a73b 100644 --- a/src/main/acp/acp-structured-fixture-replay.test-support.ts +++ b/src/main/acp/acp-structured-fixture-replay.test-support.ts @@ -16,7 +16,10 @@ const messageSchema = z.object({ id: z.union([z.string(), z.number()]).optional(), method: z.string().optional(), params: z.unknown().optional(), - result: z.unknown().optional() + result: z.unknown().optional(), + error: z + .object({ code: z.number(), message: z.string(), data: z.unknown().optional() }) + .optional() }) function promptIdOf(params: unknown): string | undefined { @@ -83,13 +86,13 @@ export class GrokFixtureReplay { agent.send({ jsonrpc: '2.0', id: this.requestIds.get(message.id ?? '') ?? message.id, - result: message.result + ...(message.error === undefined ? { result: message.result } : { error: message.error }) }) } } } - private rewrite(message: AcpFixtureFrame['message']): AcpFixtureFrame['message'] { + private rewrite(message: AcpFixtureFrame['message']): z.infer { let text = JSON.stringify(message).replaceAll( RECORDED_SESSION, JSON.stringify(PROVIDER_SESSION) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts index afc1c1c42401..cc6dc7244913 100644 --- a/src/main/acp/acp-structured-host.test-support.ts +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -61,6 +61,7 @@ export function launch(resume: () => boolean): () => Promise { + it('sends an image to an agent whose row offers images and whose start advertised them', async () => { + const rig = await openAcpAdapterRig({ + spec: acpLaunchSpecFor('opencode')!, + initialize: { agentCapabilities: { loadSession: true, promptCapabilities: { image: true } } } + }) + await rig.acquire() + await expect( + rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'm1', body: withImage, fence: 1 }) + ).resolves.toEqual({ state: 'admitted' }) + expect((await rig.frame('session/prompt')).params).toMatchObject({ + prompt: [ + { type: 'image', mimeType: 'image/png', data: DATA }, + { type: 'text', text: 'what is this?' } + ] + }) + }) + + it('refuses the message, sending nothing, when the agent did not advertise images', async () => { + const rig = await openAcpAdapterRig({ spec: acpLaunchSpecFor('opencode')! }) + await rig.acquire() + await expect( + rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'm1', body: withImage, fence: 1 }) + ).resolves.toMatchObject({ state: 'rejected', rejection: { kind: 'attachmentInvalid' } }) + expect(rig.sent('session/prompt')).toEqual([]) + }) + + it('refuses an image for an agent whose row offers none, whatever it advertises', async () => { + const rig = await openAcpAdapterRig({ + initialize: { agentCapabilities: { loadSession: true, promptCapabilities: { image: true } } } + }) + await rig.acquire() + await expect( + rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'm1', body: withImage, fence: 1 }) + ).resolves.toMatchObject({ state: 'rejected' }) + expect(rig.sent('session/prompt')).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-structured-launch-resolution-omp.test.ts b/src/main/acp/acp-structured-launch-resolution-omp.test.ts new file mode 100644 index 000000000000..8e34dee2e9d7 --- /dev/null +++ b/src/main/acp/acp-structured-launch-resolution-omp.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' +import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs' +import { + createAcpStructuredLaunchResolver, + type AcpStructuredLaunchResolverDeps +} from './acp-structured-launch-resolution' + +const OMP = acpLaunchSpecFor('omp')! +const identity = { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'omp', + providerHandle: null +} + +function ompRecord( + accountHome: AgentSessionRecord['accountHome'] = { + variable: 'PI_CODING_AGENT_DIR', + path: '/home/user/.omp/agent' + } +): AgentSessionRecord { + return { ...agentSessionRecordFixture(), provider: 'omp', providerHandleChain: [], accountHome } +} + +function resolver( + record: AgentSessionRecord, + options: { + launchEnv?: Record + fullAccess?: boolean + probeVersion?: AcpStructuredLaunchResolverDeps['probeVersion'] + } = {} +) { + return createAcpStructuredLaunchResolver(OMP, { + store: { getRecord: () => record }, + readJournal: () => null, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + resolveLaunchEnv: () => options.launchEnv ?? {}, + resolveFullAccess: () => options.fullAccess ?? false, + resolveCommand: (command) => `/resolved/${command}`, + inheritedEnv: {}, + probeVersion: options.probeVersion ?? (async (_input, supports) => supports('17.0.5')) + }) +} + +describe('OMP ACP launch resolution', () => { + it('runs `omp acp` pointed at the agent directory the chat pinned, with or without full access', async () => { + for (const fullAccess of [false, true]) { + const launch = await resolver(ompRecord(), { fullAccess })({ identity }) + expect(launch).toMatchObject({ command: '/resolved/omp', args: ['acp'], fullAccess }) + expect(launch.env.PI_CODING_AGENT_DIR).toBe('/home/user/.omp/agent') + } + }) + + it("passes the user's own OMP environment through unchanged", async () => { + const userEnv = { + OMP_PROFILE: 'work', + PI_CONFIG_DIR: 'relative/config', + PI_SMOL_MODEL: 'fast', + ANTHROPIC_API_KEY: 'sk-test' + } + const launch = await resolver(ompRecord(), { launchEnv: userEnv })({ identity }) + expect(launch.envToDelete).toEqual([]) + const child = createProviderSpawnSpec( + { + command: launch.command, + args: launch.args, + env: launch.env, + envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete] + }, + { ORCA_PANE_KEY: 'tab-1:pane-1' }, + 'darwin' + ).env + expect(child).toMatchObject(userEnv) + // A pane's identity would let OMP's Orca status extension report for this chat too. + expect(child.ORCA_PANE_KEY).toBeUndefined() + }) + + it('keeps a chat on the directory it pinned when the launch env names another', async () => { + const launch = await resolver(ompRecord(), { + launchEnv: { PI_CODING_AGENT_DIR: '/data/other-agent' } + })({ identity }) + expect(launch.env.PI_CODING_AGENT_DIR).toBe('/home/user/.omp/agent') + }) + + it("refuses a record that pins another agent's directory", async () => { + await expect( + resolver(ompRecord({ variable: 'GROK_HOME', path: '/home/user/.grok' }))({ identity }) + ).rejects.toThrow(/pin PI_CODING_AGENT_DIR/) + }) + + it.each(['17.0.5', '17.2.12', '18.4.5'])('admits OMP %s', async (version) => { + const launch = resolver(ompRecord(), { + probeVersion: async (_input, supports) => supports(version) + }) + await expect(launch({ identity })).resolves.toMatchObject({ args: ['acp'] }) + }) + + it.each(['17.0.4', '16.9.0', '18.0.0-beta.1'])( + 'refuses OMP %s before spawning it, as a host that cannot run the chat', + async (version) => { + const launch = resolver(ompRecord(), { + probeVersion: async (_input, supports) => supports(version) + }) + await expect(launch({ identity })).rejects.toMatchObject({ + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } + }) + } + ) + + it('refuses when the version cannot be read', async () => { + const launch = resolver(ompRecord(), { probeVersion: async () => false }) + await expect(launch({ identity })).rejects.toMatchObject({ + refusal: { code: 'structured_agent_session_unsupported' } + }) + }) + + it('asks the version of exactly the binary, folder and environment it spawns', async () => { + const asked: Parameters>[0][] = [] + const launch = await resolver(ompRecord(), { + probeVersion: async (input, supports) => (asked.push(input), supports('17.0.5')) + })({ identity }) + expect(asked).toEqual([{ program: launch.command, cwd: launch.cwd, env: launch.env }]) + }) +}) diff --git a/src/main/acp/acp-structured-launch-resolution-opencode.test.ts b/src/main/acp/acp-structured-launch-resolution-opencode.test.ts new file mode 100644 index 000000000000..a473dd07b397 --- /dev/null +++ b/src/main/acp/acp-structured-launch-resolution-opencode.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { ManagedDataAccountsState } from '../../shared/managed-account-types' +import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' +import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' +import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' +import { scrubOpenCodeAcpEnvironment } from '../opencode/opencode-acp-environment' +import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs' +import { + createAcpStructuredLaunchResolver, + type AcpStructuredLaunchResolverDeps +} from './acp-structured-launch-resolution' + +const PROFILE = '123e4567-e89b-42d3-a456-426614174000' + +const managedAccounts = { + list: (): ManagedDataAccountsState => ({ + accounts: [{ id: PROFILE, label: 'work', integrations: [], createdAt: 0 }], + activeAccountId: PROFILE + }), + restoreOriginalEnvironment: (environment: Record) => + restoreManagedDataAccountEnvironment(environment), + environmentForAccount: (_provider: 'opencode' | 'devin', id: string) => ({ + XDG_DATA_HOME: `/profiles/${id}/data`, + XDG_STATE_HOME: `/profiles/${id}/state`, + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '' + }) +} + +const OPENCODE = { + ...acpLaunchSpecFor('opencode')!, + account: openCodeAcpAccountBinding(() => managedAccounts) +} +const identity = { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'opencode', + providerHandle: null +} + +function openCodeRecord(accountHome: AgentSessionRecord['accountHome']): AgentSessionRecord { + return { + ...agentSessionRecordFixture(), + provider: 'opencode', + providerHandleChain: [], + accountHome + } +} + +const UNMANAGED: AgentSessionRecord['accountHome'] = { + kind: 'opencode', + locator: { kind: 'unmanaged' } +} + +function resolver( + record: AgentSessionRecord, + options: { + launchEnv?: Record + inheritedEnv?: NodeJS.ProcessEnv + base?: Record + probeVersion?: AcpStructuredLaunchResolverDeps['probeVersion'] + } = {} +) { + return createAcpStructuredLaunchResolver(OPENCODE, { + store: { getRecord: () => record }, + readJournal: () => null, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user', ...options.base }), + resolveLaunchEnv: () => options.launchEnv ?? {}, + resolveCommand: (command) => `/resolved/${command}`, + inheritedEnv: options.inheritedEnv ?? {}, + probeVersion: options.probeVersion ?? (async (_input, supports) => supports('1.18.31')) + }) +} + +describe('OpenCode ACP launch resolution', () => { + it('runs `opencode acp` as an ACP client with no question tool, whatever the user set', async () => { + const launch = await resolver(openCodeRecord(UNMANAGED), { + launchEnv: { OPENCODE_CLIENT: 'tui', OPENCODE_ENABLE_QUESTION_TOOL: 'true' } + })({ identity }) + expect(launch).toMatchObject({ command: '/resolved/opencode', args: ['acp'] }) + expect(launch.env).toMatchObject({ + OPENCODE_CLIENT: 'acp', + OPENCODE_ENABLE_QUESTION_TOOL: 'false' + }) + }) + + it("starts OpenCode with the user's own folders, database and inline credentials", async () => { + const userEnv = { + HOME: 'relative/home', + XDG_DATA_HOME: 'relative/data', + XDG_STATE_HOME: '/data/opencode-state', + OPENCODE_DB: 'work.db', + OPENCODE_AUTH_CONTENT: '{"secret":1}' + } + const launch = await resolver(openCodeRecord(UNMANAGED), { + base: { XDG_DATA_HOME: '/elsewhere' }, + launchEnv: userEnv + })({ identity }) + expect(launch.env).toMatchObject(userEnv) + const child = createProviderSpawnSpec( + { + command: launch.command, + args: launch.args, + env: launch.env, + envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete] + }, + {}, + 'darwin' + ).env + expect(child).toMatchObject(userEnv) + }) + + it('leaves unset what the user left unset, whatever Orca itself inherited', async () => { + const inheritedEnv = { OPENCODE_DB: '/elsewhere/other.db', OPENCODE_AUTH_CONTENT: '' } + const launch = await resolver(openCodeRecord(UNMANAGED), { inheritedEnv })({ identity }) + expect(launch.env.XDG_DATA_HOME).toBeUndefined() + expect(launch.env.OPENCODE_DB).toBeUndefined() + const child = createProviderSpawnSpec( + { + command: launch.command, + args: launch.args, + env: launch.env, + envToDelete: launch.envToDelete + }, + inheritedEnv, + 'darwin' + ).env + expect(child.OPENCODE_DB).toBeUndefined() + expect(child.OPENCODE_AUTH_CONTENT).toBeUndefined() + }) + + it('points a managed profile at its own directories', async () => { + const launch = await resolver( + openCodeRecord({ kind: 'opencode', locator: { kind: 'managed', managedProfileId: PROFILE } }) + )({ identity }) + expect(launch.env).toMatchObject({ + XDG_DATA_HOME: `/profiles/${PROFILE}/data`, + XDG_STATE_HOME: `/profiles/${PROFILE}/state`, + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '' + }) + }) + + it("restores the user's own config directory over Orca's status overlay", async () => { + const inheritedEnv = { + OPENCODE_CONFIG_DIR: '/orca/overlay', + ORCA_OPENCODE_CONFIG_DIR: '/orca/overlay', + ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/home/user/.config/opencode-mine', + ORCA_OPENCODE_AGENT: 'opencode' + } + const launch = await resolver(openCodeRecord(UNMANAGED), { inheritedEnv })({ identity }) + expect(launch.env.OPENCODE_CONFIG_DIR).toBe('/home/user/.config/opencode-mine') + const child = createProviderSpawnSpec( + { + command: launch.command, + args: launch.args, + env: launch.env, + envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete] + }, + inheritedEnv, + 'darwin' + ).env + expect(Object.keys(child).filter((key) => key.startsWith('ORCA_OPENCODE_'))).toEqual([]) + expect(child.OPENCODE_CONFIG_DIR).toBe('/home/user/.config/opencode-mine') + }) + + it('drops an inherited overlay with no recorded source, so the default config is read', async () => { + const inheritedEnv = { + OPENCODE_CONFIG_DIR: '/orca/overlay', + ORCA_OPENCODE_CONFIG_DIR: '/orca/overlay' + } + const launch = await resolver(openCodeRecord(UNMANAGED), { inheritedEnv })({ identity }) + const child = createProviderSpawnSpec( + { + command: launch.command, + args: launch.args, + env: launch.env, + envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete] + }, + inheritedEnv, + 'darwin' + ).env + expect(child.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(child.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + }) + + it("drops Orca's retired shared plugin directory an old shell still exports", () => { + const env: Record = { + OPENCODE_CONFIG_DIR: '/orca-data/opencode-hooks/shared', + ORCA_DATA_ACCOUNT_PROVIDER: 'opencode' + } + const removed = scrubOpenCodeAcpEnvironment(env, {}, '/orca-data') + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(removed).toEqual( + expect.arrayContaining(['OPENCODE_CONFIG_DIR', 'ORCA_DATA_ACCOUNT_PROVIDER']) + ) + }) + + it("keeps a config directory the user set for OpenCode's launches", async () => { + const launch = await resolver(openCodeRecord(UNMANAGED), { + launchEnv: { OPENCODE_CONFIG_DIR: '/home/user/team-config' }, + inheritedEnv: { + OPENCODE_CONFIG_DIR: '/orca/overlay', + ORCA_OPENCODE_CONFIG_DIR: '/orca/overlay' + } + })({ identity }) + expect(launch.env.OPENCODE_CONFIG_DIR).toBe('/home/user/team-config') + expect(launch.envToDelete).not.toContain('OPENCODE_CONFIG_DIR') + }) + + it('refuses a record that pins a single directory instead of an OpenCode account', async () => { + await expect( + resolver(openCodeRecord({ variable: 'XDG_DATA_HOME', path: '/data' }))({ identity }) + ).rejects.toThrow(/pinned data account/) + }) + + it('runs an `opencode` that is stable 2.x', async () => { + const launch = await resolver(openCodeRecord(UNMANAGED), { + probeVersion: async (_input, supports) => supports('2.0.21') + })({ identity }) + expect(launch.args).toEqual(['acp']) + }) + + it('refuses a release outside its lines before spawning it, as a host that cannot run the chat', async () => { + const launch = resolver(openCodeRecord(UNMANAGED), { + probeVersion: async (_input, supports) => supports('3.0.0') + })({ identity }) + await expect(launch).rejects.toMatchObject({ + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } + }) + }) + + it('refuses when the version cannot be read', async () => { + const launch = resolver(openCodeRecord(UNMANAGED), { probeVersion: async () => false }) + await expect(launch({ identity })).rejects.toMatchObject({ + refusal: { code: 'structured_agent_session_unsupported' } + }) + }) + + it('asks the version of exactly the binary, folder and environment it spawns', async () => { + const asked: Parameters>[0][] = [] + const launch = await resolver(openCodeRecord(UNMANAGED), { + probeVersion: async (input, supports) => (asked.push(input), supports('1.18.31')) + })({ identity }) + expect(asked).toEqual([{ program: launch.command, cwd: launch.cwd, env: launch.env }]) + }) +}) diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index dbe372495a0a..8c13e2f3e620 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -14,7 +14,13 @@ import { type AgentSessionProviderHandleChain } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { + resolveStructuredAgentCommand, + type StructuredAgentCommandSettings +} from '../native-chat/structured-agent-command-resolution' +import { probeAgentCliVersion } from '../agent-cli-version-probe' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' import { @@ -32,6 +38,8 @@ export type AcpStructuredLaunch = { args: string[] cwd: string env: Record + /** Keys the child must not inherit from this process's own environment. */ + envToDelete: string[] fullAccess: boolean /** The provider session to load; null starts a new one. */ resume: { @@ -58,8 +66,52 @@ export type AcpStructuredLaunchResolverDeps = { resolveLaunchEnv?: (agent: string) => Record /** The Agent Permissions setting's bypass posture for this agent, re-read per acquisition. */ resolveFullAccess?: (agent: string) => boolean + /** The user's per-agent Command setting, re-read per acquisition; none runs the stock binary. */ + resolveCommandSettings?: () => StructuredAgentCommandSettings resolveCommand?: typeof resolveCliCommand homePath?: string + /** The environment the child process inherits at spawn; this process's own by default. */ + inheritedEnv?: NodeJS.ProcessEnv + probeVersion?: typeof probeAgentCliVersion +} + +/** The binary a launch with `env` spawns: the user's Command setting when set, else the stock + * binary from PATH, then the agent's own install directories. A create's version check resolves + * through here too, so it asks about the same file. */ +export function resolveAcpLaunchCommand( + spec: AcpLaunchSpec, + env: Readonly>, + options: { + commandSettings?: StructuredAgentCommandSettings + resolveCommand?: typeof resolveCliCommand + homePath?: string + } = {} +): string { + const homePath = env.HOME ?? env.USERPROFILE ?? options.homePath ?? homedir() + const pathEnv = [env.PATH ?? env.Path, ...spec.installDirectories({ env, homePath })] + .filter((entry): entry is string => Boolean(entry)) + .join(delimiter) + return resolveStructuredAgentCommand( + spec.agent, + options.commandSettings ?? {}, + { pathEnv, homePath }, + { + command: spec.command, + ...(options.resolveCommand ? { resolve: options.resolveCommand } : {}) + } + ) +} + +/** Whether `command` is a release the spec runs a structured chat on; true when it names none. */ +export async function acpLaunchVersionSupported( + spec: AcpLaunchSpec, + launch: { command: string; cwd: string; env: Record }, + probe: typeof probeAgentCliVersion = probeAgentCliVersion +): Promise { + return ( + !spec.supportsVersion || + probe({ program: launch.command, cwd: launch.cwd, env: launch.env }, spec.supportsVersion) + ) } export function createAcpStructuredLaunchResolver( @@ -82,33 +134,33 @@ export function createAcpStructuredLaunchResolver( `${spec.agent} structured sessions run on this runtime's host, not ${location.executionHostId}` ) } - if (accountHome.variable !== spec.accountHomeVariable) { - throw new Error( - `${spec.agent} sessions pin ${spec.accountHomeVariable}, not ${accountHome.variable}` - ) - } const base = await deps.resolveEnvironment() - const env: Record = { + const env: Record = spec.account.environment(accountHome, { ...base, - ...deps.resolveLaunchEnv?.(spec.agent), - ...spec.env, - [spec.accountHomeVariable]: accountHome.path - } - const pathEnv = [env.PATH ?? env.Path, ...spec.installDirectories(accountHome.path)] - .filter((entry): entry is string => Boolean(entry)) - .join(delimiter) - const command = (deps.resolveCommand ?? resolveCliCommand)(spec.command, { - pathEnv, - homePath: env.HOME ?? env.USERPROFILE ?? deps.homePath ?? homedir() + ...deps.resolveLaunchEnv?.(spec.agent) }) + const envToDelete = spec.scrubEnvironment?.(env, deps.inheritedEnv ?? process.env) ?? [] + Object.assign(env, spec.env) + const command = resolveAcpLaunchCommand(spec, env, { + ...deps, + ...(deps.resolveCommandSettings ? { commandSettings: deps.resolveCommandSettings() } : {}) + }) + const cwd = await deps.resolveWorkspacePath(location.workspaceId) + // Again at every launch: the binary on PATH may have changed since the chat was created. + if (!(await acpLaunchVersionSupported(spec, { command, cwd, env }, deps.probeVersion))) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } const fullAccess = deps.resolveFullAccess?.(spec.agent) ?? false const head = agentSessionProviderHandleChainHead(record.providerHandleChain) return { spec, command, args: spec.args({ fullAccess }), - cwd: await deps.resolveWorkspacePath(location.workspaceId), + cwd, env, + envToDelete, fullAccess, resume: head ? { diff --git a/src/main/acp/acp-structured-omp-recordings.test.ts b/src/main/acp/acp-structured-omp-recordings.test.ts new file mode 100644 index 000000000000..4c0dc40c03d6 --- /dev/null +++ b/src/main/acp/acp-structured-omp-recordings.test.ts @@ -0,0 +1,117 @@ +// The adapter against OMP's recorded `omp acp` traffic (17.0.5): each recording plays back as the +// agent, and the assertions read the journal a client would see. + +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { + openAcpAdapterRig, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' +import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support' +import { readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const ask: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Fixture prompt' }] +} + +async function replaying(name: string) { + const replay = new GrokFixtureReplay(await readAcpFixture(name)) + const rig = await openAcpAdapterRig({ + spec: acpLaunchSpecFor('omp')!, + script: (scripted) => replay.attach(scripted) + }) + await rig.acquire() + await rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'send-1', body: ask, fence: 1 }) + return { rig, replay } +} + +async function turns(rig: AcpAdapterRig) { + return (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) +} + +async function toolRow(rig: AcpAdapterRig) { + const rows = (await rig.rig.rows()).filter((row) => row.body.kind === 'tool-call') + expect(rows).toHaveLength(1) + return rows[0]!.body +} + +async function allowOnce(rig: AcpAdapterRig, replay: GrokFixtureReplay) { + const approval = await waitFor(async () => { + const found = (await rig.rig.rows()).find((row) => row.body.kind === 'approval') + if (!found || replay.awaiting !== 'reply') { + throw new Error('no pending approval yet') + } + return found + }) + await rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: approval.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'allow_once' }, + fence: 1, + commit: async () => {} + }) +} + +describe('OMP recordings through the adapter', () => { + it("a failed command's row shows its output and exit code, not the command again", async () => { + const { rig, replay } = await replaying('omp-v17-shell-exit-3') + await allowOnce(rig, replay) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) + ) + expect(await toolRow(rig)).toMatchObject({ + state: 'failed', + input: { command: 'printf hi; exit 3' }, + exitCode: 3, + output: { head: 'hi', truncated: false } + }) + }) + + it("a successful command's row shows its output", async () => { + const { rig, replay } = await replaying('omp-v17-shell-ok') + await allowOnce(rig, replay) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) + ) + const tool = await toolRow(rig) + expect(tool).toMatchObject({ state: 'completed', output: { head: 'ok', truncated: false } }) + // OMP reports an exit code only when it is not zero. + expect(tool).not.toHaveProperty('exitCode') + }) + + it('Stop during a running command ends the turn and leaves the row without the command as output', async () => { + const { rig, replay } = await replaying('omp-v17-stop-mid-tool') + await allowOnce(rig, replay) + await waitFor(() => expect(replay.awaiting).toBe('session/cancel')) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'interrupted', outcome: 'cancellation' }]) + ) + // OMP sends nothing more for the stopped command; Orca settles its row with the turn. + const tool = await toolRow(rig) + expect(tool).toMatchObject({ + endedAs: 'interrupted', + input: { command: 'sleep 30', timeout: 60 } + }) + expect(tool).not.toHaveProperty('output') + }) +}) diff --git a/src/main/acp/acp-structured-opencode-recordings.test.ts b/src/main/acp/acp-structured-opencode-recordings.test.ts new file mode 100644 index 000000000000..6adf5d1d910e --- /dev/null +++ b/src/main/acp/acp-structured-opencode-recordings.test.ts @@ -0,0 +1,129 @@ +// The adapter against OpenCode's recorded `opencode acp` traffic (1.18.31): each +// recording plays back as the agent, and the assertions read the journal a client would see. + +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { + openAcpAdapterRig, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' +import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support' +import { readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const ask: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Fixture prompt' }] +} + +async function replaying(name: string) { + const replay = new GrokFixtureReplay(await readAcpFixture(name)) + const rig = await openAcpAdapterRig({ + spec: acpLaunchSpecFor('opencode')!, + script: (scripted) => replay.attach(scripted) + }) + await rig.acquire() + await rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'send-1', body: ask, fence: 1 }) + return { rig, replay } +} + +async function turns(rig: AcpAdapterRig) { + return (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) +} + +async function rowsOf(rig: AcpAdapterRig, kind: string) { + return (await rig.rig.rows()).filter((row) => row.body.kind === kind) +} + +async function answer(rig: AcpAdapterRig, replay: GrokFixtureReplay, optionId: string, index = 0) { + const approval = await waitFor(async () => { + const found = (await rowsOf(rig, 'approval'))[index] + if (!found || replay.awaiting !== 'reply') { + throw new Error('no pending approval yet') + } + return found + }) + await rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: approval.itemId, + kind: 'approval', + response: { kind: 'option', optionId }, + fence: 1, + commit: async () => {} + }) + return approval +} + +describe('OpenCode recordings through the adapter', () => { + it('an approved shell command lands with its exit code, and "always" keeps OpenCode\'s own name', async () => { + const { rig, replay } = await replaying('opencode-v1-tool') + const approval = await answer(rig, replay, 'once') + expect(approval.body).toMatchObject({ + kind: 'approval', + options: [ + { id: 'once', label: 'Allow once' }, + { id: 'always', label: 'Always allow' }, + { id: 'reject', label: 'Reject' } + ] + }) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) + ) + const shell = (await rowsOf(rig, 'tool-call')).find( + (row) => row.body.kind === 'tool-call' && row.body.exitCode !== undefined + ) + expect(shell?.body).toMatchObject({ state: 'completed', exitCode: 0 }) + }) + + it('a rejected permission fails its tool and the turn still ends', async () => { + const { rig, replay } = await replaying('opencode-v1-deny') + await answer(rig, replay, 'reject') + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => expect(await turns(rig)).toMatchObject([{ state: 'completed' }])) + expect((await rowsOf(rig, 'tool-call')).at(-1)?.body).toMatchObject({ state: 'failed' }) + }) + + it("a subagent's approval never arrives (a known OpenCode bug); Stop still ends the turn", async () => { + const { rig, replay } = await replaying('opencode-v1-subagent-hang') + await waitFor(() => expect(replay.awaiting).toBe('session/cancel')) + expect(await rowsOf(rig, 'approval')).toEqual([]) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'interrupted', outcome: 'cancellation' }]) + ) + }) + + it("OpenCode's stray file write after an approved edit is refused without breaking the turn", async () => { + const { rig, replay } = await replaying('opencode-v1-stray-write') + await answer(rig, replay, 'once', 0) + await answer(rig, replay, 'reject', 1) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => expect(await turns(rig)).toMatchObject([{ state: 'completed' }])) + // The write OpenCode repeats to a client that took no file system leaves no row. + expect(JSON.stringify((await rig.rig.rows()).map((row) => row.body))).not.toContain( + 'fs/write_text_file' + ) + }) + + it("a provider error fails the turn in OpenCode's own words", async () => { + const { rig, replay } = await replaying('opencode-v1-provider-error') + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => expect(await turns(rig)).toMatchObject([{ outcome: 'failure' }])) + const texts = JSON.stringify((await rig.rig.rows()).map((row) => row.body)) + expect(texts).toContain('CAPTURE_PROVIDER_400') + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts index 3f59f4db3e4a..f8605b12daf4 100644 --- a/src/main/acp/acp-structured-session-adapter-deps.ts +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -5,6 +5,7 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' import type { AcpLaunchSpec } from './acp-launch-specs' import type { ConnectAcpAgent } from './acp-structured-connection' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' @@ -17,6 +18,8 @@ export const ACP_OPTION_WRITE_TIMEOUT_MS = 30_000 export type AcpStructuredSessionAdapterDeps = { spec: AcpLaunchSpec resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise + /** The chat's journal as it stands, read without opening it; null when it has none. */ + readJournal?: (sessionId: string) => JournalLoad | null /** Starts the agent's process and owns its protocol: `createAcpAgentConnection` in production. */ connect: ConnectAcpAgent readProcessStartTime?: (pid: number) => Promise diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index bb845eb429fb..e20058bfd726 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -421,6 +421,7 @@ describe('ACP startup that never answers', () => { args: [], cwd: '/workspace/project', env: {}, + envToDelete: [], fullAccess: false, resume: null } diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index b0dd9804b5ea..664580848706 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -27,25 +27,35 @@ import { endAcpStructuredSession, type AcpStructuredSession } from './acp-structured-session' -import { AcpStructuredStarts, type AcpStartAttempt } from './acp-structured-starts' +import { + ProviderAcquisitionStarts, + type ProviderStartAttempt +} from '../provider-process/provider-acquisition-starts' +import type { AcpStructuredConnection } from './acp-structured-connection' import { waitForAcpExit } from './acp-structured-connection' import { AcpConnectionClosedError } from './acp-errors' -import { acpPromptBlocks } from './acp-structured-turns' import { awaitAcpTurnEnd, interruptAcpTurn, windDownAcpTurn } from './acp-structured-stop' +import { acpDispatchPrompt } from './acp-prompt-content' import { ACP_OPTION_WRITE_TIMEOUT_MS, ACP_STOP_GRACE_MS, type AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' import { writeAcpSessionOption } from './acp-structured-options' +import { readAcpRecoveryHistory } from './acp-recovery-history' export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { /** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */ private readonly sessions = new Map() - private readonly starts = new AcpStructuredStarts() + private readonly starts = new ProviderAcquisitionStarts() constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} + /** Restart recovery's evidence; null for an agent whose own store Orca cannot read. */ + providerHistoryWindow: NonNullable = ({ + identity + }) => readAcpRecoveryHistory(this.deps, identity) + // The child runs on this runtime's own machine; Windows needs process start-time proof. supportsLocation = (location: AgentSessionExecutionLocation): boolean => supportsSupervisedProviderChildLocation(location, this.deps.isWindowsProcessStartTimeAvailable) @@ -69,7 +79,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte private async start( input: StructuredAgentSessionAcquireInput, - attempt: AcpStartAttempt + attempt: ProviderStartAttempt ): Promise { const sessionId = input.identity.sessionId const generation = this.deps.mintGeneration?.() ?? randomUUID() @@ -142,9 +152,13 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return this.rejected(lost, 'providerExited') } const session = this.live(input.sessionId) - const prompt = acpPromptBlocks(input.body) - if (!prompt) { - return this.rejected(session, 'attachmentInvalid') + const prompt = await acpDispatchPrompt(input.body, session) + if (!Array.isArray(prompt)) { + return { state: 'rejected', ...prompt } + } + if (this.sessions.get(input.sessionId) !== session || session.journalClosed !== null) { + // The child ended while its attachments were read: nothing left Orca. + return this.rejected(session, 'providerExited') } await input.beforeDispatch?.() session.turns.dispatch({ @@ -312,7 +326,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte private rejected( session: AcpStructuredSession, - kind: 'providerExited' | 'attachmentInvalid' + kind: 'providerExited' ): AgentSessionDispatchOutcome { return { state: 'rejected', diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index df5495f65edc..4bbf773ea51f 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -17,10 +17,7 @@ import { agentSessionFailureWords, type AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem -} from '../../shared/agent-session-journal-types' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import { AcpAgentError, AcpConnectionClosedError } from './acp-errors' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpStructuredLane } from './acp-structured-lane' @@ -32,19 +29,6 @@ export type AcpDispatchSettlement = { clientMessageId: string } & ( | { state: 'unknown'; reason: string } ) -/** A person's message as an ACP prompt; null when it carries what the agent cannot take. */ -export function acpPromptBlocks(body: AgentJournalMessageItem): ContentBlock[] | null { - const blocks: ContentBlock[] = [] - for (const block of body.blocks) { - if (block.type !== 'text') { - // Images wait for an ACP image path; the chat offers none while `imagePrompts` is off. - return null - } - blocks.push({ type: 'text', text: block.text }) - } - return blocks -} - type Send = { clientMessageId: string; prompt: ContentBlock[]; requestedAt: number } export type AcpStructuredTurnsDeps = { diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index ae9eac456e47..c611cfabbb99 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -20,7 +20,8 @@ const frameSchema = z.object({ id: z.union([z.string(), z.number()]).optional(), method: z.string().optional(), params: z.unknown().optional(), - result: z.unknown().optional() + result: z.unknown().optional(), + error: z.unknown().optional() }) }) export type AcpFixtureFrame = z.infer diff --git a/src/main/acp/acp-timeline-opencode-dialect.test.ts b/src/main/acp/acp-timeline-opencode-dialect.test.ts new file mode 100644 index 000000000000..e82a72235fde --- /dev/null +++ b/src/main/acp/acp-timeline-opencode-dialect.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +async function openCodeRig() { + const rig = await openProviderTimelineRig() + const translator = new AcpTimelineTranslator({ + sessionId: 'provider-1', + dialect: OPENCODE_ACP_DIALECT + }) + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const update = (body: Record, at: number) => + apply(translator.notification('session/update', { sessionId: 'provider-1', update: body }, at)) + apply(translator.openPrompt('send-1', 1000).events) + return { rig, translator, apply, update } +} + +// The shapes OpenCode's ACP server writes for a shell tool: its output and exit code ride under +// `rawOutput.metadata`, beside the text content. +const bashStart = { + sessionUpdate: 'tool_call', + toolCallId: 'call-1', + title: 'List files', + kind: 'execute', + status: 'pending', + rawInput: { command: 'ls missing', description: 'List files' } +} + +describe('OpenCode over ACP', () => { + it("reads a shell tool's exit code and output from OpenCode's metadata", async () => { + const { rig, update } = await openCodeRig() + update(bashStart, 1001) + update( + { + sessionUpdate: 'tool_call_update', + toolCallId: 'call-1', + status: 'completed', + rawOutput: { + output: 'ls: missing: No such file or directory\n', + metadata: { output: 'ls: missing: No such file or directory\n', exit: 1 } + } + }, + 1002 + ) + const tool = (await rig.rows()).find((row) => row.body.kind === 'tool-call')?.body + expect(tool).toMatchObject({ + kind: 'tool-call', + state: 'completed', + exitCode: 1, + output: { head: 'ls: missing: No such file or directory\n' } + }) + }) + + it('keeps a shared exit code the agent already reported', () => { + const normalized = OPENCODE_ACP_DIALECT.normalizeToolUpdate!({ + toolCallId: 'call-1', + rawOutput: { exitCode: 0, metadata: { exit: 3 } } + }) + expect(normalized.rawOutput).toEqual({ exitCode: 0, metadata: { exit: 3 } }) + }) + + it('leaves a tool with no command metadata as it came', () => { + const plain = { toolCallId: 'call-2', rawOutput: 'done' } + expect(OPENCODE_ACP_DIALECT.normalizeToolUpdate!(plain)).toBe(plain) + }) + + it("shows OpenCode's own permission option names", async () => { + const { translator, apply, update } = await openCodeRig() + update(bashStart, 1001) + const request = translator.request( + 'session/request_permission', + { + sessionId: 'provider-1', + toolCall: { toolCallId: 'call-1', title: 'ls missing', kind: 'execute' }, + options: [ + { optionId: 'once', kind: 'allow_once', name: 'Allow once' }, + { optionId: 'always', kind: 'allow_always', name: 'Always allow' }, + { optionId: 'reject', kind: 'reject_once', name: 'Reject' } + ] + }, + 0 + ) + apply(request.events) + expect(request.presentation?.body).toMatchObject({ + options: [ + { id: 'once', label: 'Allow once' }, + { id: 'always', label: 'Always allow' }, + { id: 'reject', label: 'Reject' } + ] + }) + expect(request.presentation?.reply({ kind: 'option', optionId: 'always' })).toEqual({ + outcome: { outcome: 'selected', optionId: 'always' } + }) + }) +}) diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index 176967409c9e..4afdf09cb386 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -28,7 +28,10 @@ export function acpPermissionPresentation(params: unknown): AcpRequestPresentati kind: 'approval', title: toolCall.title ?? 'Permission requested', detail: null, - options: options.map((option) => ({ id: option.optionId, label: option.name })), + options: options.map((option) => ({ + id: option.optionId, + label: option.name + })), resolution: pendingAcpResolution }, reply: (response) => { @@ -47,6 +50,7 @@ export function acpPermissionPresentation(params: unknown): AcpRequestPresentati } const requestSessionSchema = z.object({ sessionId: z.string() }) +const UNADVERTISED_CLIENT_METHOD = /^(?:fs|terminal)\// const requestToolSchema = z.object({ toolCallId: z.string().optional(), toolCall: z.object({ toolCallId: z.string() }).optional() @@ -105,6 +109,11 @@ export function translateAcpRequest( ? acpPermissionPresentation(params) : options.dialect.request?.(method, params) if (!presentation) { + // A file system or terminal call Orca never advertised is the agent's own misstep (OpenCode 1.x + // repeats each approved edit as a file write it already made): refused, with no row. + if (UNADVERTISED_CLIENT_METHOD.test(method)) { + return { events: [] } + } return { events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params, join }] } diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 28762bc4646d..f6b4a22df2f7 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -19,6 +19,7 @@ import type { AcpDialect } from './acp-dialects/acp-dialect' import type { ToolCallUpdate } from './generated/acp-protocol.generated' const rawStreamsSchema = z.object({ stdout: z.string().optional(), stderr: z.string().optional() }) +const rawExitCodeSchema = z.looseObject({ exitCode: z.number().int().safe() }) function outputText(update: ToolCallUpdate): string | undefined { const text = update.content?.flatMap((block) => @@ -89,12 +90,14 @@ export class AcpToolTimeline { }) translate( - update: ToolCallUpdate, + raw: ToolCallUpdate, dialect: AcpDialect, join: ProviderTimelineJoin ): ProviderTimelineEvent[] { + const update = dialect.normalizeToolUpdate?.(raw) ?? raw const previous = this.tools.get(update.toolCallId) const output = outputText(update) + const exitCode = rawExitCodeSchema.safeParse(update.rawOutput).data?.exitCode const state = update.status === 'completed' ? 'completed' @@ -118,6 +121,7 @@ export class AcpToolTimeline { ? (previous?.body.input ?? null) : boundToolInput(update.rawInput, DEFAULT_JOURNAL_PAYLOAD_LIMITS), state, + ...(exitCode === undefined ? {} : { exitCode }), ...(output === undefined ? previous?.body.output ? { output: previous.body.output } diff --git a/src/main/acp/acp-write-queue-retention.test.ts b/src/main/acp/acp-write-queue-retention.test.ts new file mode 100644 index 000000000000..1c9a5931737c --- /dev/null +++ b/src/main/acp/acp-write-queue-retention.test.ts @@ -0,0 +1,113 @@ +import { PassThrough, Writable } from 'node:stream' +import { expect, it } from 'vitest' +import { AcpJsonRpcPeer } from './acp-json-rpc-peer' + +async function collect(): Promise { + if (!('gc' in globalThis) || typeof globalThis.gc !== 'function') { + throw new Error('The test runner must enable --expose-gc') + } + for (let round = 0; round < 6; round++) { + await new Promise((resolve) => setImmediate(resolve)) + globalThis.gc() + } +} + +function fixture(deferred: boolean, backpressure: boolean) { + const input = new PassThrough() + const refs: WeakRef[] = [] + const writes: string[] = [] + let callback: ((error?: Error | null) => void) | undefined + const output = new Writable({ + highWaterMark: backpressure ? 1 : 1024 * 1024, + write(chunk, _encoding, done) { + writes.push(JSON.parse(chunk.toString()).method) + if (deferred) { + callback = done + } else { + done() + } + } + }) + output.on('newListener', (event, listener) => { + if (event === 'drain') { + refs.push(new WeakRef(listener)) + } + }) + const peer = new AcpJsonRpcPeer(input, output) + return { + input, + output, + refs, + peer, + writes, + complete() { + const done = callback + callback = undefined + if (!done) { + throw new Error('Missing pending stream callback') + } + done() + }, + close() { + peer.close() + input.destroy() + output.destroy() + } + } +} + +it.each([false, true])( + 'releases the completed last drain closure while the peer remains usable (backpressure=%s)', + async (backpressure) => { + const state = fixture(false, backpressure) + try { + await state.peer.notify('first', { text: 'x'.repeat(256 * 1024) }) + expect(state.output.writableLength).toBe(0) + expect(state.output.listenerCount('drain')).toBe(0) + await collect() + expect(state.refs.filter((ref) => ref.deref())).toHaveLength(0) + await state.peer.notify('next', {}) + expect(state.writes).toEqual(['first', 'next']) + } finally { + state.close() + } + } +) + +it('keeps the active write until stream completion then releases its drain closure', async () => { + const state = fixture(true, true) + try { + const first = state.peer.notify('first', {}) + const second = state.peer.notify('second', {}) + await collect() + expect(state.refs.filter((ref) => ref.deref())).toHaveLength(1) + expect(state.writes).toEqual(['first']) + state.complete() + await first + expect(state.writes).toEqual(['first', 'second']) + state.complete() + await second + await collect() + expect(state.refs.filter((ref) => ref.deref())).toHaveLength(0) + expect(state.output.listenerCount('drain')).toBe(0) + } finally { + state.close() + } +}) + +it('retires a closed peer drain closure after its late stream completion', async () => { + const state = fixture(true, true) + try { + const first = expect(state.peer.notify('first', {})).rejects.toThrow('closed') + const second = expect(state.peer.notify('second', {})).rejects.toThrow('closed') + state.peer.close() + await Promise.all([first, second]) + state.complete() + await collect() + expect(state.refs.filter((ref) => ref.deref())).toHaveLength(0) + expect(state.output.listenerCount('drain')).toBe(0) + expect(state.writes).toEqual(['first']) + } finally { + state.close() + } +}) diff --git a/src/main/acp/acp-write-queue.ts b/src/main/acp/acp-write-queue.ts index 7538fc04aa66..9a72f21c235c 100644 --- a/src/main/acp/acp-write-queue.ts +++ b/src/main/acp/acp-write-queue.ts @@ -1,119 +1,12 @@ import type { Writable } from 'node:stream' +import { ProviderStdioWriteQueue } from '../provider-process/provider-stdio-write-queue' import { AcpConnectionClosedError } from './acp-errors' -type Write = { - line: string - resolve: () => void - reject: (error: Error) => void - detachAbort?: () => void -} - -export class AcpWriteQueue { - private readonly queue: Write[] = [] - private bytes = 0 - private active?: Write - private terminalError?: Error - private detachDrain?: () => void - - constructor( - private readonly output: Writable, - private readonly maxBytes: number, - private readonly onFailure: (error: Error) => void - ) {} - - write(line: string, signal?: AbortSignal): Promise { - if (this.terminalError) { - return Promise.reject(this.terminalError) - } - if (signal?.aborted) { - return Promise.reject(signal.reason) - } - const bytes = Buffer.byteLength(line) - if (this.bytes + bytes > this.maxBytes) { - return Promise.reject(new Error('ACP write queue capacity exceeded')) - } - this.bytes += bytes - return new Promise((resolve, reject) => { - const write: Write = { line, resolve, reject } - const abort = (): void => { - const index = this.queue.indexOf(write) - if (index === -1) { - return - } - this.queue.splice(index, 1) - this.bytes -= bytes - write.detachAbort?.() - reject(signal?.reason) - } - signal?.addEventListener('abort', abort, { once: true }) - write.detachAbort = () => signal?.removeEventListener('abort', abort) - this.queue.push(write) - this.flush() +export class AcpWriteQueue extends ProviderStdioWriteQueue { + constructor(output: Writable, maxBytes: number, onFailure: (error: Error) => void) { + super(output, maxBytes, onFailure, { + capacity: () => new Error('ACP write queue capacity exceeded'), + closed: () => new AcpConnectionClosedError('ACP output is not writable') }) } - - close(error: Error): void { - if (this.terminalError) { - return - } - this.terminalError = error - this.detachDrain?.() - this.active?.reject(error) - this.active = undefined - for (const write of this.queue.splice(0)) { - write.detachAbort?.() - write.reject(error) - } - this.bytes = 0 - } - - private flush(): void { - if (this.active || this.terminalError) { - return - } - const write = this.queue.shift() - if (!write) { - return - } - write.detachAbort?.() - this.active = write - if (this.output.destroyed || !this.output.writable) { - this.onFailure(new AcpConnectionClosedError('ACP output is not writable')) - return - } - let completed = false - let drained = false - let returned = false - const finish = (): void => { - if (!returned || !completed || !drained || this.terminalError) { - return - } - this.detachDrain?.() - this.active = undefined - this.bytes -= Buffer.byteLength(write.line) - write.resolve() - this.flush() - } - const onDrain = (): void => { - drained = true - finish() - } - this.output.once('drain', onDrain) - this.detachDrain = () => this.output.removeListener('drain', onDrain) - try { - const accepted = this.output.write(write.line, (error) => { - if (error) { - this.onFailure(error) - return - } - completed = true - finish() - }) - drained ||= accepted - returned = true - finish() - } catch (error) { - this.onFailure(error instanceof Error ? error : new Error(String(error))) - } - } } diff --git a/src/main/acp/fixtures/omp-v17-shell-exit-3.jsonl b/src/main/acp/fixtures/omp-v17-shell-exit-3.jsonl new file mode 100644 index 000000000000..7bbad57dbe8a --- /dev/null +++ b/src/main/acp/fixtures/omp-v17-shell-exit-3.jsonl @@ -0,0 +1,23 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Use the bash tool exactly once to run this exact command and nothing else: printf hi; exit 3\nDo not run any other tool. Then reply in one short sentence with what it printed and its exit code."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"model","description":"Show current model selection"},{"name":"fast","description":"Toggle fast mode","input":{"hint":"[on|off|status]"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","updatedAt":"2026-10-08T03:24:13.994Z"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call-1","title":"printf hi; exit 3","kind":"execute","status":"pending","rawInput":{"command":"printf hi; exit 3","timeout":10},"content":[{"type":"content","content":{"type":"text","text":"$ printf hi; exit 3"}}],"locations":[]},"options":[{"optionId":"allow_once","name":"Allow once","kind":"allow_once"},{"optionId":"allow_always","name":"Always allow","kind":"allow_always"},{"optionId":"reject_once","name":"Reject","kind":"reject_once"},{"optionId":"reject_always","name":"Always reject","kind":"reject_always"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow_once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"$ printf hi; exit 3","kind":"execute","status":"pending","rawInput":{"command":"printf hi; exit 3","timeout":10},"content":[{"type":"content","content":{"type":"text","text":"$ printf hi; exit 3"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"in_progress","rawOutput":{"content":[{"type":"text","text":"hi"}],"details":{}},"content":[{"type":"content","content":{"type":"text","text":"$ printf hi; exit 3"}},{"type":"content","content":{"type":"text","text":"hi"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"failed","rawOutput":{"content":[{"type":"text","text":"hi\n\nWall time: 0.09 seconds\n\nCommand exited with code 3"}],"details":{"timeoutSeconds":10,"wallTimeMs":87.65562499999942,"exitCode":3},"isError":true},"content":[{"type":"content","content":{"type":"text","text":"$ printf hi; exit 3"}},{"type":"content","content":{"type":"text","text":"hi\n\nWall time: 0.09 seconds\n\nCommand exited with code 3"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"It"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" printed"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hi"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" exited"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" with"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" code"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" "},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"3"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","size":272000,"used":18220,"cost":{"amount":0.10272500000000001,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","updatedAt":"2026-10-08T03:24:19.387Z"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":18459,"outputTokens":49,"totalTokens":36428,"cachedReadTokens":17920}}}} diff --git a/src/main/acp/fixtures/omp-v17-shell-ok.jsonl b/src/main/acp/fixtures/omp-v17-shell-ok.jsonl new file mode 100644 index 000000000000..0c96d48be96e --- /dev/null +++ b/src/main/acp/fixtures/omp-v17-shell-ok.jsonl @@ -0,0 +1,23 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Use the bash tool exactly once to run this exact command and nothing else: printf ok\nDo not run any other tool. Then reply in one short sentence with what it printed and its exit code."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"model","description":"Show current model selection"},{"name":"fast","description":"Toggle fast mode","input":{"hint":"[on|off|status]"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","updatedAt":"2026-10-08T03:24:00.014Z"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call-1","title":"printf ok","kind":"execute","status":"pending","rawInput":{"command":"printf ok"},"content":[{"type":"content","content":{"type":"text","text":"$ printf ok"}}],"locations":[]},"options":[{"optionId":"allow_once","name":"Allow once","kind":"allow_once"},{"optionId":"allow_always","name":"Always allow","kind":"allow_always"},{"optionId":"reject_once","name":"Reject","kind":"reject_once"},{"optionId":"reject_always","name":"Always reject","kind":"reject_always"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow_once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"$ printf ok","kind":"execute","status":"pending","rawInput":{"command":"printf ok"},"content":[{"type":"content","content":{"type":"text","text":"$ printf ok"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"in_progress","rawOutput":{"content":[{"type":"text","text":"ok"}],"details":{}},"content":[{"type":"content","content":{"type":"text","text":"$ printf ok"}},{"type":"content","content":{"type":"text","text":"ok"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","rawOutput":{"content":[{"type":"text","text":"ok\n\nWall time: 0.41 seconds"}],"details":{"timeoutSeconds":300,"wallTimeMs":406.50874999999996}},"content":[{"type":"content","content":{"type":"text","text":"$ printf ok"}},{"type":"content","content":{"type":"text","text":"ok\n\nWall time: 0.41 seconds"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"It"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" printed"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ok"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" exited"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" with"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" code"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" "},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"0"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","size":272000,"used":18198,"cost":{"amount":0.10234000000000001,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","updatedAt":"2026-10-08T03:24:06.553Z"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":18430,"outputTokens":41,"totalTokens":36391,"cachedReadTokens":17920}}}} diff --git a/src/main/acp/fixtures/omp-v17-stop-mid-tool.jsonl b/src/main/acp/fixtures/omp-v17-stop-mid-tool.jsonl new file mode 100644 index 000000000000..660483571a23 --- /dev/null +++ b/src/main/acp/fixtures/omp-v17-stop-mid-tool.jsonl @@ -0,0 +1,8 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Use the bash tool exactly once to run this exact command and nothing else: sleep 30\nDo not run any other tool. Then reply in one short sentence with what it printed and its exit code."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"model","description":"Show current model selection"},{"name":"fast","description":"Toggle fast mode","input":{"hint":"[on|off|status]"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","updatedAt":"2026-10-08T03:32:42.139Z"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call-1","title":"sleep 30","kind":"execute","status":"pending","rawInput":{"command":"sleep 30","timeout":60},"content":[{"type":"content","content":{"type":"text","text":"$ sleep 30"}}],"locations":[]},"options":[{"optionId":"allow_once","name":"Allow once","kind":"allow_once"},{"optionId":"allow_always","name":"Always allow","kind":"allow_always"},{"optionId":"reject_once","name":"Reject","kind":"reject_once"},{"optionId":"reject_always","name":"Always reject","kind":"reject_always"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow_once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"$ sleep 30","kind":"execute","status":"pending","rawInput":{"command":"sleep 30","timeout":60},"content":[{"type":"content","content":{"type":"text","text":"$ sleep 30"}}]}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled","usage":{"inputTokens":234,"outputTokens":63,"totalTokens":18217,"cachedReadTokens":17920}}}} diff --git a/src/main/acp/fixtures/opencode-v1-deny.jsonl b/src/main/acp/fixtures/opencode-v1-deny.jsonl new file mode 100644 index 000000000000..11fd57bd2117 --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-deny.jsonl @@ -0,0 +1,21 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Use the write or edit tool to create denied.txt containing exactly DENY-ME. If permission is denied, stop and report it; do not retry via another tool."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"create/update AGENTS.md"}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":"The"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" user wants me to"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" create denied.txt"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" with content"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" DENY-ME. Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" me just do"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" it.\n\nBut wait -"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" there's a permission"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" system."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" Let me try the write"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" tool. If denied, stop"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" and report."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_rmd4myxlisfqpsm7qnvhcqvm","title":"write","kind":"edit","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_rmd4myxlisfqpsm7qnvhcqvm","status":"in_progress","kind":"edit","title":"write","locations":[{"path":"/workspace/project/denied.txt"}],"rawInput":{"filePath":"/workspace/project/denied.txt","content":"DENY-ME"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_00_rmd4myxlisfqpsm7qnvhcqvm","title":"/workspace/project/denied.txt","kind":"edit","status":"pending","locations":[{"path":"/workspace/project/denied.txt"}],"rawInput":{"filepath":"/workspace/project/denied.txt","diff":"Index: /workspace/project/denied.txt\n===================================================================\n--- /workspace/project/denied.txt\n+++ /workspace/project/denied.txt\n@@ -0,0 +1,1 @@\n+DENY-ME\n\\ No newline at end of file\n"},"content":[{"type":"diff","path":"/workspace/project/denied.txt","oldText":"","newText":"DENY-ME"}]},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"reject"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_rmd4myxlisfqpsm7qnvhcqvm","status":"failed","kind":"edit","title":"write","locations":[{"path":"/workspace/project/denied.txt"}],"rawInput":{"filePath":"/workspace/project/denied.txt","content":"DENY-ME"},"content":[{"type":"content","content":{"type":"text","text":"The user rejected permission to use this specific tool call."}}],"rawOutput":{"error":"The user rejected permission to use this specific tool call."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":10390,"size":1000000,"cost":{"amount":0.00013182,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":150,"outputTokens":87,"totalTokens":10521,"thoughtTokens":44,"cachedReadTokens":10240},"_meta":{}}}} diff --git a/src/main/acp/fixtures/opencode-v1-provider-error.jsonl b/src/main/acp/fixtures/opencode-v1-provider-error.jsonl new file mode 100644 index 000000000000..612772542c55 --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-provider-error.jsonl @@ -0,0 +1,2 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Reply OK."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":4,"error":{"code":-32603,"message":"Internal error: CAPTURE_PROVIDER_400","data":{"service":"session","errorName":"APIError"}}}} diff --git a/src/main/acp/fixtures/opencode-v1-stray-write.jsonl b/src/main/acp/fixtures/opencode-v1-stray-write.jsonl new file mode 100644 index 000000000000..8249b591c0ed --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-stray-write.jsonl @@ -0,0 +1,30 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"First use the edit tool to change edit.txt from before to approved. After that succeeds, run bash with exactly: printf \"STRAY\\n\" > stray.txt. Do not combine these actions. If the shell write permission is denied, stop and report it."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"create/update AGENTS.md"}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":"Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" me first"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" read edit.txt. Actually"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" the edit"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" tool requires reading first. Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" me read it."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_436gprdnre54g9m10b8sx6qv","title":"read","kind":"read","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_436gprdnre54g9m10b8sx6qv","status":"in_progress","kind":"read","title":"read","locations":[{"path":"/workspace/project/edit.txt"}],"rawInput":{"filePath":"/workspace/project/edit.txt"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_436gprdnre54g9m10b8sx6qv","status":"completed","title":"/workspace/project/edit.txt","content":[{"type":"content","content":{"type":"text","text":"before"}}],"rawOutput":{"output":"/workspace/project/edit.txt\nfile\n\n1: before\n\n(End of file - total 1 lines)\n","metadata":{"preview":"before","truncated":false,"loaded":[],"display":{"type":"file","path":"/workspace/project/edit.txt","text":"before","lineStart":1,"lineEnd":1,"totalLines":1,"truncated":false}}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605fae001cQnOHi1XrRfxQ4","content":{"type":"text","text":"The"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605fae001cQnOHi1XrRfxQ4","content":{"type":"text","text":" instructions say change"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605fae001cQnOHi1XrRfxQ4","content":{"type":"text","text":" \"before\""}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605fae001cQnOHi1XrRfxQ4","content":{"type":"text","text":" to \"approved\"."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","title":"edit","kind":"edit","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","status":"in_progress","kind":"edit","title":"edit","locations":[{"path":"/workspace/project/edit.txt"}],"rawInput":{"filePath":"/workspace/project/edit.txt","oldString":"before","newString":"approved"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","title":"/workspace/project/edit.txt","kind":"edit","status":"pending","locations":[{"path":"/workspace/project/edit.txt"}],"rawInput":{"filepath":"/workspace/project/edit.txt","diff":"Index: /workspace/project/edit.txt\n===================================================================\n--- /workspace/project/edit.txt\n+++ /workspace/project/edit.txt\n@@ -1,1 +1,1 @@\n-before\n+approved\n"},"content":[{"type":"diff","path":"/workspace/project/edit.txt","oldText":"before\n","newText":"approved\n"}]},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"fs/write_text_file","params":{"sessionId":"session-1","path":"/workspace/project/edit.txt","content":"approved\n"}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found: fs/write_text_file"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","status":"in_progress","kind":"edit","title":"edit","locations":[{"path":"/workspace/project/edit.txt"}],"rawInput":{"filePath":"/workspace/project/edit.txt","oldString":"before","newString":"approved"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","status":"completed","title":"/workspace/project/edit.txt","content":[{"type":"content","content":{"type":"text","text":"Edit applied successfully."}},{"type":"diff","path":"/workspace/project/edit.txt","oldText":"before","newText":"approved"}],"rawOutput":{"output":"Edit applied successfully.","metadata":{"diagnostics":{},"diff":"Index: /workspace/project/edit.txt\n===================================================================\n--- /workspace/project/edit.txt\n+++ /workspace/project/edit.txt\n@@ -1,1 +1,1 @@\n-before\n+approved\n","filediff":{"file":"/workspace/project/edit.txt","patch":"Index: /workspace/project/edit.txt\n===================================================================\n--- /workspace/project/edit.txt\n+++ /workspace/project/edit.txt\n@@ -1,1 +1,1 @@\n-before\n+approved\n","additions":1,"deletions":1},"truncated":false}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_d0qy5hwrmp85vvitkrmkhxm8","title":"bash","kind":"execute","status":"pending","locations":[{"path":"/workspace/project"}],"rawInput":{"cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_d0qy5hwrmp85vvitkrmkhxm8","status":"in_progress","kind":"execute","title":"printf \"STRAY\\n\" > stray.txt","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"STRAY\\n\" > stray.txt","cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":2,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_00_d0qy5hwrmp85vvitkrmkhxm8","title":"printf \"STRAY\\n\" > stray.txt","kind":"execute","status":"pending","locations":[],"rawInput":{"command":"printf \"STRAY\\n\" > stray.txt"}},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":2,"result":{"outcome":{"outcome":"selected","optionId":"reject"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_d0qy5hwrmp85vvitkrmkhxm8","status":"failed","kind":"execute","title":"printf \"STRAY\\n\" > stray.txt","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"STRAY\\n\" > stray.txt","cwd":"/workspace/project"},"content":[{"type":"content","content":{"type":"text","text":"The user rejected permission to use this specific tool call."}}],"rawOutput":{"error":"The user rejected permission to use this specific tool call."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":10690,"size":1000000,"cost":{"amount":0.000322062,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":194,"outputTokens":45,"totalTokens":10735,"cachedReadTokens":10496},"_meta":{}}}} diff --git a/src/main/acp/fixtures/opencode-v1-subagent-hang.jsonl b/src/main/acp/fixtures/opencode-v1-subagent-hang.jsonl new file mode 100644 index 000000000000..1c176531db2e --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-subagent-hang.jsonl @@ -0,0 +1,13 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Use the Task tool to launch one general subagent. The subagent must create child.txt in this project containing CHILD-OK, using a write or edit tool. Do not perform the edit yourself. Let the subagent finish, then reply briefly. Do not launch other subagents."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"create/update AGENTS.md"}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125d1722001x7f5Lth7HBxMQu","content":{"type":"text","text":"I'll launch"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125d1722001x7f5Lth7HBxMQu","content":{"type":"text","text":" one"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125d1722001x7f5Lth7HBxMQu","content":{"type":"text","text":" general subagent to create the"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125d1722001x7f5Lth7HBxMQu","content":{"type":"text","text":" file."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_2d1jjspyyy65tjo00sp7d1tk","title":"task","kind":"think","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_2d1jjspyyy65tjo00sp7d1tk","status":"in_progress","kind":"think","title":"Create child.txt","locations":[],"rawInput":{"description":"Create child.txt","prompt":"Create a file at the absolute path /workspace/project/child.txt using the write tool. The file must contain exactly the text: CHILD-OK\n\nDo not do anything else. After writing the file, read it back to verify its contents, then report back the exact contents and the file path. Do not touch any other files.","subagent_type":"general"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_2d1jjspyyy65tjo00sp7d1tk","status":"in_progress","kind":"think","title":"Create child.txt","locations":[],"rawInput":{"description":"Create child.txt","prompt":"Create a file at the absolute path /workspace/project/child.txt using the write tool. The file must contain exactly the text: CHILD-OK\n\nDo not do anything else. After writing the file, read it back to verify its contents, then report back the exact contents and the file path. Do not touch any other files.","subagent_type":"general"}}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_2d1jjspyyy65tjo00sp7d1tk","status":"failed","kind":"think","title":"task","locations":[],"rawInput":{"description":"Create child.txt","prompt":"Create a file at the absolute path /workspace/project/child.txt using the write tool. The file must contain exactly the text: CHILD-OK\n\nDo not do anything else. After writing the file, read it back to verify its contents, then report back the exact contents and the file path. Do not touch any other files.","subagent_type":"general"},"content":[{"type":"content","content":{"type":"text","text":"Task cancelled"}}],"rawOutput":{"error":"Task cancelled","metadata":{"parentSessionId":"session-1","sessionId":"session-2","model":{"modelID":"deepseek-v4.1-flash","providerID":"opencode-go"}}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":0,"size":1000000,"cost":{"amount":0.00016167,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled","usage":{"inputTokens":0,"outputTokens":0,"totalTokens":0},"_meta":{}}}} diff --git a/src/main/acp/fixtures/opencode-v1-tool.jsonl b/src/main/acp/fixtures/opencode-v1-tool.jsonl new file mode 100644 index 000000000000..4fec143af031 --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-tool.jsonl @@ -0,0 +1,40 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Read README.md using the read tool. Then run the bash tool with exactly: printf \"TOOL-OK\\n\". Reply briefly with the two markers. Do not inspect any other paths."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"create/update AGENTS.md"}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" me read README.md and"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" then"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" run the bash command. Per"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" instructions, read first"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":", then bash."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" Can I batch"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"? The bash"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" says"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" run with exactly"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" printf \""}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"TOOL-OK\\n\"."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" me do the"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" read first, then bash"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":".\n\nActually I can do both in"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" parallel since independent"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":". But the instruction says \""}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"Then run"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"\". Let me just"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" do them in"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" one"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" message"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_9bqxuxnngg6oum1tifcyejhj","title":"read","kind":"read","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_9bqxuxnngg6oum1tifcyejhj","status":"in_progress","kind":"read","title":"read","locations":[{"path":"/workspace/project/README.md"}],"rawInput":{"filePath":"/workspace/project/README.md"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","title":"bash","kind":"execute","status":"pending","locations":[{"path":"/workspace/project"}],"rawInput":{"cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_9bqxuxnngg6oum1tifcyejhj","status":"completed","title":"/workspace/project/README.md","content":[{"type":"content","content":{"type":"text","text":"Protocol capture scratch project. Read marker: CAPTURE-OK."}}],"rawOutput":{"output":"/workspace/project/README.md\nfile\n\n1: Protocol capture scratch project. Read marker: CAPTURE-OK.\n\n(End of file - total 1 lines)\n","metadata":{"preview":"Protocol capture scratch project. Read marker: CAPTURE-OK.","truncated":false,"loaded":[],"display":{"type":"file","path":"/workspace/project/README.md","text":"Protocol capture scratch project. Read marker: CAPTURE-OK.","lineStart":1,"lineEnd":1,"totalLines":1,"truncated":false}}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","status":"in_progress","kind":"execute","title":"printf \"TOOL-OK\\n\"","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","title":"printf \"TOOL-OK\\n\"","kind":"execute","status":"pending","locations":[],"rawInput":{"command":"printf \"TOOL-OK\\n\""}},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","status":"in_progress","kind":"execute","title":"printf \"TOOL-OK\\n\"","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","status":"in_progress","kind":"execute","title":"printf \"TOOL-OK\\n\"","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/workspace/project"},"content":[{"type":"content","content":{"type":"text","text":"TOOL-OK\n"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","status":"completed","title":"printf \"TOOL-OK\\n\"","content":[{"type":"content","content":{"type":"text","text":"TOOL-OK\n"}}],"rawOutput":{"output":"TOOL-OK\n","metadata":{"output":"TOOL-OK\n","exit":0,"truncated":false}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125cb49c001tmofdzWc1JiS6i","content":{"type":"text","text":"CAPTURE-OK"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125cb49c001tmofdzWc1JiS6i","content":{"type":"text","text":"\nTOOL-OK"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":10669,"size":1000000,"cost":{"amount":0.00148533,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":301,"outputTokens":11,"totalTokens":10680,"cachedReadTokens":10368},"_meta":{}}}} diff --git a/src/main/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index 0b0ff8ca6225..b5056e836066 100644 --- a/src/main/agent-auth-restart-preservation.test.ts +++ b/src/main/agent-auth-restart-preservation.test.ts @@ -8,7 +8,7 @@ describe('preserveAgentAuthBeforeRestart', () => { vi.restoreAllMocks() }) - it('syncs Codex then Claude before flushing the store', async () => { + it('syncs Codex and flushes the store', async () => { const calls: string[] = [] await preserveAgentAuthBeforeRestart({ @@ -17,11 +17,6 @@ describe('preserveAgentAuthBeforeRestart', () => { calls.push('codex') }) }, - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(async () => { - calls.push('claude') - }) - }, store: { flushPendingOrThrowAsync: vi.fn(async () => { calls.push('flush') @@ -29,7 +24,7 @@ describe('preserveAgentAuthBeforeRestart', () => { } }) - expect(calls).toEqual(['codex', 'claude', 'flush']) + expect(calls).toEqual(['codex', 'flush']) }) it('drains retained WSL Codex auth before flushing the store', async () => { @@ -106,27 +101,6 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) - it('does not start Claude after host Codex exhausts the lifecycle budget', async () => { - vi.useFakeTimers() - const startedAt = Date.now() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const syncClaude = vi.fn(async () => {}) - - await preserveAgentAuthBeforeRestart({ - codexRuntimeHome: { - syncForCurrentSelection: vi.fn(() => { - vi.setSystemTime(startedAt + 2_000) - }) - }, - claudeRuntimeAuth: { syncForCurrentSelection: syncClaude } - }) - - expect(syncClaude).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith( - '[agent-auth-restart] Claude auth preservation exceeded 0ms; continuing restart/update' - ) - }) - it('checkpoints admitted state without waiting for ongoing edits when auth services are missing', async () => { const flushPendingOrThrowAsync = vi.fn() @@ -148,83 +122,15 @@ describe('preserveAgentAuthBeforeRestart', () => { throw new Error('codex-token-secret') }) }, - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(async () => { - throw new Error('claude-token-secret') - }) - }, store: { flushPendingOrThrowAsync } }) ).resolves.toBeUndefined() expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) - expect(warn).toHaveBeenCalledTimes(2) + expect(warn).toHaveBeenCalledTimes(1) expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) - it('releases the lifecycle path on timeout without canceling in-flight sync', async () => { - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const calls: string[] = [] - let finishClaude!: () => void - - const preservation = preserveAgentAuthBeforeRestart({ - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(async () => { - calls.push('claude-start') - await new Promise((resolve) => { - finishClaude = resolve - }) - calls.push('claude-finish') - }) - }, - store: { - flushPendingOrThrowAsync: vi.fn(async () => { - calls.push('flush') - }) - } - }) - - await vi.advanceTimersByTimeAsync(2_000) - await preservation - - expect(calls).toEqual(['claude-start', 'flush']) - expect(warn).toHaveBeenCalledWith( - '[agent-auth-restart] Claude auth preservation exceeded 2000ms; continuing restart/update' - ) - - finishClaude() - await Promise.resolve() - - expect(calls).toEqual(['claude-start', 'flush', 'claude-finish']) - }) - - it('shares the original lifecycle timeout between Claude and store preservation', async () => { - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - let settled = false - - const preservation = preserveAgentAuthBeforeRestart({ - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(() => new Promise(() => {})) - }, - store: { - flushPendingOrThrowAsync: vi.fn(() => new Promise(() => {})) - } - }).then(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(2_000) - await vi.runOnlyPendingTimersAsync() - await preservation - - expect(settled).toBe(true) - expect(warn).toHaveBeenCalledWith( - '[agent-auth-restart] Store persistence exceeded 0ms; continuing restart/update' - ) - }) - it('bounds a store flush that never settles', async () => { vi.useFakeTimers() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/agent-auth-restart-preservation.ts b/src/main/agent-auth-restart-preservation.ts index d00641088cc2..a41be9a01833 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -1,4 +1,3 @@ -import type { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import type { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' import type { Store } from './persistence' @@ -6,23 +5,17 @@ const AUTH_PRESERVATION_TIMEOUT_MS = 2_000 type CodexRuntimeAuthSync = Pick & Partial> -type ClaudeRuntimeAuthSync = Pick type ShutdownStore = Pick -type AuthPreservationStep = - | 'Codex auth preservation' - | 'Claude auth preservation' - | 'Store persistence' +type AuthPreservationStep = 'Codex auth preservation' | 'Store persistence' export type AgentAuthRestartPreservationOptions = { codexRuntimeHome?: CodexRuntimeAuthSync | null - claudeRuntimeAuth?: ClaudeRuntimeAuthSync | null store?: ShutdownStore | null } export async function preserveAgentAuthBeforeRestart({ codexRuntimeHome, - claudeRuntimeAuth, store }: AgentAuthRestartPreservationOptions): Promise { const startedAt = Date.now() @@ -30,17 +23,6 @@ export async function preserveAgentAuthBeforeRestart({ // Why: the drain owns guest-process timeouts; a shared 2s cutoff can relaunch before promotion. const wslCodexPreservation = runWslCodexPreservationStep(codexRuntimeHome) - const claudeRemainingMs = remainingLifecycleTime(startedAt) - if (claudeRuntimeAuth && claudeRemainingMs > 0) { - await runWithinLifecycleTimeout( - 'Claude auth preservation', - () => claudeRuntimeAuth.syncForCurrentSelection(), - claudeRemainingMs - ) - } else if (claudeRuntimeAuth) { - logStepTimeout('Claude auth preservation', 0) - } - const storePreservation = store ? runWithinLifecycleTimeout( 'Store persistence', diff --git a/src/main/agent-cli-version-probe.test.ts b/src/main/agent-cli-version-probe.test.ts new file mode 100644 index 000000000000..d04480f71dce --- /dev/null +++ b/src/main/agent-cli-version-probe.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + isStableCliVersionFrom, + isStableCliVersionOnLine, + probeAgentCliVersion +} from './agent-cli-version-probe' + +const { runProcess } = vi.hoisted(() => ({ runProcess: vi.fn() })) +vi.mock('../shared/child-process/run-process', () => ({ runProcess })) + +const INPUT = { program: '/opt/bin/agent', cwd: '/repo', env: { PATH: '/opt/bin' } } +const anyVersion = (): boolean => true + +function prints(stdout: string, result: Record = {}): void { + runProcess.mockResolvedValue({ + code: 0, + signal: null, + stdout, + stderr: '', + timedOut: false, + ...result + }) +} + +describe('probeAgentCliVersion', () => { + beforeEach(() => runProcess.mockReset()) + + it('asks the program for its version, bounded, where and as the launch would run it', async () => { + prints('1.18.31\n') + const seen: string[] = [] + expect(await probeAgentCliVersion(INPUT, (version) => (seen.push(version), true))).toBe(true) + expect(seen).toEqual(['1.18.31']) + expect(runProcess).toHaveBeenCalledWith({ + ...INPUT, + args: ['--version'], + timeoutMs: 5_000, + maxOutputBytes: 4_096, + killOnOutputLimit: true + }) + }) + + it('reads a version printed with a name or a v prefix', async () => { + prints('opencode v2.0.21\n') + const seen: string[] = [] + await probeAgentCliVersion(INPUT, (version) => (seen.push(version), true)) + expect(seen).toEqual(['2.0.21']) + }) + + it('reads a version printed after the program name and a slash', async () => { + prints('omp/17.0.5\n') + const seen: string[] = [] + await probeAgentCliVersion(INPUT, (version) => (seen.push(version), true)) + expect(seen).toEqual(['17.0.5']) + }) + + it.each([ + ['a non-zero exit', { code: 1 }], + ['a timeout', { timedOut: true }], + ['oversized output', { outputTruncated: true }] + ])('cannot tell after %s', async (_label, result) => { + prints('1.18.31\n', result) + expect(await probeAgentCliVersion(INPUT, anyVersion)).toBe(false) + }) + + it('cannot tell when the output names no version, or the program does not start', async () => { + prints('usage: agent [options]\n') + expect(await probeAgentCliVersion(INPUT, anyVersion)).toBe(false) + runProcess.mockImplementationOnce(() => { + throw new Error('spawn ENOENT') + }) + expect(await probeAgentCliVersion(INPUT, anyVersion)).toBe(false) + }) +}) + +describe('probeAgentCliVersion refusal', () => { + beforeEach(() => runProcess.mockReset()) + + it('says why it refused, with what the binary printed', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + prints('', { code: 1, stderr: 'Error: EACCES mkdir /x\n' }) + await probeAgentCliVersion(INPUT, anyVersion) + prints('2.0.21\n') + await probeAgentCliVersion(INPUT, () => false) + expect(warn.mock.calls.map(([line]) => line)).toEqual([ + '[agent-cli-version] /opt/bin/agent --version: exited 1: Error: EACCES mkdir /x', + '[agent-cli-version] /opt/bin/agent --version: 2.0.21 is not a supported release' + ]) + warn.mockRestore() + }) +}) + +describe('isStableCliVersionOnLine', () => { + const line = { major: 1, floor: '1.18.31' } + + it.each([ + ['1.18.31', true], + ['1.19.2', true], + ['1.18.30', false], + ['2.0.21', false], + ['1.18.31-beta.1', false], + ['11.0.0', false] + ])('%s -> %s', (version, supported) => { + expect(isStableCliVersionOnLine(version, line)).toBe(supported) + }) +}) + +describe('isStableCliVersionFrom', () => { + it.each([ + ['17.0.5', true], + ['17.2.12', true], + ['18.4.5', true], + ['17.0.4', false], + ['16.9.0', false], + ['18.0.0-beta.1', false] + ])('%s -> %s', (version, supported) => { + expect(isStableCliVersionFrom(version, '17.0.5')).toBe(supported) + }) +}) diff --git a/src/main/agent-cli-version-probe.ts b/src/main/agent-cli-version-probe.ts new file mode 100644 index 000000000000..4cf45ce0e4d2 --- /dev/null +++ b/src/main/agent-cli-version-probe.ts @@ -0,0 +1,67 @@ +import { + hasReachedAppVersion, + isPrereleaseAppVersion, + parseCliVersion +} from '../shared/app-version' +import { runProcess, type ProcessSpec } from '../shared/child-process/run-process' + +/** One line naming why a probe said no; the binary's own words, bounded. */ +function warnRefused(program: string, why: string, output = ''): void { + const said = output.trim().replace(/\s+/g, ' ').slice(0, 200) + console.warn(`[agent-cli-version] ${program} --version: ${why}${said ? `: ${said}` : ''}`) +} + +/** Runs ` --version` on this host and asks `supports` about the version it prints. False + * when it cannot tell: a failed spawn, a non-zero exit, a timeout, or output with no version. */ +export async function probeAgentCliVersion( + input: Pick, + supports: (version: string) => boolean +): Promise { + let result + try { + result = await runProcess({ + ...input, + args: ['--version'], + timeoutMs: 5_000, + maxOutputBytes: 4_096, + killOnOutputLimit: true + }) + } catch (error) { + warnRefused(input.program, `did not start (${String(error)})`) + return false + } + const output = `${result.stdout}\n${result.stderr}` + if (result.timedOut || result.outputTruncated || result.code !== 0) { + const why = result.timedOut + ? 'timed out' + : result.outputTruncated + ? 'printed too much' + : `exited ${result.code ?? result.signal}` + warnRefused(input.program, why, output) + return false + } + const version = parseCliVersion(result.stdout) + if (version === null) { + warnRefused(input.program, 'printed no version', output) + return false + } + if (!supports(version)) { + warnRefused(input.program, `${version} is not a supported release`) + return false + } + console.info(`[agent-cli-version] ${input.program} --version: ${version} is supported`) + return true +} + +/** A stable release at or after `floor`, on any later major line too. */ +export function isStableCliVersionFrom(version: string, floor: string): boolean { + return !isPrereleaseAppVersion(version) && hasReachedAppVersion(version, floor) +} + +/** A stable release on `major`'s line, at or after `floor`. */ +export function isStableCliVersionOnLine( + version: string, + line: { major: number; floor: string } +): boolean { + return version.startsWith(`${line.major}.`) && isStableCliVersionFrom(version, line.floor) +} diff --git a/src/main/agent-hooks/managed-hook-runtime.test.ts b/src/main/agent-hooks/managed-hook-runtime.test.ts index e4c23e13959e..79d0742ce6cd 100644 --- a/src/main/agent-hooks/managed-hook-runtime.test.ts +++ b/src/main/agent-hooks/managed-hook-runtime.test.ts @@ -155,6 +155,18 @@ describe.runIf(process.platform !== 'win32')('installManagedHooks', () => { errors: 0 }) - expect((await readdir(home)).sort()).toEqual(['.claude', '.orca', SHELL_NAME, SHELL_RUNS_NAME]) + expect((await readdir(home)).sort()).toEqual(['.claude', '.orca', SHELL_NAME]) + }) + + it('still probes the login shell when Grok is selected', async () => { + const home = await createTempHome() + await stubLoginShell(home) + + await expect(installManagedHooks({ agents: ['grok'] })).resolves.toEqual({ + installers: 1, + errors: 0 + }) + + expect((await readdir(home)).sort()).toEqual(['.grok', '.orca', SHELL_NAME, SHELL_RUNS_NAME]) }) }) diff --git a/src/main/agent-hooks/managed-hook-runtime.ts b/src/main/agent-hooks/managed-hook-runtime.ts index 99a2c68be89f..4d1baeef609e 100644 --- a/src/main/agent-hooks/managed-hook-runtime.ts +++ b/src/main/agent-hooks/managed-hook-runtime.ts @@ -86,7 +86,9 @@ export async function installManagedHooks(options?: { return { installers: 0, errors: 0 } } const home = homedir() - const grokHomeDir = await resolveRelayGrokHome(home, options?.signal) + const grokHomeDir = agents.includes('grok') + ? await resolveRelayGrokHome(home, options?.signal) + : undefined options?.signal?.throwIfAborted() const hostIdentity = scopeManagedHookHostIdentity( await readManagedHookHostIdentity(), diff --git a/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts b/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts index a270b462202c..1ef842aa0d9e 100644 --- a/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts +++ b/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts @@ -57,6 +57,8 @@ async function host(remote: boolean, launchToken?: string) { method: 'POST', headers: { 'Content-Type': 'application/json', + // Advancing fake time can expire a pooled HTTP socket before the next post. + Connection: 'close', 'X-Orca-Agent-Hook-Token': relay.getCoordinates().token }, body: JSON.stringify(body) diff --git a/src/main/agent-hooks/server.test-fixtures.test.ts b/src/main/agent-hooks/server.test-fixtures.test.ts new file mode 100644 index 000000000000..9b558e91419a --- /dev/null +++ b/src/main/agent-hooks/server.test-fixtures.test.ts @@ -0,0 +1,48 @@ +import type { Socket } from 'node:net' +import { expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { buildBody, postHookEvent } from './server.test-fixtures' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) + +class SocketTrackingHookServer extends AgentHookServer { + readonly requestSockets = new Set() + + trackRequestSockets(): void { + this.server?.on('request', (request) => this.requestSockets.add(request.socket)) + } +} + +it('uses a fresh connection for each hook across fake-time advances', async () => { + vi.useFakeTimers({ + shouldAdvanceTime: true, + toFake: ['Date', 'performance', 'setTimeout', 'clearTimeout'] + }) + const server = new SocketTrackingHookServer() + try { + await server.start({ env: 'production' }) + server.trackRequestSockets() + for (let index = 0; index < 3; index += 1) { + const response = await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'fresh turn' }) + ) + expect(response.status).toBe(204) + if (index === 1) { + vi.advanceTimersByTime(120_000) + } + } + expect(server.requestSockets.size).toBe(3) + } finally { + server.stop() + vi.useRealTimers() + } +}) diff --git a/src/main/agent-hooks/server.test-fixtures.ts b/src/main/agent-hooks/server.test-fixtures.ts index 99bb0aeee696..565515239e77 100644 --- a/src/main/agent-hooks/server.test-fixtures.ts +++ b/src/main/agent-hooks/server.test-fixtures.ts @@ -57,6 +57,8 @@ export async function postHookEvent( method: 'POST', headers: { 'Content-Type': 'application/json', + // Fake-time advances must not expire a pooled socket before the next hook. + Connection: 'close', 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN }, body: JSON.stringify(body) diff --git a/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts index 018d91019153..a603a22282aa 100644 --- a/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts @@ -1,10 +1,14 @@ // Restart-timer policy: recovery must re-ensure a running distro, must NOT // boot a stopped one (wsl -d starts stopped distros), and must respect state // currency and disposal. -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WslRelayRecovery, type WslRelayRecoveryState } from './wsl-hook-relay-recovery' +beforeEach(() => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })) + +afterEach(() => vi.useRealTimers()) + function makeState(): WslRelayRecoveryState { return { distro: 'Ubuntu', cooldownUntil: Date.now() - 1_000 } } @@ -26,6 +30,7 @@ describe('WslRelayRecovery', () => { }) const state = makeState() recovery.scheduleRestart(state) + await vi.advanceTimersByTimeAsync(250) await waitFor(() => restart.mock.calls.length === 1) expect(restart).toHaveBeenCalledWith('Ubuntu') }) @@ -44,6 +49,7 @@ describe('WslRelayRecovery', () => { }) const state = makeState() recovery.scheduleRestart(state) + await vi.advanceTimersByTimeAsync(250) await waitFor(() => dropState.mock.calls.length === 1) expect(restart).not.toHaveBeenCalled() expect(warn).toHaveBeenCalledWith(expect.stringContaining('distro not running')) @@ -62,7 +68,7 @@ describe('WslRelayRecovery', () => { }) const state = makeState() recovery.scheduleRestart(state) - await new Promise((resolve) => setTimeout(resolve, 500)) + await vi.advanceTimersByTimeAsync(500) expect(probe).not.toHaveBeenCalled() expect(restart).not.toHaveBeenCalled() }) @@ -83,11 +89,12 @@ describe('WslRelayRecovery', () => { }) const state = makeState() recovery.scheduleRestart(state) + await vi.advanceTimersByTimeAsync(250) await waitFor(() => probe.mock.calls.length === 1) // A fresh ensure() replaces this state while the probe is in flight. current = false resolveProbe?.(false) - await new Promise((resolve) => setTimeout(resolve, 100)) + await vi.advanceTimersByTimeAsync(100) expect(dropState).not.toHaveBeenCalled() expect(restart).not.toHaveBeenCalled() }) @@ -107,10 +114,11 @@ describe('WslRelayRecovery', () => { }) const state = makeState() recovery.scheduleRestart(state) + await vi.advanceTimersByTimeAsync(250) await waitFor(() => probe.mock.calls.length === 1) current = false resolveProbe?.(true) - await new Promise((resolve) => setTimeout(resolve, 100)) + await vi.advanceTimersByTimeAsync(100) expect(restart).not.toHaveBeenCalled() }) @@ -126,7 +134,7 @@ describe('WslRelayRecovery', () => { }) const state = makeState() recovery.scheduleOneShotReinstall(state, 10, run) - await new Promise((resolve) => setTimeout(resolve, 100)) + await vi.advanceTimersByTimeAsync(100) expect(run).not.toHaveBeenCalled() expect(state.reinstallTimer).toBeUndefined() }) @@ -146,7 +154,7 @@ describe('WslRelayRecovery', () => { recovery.scheduleRestart(state) recovery.scheduleOneShotReinstall(state, 100, reinstall) recovery.clearTimers(state) - await new Promise((resolve) => setTimeout(resolve, 500)) + await vi.advanceTimersByTimeAsync(500) expect(restart).not.toHaveBeenCalled() expect(reinstall).not.toHaveBeenCalled() expect(state.restartTimer).toBeUndefined() diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 30e652a6cb4c..c27d84cf0379 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -159,6 +159,31 @@ describe('a structured launch that creates its own worktree', () => { expect(result.receipt).toMatchObject({ reason: 'structured_support_unknown' }) }) + it('keeps Pi on the terminal path when its RPC version is unsupported', async () => { + const h = harness({ createSupport: { supported: false, reason: 'agent' } }) + const result = await h.run({ + ...CREATE_INTENT, + agent: 'pi', + prompt: { text: 'continue my task', delivery: 'submit' } + }) + expect(h.calls).toEqual([ + 'createWorktree(startupAgent=undefined)', + 'createSupport', + 'createTerminalAgent' + ]) + expect(h.createStructuredSession).not.toHaveBeenCalled() + expect(h.createTerminalAgent).toHaveBeenCalledWith( + expect.objectContaining({ + startupPrompt: 'continue my task' + }) + ) + expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' }) + expect(result.receipt).toMatchObject({ + mode: 'terminal', + reason: 'structured_unsupported_on_host' + }) + }) + it('falls back only for a definitive structured refusal after the worktree exists', async () => { const h = harness({ structuredCreateError: new AgentLaunchStructuredSessionRefusedError( diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 3b5794a0ba74..642e59914148 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -55,7 +55,8 @@ import { resolveAgentLaunchModeOnHost, type AgentLaunchModeReceipt, type AgentLaunchModeVocabulary, - DEFAULT_LAUNCH_VOCABULARY + DEFAULT_LAUNCH_VOCABULARY, + warnStructuredLaunchDowngrade } from './agent-launch-mode' import { AgentLaunchStructuredSessionRefusedError, @@ -212,6 +213,7 @@ function published( execution: AgentLaunchExecution, surface: AgentLaunchPublishedSurface ): AgentLaunchPublishedSurface { + warnStructuredLaunchDowngrade(execution.intent.agent, surface.receipt) execution.onSurfacePublished?.(surface) return surface } diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 2a96c4f58088..be3837f97b6b 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -256,6 +256,16 @@ function downgraded( } } +/** One main-log line when a launch the user's default asked to be a chat opened a terminal. */ +export function warnStructuredLaunchDowngrade( + agent: string, + receipt: AgentLaunchModeReceipt +): void { + if (receipt.mode === 'terminal' && receipt.preferred === 'structured') { + console.warn(`[agent-launch] ${agent} opened a terminal: ${receipt.reason}`) + } +} + /** The store can be missing on a runtime that never opened one; that reads as no preference. */ export function readAgentLaunchModeSettings( runtime: Pick diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index cb664b41581c..357816cb9b44 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -230,7 +230,7 @@ export function addPreviewMessage( () => (args.text ? normalizeFullFirstUserPromptText(args.text) : null), args.seedFirstUserPrompt ) - const text = normalizePreviewText(args.text ?? '') + const text = normalizePreviewText(args.text ?? '', args.role) if (!text) { return } @@ -267,7 +267,7 @@ export function addPreviewContent( } addPreviewMessage(accumulator, { role, - text: extractPreviewContentText(content), + text: extractPreviewContentText(content, role), timestamp, // Content path already seeded above when capture is enabled. seedFirstUserPrompt: false, diff --git a/src/main/ai-vault/session-scanner-text-normalization.ts b/src/main/ai-vault/session-scanner-text-normalization.ts index 50fc54ab7aaa..9ad459575f66 100644 --- a/src/main/ai-vault/session-scanner-text-normalization.ts +++ b/src/main/ai-vault/session-scanner-text-normalization.ts @@ -1,4 +1,5 @@ import { sliceAtCodeUnitLimit } from '../../shared/surrogate-safe-text-slice' +import { withoutNativeChatVisualDirectiveLines } from '../../shared/native-chat-visual-directive' export { sliceAtCodeUnitLimit } @@ -38,17 +39,31 @@ export function normalizeTitleText(value: string): string | null { return finalizeNormalizedText(normalizeStringText(value, SESSION_TITLE_TEXT_LIMIT)) } -export function extractPreviewContentText(value: unknown): string | null { - return normalizeContentText(value, SESSION_PREVIEW_TEXT_LIMIT) +/** + * `role` 'assistant': a reply's visual lines show only in a chat transcript, so a preview drops + * them, per text part and before lines are folded into one. + */ +export function extractPreviewContentText(value: unknown, role?: string): string | null { + return normalizeContentText(value, SESSION_PREVIEW_TEXT_LIMIT, role === 'assistant') } -export function normalizePreviewText(value: string): string | null { - return finalizeNormalizedText(normalizeStringText(value, SESSION_PREVIEW_TEXT_LIMIT)) +export function normalizePreviewText(value: string, role?: string): string | null { + return finalizeNormalizedText( + normalizeStringText(previewSource(value, role === 'assistant'), SESSION_PREVIEW_TEXT_LIMIT) + ) +} + +function previewSource(text: string, dropVisualLines: boolean): string { + return dropVisualLines ? withoutNativeChatVisualDirectiveLines(text) : text } -function normalizeContentText(value: unknown, limit: number): string | null { +function normalizeContentText( + value: unknown, + limit: number, + dropVisualLines = false +): string | null { if (typeof value === 'string') { - return finalizeNormalizedText(normalizeStringText(value, limit)) + return finalizeNormalizedText(normalizeStringText(previewSource(value, dropVisualLines), limit)) } if (!Array.isArray(value)) { return null @@ -61,7 +76,7 @@ function normalizeContentText(value: unknown, limit: number): string | null { continue } appendInterPartSpace(builder) - appendNormalizedString(builder, text) + appendNormalizedString(builder, previewSource(text, dropVisualLines)) if (builder.truncated) { break } diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts index 10f6195b07c2..8fce0a6c49bf 100644 --- a/src/main/ai-vault/session-scanner-values.test.ts +++ b/src/main/ai-vault/session-scanner-values.test.ts @@ -24,6 +24,24 @@ describe('AI Vault session scanner text values', () => { expect(normalizeTitleText('Use this repo guidance')).toBeNull() }) + it("drops a reply's visual lines from its preview, but not a user's", () => { + const line = '::orca-visual{file="latency.html" title="p95"}' + expect(normalizePreviewText(`p95 is highest.\n\n${line}\n\nDone.`, 'assistant')).toBe( + 'p95 is highest. Done.' + ) + expect(normalizePreviewText(line, 'assistant')).toBeNull() + expect(normalizePreviewText(line, 'user')).toBe(line) + // Provider content (Claude/Codex message parts) folds lines; the visual line goes first. + expect( + extractPreviewContentText( + [{ type: 'text', text: `Here it is.\n\n${line}\n\nDone.` }], + 'assistant' + ) + ).toBe('Here it is. Done.') + expect(extractPreviewContentText(`Here it is.\n${line}`, 'assistant')).toBe('Here it is.') + expect(extractPreviewContentText([{ type: 'text', text: line }], 'user')).toBe(line) + }) + it('folds large preview text directly without full-string replacement', () => { const replaceSpy = vi.spyOn(String.prototype, 'replace') const hiddenContext = `${'SECRET\n'.repeat(10_000)}` diff --git a/src/main/automations/automation-owner-fencing.test.ts b/src/main/automations/automation-owner-fencing.test.ts index 14852c0267d9..87c18e466674 100644 --- a/src/main/automations/automation-owner-fencing.test.ts +++ b/src/main/automations/automation-owner-fencing.test.ts @@ -1,4 +1,10 @@ -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { + closeTestStores, + createSqliteTestStore, + createStore as createFreshStore, + testState +} from '../persistence-test-harness' +import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement' /** * The desktop authority's end of the scoped-list and owner-fenced contracts: * a parameterless read still answers with everything it stores, a scoped read is @@ -16,7 +22,7 @@ import { getDefaultPersistedState } from '../../shared/constants' import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../shared/automation-owner-conflict' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -103,6 +109,10 @@ async function createStore() { }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('../persistence') @@ -111,6 +121,8 @@ async function createStore() { } beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'automation-fencing-')) }) diff --git a/src/main/automations/automation-update-host-retarget.test.ts b/src/main/automations/automation-update-host-retarget.test.ts index 62c2b000d6fd..69fd40826429 100644 --- a/src/main/automations/automation-update-host-retarget.test.ts +++ b/src/main/automations/automation-update-host-retarget.test.ts @@ -1,4 +1,10 @@ -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { + closeTestStores, + createSqliteTestStore, + createStore as createFreshStore, + testState +} from '../persistence-test-harness' +import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement' /** * An update may not move a record to another host unless it was asked to. * @@ -20,7 +26,7 @@ import { AUTOMATION_ORPHAN_ISSUES } from '../../shared/automation-list-scope' import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../shared/automation-owner-conflict' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -120,6 +126,10 @@ async function createStore() { }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('../persistence') @@ -144,6 +154,8 @@ function projectedSelector(store: Store, id: string) { const ORPHAN = { selector: { kind: 'orphan' } } as const beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'automation-retarget-')) }) diff --git a/src/main/automations/automation-workspace-host-attribution.test.ts b/src/main/automations/automation-workspace-host-attribution.test.ts index 326de8fad23b..6a4ab525557d 100644 --- a/src/main/automations/automation-workspace-host-attribution.test.ts +++ b/src/main/automations/automation-workspace-host-attribution.test.ts @@ -1,4 +1,10 @@ -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { + closeTestStores, + createSqliteTestStore, + createStore as createFreshStore, + testState +} from '../persistence-test-harness' +import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement' /** * A folder workspace can pin its execution host while its repo stays local. * The desktop authority used to project only the repo, so those records listed @@ -19,7 +25,7 @@ import { AUTOMATION_ORPHAN_ISSUES } from '../../shared/automation-list-scope' import { AutomationService } from './service' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -116,6 +122,10 @@ async function createStore( }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('../persistence') @@ -124,6 +134,8 @@ async function createStore( } beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'automation-workspace-host-')) }) diff --git a/src/main/automations/service-precheck.test.ts b/src/main/automations/service-precheck.test.ts index 2bd3b5dd969e..7e3ec5886ced 100644 --- a/src/main/automations/service-precheck.test.ts +++ b/src/main/automations/service-precheck.test.ts @@ -2,8 +2,11 @@ import { closeTestStores, createSqliteTestStore, readPersistedStateJson, - writePersistedStateJson + writePersistedStateJson, + createStore as createFreshStore, + testState } from '../persistence-test-harness' +import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' @@ -15,7 +18,7 @@ import { createAutomationRunWriter } from './automation-run-writer' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' const runAutomationPrecheckMock = vi.hoisted(() => vi.fn()) -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { @@ -33,6 +36,10 @@ vi.mock('./precheck-runner', () => ({ })) async function createStore() { + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('../persistence') @@ -64,6 +71,8 @@ function mutateDataFile( describe('AutomationService prechecks', () => { beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-automations-test-')) runAutomationPrecheckMock.mockReset() vi.useFakeTimers() diff --git a/src/main/browser/agent-browser-bridge-raw-process-retention.test.ts b/src/main/browser/agent-browser-bridge-raw-process-retention.test.ts new file mode 100644 index 000000000000..014e0f392a2e --- /dev/null +++ b/src/main/browser/agent-browser-bridge-raw-process-retention.test.ts @@ -0,0 +1,117 @@ +import * as childProcess from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const { userDataPath } = vi.hoisted(() => ({ userDataPath: { current: '' } })) + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => userDataPath.current), + getAppPath: vi.fn(() => userDataPath.current), + isPackaged: false + } +})) + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, execFile: vi.fn(actual.execFile) } +}) + +import { AgentBrowserBridge } from './agent-browser-bridge' +import { mockBrowserManager } from './agent-browser-bridge-test-harness' +import { AGENT_BROWSER_EXIT_DRAIN_MS } from './agent-browser-bridge-types' + +let bridge: AgentBrowserBridge + +beforeEach(() => { + vi.clearAllMocks() + userDataPath.current = mkdtempSync(join(tmpdir(), 'orca-agent-browser-retention-')) + bridge = new AgentBrowserBridge(mockBrowserManager()) + Object.defineProperty(bridge, 'agentBrowserBin', { value: process.execPath, configurable: true }) +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(userDataPath.current, { recursive: true, force: true }) +}) + +function run(script: string): Promise { + return bridge['runAgentBrowserRaw']('orca-tab-retention', ['-e', script]) +} + +function rememberChild(script: string) { + const spawn = vi.mocked(childProcess.execFile) + const pending = run(script) + const result = spawn.mock.results[0] + if (result?.type !== 'return') { + throw new Error('Expected a real helper process') + } + const child = new WeakRef(result.value) + // Mock call records must not keep the completed helper alive. + spawn.mockClear() + return { pending, child } +} + +async function collect(): Promise { + if (!global.gc) { + throw new Error('This retention test requires --expose-gc') + } + for (let turn = 0; turn < 5; turn++) { + await new Promise((resolve) => setImmediate(resolve)) + global.gc() + } +} + +it('releases the completed helper before the fallback deadline while its bridge stays usable', async () => { + const { pending, child } = rememberChild("process.stdout.write('complete')") + expect(await pending).toBe('complete') + await collect() + expect(child.deref()).toBeUndefined() + expect(await run("process.stdout.write('next')")).toBe('next') +}) + +it.each(['error', 'cancel'] as const)( + 'retires the unused drain timer after %s', + async (outcome) => { + const timers = vi.spyOn(globalThis, 'setTimeout') + const clear = vi.spyOn(globalThis, 'clearTimeout') + const spawned = vi.mocked(childProcess.execFile) + const pending = run( + outcome === 'error' + ? "process.stderr.write('fixture-error');process.exitCode=1" + : 'setInterval(() => {}, 1000)' + ) + if (outcome === 'cancel') { + const result = spawned.mock.results[0] + if (result?.type !== 'return') { + throw new Error('Expected a real helper process') + } + bridge['cancelledProcesses'].add(result.value) + result.value.kill() + } + await expect(pending).rejects.toThrow(outcome === 'error' ? 'fixture-error' : 'Tab was closed') + const completed = spawned.mock.results[0] + if (completed?.type !== 'return') { + throw new Error('Expected a real helper process') + } + expect(bridge['cancelledProcesses'].has(completed.value)).toBe(false) + const drainIndex = timers.mock.calls.findIndex( + ([, delay]) => delay === AGENT_BROWSER_EXIT_DRAIN_MS + ) + expect(drainIndex).toBeGreaterThanOrEqual(0) + const drain = timers.mock.results[drainIndex] + if (drain?.type !== 'return') { + throw new Error('Expected a scheduled drain timer') + } + expect(clear).toHaveBeenCalledWith(drain.value) + } +) + +it('preserves spawn failure without scheduling an exit drain', async () => { + Object.defineProperty(bridge, 'agentBrowserBin', { value: join(userDataPath.current, 'missing') }) + const timers = vi.spyOn(globalThis, 'setTimeout') + await expect(run('')).rejects.toThrow('ENOENT') + expect(timers.mock.calls.some(([, delay]) => delay === AGENT_BROWSER_EXIT_DRAIN_MS)).toBe(false) +}) diff --git a/src/main/browser/agent-browser-bridge-raw-process.ts b/src/main/browser/agent-browser-bridge-raw-process.ts index 49663d260c27..aef9d2ce579c 100644 --- a/src/main/browser/agent-browser-bridge-raw-process.ts +++ b/src/main/browser/agent-browser-bridge-raw-process.ts @@ -23,6 +23,7 @@ export abstract class AgentBrowserBridgeRawProcess extends AgentBrowserBridgeExe return new Promise((resolve, reject) => { const session = this.sessions.get(sessionName) let child: ChildProcess | null = null + let exitDrainTimer: ReturnType | undefined child = execFile( this.agentBrowserBin, args, @@ -38,6 +39,7 @@ export abstract class AgentBrowserBridgeRawProcess extends AgentBrowserBridgeExe : this.agentBrowserEnv }, (error, stdout, stderr) => { + clearTimeout(exitDrainTimer) if (session && session.activeProcess === child) { session.activeProcess = null } @@ -99,7 +101,7 @@ export abstract class AgentBrowserBridgeRawProcess extends AgentBrowserBridgeExe // Why: a Windows daemon inherits the helper's stdio pipes (vercel-labs/agent-browser#1407), so // EOF never arrives; after exit, drain briefly then release the pipes so execFile settles. child.once('exit', () => { - setTimeout(() => { + exitDrainTimer = setTimeout(() => { child.stdout?.destroy() child.stderr?.destroy() }, AGENT_BROWSER_EXIT_DRAIN_MS) diff --git a/src/main/browser/browser-cookie-chromium-prepare.ts b/src/main/browser/browser-cookie-chromium-prepare.ts index 8b71b102f13d..6253e50116e7 100644 --- a/src/main/browser/browser-cookie-chromium-prepare.ts +++ b/src/main/browser/browser-cookie-chromium-prepare.ts @@ -142,185 +142,198 @@ export async function prepareChromiumCookieImport( } } - // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. - sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { readOnly: true, readBigInts: true }) - let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null - let colList: string | null = null - let placeholders: string | null = null - if (stagingAvailable) { - // Why: the staged file is Orca's own partition DB, also named "Cookies", so the same - // transient AV handle can make opening it throw — degrade instead of killing the import. - try { - stagingDb = new DatabaseSync(stagingCookiesPath) - // Why (STA-4797): a new-format stage must be one self-contained file. Otherwise a lost WAL - // can erase its scope marker and make cold-start replay mistake it for a legacy whole-image - // import, restoring the unrelated-cookie data loss this format is meant to prevent. - stagingDb.exec('PRAGMA journal_mode = DELETE') - targetColumnInfo = stagingDb - .prepare('PRAGMA table_info(cookies)') - .all() as ChromiumCookieColumnInfo[] - const targetCols = targetColumnInfo.map((row) => row.name) - colList = targetCols.join(', ') - placeholders = targetCols.map(() => '?').join(', ') - } catch (err) { - diag(` staging database unusable, restart fallback disabled: ${String(err)}`) - stagingAvailable = false - targetColumnInfo = null - colList = null - placeholders = null - closeStagingDb() - // Why: the copy holds real partition cookies; discard it now rather than at the exit branches. - discardStagingFile() + let ownershipTransferred = false + try { + // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. + sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { readOnly: true, readBigInts: true }) + let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null + let colList: string | null = null + let placeholders: string | null = null + if (stagingAvailable) { + // Why: the staged file is Orca's own partition DB, also named "Cookies", so the same + // transient AV handle can make opening it throw — degrade instead of killing the import. + try { + stagingDb = new DatabaseSync(stagingCookiesPath) + // Why (STA-4797): a new-format stage must be one self-contained file. Otherwise a lost WAL + // can erase its scope marker and make cold-start replay mistake it for a legacy whole-image + // import, restoring the unrelated-cookie data loss this format is meant to prevent. + stagingDb.exec('PRAGMA journal_mode = DELETE') + targetColumnInfo = stagingDb + .prepare('PRAGMA table_info(cookies)') + .all() as ChromiumCookieColumnInfo[] + const targetCols = targetColumnInfo.map((row) => row.name) + colList = targetCols.join(', ') + placeholders = targetCols.map(() => '?').join(', ') + } catch (err) { + diag(` staging database unusable, restart fallback disabled: ${String(err)}`) + stagingAvailable = false + targetColumnInfo = null + colList = null + placeholders = null + closeStagingDb() + // Why: the copy holds real partition cookies; discard it now rather than at the exit branches. + discardStagingFile() + } } - } - // Why (STA-4300): the partition columns drift across Chromium versions, so read the source - // schema rather than assuming a row's missing column means "unpartitioned". - const sourceColumns = new Set( - (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( - (column) => column.name + // Why (STA-4300): the partition columns drift across Chromium versions, so read the source + // schema rather than assuming a row's missing column means "unpartitioned". + const sourceColumns = new Set( + (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( + (column) => column.name + ) ) - ) - const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< - string, - unknown - >[] - sourceDb.close() - sourceDb = null - diag(` source has ${sourceRows.length} cookies`) - if (sourceRows.length === 0) { - closeStagingDb() - discardStagingFile() - return { result: { ok: false, reason: `No cookies found in ${browser.label}.` } } - } - - // Why (STA-4300): partition fidelity is a property of the source row, even when its value - // cannot be decrypted. Plan first so decryption failure cannot discard a family's skip. - const partitionCandidates = sourceRows.flatMap((sourceRow) => { - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain) - ? [] - : [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }] - }) - const nativePlan = planImportWrites(partitionCandidates) - const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow)) - const partitionBySourceRow = new Map( - partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) - ) - // Why (§4.3c): a family we cannot name is one we cannot exclude from the clear, and clearing a - // family we cannot protect is the P0. Refuse before the jar is touched. - if (nativePlan.hasUnrepresentableSkip) { - closeStagingDb() - discardStagingFile() - return { - result: { - ok: false, - reason: - 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' - } + const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< + string, + unknown + >[] + sourceDb.close() + sourceDb = null + diag(` source has ${sourceRows.length} cookies`) + if (sourceRows.length === 0) { + return { result: { ok: false, reason: `No cookies found in ${browser.label}.` } } } - } - const needsSourceKey = sourceRows.some((sourceRow) => { - const encrypted = sourceRow.encrypted_value - if (!(encrypted instanceof Uint8Array) || encrypted.length === 0) { - return false - } - return ( - !isGoogleSourceBoundCookie(sourceRow.name as string, sourceRow.host_key as string) && - !isNonTransplantableCookieDomain(sourceRow.host_key as string) + // Why (STA-4300): partition fidelity is a property of the source row, even when its value + // cannot be decrypted. Plan first so decryption failure cannot discard a family's skip. + const partitionCandidates = sourceRows.flatMap((sourceRow) => { + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain) + ? [] + : [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }] + }) + const nativePlan = planImportWrites(partitionCandidates) + const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow)) + const partitionBySourceRow = new Map( + partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) ) - }) - const sourceKey = needsSourceKey - ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) - : null - if (needsSourceKey && !sourceKey) { - closeStagingDb() - // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. - discardStagingFile() - return { - result: { - ok: false, - reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` + // Why (§4.3c): a family we cannot name is one we cannot exclude from the clear, and clearing a + // family we cannot protect is the P0. Refuse before the jar is touched. + if (nativePlan.hasUnrepresentableSkip) { + return { + result: { + ok: false, + reason: + 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' + } } } - } - // Why: staging only backs the cold-restart replay, so any failure writing it disables that - // fallback instead of aborting an import whose in-memory half still works. - let insertStmt: ChromiumImportContext['insertStmt'] = null - const context: ChromiumImportContext = { - browser, - targetPartition, - options, - targetSession, - stagingCookiesPath, - stagingAvailable, - sourceSnapshot, - sourceDb, - stagingDb, - targetColumnInfo, - colList, - placeholders, - sourceColumns, - sourceRows, - nativePlan, - plannedSourceRows, - partitionBySourceRow, - sourceKey, - imported: 0, - skipped: 0, - decryptFailed: 0, - appBoundFailed: 0, - keyringUnavailableFailed: 0, - integritySkipped: 0, - nonTransplantableSkipped: 0, - partitionSkipped: nativePlan.skips.length, - googleCookiesSkipped: 0, - memoryLoaded: 0, - memoryFailed: 0, - domainSet: new Set(), - decryptedCookies: [], - // Why: the staging insert needs the RAW source row, so each scanned candidate carries it. - // A plan record holding only the derived fields compiles fine and then cannot stage. - scanned: [], - sourceDomainValidity: new Map(), - insertStmt, - importScope: { - exact: new Set(), - ancestors: new Set(), - descendantRoots: new Set() - }, - closeStagingDb, - discardStagingFile, - disableStaging: (reason: string): void => { - diag(` staging disabled, restart fallback unavailable: ${reason}`) - context.stagingAvailable = false - context.insertStmt = null - context.closeStagingDb() - context.discardStagingFile() + const needsSourceKey = sourceRows.some((sourceRow) => { + const encrypted = sourceRow.encrypted_value + if (!(encrypted instanceof Uint8Array) || encrypted.length === 0) { + return false + } + return ( + !isGoogleSourceBoundCookie(sourceRow.name as string, sourceRow.host_key as string) && + !isNonTransplantableCookieDomain(sourceRow.host_key as string) + ) + }) + const sourceKey = needsSourceKey + ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) + : null + if (needsSourceKey && !sourceKey) { + return { + result: { + ok: false, + reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` + } + } } - } satisfies ChromiumImportContext - if (context.stagingDb && context.colList && context.placeholders) { - try { - context.insertStmt = context.stagingDb.prepare( - `INSERT OR REPLACE INTO cookies (${context.colList}) VALUES (${context.placeholders})` + // Why: staging only backs the cold-restart replay, so any failure writing it disables that + // fallback instead of aborting an import whose in-memory half still works. + let insertStmt: ChromiumImportContext['insertStmt'] = null + const context: ChromiumImportContext = { + browser, + targetPartition, + options, + targetSession, + stagingCookiesPath, + stagingAvailable, + sourceSnapshot, + sourceDb, + stagingDb, + targetColumnInfo, + colList, + placeholders, + sourceColumns, + sourceRows, + nativePlan, + plannedSourceRows, + partitionBySourceRow, + sourceKey, + imported: 0, + skipped: 0, + decryptFailed: 0, + appBoundFailed: 0, + keyringUnavailableFailed: 0, + integritySkipped: 0, + nonTransplantableSkipped: 0, + partitionSkipped: nativePlan.skips.length, + googleCookiesSkipped: 0, + memoryLoaded: 0, + memoryFailed: 0, + domainSet: new Set(), + decryptedCookies: [], + // Why: the staging insert needs the RAW source row, so each scanned candidate carries it. + // A plan record holding only the derived fields compiles fine and then cannot stage. + scanned: [], + sourceDomainValidity: new Map(), + insertStmt, + importScope: { + exact: new Set(), + ancestors: new Set(), + descendantRoots: new Set() + }, + closeStagingDb, + discardStagingFile, + disableStaging: (reason: string): void => { + diag(` staging disabled, restart fallback unavailable: ${reason}`) + context.stagingAvailable = false + context.insertStmt = null + context.closeStagingDb() + context.discardStagingFile() + } + } satisfies ChromiumImportContext + + if (context.stagingDb && context.colList && context.placeholders) { + try { + context.insertStmt = context.stagingDb.prepare( + `INSERT OR REPLACE INTO cookies (${context.colList}) VALUES (${context.placeholders})` + ) + context.stagingDb.exec('BEGIN TRANSACTION') + } catch (err) { + context.disableStaging(String(err)) + } + } else if (context.stagingAvailable) { + context.disableStaging('staged database exposed no cookies columns') + } + // Why: keep the existing conservative fallback boundary for family-level omissions. Expanding + // partial-import restart behavior is separate from narrowing what a staged replay may replace. + if (context.nativePlan.skippedFamilies.size > 0) { + context.disableStaging( + `${context.nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` ) - context.stagingDb.exec('BEGIN TRANSACTION') - } catch (err) { - context.disableStaging(String(err)) } - } else if (context.stagingAvailable) { - context.disableStaging('staged database exposed no cookies columns') - } - // Why: keep the existing conservative fallback boundary for family-level omissions. Expanding - // partial-import restart behavior is separate from narrowing what a staged replay may replace. - if (context.nativePlan.skippedFamilies.size > 0) { - context.disableStaging( - `${context.nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` - ) + // Why: the importer owns successful preparation, including any restart fallback. + ownershipTransferred = true + return { context } + } finally { + if (!ownershipTransferred) { + try { + sourceDb?.close() + } catch { + /* best-effort */ + } + closeStagingDb() + discardStagingFile() + try { + sourceSnapshot.cleanup() + } catch (err) { + diag(` Chromium snapshot cleanup failed: ${String(err)}`) + } + } } - return { context } } diff --git a/src/main/browser/browser-cookie-import-snapshot-ownership.test.ts b/src/main/browser/browser-cookie-import-snapshot-ownership.test.ts new file mode 100644 index 000000000000..c704ae8c9ad0 --- /dev/null +++ b/src/main/browser/browser-cookie-import-snapshot-ownership.test.ts @@ -0,0 +1,281 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeSqlite from 'node:sqlite' +import type * as ChromiumCookieSnapshotModule from './chromium-cookie-snapshot' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { tmpdir } from 'node:os' + +const { + appGetPathMock, + sessionFromPartitionMock, + runProcessSyncMock, + snapshotRootMock, + setPendingCookieImportMock, + clearPendingCookieImportMock, + writeCookieIdentityMock, + openedDatabases, + cleanupStates +} = vi.hoisted(() => { + const openedDatabases: { path: string; database: NodeSqlite.DatabaseSync }[] = [] + const cleanupStates: boolean[][] = [] + return { + appGetPathMock: vi.fn(), + sessionFromPartitionMock: vi.fn(), + runProcessSyncMock: vi.fn(), + snapshotRootMock: vi.fn(), + setPendingCookieImportMock: vi.fn(), + clearPendingCookieImportMock: vi.fn(), + writeCookieIdentityMock: vi.fn(), + openedDatabases, + cleanupStates + } +}) + +vi.mock('electron', () => ({ + app: { getPath: appGetPathMock }, + session: { fromPartition: sessionFromPartitionMock } +})) +vi.mock('../../shared/child-process/run-process', () => ({ + runProcessSync: runProcessSyncMock +})) +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock + } +})) +vi.mock('node:sqlite', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + DatabaseSync: class extends actual.DatabaseSync { + constructor(...args: ConstructorParameters) { + super(...args) + openedDatabases.push({ path: String(args[0]), database: this }) + } + } + } +}) +vi.mock('./chromium-cookie-snapshot', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createChromiumCookieSnapshot: (sourcePath: string) => { + const snapshot = actual.createChromiumCookieSnapshot(sourcePath, { + tempRoot: snapshotRootMock() + }) + return { + ...snapshot, + cleanup: () => { + cleanupStates.push( + openedDatabases + .filter( + ({ path }) => + path === snapshot.databasePath || path.includes('cookie-import-staging') + ) + .map(({ database }) => database.isOpen) + ) + snapshot.cleanup() + } + } + } + } +}) +vi.mock('./browser-cookie-clear-store', () => ({ + openCookieClearStore: (targetSession: { + cookies: { get: () => Promise; remove: (url: string, name: string) => Promise } + }) => ({ + get: () => targetSession.cookies.get(), + remove: (url: string, name: string) => targetSession.cookies.remove(url, name), + snapshotClearIdentities: async () => [], + restoreClearIdentities: async () => undefined, + writeCookieIdentity: writeCookieIdentityMock, + dispose: () => undefined + }) +})) + +import { importChromiumCookies } from './browser-cookie-chromium-import' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' +import { DatabaseSync } from 'node:sqlite' + +const PARTITION = 'persist:test' + +function chromeBrowser(cookiesPath: string): DetectedBrowser { + return { + family: 'chrome', + label: 'Google Chrome', + cookiesPath, + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } +} + +describe('Chromium import snapshot ownership', () => { + let root: string + let sourcePath: string + let targetPath: string + let snapshotRoot: string + let stagingRoot: string + let sourceDatabase: NodeSqlite.DatabaseSync + let targetBefore: Buffer + let cookiesRemoveMock: ReturnType + let platformSpy: ReturnType + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-cookie-snapshot-owner-test-')) + snapshotRoot = root + sourcePath = join(root, 'Chrome', 'Default', 'Network', 'Cookies') + targetPath = join(root, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + stagingRoot = join(root, 'userData', 'cookie-import-staging') + sourceDatabase = createChromiumCookieTestDatabase(sourcePath, [], { journalMode: 'wal' }) + createChromiumCookieTestDatabase(targetPath, [ + { domain: '.other.test', name: 'old', value: 'target' } + ]).close() + targetBefore = readFileSync(targetPath) + cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) + appGetPathMock.mockReturnValue(join(root, 'userData')) + snapshotRootMock.mockReturnValue(snapshotRoot) + sessionFromPartitionMock.mockReturnValue({ + getStoragePath: () => dirname(dirname(targetPath)), + cookies: { + flushStore: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockResolvedValue([]), + remove: cookiesRemoveMock, + set: vi.fn().mockRejectedValue(new Error('Unexpected target initialization')) + } + }) + runProcessSyncMock.mockReturnValue({ + code: 1, + signal: null, + stdout: '', + stderr: '', + timedOut: false + }) + writeCookieIdentityMock.mockResolvedValue(undefined) + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + cleanupStates.length = 0 + }) + + afterEach(() => { + for (const { database } of openedDatabases) { + if (database.isOpen) { + database.close() + } + } + openedDatabases.length = 0 + platformSpy.mockRestore() + vi.clearAllMocks() + rmSync(root, { recursive: true, force: true }) + }) + + function snapshotDirectories() { + return readdirSync(snapshotRoot).filter((name) => name.startsWith('orca-cookie-import-')) + } + + function expectRefusalCleanup() { + expect(snapshotDirectories()).toEqual([]) + expect(readdirSync(stagingRoot)).toEqual([]) + expect(readFileSync(targetPath)).toEqual(targetBefore) + expect(writeCookieIdentityMock).not.toHaveBeenCalled() + expect(cookiesRemoveMock).not.toHaveBeenCalled() + expect(setPendingCookieImportMock).not.toHaveBeenCalled() + expect(cleanupStates.every((states) => states.every((open) => !open))).toBe(true) + } + + function insertSourceCookie(encrypted: boolean) { + sourceDatabase.exec(`INSERT INTO cookies ( + creation_utc, host_key, name, value, encrypted_value, path, expires_utc, + is_secure, is_httponly, samesite + ) VALUES (133000000000000, '.example.test', 'sid', + ${encrypted ? "''" : "'source-value'"}, + ${encrypted ? "X'763130656E63727970746564'" : "X''"}, '/', 0, 0, 0, -1)`) + } + + it('retires an empty live-WAL source snapshot before returning its existing refusal', async () => { + expect(existsSync(`${sourcePath}-wal`)).toBe(true) + expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toEqual({ + ok: false, + reason: 'No cookies found in Google Chrome.' + }) + expect(cleanupStates).toHaveLength(1) + expectRefusalCleanup() + }) + + it('retires every snapshot when denied credential access is retried', async () => { + insertSourceCookie(true) + for (let attempt = 0; attempt < 3; attempt++) { + expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toEqual({ + ok: false, + reason: 'Could not access Google Chrome encryption key. The OS may have denied access.' + }) + expectRefusalCleanup() + } + expect(runProcessSyncMock).toHaveBeenCalledTimes(3) + expect(cleanupStates).toHaveLength(3) + }) + + it('retires the snapshot when an unreadable partition has no preservable family', async () => { + insertSourceCookie(false) + sourceDatabase.exec( + "UPDATE cookies SET host_key = 'com', top_frame_site_key = 'https://top.test', has_cross_site_ancestor = 2" + ) + expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toEqual({ + ok: false, + reason: + 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' + }) + expect(cleanupStates).toHaveLength(1) + expectRefusalCleanup() + }) + + it('closes private databases before cleanup when source schema preparation throws', async () => { + sourceDatabase.exec('DROP TABLE cookies; CREATE TABLE other_data(value TEXT)') + expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toEqual({ + ok: false, + reason: expect.stringContaining('no such table: cookies') + }) + expect(cleanupStates).toHaveLength(1) + expect(cleanupStates[0].length).toBeGreaterThanOrEqual(2) + expectRefusalCleanup() + }) + + it('hands successful preparation to the importer for ordinary completion cleanup', async () => { + insertSourceCookie(false) + const result = await importChromiumCookies(chromeBrowser(sourcePath), PARTITION) + expect(result).toMatchObject({ ok: true, summary: { totalCookies: 1, importedCookies: 1 } }) + expect(writeCookieIdentityMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'sid', value: 'source-value' }) + ) + expect(snapshotDirectories()).toEqual([]) + expect(readdirSync(stagingRoot)).toEqual([]) + expect(cleanupStates).toEqual([[false, false]]) + }) + + it('removes the source snapshot while preserving a usable stage owned by restart replay', async () => { + insertSourceCookie(false) + writeCookieIdentityMock.mockRejectedValue(new Error('Cookie rejected')) + expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toMatchObject({ + ok: true + }) + expect(snapshotDirectories()).toEqual([]) + expect(cleanupStates).toEqual([[false, false]]) + expect(setPendingCookieImportMock).toHaveBeenCalledTimes(1) + const stagedPath: unknown = setPendingCookieImportMock.mock.calls[0][1] + if (typeof stagedPath !== 'string') { + throw new Error('No staged replay path') + } + expect(readdirSync(stagingRoot)).toEqual([basename(stagedPath)]) + const stage = new DatabaseSync(stagedPath, { readOnly: true }) + try { + const row = stage + .prepare("SELECT CAST(value AS TEXT) AS value FROM cookies WHERE name = 'sid'") + .get() + expect(row).toEqual({ value: 'source-value' }) + } finally { + stage.close() + } + }) +}) diff --git a/src/main/browser/browser-screencast-first-frame-timeout.test.ts b/src/main/browser/browser-screencast-first-frame-timeout.test.ts new file mode 100644 index 000000000000..63e92e0de27b --- /dev/null +++ b/src/main/browser/browser-screencast-first-frame-timeout.test.ts @@ -0,0 +1,120 @@ +/** + * A hidden, throttled embedder stops compositing: capturePage never settles and no live frame + * comes. The stream says so once, 10 s after it started, and only if no frame arrived by then. + */ +import { Buffer } from 'node:buffer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { startBrowserScreencast } from './browser-screencast-stream' +import type { BrowserScreencastOptions } from './browser-screencast-stream-types' +import { createMockScreencastWebContents } from './browser-screencast-web-contents-test-double' + +function never(): Promise { + return new Promise(() => {}) +} + +function start( + webContents: ReturnType, + options: BrowserScreencastOptions +) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stream touches only the debugger, isDestroyed and capturePage the doubles provide. + return startBrowserScreencast(webContents as never, options) +} + +function liveFrame(webContents: ReturnType): void { + webContents.debugger.emit('message', {}, 'Page.screencastFrame', { + data: Buffer.from('live').toString('base64'), + sessionId: 1, + metadata: { deviceWidth: 390, deviceHeight: 844, pageScaleFactor: 1 } + }) +} + +function startOptions(viewport: boolean) { + return { + format: 'jpeg' as const, + quality: 70, + maxWidth: 1440, + maxHeight: 1200, + everyNthFrame: 2, + minFrameIntervalMs: 0, + ...(viewport ? { viewportWidth: 390, viewportHeight: 844 } : {}), + onFrame: vi.fn(() => true), + onError: vi.fn() + } +} + +describe('browser screencast first-frame deadline', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('reports no frame at exactly 10 s when the captures hang', async () => { + const webContents = Object.assign(createMockScreencastWebContents(), { + capturePage: vi.fn(() => never()) + }) + webContents.debugger.sendCommand.mockImplementation(async (method: string) => + method === 'Page.captureScreenshot' ? never() : {} + ) + const options = startOptions(true) + const session = await start(webContents, options) + + await vi.advanceTimersByTimeAsync(9_999) + expect(options.onError).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + + expect(options.onError).toHaveBeenCalledExactlyOnceWith('Browser stream timed out.') + session.stop() + // The stop queues behind the capture: capturePage's 10 s bound, then the fallback's 8 s. + await vi.advanceTimersByTimeAsync(8_000) + await session.done + }) + + it.each(['live', 'snapshot'] as const)('stays quiet once a %s frame arrives', async (kind) => { + const webContents = Object.assign(createMockScreencastWebContents(), { + capturePage: vi.fn(() => never()) + }) + webContents.debugger.sendCommand.mockImplementation(async (method: string) => + method === 'Page.captureScreenshot' ? { data: Buffer.from('frame').toString('base64') } : {} + ) + const options = startOptions(kind === 'live') + const session = await start(webContents, options) + if (kind === 'live') { + liveFrame(webContents) + } + + await vi.advanceTimersByTimeAsync(30_000) + + expect(options.onFrame).toHaveBeenCalled() + expect(options.onError).not.toHaveBeenCalled() + session.stop() + await session.done + }) + + it('drops a capture that settles after the limit', async () => { + let settleCapture!: (image: unknown) => void + const webContents = Object.assign(createMockScreencastWebContents(), { + capturePage: vi.fn( + () => + new Promise((resolve) => { + settleCapture = resolve + }) + ) + }) + const options = startOptions(true) + const session = await start(webContents, options) + await vi.advanceTimersByTimeAsync(10_000) + + settleCapture({ + getSize: () => ({ width: 390, height: 844 }), + toJPEG: () => Buffer.from('late'), + toPNG: () => Buffer.from('late') + }) + await vi.advanceTimersByTimeAsync(1_000) + + expect(options.onFrame).not.toHaveBeenCalled() + session.stop() + await session.done + }) +}) diff --git a/src/main/browser/browser-screencast-snapshot-capture.ts b/src/main/browser/browser-screencast-snapshot-capture.ts index b06ce8bb5cf3..70c4d4449832 100644 --- a/src/main/browser/browser-screencast-snapshot-capture.ts +++ b/src/main/browser/browser-screencast-snapshot-capture.ts @@ -7,6 +7,10 @@ import type { PendingScreencastFrame } from './browser-screencast-stream-types' import { positiveInteger, scaleSnapshotToFit } from './browser-screencast-viewport-fit' +import { withTimeout } from '../../shared/promise-timeout-fallback' + +// Why: a hung capture would also wedge the viewport updates and stop queued behind it. +const SNAPSHOT_CAPTURE_TIMEOUT_MS = 10_000 type BrowserScreencastSnapshotCaptureDeps = { webContents: WebContents @@ -76,19 +80,19 @@ export function createBrowserScreencastSnapshotCapture( } if (viewportWidth && viewportHeight && typeof webContents.capturePage === 'function') { try { - // Why: CDP captureScreenshot can tile BrowserView surfaces under - // mobile emulation; Electron captures the actual visible viewport. - const nativeImage = await webContents.capturePage({ - x: 0, - y: 0, - width: viewportWidth, - height: viewportHeight - }) - const capture = scaleSnapshotToFit(nativeImage, options) - const buffer = - options.format === 'png' ? capture.toPNG() : capture.toJPEG(options.quality) - if (buffer.byteLength > 0) { - image = new Uint8Array(buffer) + // Why: captures the real viewport (CDP can tile it) but never settles once compositing stops. + const nativeImage = await withTimeout( + webContents.capturePage({ x: 0, y: 0, width: viewportWidth, height: viewportHeight }), + SNAPSHOT_CAPTURE_TIMEOUT_MS, + null + ) + if (nativeImage) { + const capture = scaleSnapshotToFit(nativeImage, options) + const buffer = + options.format === 'png' ? capture.toPNG() : capture.toJPEG(options.quality) + if (buffer.byteLength > 0) { + image = new Uint8Array(buffer) + } } } catch { image = null diff --git a/src/main/browser/browser-screencast-stream.ts b/src/main/browser/browser-screencast-stream.ts index 237c94852e88..6456f4e3102d 100644 --- a/src/main/browser/browser-screencast-stream.ts +++ b/src/main/browser/browser-screencast-stream.ts @@ -13,6 +13,9 @@ import type { BrowserScreencastViewport } from './browser-screencast-stream-types' +// Why: long enough for a slow first paint, short enough that a frameless viewer is told soon. +const NO_FRAME_TIMEOUT_MS = 10_000 + export async function startBrowserScreencast( webContents: WebContents, options: BrowserScreencastOptions @@ -108,8 +111,9 @@ export async function startBrowserScreencast( } const handleDetach = (): void => { - options.onError?.('Browser debugger detached while streaming.') + // Why: finish first so an owner that stops the stream on this error finds it already closed. finish() + options.onError?.('Browser debugger detached while streaming.') } dbg.on('message', handleMessage as never) @@ -120,6 +124,13 @@ export async function startBrowserScreencast( await deviceMetrics.apply() await startScreencast() pendingUpdate = snapshotCapture.emitSnapshotFrame(true) + // Why: a page whose embedder stopped compositing never yields a frame; the owner ends on this. + const noFrameTimer = setTimeout(() => { + if (!closed && !stopping && framePacer.getSeq() === 0) { + options.onError?.('Browser stream timed out.') + } + }, NO_FRAME_TIMEOUT_MS) + void done.then(() => clearTimeout(noFrameTimer)) } catch (error) { if (deviceMetrics.isOverridden()) { await deviceMetrics.clear().catch(() => {}) @@ -188,7 +199,7 @@ export async function startBrowserScreencast( return pendingUpdate }, stop: () => { - if (closed) { + if (closed || stopping) { return } stopping = true diff --git a/src/main/claude-accounts/claude-account-folder.test.ts b/src/main/claude-accounts/claude-account-folder.test.ts new file mode 100644 index 000000000000..5374afed213a --- /dev/null +++ b/src/main/claude-accounts/claude-account-folder.test.ts @@ -0,0 +1,106 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as Os from 'node:os' + +const keychain = vi.hoisted(() => ({ items: new Map(), userHome: '' })) +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => keychain.userHome +})) +vi.mock('../macos-keychain/generic-password', () => ({ + execSecurityCommand: async (args: string[]) => { + const service = args[2] + const count = keychain.items.get(service) ?? 0 + if (count === 0) { + throw new Error('The specified item could not be found in the keychain.') + } + keychain.items.set(service, count - 1) + return { stdout: '', stderr: '' } + } +})) + +import { readClaudeFolderLogin, removeClaudeAccountFolder } from './claude-account-folder' +import { claudeKeychainService } from './keychain' + +const roots: string[] = [] +afterEach(() => { + keychain.items.clear() + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'claude-account-folder-')) + roots.push(root) + keychain.userHome = root + const dataRoot = join(root, 'data') + const home = join(dataRoot, 'claude-profiles', 'a', 'home') + mkdirSync(home, { recursive: true }) + return { root, dataRoot, home } +} + +describe('claude account folder', () => { + it("reads the folder's login and treats a signed-out state file as no login", () => { + const { home } = fixture() + const stateFile = join(home, '.claude.json') + expect(readClaudeFolderLogin(stateFile)).toBeNull() + writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: ' a@example.test ' } })) + expect(readClaudeFolderLogin(stateFile)).toEqual({ + email: 'a@example.test', + organizationUuid: null, + organizationName: null + }) + writeFileSync(stateFile, JSON.stringify({ numStartups: 2 })) + expect(readClaudeFolderLogin(stateFile)).toBeNull() + }) + + it('reuses a recent answer within maxAgeMs even though the file changed', () => { + const { home } = fixture() + const stateFile = join(home, '.claude.json') + writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: 'a@example.test' } })) + expect(readClaudeFolderLogin(stateFile, 5_000)?.email).toBe('a@example.test') + writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: 'b@example.test!' } })) + expect(readClaudeFolderLogin(stateFile, 5_000)?.email).toBe('a@example.test') + expect(readClaudeFolderLogin(stateFile)?.email).toBe('b@example.test!') + }) + + // A real symlink needs Developer Mode on Windows (EPERM otherwise). + it.skipIf(process.platform === 'win32')( + 'unlinks shared history before deleting, and leaves the older layout alone', + async () => { + const { root, dataRoot, home } = fixture() + const history = join(root, '.claude', 'projects') + mkdirSync(history, { recursive: true }) + writeFileSync(join(history, 'chat.jsonl'), '{}') + symlinkSync(history, join(home, 'projects')) + const older = join(dataRoot, 'claude-accounts', 'a') + mkdirSync(older, { recursive: true }) + + await removeClaudeAccountFolder(dataRoot, 'a') + expect(existsSync(join(dataRoot, 'claude-profiles', 'a'))).toBe(false) + expect(existsSync(join(history, 'chat.jsonl'))).toBe(true) + expect(existsSync(older)).toBe(true) + } + ) + + it.skipIf(process.platform !== 'darwin')( + 'deletes every Keychain item Claude may have keyed to the folder', + async () => { + const { dataRoot, home } = fixture() + const rest = home.slice(keychain.userHome.length) + const spellings = [home, `${home}/`, `~${rest}`, `$HOME${rest}/`] + for (const spelling of spellings) { + keychain.items.set(claudeKeychainService(spelling), 2) + } + const unrelated = claudeKeychainService(join(keychain.userHome, 'other')) + keychain.items.set(unrelated, 1) + + await removeClaudeAccountFolder(dataRoot, 'a') + for (const spelling of spellings) { + expect(keychain.items.get(claudeKeychainService(spelling))).toBe(0) + } + expect(keychain.items.get(unrelated)).toBe(1) + } + ) +}) diff --git a/src/main/claude-accounts/claude-account-folder.ts b/src/main/claude-accounts/claude-account-folder.ts new file mode 100644 index 000000000000..7e4bfe8e3efd --- /dev/null +++ b/src/main/claude-accounts/claude-account-folder.ts @@ -0,0 +1,153 @@ +import { + existsSync, + lstatSync, + readdirSync, + rmdirSync, + rmSync, + statSync, + unlinkSync +} from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { z } from 'zod' +import { resolveClaudeGlobalConfigFile } from '../claude/claude-folder-trust-file' +import { execSecurityCommand } from '../macos-keychain/generic-password' +import { describeClaudeProfile, readClaudeProfileObject } from './claude-profile-paths' +import { claudeConfigDirSpellings, claudeKeychainService } from './keychain' + +export type ClaudeFolderLogin = { + email: string + organizationUuid: string | null + organizationName: string | null +} + +const text = z + .string() + .transform((value) => value.trim() || null) + .nullish() + .catch(null) +const oauthAccount = z.object({ + emailAddress: text, + organizationUuid: text, + organizationName: text +}) + +/** Claude's state file for a config folder; undefined is `~/.claude`, whose file sits in home. */ +export function claudeStateFile(configDir: string | undefined, userHome = homedir()): string { + return resolveClaudeGlobalConfigFile({ + env: { CLAUDE_CONFIG_DIR: configDir }, + homeDir: userHome, + style: process.platform === 'win32' ? 'win32' : 'posix', + exists: existsSync + }) +} + +// Why memoized: a state file grows with history and the account list is read on every refresh. +const logins = new Map< + string, + { mtimeMs: number; size: number; readAt: number; login: ClaudeFolderLogin | null } +>() + +/** The login parsed Claude state names; null before Claude finishes a sign-in. */ +export function claudeStateLogin(state: Record): ClaudeFolderLogin | null { + const parsed = oauthAccount.safeParse(state.oauthAccount) + const email = parsed.success ? parsed.data.emailAddress : null + return parsed.success && email + ? { + email, + organizationUuid: parsed.data.organizationUuid ?? null, + organizationName: parsed.data.organizationName ?? null + } + : null +} + +/** + * The login a Claude state file names; null when signed out (superset U/profiles.ts:121-137). + * `maxAgeMs` reuses a recent answer without a stat, for a file Claude rewrites constantly. + */ +export function readClaudeFolderLogin(stateFile: string, maxAgeMs = 0): ClaudeFolderLogin | null { + const recent = logins.get(stateFile) + if (recent && Date.now() - recent.readAt < maxAgeMs) { + return recent.login + } + let stat: { mtimeMs: number; size: number } + try { + stat = statSync(stateFile) + } catch { + logins.delete(stateFile) + return null + } + if (recent?.mtimeMs === stat.mtimeMs && recent.size === stat.size) { + recent.readAt = Date.now() + return recent.login + } + const read = readClaudeProfileObject(stateFile) + const login = read.kind === 'present' ? claudeStateLogin(read.value) : null + logins.set(stateFile, { mtimeMs: stat.mtimeMs, size: stat.size, readAt: Date.now(), login }) + return login +} + +/** + * Deletes a host account's folder and, on macOS, the Keychain items Claude keyed to it + * (superset profile-remove.ts). Only `claude-profiles/` is reachable from an id, so an + * older Orca's `claude-accounts/` folders are never touched. + */ +export async function removeClaudeAccountFolder( + dataRoot: string, + accountId: string +): Promise { + const { home } = describeClaudeProfile(dataRoot, accountId, { + executionHostId: 'local', + runtime: 'host' + }) + if (process.platform === 'darwin') { + for (const spelling of claudeConfigDirSpellings(home, homedir())) { + // Why a loop: items are filed per Keychain account name, and each delete removes one. + for (let attempt = 0; attempt < 4; attempt += 1) { + const deleted = await execSecurityCommand([ + 'delete-generic-password', + '-s', + claudeKeychainService(spelling) + ]).then( + () => true, + () => false + ) + if (!deleted) { + break + } + } + } + } + const folder = dirname(home) + unlinkLinks(folder) + rmSync(folder, { recursive: true, force: true }) +} + +// Why first: history is linked into ~/.claude, and a recursive delete that followed a Windows +// junction would delete the user's own history. +function unlinkLinks(dir: string): void { + let names: string[] + try { + names = readdirSync(dir) + } catch { + return + } + for (const name of names) { + const path = join(dir, name) + // Why no throw: an entry may vanish between the listing and the stat. + const stat = lstatSync(path, { throwIfNoEntry: false }) + if (!stat) { + continue + } + if (stat.isSymbolicLink()) { + try { + unlinkSync(path) + } catch { + // A Windows directory link is removed as a directory. + rmdirSync(path) + } + } else if (stat.isDirectory()) { + unlinkLinks(path) + } + } +} diff --git a/src/main/claude-accounts/claude-account-registration.ts b/src/main/claude-accounts/claude-account-registration.ts index bebc26be6804..13ec68d7dee8 100644 --- a/src/main/claude-accounts/claude-account-registration.ts +++ b/src/main/claude-accounts/claude-account-registration.ts @@ -1,250 +1,152 @@ import { randomUUID } from 'node:crypto' import type { - ClaudeManagedAccount, - ClaudeRateLimitAccountsState + ClaudeAccountSignIn, + ClaudeRateLimitAccountsState, + ClaudeSignInRequest } from '../../shared/managed-account-types' -import type { Store } from '../persistence' -import type { RateLimitService } from '../rate-limits/service' -import { findDuplicateClaudeAccount } from './claude-duplicate-account' -import type { CapturedClaudeAuth } from './claude-auth-capture' +import { claudeStateFile, readClaudeFolderLogin } from './claude-account-folder' import type { - ClaudeManagedAuthLocation, - ClaudeManagedAuthSnapshot, - ClaudeManagedAuthTarget -} from './claude-managed-auth-storage' -import type { ClaudeRuntimeAuthService } from './runtime-auth-service' + ClaudeAccountRuntime, + ClaudeAccountSelection, + ClaudeAccountStore, + ClaudeAccountUsage +} from './claude-account-selection' +import type { ClaudeCommandConfig } from './claude-command-process' +import { findDuplicateClaudeAccount } from './claude-duplicate-account' import { getClaudeSelectionTargetForAccount, - normalizeClaudeRuntimeSelection + normalizeClaudeAccountSelectionTarget, + type ClaudeAccountSelectionTarget } from './runtime-selection' -import type { ClaudeAccountSelection } from './claude-account-selection' -type ClaudeAccountRegistrationDependencies = { - store: Store - rateLimits: RateLimitService - runtimeAuth: ClaudeRuntimeAuthService - selection: ClaudeAccountSelection - createManagedAuth: ( - accountId: string, - target?: ClaudeManagedAuthTarget - ) => Promise - assertManagedAuth: (path: string, accountId: string) => Promise - removeManagedAuth: (accountId: string, path: string) => Promise - writeManagedAuth: (accountId: string, path: string, captured: CapturedClaudeAuth) => Promise - writeCredentials: (accountId: string, path: string, value: string) => Promise - writeOauth: (accountId: string, path: string, value: unknown) => Promise - readSnapshot: (accountId: string, path: string) => Promise - restoreCredentials: ( - accountId: string, - path: string, - snapshot: ClaudeManagedAuthSnapshot - ) => Promise - restoreOauth: ( - accountId: string, - path: string, - snapshot: ClaudeManagedAuthSnapshot - ) => Promise - login: (location: ClaudeManagedAuthLocation) => Promise - captureExisting: ( - configDir: string, - previousLegacyCredentialsSha256?: string | null - ) => Promise -} - -class DuplicateClaudeAccountError extends Error {} +export const CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE = + 'Claude has not finished signing in to this account yet. Finish the sign-in in the terminal, then try again.' +const CLAUDE_LOGIN_WITHOUT_ACCOUNT_MESSAGE = + 'Claude login completed, but Orca could not resolve the account email.' +/** + * Sign-in runs `claude auth login` against the account's own folder: hidden from Settings, in + * the user's terminal from the CLI. A folder becomes an account only once it holds a login + * (superset U/profiles.ts:132,178), so an abandoned sign-in never shows as a row. + */ export class ClaudeAccountRegistration { - constructor(private readonly dependencies: ClaudeAccountRegistrationDependencies) {} - - async add(target?: ClaudeManagedAuthTarget): Promise { - const accountId = randomUUID() - const location = await this.dependencies.createManagedAuth(accountId, target) - const previousSettings = this.dependencies.store.getSettings() - try { - const captured = await this.dependencies.login(location) - return await this.persist(accountId, location, previousSettings, captured) - } catch (error) { - await this.cleanupFailedAdd(accountId, location.managedAuthPath, previousSettings, error) - throw error + constructor( + private readonly deps: { + store: ClaudeAccountStore + rateLimits: ClaudeAccountUsage + runtimeAuth: ClaudeAccountRuntime + selection: ClaudeAccountSelection } + ) {} + + /** A new folder, or a saved account's own folder when `accountId` is given. */ + async begin(request: ClaudeSignInRequest): Promise { + const { accountId, target, folder } = await this.prepare(request) + return { accountId, configDir: folder.configDir, ...target } } - async addFromConfigDir( - configDir: string, - options?: ClaudeManagedAuthTarget & { previousLegacyCredentialsSha256?: string | null } + /** Runs `login` into the account's folder, then saves it; a failed new folder is deleted. */ + async signIn( + request: ClaudeSignInRequest, + login: (folder: ClaudeCommandConfig) => Promise ): Promise { - const accountId = randomUUID() - const location = await this.dependencies.createManagedAuth(accountId, options) - const previousSettings = this.dependencies.store.getSettings() + const { accountId, target, folder } = await this.prepare(request) + const signIn = { accountId, ...target } try { - const captured = await this.dependencies.captureExisting( - configDir, - options?.previousLegacyCredentialsSha256 - ) - return await this.persist(accountId, location, previousSettings, captured) + await login({ + windowsPath: folder.readPath, + linuxPath: target.runtime === 'wsl' ? folder.configDir : null, + wslDistro: target.runtime === 'wsl' ? (target.wslDistro ?? null) : null + }) + return await this.finish(signIn, CLAUDE_LOGIN_WITHOUT_ACCOUNT_MESSAGE) } catch (error) { - await this.cleanupFailedAdd(accountId, location.managedAuthPath, previousSettings, error) + // Why caught: the sign-in's own failure is what the user needs to see. + await this.cancel(signIn).catch((cleanupError: unknown) => + console.warn( + '[claude-accounts] Could not delete an unfinished sign-in folder:', + cleanupError + ) + ) throw error } } - async reauthenticate(accountId: string): Promise { - const account = this.dependencies.selection.requireAccount(accountId) - const managedAuthPath = await this.dependencies.assertManagedAuth( - account.managedAuthPath, - accountId - ) - const previousSettings = this.dependencies.store.getSettings() - const previousAuth = await this.dependencies.readSnapshot(accountId, managedAuthPath) - const captured = await this.dependencies.login({ - managedAuthPath, - managedAuthRuntime: account.managedAuthRuntime ?? 'host', - wslDistro: account.wslDistro ?? null, - wslLinuxAuthPath: account.wslLinuxAuthPath ?? null - }) - if (!captured.identity.email) { - throw new Error('Claude login completed, but Orca could not resolve the account email.') - } + private async prepare(request: ClaudeSignInRequest) { + const saved = request.accountId ? this.deps.selection.requireAccount(request.accountId) : null + const target = saved ? getClaudeSelectionTargetForAccount(saved) : signInTarget(request) + const accountId = saved?.id ?? randomUUID() + const folder = await this.deps.runtimeAuth.prepareAccountFolder(accountId, target) + return { accountId, target, folder } + } - const settings = this.dependencies.store.getSettings() - const now = Date.now() - const nextAccounts = settings.claudeManagedAccounts.map((entry) => - entry.id === accountId - ? { - ...entry, - email: captured.identity.email!, - organizationUuid: captured.identity.organizationUuid, - organizationName: captured.identity.organizationName, - updatedAt: now, - lastAuthenticatedAt: now - } - : entry - ) - let wroteCredentials = false - try { - await this.dependencies.writeOauth(accountId, managedAuthPath, captured.oauthAccount) - await this.dependencies.writeCredentials(accountId, managedAuthPath, captured.credentialsJson) - wroteCredentials = true - this.dependencies.store.updateSettings({ claudeManagedAccounts: nextAccounts }) - this.dependencies.runtimeAuth.clearLastWrittenCredentialsJson(accountId) - this.dependencies.rateLimits.evictInactiveClaudeCache(accountId) - const target = getClaudeSelectionTargetForAccount(account) - await this.dependencies.selection.syncRuntimeAuth(target) - await this.dependencies.rateLimits.refreshForClaudeAccountChange(undefined, target) - return this.dependencies.selection.snapshot() - } catch (error) { - await this.rollbackReauthentication( - accountId, - managedAuthPath, - previousAuth, - previousSettings, - nextAccounts, - wroteCredentials - ) - throw error + async cancel(signIn: Omit): Promise { + if (!this.deps.selection.findAccount(signIn.accountId)) { + await this.deps.runtimeAuth.removeAccountFolder(signIn.accountId, signInTarget(signIn)) } } - private async persist( - accountId: string, - location: ClaudeManagedAuthLocation, - previousSettings: ReturnType, - captured: CapturedClaudeAuth + async finish( + signIn: Omit, + notSignedInMessage = CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE ): Promise { - if (!captured.identity.email) { - throw new Error('Claude login completed, but Orca could not resolve the account email.') + const { store, rateLimits, runtimeAuth, selection } = this.deps + const saved = selection.findAccount(signIn.accountId) + const target = saved ? getClaudeSelectionTargetForAccount(saved) : signInTarget(signIn) + // Why set up again: onboarding is merged only into a state file the login just created. + const folder = await runtimeAuth.prepareAccountFolder(signIn.accountId, target) + const login = readClaudeFolderLogin(claudeStateFile(folder.readPath)) + if (!login) { + throw new Error(notSignedInMessage) } + const accounts = store.getSettings().claudeManagedAccounts + const scope = { + managedAuthRuntime: target.runtime ?? 'host', + wslDistro: target.runtime === 'wsl' ? (target.wslDistro ?? null) : null + } as const if ( - findDuplicateClaudeAccount(previousSettings.claudeManagedAccounts, { - email: captured.identity.email, - organizationUuid: captured.identity.organizationUuid, - managedAuthRuntime: location.managedAuthRuntime, - wslDistro: location.wslDistro + !saved && + findDuplicateClaudeAccount(accounts, { + email: login.email, + organizationUuid: login.organizationUuid, + ...scope }) ) { - throw new DuplicateClaudeAccountError('This Claude account is already added.') + await runtimeAuth.removeAccountFolder(signIn.accountId, target) + throw new Error('This Claude account is already added.') } - await this.dependencies.writeManagedAuth(accountId, location.managedAuthPath, captured) const now = Date.now() - const account: ClaudeManagedAccount = { - id: accountId, - email: captured.identity.email, - managedAuthPath: location.managedAuthPath, - managedAuthRuntime: location.managedAuthRuntime, - wslDistro: location.wslDistro, - wslLinuxAuthPath: location.wslLinuxAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: captured.identity.organizationUuid, - organizationName: captured.identity.organizationName, - createdAt: now, - updatedAt: now, - lastAuthenticatedAt: now - } - const selection = normalizeClaudeRuntimeSelection(previousSettings) - this.dependencies.store.updateSettings({ - claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], - activeClaudeManagedAccountId: selection.host, - activeClaudeManagedAccountIdsByRuntime: selection + selection.saveSettings({ + claudeManagedAccounts: [ + ...accounts.filter((entry) => entry.id !== signIn.accountId), + { + id: signIn.accountId, + email: login.email, + organizationUuid: login.organizationUuid, + organizationName: login.organizationName, + managedAuthPath: folder.readPath, + ...scope, + wslLinuxAuthPath: target.runtime === 'wsl' ? folder.configDir : null, + authMethod: 'subscription-oauth', + createdAt: saved?.createdAt ?? now, + updatedAt: now, + lastAuthenticatedAt: now + } + ] }) - this.dependencies.runtimeAuth.clearLastWrittenCredentialsJson(accountId) - this.dependencies.rateLimits.evictInactiveClaudeCache(accountId) - return this.dependencies.selection.snapshot() - } - - private async cleanupFailedAdd( - accountId: string, - managedAuthPath: string, - previousSettings: ReturnType, - error: unknown - ): Promise { - if (error instanceof DuplicateClaudeAccountError) { - await this.dependencies.removeManagedAuth(accountId, managedAuthPath) - return - } - this.dependencies.selection.restoreSettings(previousSettings) - try { - await this.dependencies.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - } catch (rollbackError) { - console.warn('[claude-accounts] Rollback rematerialization failed:', rollbackError) - } - await this.dependencies.removeManagedAuth(accountId, managedAuthPath) + rateLimits.evictInactiveClaudeCache(signIn.accountId) + // Why publish: a selected account's terminals follow its folder only once it exists. + await runtimeAuth.syncForCurrentSelection(target) + selection.refreshUsage(undefined, target) + return selection.list() } +} - private async rollbackReauthentication( - accountId: string, - path: string, - snapshot: ClaudeManagedAuthSnapshot, - previousSettings: ReturnType, - nextAccounts: ClaudeManagedAccount[], - wroteCredentials: boolean - ): Promise { - let restoredCredentials = false - try { - await this.dependencies.restoreCredentials(accountId, path, snapshot) - restoredCredentials = true - } catch (rollbackError) { - console.warn( - '[claude-accounts] Failed to restore managed credentials during rollback:', - rollbackError - ) - } - if (restoredCredentials || !wroteCredentials) { - try { - await this.dependencies.restoreOauth(accountId, path, snapshot) - } catch (rollbackError) { - console.warn( - '[claude-accounts] Failed to restore managed oauth metadata during rollback:', - rollbackError - ) - } - } - if (restoredCredentials) { - this.dependencies.selection.restoreSettings(previousSettings) - await this.dependencies.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - } else if (wroteCredentials) { - this.dependencies.store.updateSettings({ claudeManagedAccounts: nextAccounts }) - } else { - this.dependencies.selection.restoreSettings(previousSettings) - } +function signInTarget(request: ClaudeAccountSelectionTarget): ClaudeAccountSelectionTarget { + const target = normalizeClaudeAccountSelectionTarget(request) + if (target.runtime === 'wsl' && !target.wslDistro) { + throw new Error('Choose a WSL distro before signing in.') } + return target.runtime === 'wsl' + ? { runtime: 'wsl', wslDistro: target.wslDistro } + : { runtime: 'host' } } diff --git a/src/main/claude-accounts/claude-account-selection.ts b/src/main/claude-accounts/claude-account-selection.ts index d81f2346eb70..c0b089e902cf 100644 --- a/src/main/claude-accounts/claude-account-selection.ts +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -3,9 +3,10 @@ import type { ClaudeManagedAccountSummary, ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' -import type { Store } from '../persistence' +import type { GlobalSettings } from '../../shared/global-settings-types' import type { RateLimitService } from '../rate-limits/service' -import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' +import { claudeStateFile, readClaudeFolderLogin } from './claude-account-folder' +import { savedWslClaudeAccountHome } from './claude-profile-wsl-paths' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import { getClaudeSelectionTargetForAccount, @@ -18,59 +19,137 @@ import { type ClaudeAccountSelectionTarget } from './runtime-selection' +export const CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE = + 'Sign in to this Claude account again before selecting it.' + +export const CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE = + "Orca couldn't delete this Claude account's folder. Quit any Claude running in this account, then remove it again." + +export type ClaudeAccountSettings = Pick< + GlobalSettings, + | 'claudeManagedAccounts' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' +> + +export type ClaudeAccountStore = { + getSettings: () => ClaudeAccountSettings + updateSettings: ( + patch: Partial, + options?: { notifyListeners?: boolean } + ) => unknown +} + +export type ClaudeAccountRuntime = Pick< + ClaudeRuntimeAuthService, + 'syncForCurrentSelection' | 'publishAll' | 'removeAccountFolder' | 'prepareAccountFolder' +> & { + router: Pick< + ClaudeRuntimeAuthService['router'], + 'accountHome' | 'userConfigDir' | 'copiedLoginIntoSystemDefault' + > +} + +export type ClaudeAccountUsage = Pick< + RateLimitService, + 'evictInactiveClaudeCache' | 'refreshForClaudeAccountChange' +> + export class ClaudeAccountSelection { constructor( - private readonly store: Store, - private readonly rateLimits: RateLimitService, - private readonly runtimeAuth: ClaudeRuntimeAuthService, - private readonly removeManagedAuth: (accountId: string, path: string) => Promise + private readonly store: ClaudeAccountStore, + private readonly rateLimits: ClaudeAccountUsage, + private readonly runtimeAuth: ClaudeAccountRuntime ) {} + /** Each row is labelled with the login its folder holds (superset U/profiles.ts:121-137). */ list(): ClaudeRateLimitAccountsState { - this.normalizeActiveSelection() - return this.snapshot() + this.pruneSelection() + const settings = this.store.getSettings() + const selection = normalizeClaudeRuntimeSelection(settings) + const accounts = settings.claudeManagedAccounts + .map((account) => this.summarize(account)) + .sort((a, b) => b.updatedAt - a.updatedAt) + const userConfigDir = this.runtimeAuth.router.userConfigDir() + // Why only with accounts, and at most every 5 s: the personal state file is large and every + // running Claude rewrites it, so a list would otherwise re-parse it on main each time. + const systemDefault = + accounts.length > 0 ? readClaudeFolderLogin(claudeStateFile(userConfigDir), 5_000) : null + return { + accounts, + activeAccountId: selection.host, + activeAccountIdsByRuntime: selection, + ...(systemDefault ? { systemDefaultEmail: systemDefault.email } : {}), + ...(systemDefault && this.runtimeAuth.router.copiedLoginIntoSystemDefault() + ? { systemDefaultMayBeCopied: true } + : {}) + } + } + + private summarize(account: ClaudeManagedAccount): ClaudeManagedAccountSummary { + const summary: ClaudeManagedAccountSummary = { + id: account.id, + email: account.email, + managedAuthRuntime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, + authMethod: account.authMethod ?? 'unknown', + organizationUuid: account.organizationUuid ?? null, + organizationName: account.organizationName ?? null, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + lastAuthenticatedAt: account.lastAuthenticatedAt + } + if (account.managedAuthRuntime === 'wsl') { + // Why no read: listing must not start a stopped distro. Only an older Orca's folder layout + // is known to hold no login; a launch refuses a missing folder with its own reason. + return savedWslClaudeAccountHome(account) ? summary : { ...summary, needsSignIn: true } + } + const login = readClaudeFolderLogin( + claudeStateFile(this.runtimeAuth.router.accountHome(account.id)) + ) + return login + ? { + ...summary, + email: login.email, + organizationUuid: login.organizationUuid, + organizationName: login.organizationName + } + : { ...summary, needsSignIn: true } } async remove(accountId: string): Promise { const account = this.requireAccount(accountId) const settings = this.store.getSettings() - const nextAccounts = settings.claudeManagedAccounts.filter((entry) => entry.id !== accountId) + const target = getClaudeSelectionTargetForAccount(account) + const wasSelected = getSelectedClaudeAccountIdForTarget(settings, target) === accountId const nextSelection = removeClaudeAccountIdFromSelection( normalizeClaudeRuntimeSelection(settings), accountId ) - const nextActiveId = - settings.activeClaudeManagedAccountId === accountId ? null : nextSelection.host - const target = getClaudeSelectionTargetForAccount(account) - const wasSelected = getSelectedClaudeAccountIdForTarget(settings, target) === accountId + this.saveSettings({ + activeClaudeManagedAccountId: + settings.activeClaudeManagedAccountId === accountId ? null : nextSelection.host, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + // Why before the delete: no new launch may pick the folder while it is being removed. + await this.runtimeAuth.syncForCurrentSelection(target) try { - if (wasSelected) { - this.store.updateSettings({ - activeClaudeManagedAccountId: nextActiveId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - await this.syncRuntimeAuth(target) - this.store.updateSettings({ claudeManagedAccounts: nextAccounts }) - } else { - this.store.updateSettings({ - claudeManagedAccounts: nextAccounts, - activeClaudeManagedAccountId: nextActiveId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - await this.syncRuntimeAuth(target) - } - await this.removeManagedAuth(accountId, account.managedAuthPath) - this.rateLimits.evictInactiveClaudeCache(accountId) - await this.rateLimits.refreshForClaudeAccountChange( - wasSelected ? accountId : undefined, - target - ) - return this.snapshot() + await this.runtimeAuth.removeAccountFolder(accountId, target) } catch (error) { - this.restoreSettings(settings) - await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - throw error + // Why keep the row: the folder (on Windows, with its history) is still there to retry. + console.warn('[claude-accounts] Could not delete the account folder:', error) + throw new Error(CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE) } + this.saveSettings({ + claudeManagedAccounts: this.store + .getSettings() + .claudeManagedAccounts.filter((entry) => entry.id !== accountId) + }) + // Why again: the last account's removal also removes the which-account file. + await this.runtimeAuth.syncForCurrentSelection(target) + this.rateLimits.evictInactiveClaudeCache(accountId) + this.refreshUsage(wasSelected ? accountId : undefined, target) + return this.list() } async select( @@ -81,111 +160,90 @@ export class ClaudeAccountSelection { if (accountId !== null) { const account = this.requireAccount(accountId) const accountTarget = getClaudeSelectionTargetForAccount(account) - const requestedTarget = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) - const normalizedAccountTarget = normalizeClaudeAccountSelectionTarget(accountTarget) + const requested = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) + const owned = normalizeClaudeAccountSelectionTarget(accountTarget) if ( - requestedTarget.runtime !== normalizedAccountTarget.runtime || - (requestedTarget.wslDistro !== null && - requestedTarget.wslDistro !== normalizedAccountTarget.wslDistro) + requested.runtime !== owned.runtime || + (requested.wslDistro !== null && requested.wslDistro !== owned.wslDistro) ) { throw new Error('That Claude account belongs to a different runtime.') } + // Why on the host: the CLI and paired clients select without the renderer's check. + if (this.summarize(account).needsSignIn) { + throw new Error(CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE) + } effectiveTarget = accountTarget } - const previousSettings = this.store.getSettings() - const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previousSettings, effectiveTarget) + const previous = this.store.getSettings() + const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previous, effectiveTarget) const nextSelection = setSelectedClaudeAccountIdForTarget( - normalizeClaudeRuntimeSelection(previousSettings), + normalizeClaudeRuntimeSelection(previous), accountId, effectiveTarget ) - this.store.updateSettings({ + this.saveSettings({ activeClaudeManagedAccountId: effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, activeClaudeManagedAccountIdsByRuntime: nextSelection }) try { - await this.syncRuntimeAuth(effectiveTarget) - await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId, effectiveTarget) - return this.snapshot() + await this.runtimeAuth.syncForCurrentSelection(effectiveTarget) } catch (error) { - this.restoreSettings(previousSettings) - await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + this.saveSettings({ + activeClaudeManagedAccountId: previous.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: previous.activeClaudeManagedAccountIdsByRuntime + }) + await this.runtimeAuth.publishAll().catch((rollbackError: unknown) => { + console.warn('[claude-accounts] Rollback republish failed:', rollbackError) + }) throw error } + this.refreshUsage(outgoingAccountId, effectiveTarget) + return this.list() } - snapshot(): ClaudeRateLimitAccountsState { - const settings = this.store.getSettings() - return { - accounts: settings.claudeManagedAccounts - .map(toClaudeAccountSummary) - .sort((a, b) => b.updatedAt - a.updatedAt), - activeAccountId: normalizeClaudeRuntimeSelection(settings).host, - activeAccountIdsByRuntime: normalizeClaudeRuntimeSelection(settings) - } + /** Bookkeeping: a usage failure never undoes the account change that triggered it. */ + refreshUsage( + outgoingAccountId: string | null | undefined, + target: ClaudeAccountSelectionTarget | undefined + ): void { + void this.rateLimits + .refreshForClaudeAccountChange(outgoingAccountId ?? undefined, target) + .catch((error: unknown) => + console.warn('[claude-accounts] Usage unavailable after account change:', error) + ) + } + + findAccount(accountId: string): ClaudeManagedAccount | undefined { + return this.store.getSettings().claudeManagedAccounts.find((entry) => entry.id === accountId) } requireAccount(accountId: string): ClaudeManagedAccount { - const account = this.store - .getSettings() - .claudeManagedAccounts.find((entry) => entry.id === accountId) + const account = this.findAccount(accountId) if (!account) { throw new Error('That Claude account no longer exists.') } return account } - restoreSettings(settings: ReturnType): void { - this.store.updateSettings({ - claudeManagedAccounts: settings.claudeManagedAccounts, - activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: settings.activeClaudeManagedAccountIdsByRuntime - }) + // Why notify: every window's switcher and Settings read the selection from settings, including + // selections made by the CLI, a paired client or another window. + saveSettings(patch: Partial): void { + this.store.updateSettings(patch, { notifyListeners: true }) } - async syncRuntimeAuth( - target?: ClaudeAccountSelectionTarget, - operation?: () => Promise - ): Promise { - beginClaudeAuthSwitch() - try { - await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection(target)) - } finally { - endClaudeAuthSwitch() - } - } - - private normalizeActiveSelection(): void { + private pruneSelection(): void { const settings = this.store.getSettings() - const currentSelection = normalizeClaudeRuntimeSelection(settings) - const nextSelection = pruneInvalidClaudeRuntimeSelection( - currentSelection, - settings.claudeManagedAccounts - ) + const current = normalizeClaudeRuntimeSelection(settings) + const next = pruneInvalidClaudeRuntimeSelection(current, settings.claudeManagedAccounts) if ( - nextSelection.host !== settings.activeClaudeManagedAccountId || - JSON.stringify(nextSelection) !== JSON.stringify(currentSelection) + next.host !== settings.activeClaudeManagedAccountId || + JSON.stringify(next) !== JSON.stringify(current) ) { this.store.updateSettings({ - activeClaudeManagedAccountId: nextSelection.host, - activeClaudeManagedAccountIdsByRuntime: nextSelection + activeClaudeManagedAccountId: next.host, + activeClaudeManagedAccountIdsByRuntime: next }) } } } - -function toClaudeAccountSummary(account: ClaudeManagedAccount): ClaudeManagedAccountSummary { - return { - id: account.id, - email: account.email, - managedAuthRuntime: account.managedAuthRuntime ?? 'host', - wslDistro: account.wslDistro ?? null, - authMethod: account.authMethod ?? 'unknown', - organizationUuid: account.organizationUuid ?? null, - organizationName: account.organizationName ?? null, - createdAt: account.createdAt, - updatedAt: account.updatedAt, - lastAuthenticatedAt: account.lastAuthenticatedAt - } -} diff --git a/src/main/claude-accounts/claude-account-service-account-selection.test.ts b/src/main/claude-accounts/claude-account-service-account-selection.test.ts deleted file mode 100644 index f055475506bc..000000000000 --- a/src/main/claude-accounts/claude-account-service-account-selection.test.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-selection-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -describe('ClaudeAccountService credential capture', () => { - let tempDir: string | null = null - - beforeEach(() => { - setPlatform('darwin') - tempDir = null - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('refreshes rate limits without recaching a removed active account', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await service.removeAccount('account-1') - - expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('account-1', { - runtime: 'host' - }) - expect(settings).toMatchObject({ - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null - }) - }) - - it('switches the active Claude account while PTYs are live', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const firstAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const secondAuthPath = join(tempDir, 'claude-accounts', 'account-2', 'auth') - mkdirSync(firstAuthPath, { recursive: true }) - mkdirSync(secondAuthPath, { recursive: true }) - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'first@example.com', - managedAuthPath: firstAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'second@example.com', - managedAuthPath: secondAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeClaudeManagedAccountId: 'account-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - markClaudePtySpawned('live-claude-pty') - try { - await service.selectAccount('account-2') - } finally { - markClaudePtyExited('live-claude-pty') - } - - expect(settings.activeClaudeManagedAccountId).toBe('account-2') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'account-2', - wsl: {} - }) - expect(runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('account-1', { - runtime: 'host' - }) - }) - - it('restores the previous selection when a Claude account switch fails', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const firstAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const secondAuthPath = join(tempDir, 'claude-accounts', 'account-2', 'auth') - mkdirSync(firstAuthPath, { recursive: true }) - mkdirSync(secondAuthPath, { recursive: true }) - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'first@example.com', - managedAuthPath: firstAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'second@example.com', - managedAuthPath: secondAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeClaudeManagedAccountId: 'account-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => { - throw new Error('runtime sync failed') - }), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await expect(service.selectAccount('account-2')).rejects.toThrow('runtime sync failed') - - expect(settings.activeClaudeManagedAccountId).toBe('account-1') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'account-1', - wsl: {} - }) - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() - }) - - it('selects a WSL account without changing the Windows active account', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') - const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') - mkdirSync(hostAuthPath, { recursive: true }) - mkdirSync(wslAuthPath, { recursive: true }) - let settings = { - claudeManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedAuthPath: hostAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'wsl-account', - email: 'wsl@example.com', - managedAuthPath: wslAuthPath, - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'host-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - const snapshot = await service.selectAccountForTarget('wsl-account', { - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - - expect(settings.activeClaudeManagedAccountId).toBe('host-account') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'host-account', - wsl: { Ubuntu: 'wsl-account' } - }) - expect(snapshot.activeAccountIdsByRuntime).toEqual({ - host: 'host-account', - wsl: { Ubuntu: 'wsl-account' } - }) - expect(runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(null, { - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - }) - - it('rejects selecting a WSL account for the Windows target', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') - mkdirSync(wslAuthPath, { recursive: true }) - const settings = { - claudeManagedAccounts: [ - { - id: 'wsl-account', - email: 'wsl@example.com', - managedAuthPath: wslAuthPath, - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn() - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await expect( - service.selectAccountForTarget('wsl-account', { runtime: 'host' }) - ).rejects.toThrow('different runtime') - expect(runtimeAuth.syncForCurrentSelection).not.toHaveBeenCalled() - expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() - }) - - it('removes a WSL account without clearing the Windows active account', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') - const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') - mkdirSync(hostAuthPath, { recursive: true }) - mkdirSync(wslAuthPath, { recursive: true }) - writeFileSync(join(wslAuthPath, '.orca-managed-claude-auth'), 'wsl-account\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedAuthPath: hostAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'wsl-account', - email: 'wsl@example.com', - managedAuthPath: wslAuthPath, - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'host-account', - activeClaudeManagedAccountIdsByRuntime: { - host: 'host-account', - wsl: { Ubuntu: 'wsl-account' } - } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await service.removeAccount('wsl-account') - - expect(settings.activeClaudeManagedAccountId).toBe('host-account') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'host-account', - wsl: { Ubuntu: null } - }) - expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('wsl-account') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('wsl-account', { - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-add-account.test.ts b/src/main/claude-accounts/claude-account-service-add-account.test.ts deleted file mode 100644 index 3e17f4c00ebb..000000000000 --- a/src/main/claude-accounts/claude-account-service-add-account.test.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-add-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -describe('ClaudeAccountService credential capture', () => { - let tempDir: string | null = null - - beforeEach(() => { - setPlatform('darwin') - tempDir = null - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('adds an account without switching the active Claude auth while PTYs are live', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') - mkdirSync(hostAuthPath, { recursive: true }) - let settings = { - claudeManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedAuthPath: hostAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'host-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - markClaudePtySpawned('live-claude-pty') - try { - await service.addAccount({ runtime: 'host' }) - } finally { - markClaudePtyExited('live-claude-pty') - } - - expect(settings.claudeManagedAccounts).toHaveLength(2) - expect(settings.claudeManagedAccounts[1].email).toBe('new@example.com') - expect(settings.activeClaudeManagedAccountId).toBe('host-account') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'host-account', - wsl: { Ubuntu: null } - }) - expect(runtimeAuth.syncForCurrentSelection).not.toHaveBeenCalled() - expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() - expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith( - settings.claudeManagedAccounts[1].id - ) - }) - - it('reports the original add failure and still removes managed auth when rollback rematerialization fails', async () => { - // Why: this is the desktop add path. Previously the rollback's rematerialize - // was unguarded, so when it threw it replaced the real add error and skipped - // safeRemoveManagedAuth, leaking the throwaway auth dir. - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') - mkdirSync(hostAuthPath, { recursive: true }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - let settings = { - claudeManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedAuthPath: hostAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'host-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => { - throw new Error('rematerialize failed') - }) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: null, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - ;(service as unknown as { writeManagedAuth(): Promise }).writeManagedAuth = vi.fn( - async () => { - throw new Error('managed auth write failed') - } - ) - - await expect(service.addAccount({ runtime: 'host' })).rejects.toThrow( - 'managed auth write failed' - ) - - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - expect(settings.claudeManagedAccounts).toHaveLength(1) - // Why: the throwaway account directory must be gone even though rollback threw. - expect(readdirSync(join(tempDir, 'claude-accounts'))).toEqual(['host-account']) - warn.mockRestore() - }) - - it('rejects adding a Claude account whose identity already exists', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const existingAuthPath = join(tempDir, 'claude-accounts', 'existing-account', 'auth') - mkdirSync(existingAuthPath, { recursive: true }) - const existingMarkerPath = join(existingAuthPath, '.orca-managed-claude-auth') - writeFileSync(existingMarkerPath, 'existing-account\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'existing-account', - email: 'new@example.com', - managedAuthPath: existingAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'existing-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'existing-account', wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: string | null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.addAccount({ runtime: 'host' })).rejects.toThrow( - 'This Claude account is already added.' - ) - - expect(settings.claudeManagedAccounts).toHaveLength(1) - expect(readFileSync(existingMarkerPath, 'utf-8')).toBe('existing-account\n') - // The guard fires before credentials/settings change, so rollback I/O - // would only add latency and could mask the duplicate error. - expect(store.updateSettings).not.toHaveBeenCalled() - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled() - // The rejected add's throwaway managed-auth dir must be cleaned up, leaving - // only the pre-existing account's dir behind. - expect(readdirSync(join(tempDir, 'claude-accounts')).sort()).toEqual(['existing-account']) - }) - - it('adds a Claude account with the same email under a different organization', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const existingAuthPath = join(tempDir, 'claude-accounts', 'existing-account', 'auth') - mkdirSync(existingAuthPath, { recursive: true }) - writeFileSync( - join(existingAuthPath, '.orca-managed-claude-auth'), - 'existing-account\n', - 'utf-8' - ) - let settings = { - claudeManagedAccounts: [ - { - id: 'existing-account', - email: 'new@example.com', - managedAuthPath: existingAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: 'org-A', - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'existing-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'existing-account', wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: string | null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: 'org-B', organizationName: null } - })) - - await service.addAccount({ runtime: 'host' }) - - expect(settings.claudeManagedAccounts).toHaveLength(2) - expect(settings.claudeManagedAccounts[1].email).toBe('new@example.com') - expect(settings.claudeManagedAccounts[1].organizationUuid).toBe('org-B') - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-api-parity.test.ts b/src/main/claude-accounts/claude-account-service-api-parity.test.ts deleted file mode 100644 index 09ec7813ffe8..000000000000 --- a/src/main/claude-accounts/claude-account-service-api-parity.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' -import { ClaudeAccountService } from './service' -import type { ClaudeAccountAddTarget } from './service' - -vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-claude-api-parity' } })) - -function createService(): ClaudeAccountService { - const settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - return new ClaudeAccountService( - { getSettings: () => settings, updateSettings: vi.fn() } as never, - {} as never, - { getRuntimeConfigDir: () => '/tmp/claude' } as never - ) -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - return { promise: new Promise((done) => (resolve = done)), resolve } -} - -describe('ClaudeAccountService mutation serialization', () => { - it('serializes mutations within one service', async () => { - const service = createService() - const first = deferred() - const calls: string[] = [] - const registration = ( - service as unknown as { - registration: { - add(target?: ClaudeAccountAddTarget): Promise - } - } - ).registration - registration.add = vi.fn(async (target) => { - calls.push(target?.wslDistro ?? 'host') - if (calls.length === 1) { - await first.promise - } - return { - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - } - }) - - const firstAdd = service.addAccount({ runtime: 'wsl', wslDistro: 'one' }) - const secondAdd = service.addAccount({ runtime: 'wsl', wslDistro: 'two' }) - await vi.waitFor(() => expect(calls).toEqual(['one'])) - first.resolve() - await Promise.all([firstAdd, secondAdd]) - expect(calls).toEqual(['one', 'two']) - }) - - it('isolates mutation and cancellation state between runtime hosts', async () => { - const firstService = createService() - const secondService = createService() - const first = deferred() - const firstAdd = vi.fn(async () => { - await first.promise - return { - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - } - }) - const secondAdd = vi.fn(async () => ({ - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - })) - ;(firstService as unknown as { registration: { add: typeof firstAdd } }).registration.add = - firstAdd - ;(secondService as unknown as { registration: { add: typeof secondAdd } }).registration.add = - secondAdd - ;( - firstService as unknown as { cancelPendingClaudeLogin: () => boolean } - ).cancelPendingClaudeLogin = vi.fn(() => true) - - const pendingFirst = firstService.addAccount() - await expect(secondService.addAccount()).resolves.toMatchObject({ accounts: [] }) - expect(firstService.cancelPendingLogin()).toBe(true) - expect(secondService.cancelPendingLogin()).toBe(false) - first.resolve() - await pendingFirst - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts b/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts deleted file mode 100644 index 8ed94bebc6eb..000000000000 --- a/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createHash } from 'node:crypto' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { ClaudeManagedAccount } from '../../shared/managed-account-types' -import { - readActiveClaudeKeychainCredentialsStrict, - writeManagedClaudeKeychainCredentials -} from './keychain' -import { restorePlatform, setPlatform } from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-config-dir-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -describe('ClaudeAccountService.addAccountFromConfigDir', () => { - const managedRoot = CLAUDE_SERVICE_TEST_ROOT - let sourceDir: string | null = null - - beforeEach(() => { - setPlatform('linux') - rmSync(managedRoot, { recursive: true, force: true }) - sourceDir = null - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockReset() - vi.mocked(writeManagedClaudeKeychainCredentials).mockReset().mockResolvedValue() - }) - - afterEach(() => { - restorePlatform() - rmSync(managedRoot, { recursive: true, force: true }) - if (sourceDir) { - rmSync(sourceDir, { recursive: true, force: true }) - } - }) - - function makeDeps() { - let settings = { - claudeManagedAccounts: [] as ClaudeManagedAccount[], - activeClaudeManagedAccountId: null as string | null, - activeClaudeManagedAccountIdsByRuntime: { host: null as string | null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const rateLimits = { evictInactiveClaudeCache: vi.fn() } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - return { store, rateLimits, runtimeAuth, getSettings: () => settings } - } - - it('registers a managed account by capturing an authenticated config dir', async () => { - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-')) - writeFileSync( - join(sourceDir, '.credentials.json'), - '{"claudeAiOauth":{"accessToken":"tok"}}\n', - 'utf-8' - ) - writeFileSync( - join(sourceDir, '.claude.json'), - JSON.stringify({ oauthAccount: { emailAddress: 'new@example.com' } }), - 'utf-8' - ) - - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - // Why: avoid spawning a real `claude auth status` subprocess in the test. - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => '{"email":"new@example.com"}' - ) - - const result = await service.addAccountFromConfigDir(sourceDir) - - const accounts = deps.getSettings().claudeManagedAccounts - expect(accounts).toHaveLength(1) - expect(accounts[0].email).toBe('new@example.com') - expect(result.accounts[0]?.email).toBe('new@example.com') - expect(readFileSync(join(accounts[0].managedAuthPath, '.credentials.json'), 'utf-8')).toBe( - '{"claudeAiOauth":{"accessToken":"tok"}}\n' - ) - expect(deps.runtimeAuth.clearLastWrittenCredentialsJson).toHaveBeenCalledWith(accounts[0].id) - }) - - it('captures only the config-scoped macOS Keychain credential', async () => { - setPlatform('darwin') - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-')) - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => - configDir ? '{"claudeAiOauth":{"accessToken":"scoped"}}' : 'legacy-credentials' - ) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => '{"email":"new@example.com"}' - ) - - await service.addAccountFromConfigDir(sourceDir) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(sourceDir) - expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( - expect.any(String), - '{"claudeAiOauth":{"accessToken":"scoped"}}' - ) - }) - - it('does not mistake an unchanged legacy Keychain credential for the temp login', async () => { - setPlatform('darwin') - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-empty-')) - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => - configDir ? null : 'legacy-credentials' - ) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => '{"email":"existing@example.com"}' - ) - - await expect(service.addAccountFromConfigDir(sourceDir)).rejects.toThrow( - 'no OAuth credentials were captured' - ) - expect(deps.getSettings().claudeManagedAccounts).toHaveLength(0) - }) - - it('captures a legacy Keychain credential that changed after login began', async () => { - setPlatform('darwin') - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-legacy-')) - const previousCredentials = '{"claudeAiOauth":{"accessToken":"previous"}}' - const newCredentials = '{"claudeAiOauth":{"accessToken":"new","email":"new@example.com"}}' - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => - configDir ? null : newCredentials - ) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => '{"email":"new@example.com"}' - ) - - await service.addAccountFromConfigDir(sourceDir, { - previousLegacyCredentialsSha256: createHash('sha256') - .update(previousCredentials) - .digest('hex') - }) - - expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( - expect.any(String), - newCredentials - ) - expect(deps.getSettings().claudeManagedAccounts[0]?.email).toBe('new@example.com') - }) - - it('still registers when the daemon cannot spawn `claude auth status`', async () => { - // Why: `allowFailure` covers a non-zero exit but not a spawn error, so a daemon - // started with a minimal PATH would hard-fail an add the user already signed in for. - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-nostatus-')) - writeFileSync( - join(sourceDir, '.credentials.json'), - '{"claudeAiOauth":{"accessToken":"tok"}}\n', - 'utf-8' - ) - writeFileSync( - join(sourceDir, '.claude.json'), - JSON.stringify({ oauthAccount: { emailAddress: 'new@example.com' } }), - 'utf-8' - ) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => { - throw Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }) - } - ) - - const result = await service.addAccountFromConfigDir(sourceDir) - - expect(result.accounts[0]?.email).toBe('new@example.com') - expect(deps.getSettings().claudeManagedAccounts).toHaveLength(1) - warn.mockRestore() - }) - - it('rejects and rolls back when the config dir has no credentials', async () => { - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-empty-')) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - - await expect(service.addAccountFromConfigDir(sourceDir)).rejects.toThrow( - /No Claude credentials found/ - ) - expect(deps.getSettings().claudeManagedAccounts).toHaveLength(0) - expect(deps.runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-credential-capture.test.ts b/src/main/claude-accounts/claude-account-service-credential-capture.test.ts deleted file mode 100644 index 641ef58b5bee..000000000000 --- a/src/main/claude-accounts/claude-account-service-credential-capture.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict, - writeActiveClaudeKeychainCredentials -} from './keychain' -import { - createService, - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-capture-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -async function readCapturedCredentials( - configDir: string, - previousLegacyKeychain: string | null -): Promise { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - return ( - service as unknown as { - readCapturedCredentials( - configDir: string, - previousLegacyKeychain: string | null - ): Promise - } - ).readCapturedCredentials(configDir, previousLegacyKeychain) -} - -describe('ClaudeAccountService credential capture', () => { - let tempDir: string | null = null - - beforeEach(() => { - setPlatform('darwin') - tempDir = null - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('accepts scoped Keychain capture even when it matches the previous legacy item', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce('same-account') - .mockResolvedValueOnce('same-account') - - await expect(readCapturedCredentials('/tmp/claude-config', 'same-account')).resolves.toBe( - 'same-account' - ) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith('/tmp/claude-config') - expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() - }) - - it('rejects unchanged legacy fallback when scoped capture is missing', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce('previous') - - await expect(readCapturedCredentials('/tmp/claude-config', 'previous')).resolves.toBeNull() - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith( - 1, - '/tmp/claude-config' - ) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2) - }) - - it('accepts changed legacy fallback for old Claude Code builds', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce('new-legacy') - - await expect(readCapturedCredentials('/tmp/claude-config', 'previous')).resolves.toBe( - 'new-legacy' - ) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith( - 1, - '/tmp/claude-config' - ) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2) - }) - - it('falls back to captured credentials file on macOS', async () => { - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-capture-')) - writeFileSync(join(tempDir, '.credentials.json'), '{"token":"file"}\n', 'utf-8') - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce('previous') - - await expect(readCapturedCredentials(tempDir, 'previous')).resolves.toBe('{"token":"file"}\n') - }) - - it('fails login capture when legacy Keychain cleanup fails', async () => { - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue('previous-legacy') - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue('captured-scoped') - vi.mocked(writeActiveClaudeKeychainCredentials).mockRejectedValue(new Error('restore failed')) - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - const testService = service as unknown as { - runClaudeCommand: () => Promise - runClaudeLoginAndCapture(): Promise<{ credentialsJson: string }> - } - testService.runClaudeCommand = vi.fn(async () => '{"account":{"email":"user@example.com"}}') - - await expect(testService.runClaudeLoginAndCapture()).rejects.toThrow('restore failed') - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-login-process.test.ts b/src/main/claude-accounts/claude-account-service-login-process.test.ts deleted file mode 100644 index 8aee047e5fa5..000000000000 --- a/src/main/claude-accounts/claude-account-service-login-process.test.ts +++ /dev/null @@ -1,684 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { EventEmitter } from 'node:events' -import { writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { PassThrough } from 'node:stream' -import type { ClaudeManagedAccount } from '../../shared/managed-account-types' -import { readActiveClaudeKeychainCredentials } from './keychain' -import { getCmdExePath } from '../../shared/windows-batch-spawn' -import { - createService, - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-login-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the doubles below implement every member the add-account login path reaches; this case drives the service only through addAccount. -const asServiceDouble = (double: unknown): T => double as T - -describe('ClaudeAccountService credential capture', () => { - beforeEach(() => { - setPlatform('darwin') - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - }) - - it('removes command listeners when Claude sign-in times out', async () => { - vi.resetModules() - vi.useFakeTimers() - const child = new EventEmitter() as EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: () => void - } - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const spawnMock = vi.fn(() => child) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - const commandPromise = ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number, - options?: { keepStdinOpen?: boolean } - ): Promise - } - ).runClaudeCommand( - ['login'], - { windowsPath: '/tmp/claude-auth', linuxPath: null, wslDistro: null }, - 1000, - { keepStdinOpen: true } - ) - const rejection = expect(commandPromise).rejects.toThrow( - 'Claude sign-in took too long to finish.' - ) - - await vi.advanceTimersByTimeAsync(1000) - - await rejection - expect(child.kill).toHaveBeenCalledTimes(1) - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - vi.doUnmock('node:child_process') - } - }) - - it('owns the complete cmd.exe command line for a resolved Windows Claude command', async () => { - setPlatform('win32') - vi.resetModules() - commandMocks.resolveClaudeCommand.mockReturnValueOnce( - 'C:\\Users\\First Last\\AppData\\Roaming\\npm\\claude.cmd' - ) - const child = new EventEmitter() as EventEmitter & { - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const spawnMock = vi.fn(() => { - child.stdout.write('{"email":"user@example.com"}\n') - queueMicrotask(() => child.emit('close', 0)) - return child - }) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - await ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number - ): Promise - } - ).runClaudeCommand( - ['auth', 'status', '--json'], - { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, - 1000 - ) - - expect(spawnMock).toHaveBeenCalledWith( - process.env.ComSpec ?? 'cmd.exe', - [ - '/d', - '/v:off', - '/s', - '/c', - '""C:\\Users\\First Last\\AppData\\Roaming\\npm\\claude.cmd" "auth" "status" "--json""' - ], - expect.objectContaining({ shell: false, windowsVerbatimArguments: true }) - ) - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('keeps WSL execution separate from Windows command resolution', async () => { - setPlatform('win32') - vi.resetModules() - commandMocks.resolveClaudeCommand.mockClear() - const child = new EventEmitter() as EventEmitter & { - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const spawnMock = vi.fn(() => { - child.stdout.write('{"email":"user@example.com"}\n') - queueMicrotask(() => child.emit('close', 0)) - return child - }) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - await ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number - ): Promise - } - ).runClaudeCommand( - ['auth', 'status', '--json'], - { - windowsPath: 'C:\\tmp\\claude-auth', - linuxPath: '/home/user/.config/orca auth', - wslDistro: 'Ubuntu Test' - }, - 1000 - ) - - expect(commandMocks.resolveClaudeCommand).not.toHaveBeenCalled() - expect(spawnMock).toHaveBeenCalledWith( - 'wsl.exe', - [ - '-d', - 'Ubuntu Test', - '--exec', - 'bash', - '-lc', - "export CLAUDE_CONFIG_DIR='/home/user/.config/orca auth'; export CLAUDE_SECURESTORAGE_CONFIG_DIR='/home/user/.config/orca auth'; exec claude 'auth' 'status' '--json'" - ], - expect.objectContaining({ shell: false, windowsVerbatimArguments: false }) - ) - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('pipes stdin only for the explicit Claude account login command', async () => { - setPlatform('linux') - vi.resetModules() - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) - const loginChild = new EventEmitter() as EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - loginChild.stdin = new PassThrough() - loginChild.stdout = new PassThrough() - loginChild.stderr = new PassThrough() - loginChild.kill = vi.fn() - const statusChild = new EventEmitter() as EventEmitter & { - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - statusChild.stdout = new PassThrough() - statusChild.stderr = new PassThrough() - statusChild.kill = vi.fn() - const spawnMock = vi.fn( - (_command: string, args: string[], options: { env: NodeJS.ProcessEnv }) => { - if (args[1] === 'login') { - writeFileSync( - join(options.env.CLAUDE_CONFIG_DIR!, '.credentials.json'), - '{"claudeAiOauth":{"email":"user@example.com","accessToken":"token"}}\n', - 'utf-8' - ) - queueMicrotask(() => loginChild.emit('close', 0)) - return loginChild - } - statusChild.stdout.write('{"email":"user@example.com"}\n') - queueMicrotask(() => statusChild.emit('close', 0)) - return statusChild - } - ) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [] as ClaudeManagedAccount[], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await service.addAccount() - - expect(spawnMock).toHaveBeenNthCalledWith( - 1, - 'claude', - ['auth', 'login', '--claudeai'], - expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] }) - ) - expect(spawnMock).toHaveBeenNthCalledWith( - 2, - 'claude', - ['auth', 'status', '--json'], - expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] }) - ) - expect(settings.claudeManagedAccounts[0]?.email).toBe('user@example.com') - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('rejects immediately when Claude sign-in is denied', async () => { - vi.resetModules() - const child = new EventEmitter() as EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - pid: number - } - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - child.pid = 4242 - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const spawnMock = vi.fn(() => child) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - // Denial must tear down the whole detached login/browser tree (process-group kill on POSIX), - // not just the direct child — otherwise the orphaned auth processes the `detached` spawn guards against leak. - const killTree = vi.spyOn(process, 'kill').mockReturnValue(true) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - const commandPromise = ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number, - options?: { keepStdinOpen?: boolean } - ): Promise - } - ).runClaudeCommand( - ['login'], - { windowsPath: '/tmp/claude-auth', linuxPath: null, wslDistro: null }, - 180_000, - { keepStdinOpen: true } - ) - - child.stderr.write('OAuth authorization failed: access_denied\n') - - await expect(commandPromise).rejects.toThrow('Claude sign-in was denied. Please try again.') - expect(killTree).toHaveBeenCalledWith(-child.pid) - expect(child.kill).not.toHaveBeenCalled() - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - killTree.mockRestore() - vi.doUnmock('node:child_process') - } - }) - - it('cancels an in-flight Claude account add', async () => { - vi.resetModules() - const child = new EventEmitter() as EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const spawnMock = vi.fn(() => child) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - const addPromise = service.addAccount() - await vi.waitFor(() => { - expect(spawnMock).toHaveBeenCalledTimes(1) - }) - - expect(service.cancelPendingLogin()).toBe(true) - await expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') - expect(child.kill).toHaveBeenCalledTimes(1) - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(service.cancelPendingLogin()).toBe(false) - expect(settings.claudeManagedAccounts).toEqual([]) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('honors cancel before Claude login command starts', async () => { - setPlatform('linux') - vi.resetModules() - let releaseKeychainRead: (value: string | null) => void = () => {} - vi.mocked(readActiveClaudeKeychainCredentials).mockReturnValue( - new Promise((resolve) => { - releaseKeychainRead = resolve - }) - ) - const spawnMock = vi.fn() - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - const addPromise = service.addAccount() - await vi.waitFor(() => { - expect(readActiveClaudeKeychainCredentials).toHaveBeenCalled() - }) - - expect(service.cancelPendingLogin()).toBe(true) - expect(service.cancelPendingLogin()).toBe(false) - expect(spawnMock).not.toHaveBeenCalled() - releaseKeychainRead(null) - await expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') - expect(spawnMock).not.toHaveBeenCalled() - expect(service.cancelPendingLogin()).toBe(false) - expect(settings.claudeManagedAccounts).toEqual([]) - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('supersedes the login a closed Settings pane abandoned instead of queueing behind it', async () => { - setPlatform('linux') - vi.resetModules() - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) - const children: (EventEmitter & { stdin: PassThrough; kill: ReturnType })[] = [] - // No pid: the POSIX teardown signals -pid as a process group, which must - // never reach a real group from a test double. - const spawnMock = vi.fn(() => { - const child = Object.assign(new EventEmitter(), { - stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn() - }) - children.push(child) - return child - }) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - asServiceDouble(store), - asServiceDouble(rateLimits), - asServiceDouble(runtimeAuth) - ) - - const abandoned = service.addAccount({ runtime: 'host' }) - const abandonedRejection = expect(abandoned).rejects.toThrow('Claude sign-in was cancelled.') - await vi.waitFor(() => { - expect(children.length).toBe(1) - }) - - // The user reopens Settings and clicks Add Account again. - const retry = service.addAccount({ runtime: 'host' }) - const retryRejection = expect(retry).rejects.toThrow() - - await abandonedRejection - expect(children[0].kill).toHaveBeenCalled() - // Why: the point of the fix — the second login starts now, not after the - // abandoned one's whole sign-in deadline elapses. - await vi.waitFor(() => { - expect(children.length).toBe(2) - }) - - children[1].emit('close', 1) - await retryRejection - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('uses taskkill to cancel the Windows Claude login process tree', async () => { - setPlatform('win32') - vi.resetModules() - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) - const child = new EventEmitter() as EventEmitter & { - pid: number - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - child.pid = 1234 - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const taskkill = new EventEmitter() - const spawnMock = vi.fn((command: string) => (command === 'taskkill.exe' ? taskkill : child)) - const cleanupInteractiveLogin = vi.fn() - let publishTerminationPid: (pid: number) => void = () => {} - const buildInteractiveLoginSpawn = vi.fn(() => ({ - command: getCmdExePath(), - args: ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], - stdio: 'ignore' as const, - windowsHide: true, - cleanup: cleanupInteractiveLogin, - getTerminationPid: () => null, - waitForTerminationPid: () => - new Promise((resolve) => { - publishTerminationPid = resolve - }) - })) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ - buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn - })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - const addPromise = service.addAccount() - await vi.waitFor(() => { - expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('claude', [ - 'auth', - 'login', - '--claudeai' - ]) - expect(spawnMock).toHaveBeenCalledWith( - getCmdExePath(), - ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], - expect.objectContaining({ stdio: 'ignore', windowsHide: true }) - ) - }) - - expect(service.cancelPendingLogin()).toBe(true) - const rejection = expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') - expect(child.kill).not.toHaveBeenCalled() - expect(spawnMock).not.toHaveBeenCalledWith( - 'taskkill.exe', - expect.anything(), - expect.anything() - ) - publishTerminationPid(9876) - await vi.waitFor(() => { - expect(spawnMock).toHaveBeenCalledWith( - 'taskkill.exe', - ['/pid', '9876', '/t', '/f'], - expect.objectContaining({ stdio: 'ignore', windowsHide: true }) - ) - }) - expect(destroyStdin).not.toHaveBeenCalled() - taskkill.emit('close', 0) - await rejection - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(cleanupInteractiveLogin).toHaveBeenCalledTimes(1) - expect(service.cancelPendingLogin()).toBe(false) - } finally { - vi.doUnmock('node:child_process') - vi.doUnmock('../../shared/windows-interactive-login-spawn') - } - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts b/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts deleted file mode 100644 index 2ad1eb6356d1..000000000000 --- a/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - readManagedClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from './keychain' -import { - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-reauth-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -describe('ClaudeAccountService credential capture', () => { - let tempDir: string | null = null - - beforeEach(() => { - setPlatform('darwin') - tempDir = null - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('restores previous managed auth when reauth materialization fails', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), - syncForCurrentSelection: vi.fn(async () => { - throw new Error('materialize failed') - }) - } - const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') - - expect(readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')).toBe('{"old":true}\n') - expect(readFileSync(join(managedAuthPath, 'oauth-account.json'), 'utf-8')).toBe( - '{"oldOauth":true}\n' - ) - expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - }) - - it('restores settings without rematerializing when managed-auth rollback write fails', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue('{"old":true}\n') - vi.mocked(writeManagedClaudeKeychainCredentials) - .mockResolvedValueOnce() - .mockRejectedValueOnce(new Error('managed restore failed')) - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), - syncForCurrentSelection: vi.fn(async () => { - throw new Error('materialize failed') - }) - } - const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') - - expect(store.getSettings().claudeManagedAccounts[0].email).toBe('new@example.com') - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith( - '[claude-accounts] Failed to restore managed credentials during rollback:', - expect.any(Error) - ) - warn.mockRestore() - }) - - it('restores oauth metadata when new credential write and credential rollback fail', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue('{"old":true}\n') - vi.mocked(writeManagedClaudeKeychainCredentials) - .mockRejectedValueOnce(new Error('new credentials failed')) - .mockRejectedValueOnce(new Error('credential rollback failed')) - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), - syncForCurrentSelection: vi.fn() - } - const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.reauthenticateAccount('account-1')).rejects.toThrow( - 'new credentials failed' - ) - - expect(readFileSync(join(managedAuthPath, 'oauth-account.json'), 'utf-8')).toBe( - '{"oldOauth":true}\n' - ) - expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith( - '[claude-accounts] Failed to restore managed credentials during rollback:', - expect.any(Error) - ) - warn.mockRestore() - }) - - it('restores old metadata when rollback restores credentials but oauth restore fails', async () => { - setPlatform('linux') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const oauthPath = join(managedAuthPath, 'oauth-account.json') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') - writeFileSync(oauthPath, '{"oldOauth":true}\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), - syncForCurrentSelection: vi.fn(async () => { - rmSync(oauthPath, { force: true }) - mkdirSync(oauthPath) - throw new Error('materialize failed') - }) - } - const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') - - expect(readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')).toBe('{"old":true}\n') - expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('evicts inactive rate-limit cache after successful reauth', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: null - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await service.reauthenticateAccount('account-1') - - expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(undefined, { - runtime: 'host' - }) - expect(settings.claudeManagedAccounts[0].email).toBe('new@example.com') - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-test-harness.ts b/src/main/claude-accounts/claude-account-service-test-harness.ts deleted file mode 100644 index d66d1f3ea51b..000000000000 --- a/src/main/claude-accounts/claude-account-service-test-harness.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { vi } from 'vitest' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from './keychain' - -// Each suite declares its own `vi.mock('./keychain', ...)` (factories are hoisted and -// cannot close over this module); this only re-arms the doubles between tests. -export function resetClaudeKeychainMocks(): void { - vi.mocked(readActiveClaudeKeychainCredentials).mockReset() - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockReset() - vi.mocked(readManagedClaudeKeychainCredentials).mockReset() - vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockClear() - vi.mocked(writeActiveClaudeKeychainCredentials).mockReset() - vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue() - vi.mocked(writeManagedClaudeKeychainCredentials).mockReset() - vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue() -} - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - -export function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -export function restorePlatform(): void { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } -} - -export function createService(): unknown { - return {} -} diff --git a/src/main/claude-accounts/claude-account-service.test.ts b/src/main/claude-accounts/claude-account-service.test.ts new file mode 100644 index 000000000000..55c4304ca626 --- /dev/null +++ b/src/main/claude-accounts/claude-account-service.test.ts @@ -0,0 +1,349 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import { CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE } from './claude-account-registration' +import { + CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE, + CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE +} from './claude-account-selection' +import type { ClaudeCommandConfig, ClaudeCommandOptions } from './claude-command-process' +import type { ClaudeAccountSelectionTarget } from './runtime-selection' +import { ClaudeAccountService } from './service' + +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function account(id: string, extra: Partial = {}): ClaudeManagedAccount { + return { + id, + email: `${id}@example.test`, + managedAuthPath: '', + managedAuthRuntime: 'host', + wslDistro: null, + authMethod: 'subscription-oauth', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...extra + } +} + +function fixture(accounts: ClaudeManagedAccount[] = [account('a'), account('b')]) { + const root = mkdtempSync(join(tmpdir(), 'claude-accounts-')) + roots.push(root) + const home = (id: string) => join(root, id, 'home') + // Stands in for Claude finishing `claude auth login` in a folder. + const signIn = (id: string, email: string) => { + mkdirSync(home(id), { recursive: true }) + writeFileSync( + join(home(id), '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: email } }) + ) + } + let settings: GlobalSettings = { + ...getDefaultSettings('/tmp'), + claudeManagedAccounts: accounts, + activeClaudeManagedAccountId: 'a', + activeClaudeManagedAccountIdsByRuntime: { host: 'a', wsl: {} } + } + const runtimeAuth = { + router: { + accountHome: home, + userConfigDir: () => join(root, 'personal'), + copiedLoginIntoSystemDefault: () => existsSync(join(root, 'claude-runtime-auth')) + }, + syncForCurrentSelection: vi.fn(async (_target?: ClaudeAccountSelectionTarget) => {}), + publishAll: vi.fn(async () => {}), + prepareAccountFolder: vi.fn(async (id: string) => { + mkdirSync(home(id), { recursive: true }) + return { configDir: home(id), readPath: home(id) } + }), + removeAccountFolder: vi.fn(async (id: string) => rmSync(join(root, id), { recursive: true })), + getRuntimeConfigDir: () => '/unused' + } + // Stands in for the hidden `claude auth login`; each test decides how it ends. + const runLogin = vi.fn< + ( + args: string[], + config: ClaudeCommandConfig, + timeoutMs: number, + options?: ClaudeCommandOptions + ) => Promise + >(async () => '') + const service = new ClaudeAccountService( + { + getSettings: () => settings, + updateSettings: (patch) => { + settings = { ...settings, ...patch } + } + }, + { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn().mockResolvedValue(undefined) + }, + runtimeAuth, + runLogin + ) + return { root, home, service, runtimeAuth, runLogin, signIn, settings: () => settings } +} + +// The id the last sign-in prepared a folder for. +function newId(f: ReturnType): string { + return f.runtimeAuth.prepareAccountFolder.mock.calls.at(-1)![0] +} + +describe('ClaudeAccountService', () => { + it("labels each row with its folder's login and asks a folder without one to sign in", () => { + const f = fixture([ + account('a'), + account('b'), + account('old-wsl', { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/old-wsl/auth' + }), + account('new-wsl', { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/new-wsl/home' + }) + ]) + f.signIn('a', 'now-a@example.test') + const byId = new Map(f.service.listAccounts().accounts.map((row) => [row.id, row])) + expect(byId.get('a')).toMatchObject({ email: 'now-a@example.test' }) + expect(byId.get('a')?.needsSignIn).toBeUndefined() + expect(byId.get('b')).toMatchObject({ email: 'b@example.test', needsSignIn: true }) + expect(byId.get('old-wsl')).toMatchObject({ needsSignIn: true }) + expect(byId.get('new-wsl')?.needsSignIn).toBeUndefined() + }) + + it("reports System default's login from the user's own folder", () => { + const f = fixture() + mkdirSync(join(f.root, 'personal')) + writeFileSync( + join(f.root, 'personal', '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'me@example.test' } }) + ) + expect(f.service.listAccounts()).toMatchObject({ + systemDefaultEmail: 'me@example.test' + }) + expect(f.service.listAccounts()).not.toHaveProperty('userClaudeConfigDir') + expect(f.service.listAccounts().systemDefaultMayBeCopied).toBeUndefined() + mkdirSync(join(f.root, 'claude-runtime-auth')) + expect(f.service.listAccounts().systemDefaultMayBeCopied).toBe(true) + }) + + it('saves an account only once its folder holds a login', async () => { + const f = fixture() + const begun = await f.service.beginSignIn({ runtime: 'host' }) + expect(begun).toMatchObject({ + runtime: 'host', + configDir: join(f.root, begun.accountId, 'home') + }) + await expect(f.service.finishSignIn(begun)).rejects.toThrow(CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + + f.signIn(begun.accountId, 'new@example.test') + await f.service.finishSignIn(begun) + expect(f.settings().claudeManagedAccounts.at(-1)).toMatchObject({ + id: begun.accountId, + email: 'new@example.test', + managedAuthPath: begun.configDir + }) + }) + + it('refuses a second account for the same login and deletes its folder', async () => { + const f = fixture() + const begun = await f.service.beginSignIn({ runtime: 'host' }) + f.signIn(begun.accountId, 'B@example.test') + await expect(f.service.finishSignIn(begun)).rejects.toThrow('already added') + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith(begun.accountId, { + runtime: 'host' + }) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + }) + + it('relabels a saved account signed in again under another login', async () => { + const f = fixture() + const begun = await f.service.beginSignIn({ accountId: 'b' }) + f.signIn('b', 'other@example.test') + await f.service.finishSignIn(begun) + expect(f.settings().claudeManagedAccounts.find((entry) => entry.id === 'b')).toMatchObject({ + email: 'other@example.test', + createdAt: 1 + }) + }) + + it('signs in with a hidden login straight into the new account folder', async () => { + const f = fixture() + f.runLogin.mockImplementationOnce(async (_args, config) => { + f.signIn(newId(f), 'new@example.test') + expect(config).toEqual({ windowsPath: f.home(newId(f)), linuxPath: null, wslDistro: null }) + return '' + }) + await f.service.addAccount({ runtime: 'host' }) + expect(f.runLogin).toHaveBeenCalledWith( + ['auth', 'login', '--claudeai'], + expect.anything(), + expect.any(Number), + expect.objectContaining({ keepStdinOpen: true }) + ) + expect(f.settings().claudeManagedAccounts.at(-1)).toMatchObject({ + id: newId(f), + email: 'new@example.test', + managedAuthPath: f.home(newId(f)) + }) + expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() + }) + + it('runs a WSL login against the guest folder', async () => { + const f = fixture() + f.runtimeAuth.prepareAccountFolder.mockImplementation(async (id: string) => { + mkdirSync(f.home(id), { recursive: true }) + return { configDir: `/home/u/claude-profiles/${id}/home`, readPath: f.home(id) } + }) + f.runLogin.mockImplementationOnce(async (_args, config) => { + f.signIn(newId(f), 'wsl@example.test') + expect(config).toEqual({ + windowsPath: f.home(newId(f)), + linuxPath: `/home/u/claude-profiles/${newId(f)}/home`, + wslDistro: 'Ubuntu' + }) + return '' + }) + await f.service.addAccount({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(f.settings().claudeManagedAccounts.at(-1)).toMatchObject({ + email: 'wsl@example.test', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: `/home/u/claude-profiles/${newId(f)}/home` + }) + }) + + it('leaves no row or folder when the hidden login is cancelled', async () => { + const f = fixture() + f.runLogin.mockImplementationOnce( + (_args, _config, _timeoutMs, options) => + new Promise((_resolve, reject) => + options?.signal?.addEventListener('abort', () => + reject(new Error('Claude sign-in was cancelled.')) + ) + ) + ) + const adding = f.service.addAccount({ runtime: 'host' }) + await vi.waitFor(() => expect(f.runLogin).toHaveBeenCalled()) + expect(f.service.cancelPendingLogin()).toBe(true) + await expect(adding).rejects.toThrow('Claude sign-in was cancelled.') + expect(f.service.cancelPendingLogin()).toBe(false) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith(newId(f), { runtime: 'host' }) + expect(existsSync(join(f.root, newId(f)))).toBe(false) + }) + + it('leaves no row or folder when the hidden login times out or finds no login', async () => { + const f = fixture() + f.runLogin.mockRejectedValueOnce(new Error('Claude sign-in took too long to finish.')) + await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow('took too long') + await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow( + 'could not resolve the account email' + ) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledTimes(2) + }) + + it('refuses a hidden login to an already added account and deletes its folder', async () => { + const f = fixture() + f.runLogin.mockImplementationOnce(async () => { + f.signIn(newId(f), 'B@example.test') + return '' + }) + await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow( + 'This Claude account is already added.' + ) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + expect(existsSync(join(f.root, newId(f)))).toBe(false) + }) + + it('signs a saved account in again inside its own folder and keeps it on failure', async () => { + const f = fixture() + f.runLogin.mockImplementationOnce(async (_args, config) => { + expect(config.windowsPath).toBe(f.home('b')) + f.signIn('b', 'b@example.test') + return '' + }) + await f.service.reauthenticateAccount('b') + expect(f.runtimeAuth.prepareAccountFolder).toHaveBeenCalledWith('b', { runtime: 'host' }) + expect(f.settings().claudeManagedAccounts.find((entry) => entry.id === 'b')).toMatchObject({ + email: 'b@example.test', + createdAt: 1 + }) + f.runLogin.mockRejectedValueOnce(new Error('Claude sign-in was cancelled.')) + await expect(f.service.reauthenticateAccount('b')).rejects.toThrow('cancelled') + expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() + expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['a', 'b']) + }) + + it('clears the selection, republishes, then deletes the folder on remove', async () => { + const f = fixture() + f.signIn('a', 'a@example.test') + await f.service.removeAccount('a') + expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['b']) + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBeNull() + expect(f.runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith('a', { runtime: 'host' }) + }) + + it("deletes a cancelled sign-in's folder but never a saved account's", async () => { + const f = fixture() + const begun = await f.service.beginSignIn({ runtime: 'host' }) + await f.service.cancelSignIn({ accountId: begun.accountId, runtime: 'host' }) + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith(begun.accountId, { + runtime: 'host' + }) + f.runtimeAuth.removeAccountFolder.mockClear() + await f.service.cancelSignIn({ accountId: 'a', runtime: 'host' }) + expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() + }) + + it('keeps the row when its folder cannot be deleted, so the user can retry', async () => { + const f = fixture() + f.runtimeAuth.removeAccountFolder.mockRejectedValueOnce( + Object.assign(new Error('resource busy'), { code: 'EBUSY' }) + ) + await expect(f.service.removeAccount('a')).rejects.toThrow(CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE) + expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['a', 'b']) + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBeNull() + }) + + it('puts the previous account back when publishing a new selection fails', async () => { + const f = fixture() + f.signIn('b', 'b@example.test') + f.runtimeAuth.syncForCurrentSelection.mockRejectedValueOnce(new Error('publish failed')) + await expect(f.service.selectAccount('b')).rejects.toThrow('publish failed') + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('a') + expect(f.runtimeAuth.publishAll).toHaveBeenCalled() + }) + + it('refuses to select an account whose folder holds no login', async () => { + const f = fixture() + await expect(f.service.selectAccount('b')).rejects.toThrow(CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE) + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('a') + f.signIn('b', 'b@example.test') + await f.service.selectAccount('b') + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('b') + }) + + it('refuses to select a WSL account for the host', async () => { + const f = fixture([ + account('a'), + account('w', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }) + ]) + await expect(f.service.selectAccountForTarget('w', { runtime: 'host' })).rejects.toThrow( + 'That Claude account belongs to a different runtime.' + ) + }) +}) diff --git a/src/main/claude-accounts/claude-auth-capture.ts b/src/main/claude-accounts/claude-auth-capture.ts deleted file mode 100644 index 2acaa4408683..000000000000 --- a/src/main/claude-accounts/claude-auth-capture.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { createHash } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { readActiveClaudeKeychainCredentialsStrict } from './keychain' - -export type ClaudeIdentity = { - email: string | null - organizationUuid: string | null - organizationName: string | null -} - -export type CapturedClaudeAuth = { - credentialsJson: string - oauthAccount: unknown - identity: ClaudeIdentity -} - -export type ClaudeCaptureCommand = ( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number, - options?: { allowFailure?: boolean } -) => Promise - -const STATUS_TIMEOUT_MS = 20_000 - -export async function captureClaudeAuthFromExistingConfigDir( - configDir: string, - previousLegacyCredentialsSha256: string | null | undefined, - runCommand: ClaudeCaptureCommand -): Promise { - const trimmed = configDir.trim() - if (!trimmed) { - throw new Error('A Claude config directory path is required.') - } - const resolvedDir = resolve(trimmed) - if (process.platform !== 'darwin' && !existsSync(join(resolvedDir, '.credentials.json'))) { - throw new Error( - `No Claude credentials found in ${resolvedDir}. Run \`claude login\` into this directory first.` - ) - } - - let status = '' - try { - status = await runCommand( - ['auth', 'status', '--json'], - { windowsPath: resolvedDir, linuxPath: null, wslDistro: null }, - STATUS_TIMEOUT_MS, - { allowFailure: true } - ) - } catch (error) { - console.warn('[claude-accounts] Could not read `claude auth status`:', error) - } - const currentLegacyKeychain = await readActiveClaudeKeychainCredentialsStrict() - return captureClaudeAuthFromConfigDir( - resolvedDir, - status, - currentLegacyKeychain, - previousLegacyCredentialsSha256 - ) -} - -export async function captureClaudeAuthFromConfigDir( - configDir: string, - statusOutput: string, - previousLegacyKeychain: string | null, - previousLegacyCredentialsSha256?: string | null, - readCredentials = readCapturedClaudeCredentials -): Promise { - const credentialsJson = await readCredentials( - configDir, - previousLegacyKeychain, - previousLegacyCredentialsSha256 - ) - if (!credentialsJson) { - throw new Error('Claude login completed, but no OAuth credentials were captured.') - } - const oauthAccount = readClaudeOauthAccount(configDir) - return { - credentialsJson, - oauthAccount, - identity: resolveClaudeIdentity(statusOutput, oauthAccount, credentialsJson) - } -} - -export async function readCapturedClaudeCredentials( - configDir: string, - previousLegacyKeychain: string | null, - previousLegacyCredentialsSha256?: string | null -): Promise { - if (process.platform === 'darwin') { - const scopedCredentialsJson = await readActiveClaudeKeychainCredentialsStrict(configDir) - if (scopedCredentialsJson) { - return scopedCredentialsJson - } - const legacyCredentialsJson = await readActiveClaudeKeychainCredentialsStrict() - const legacyChanged = - previousLegacyCredentialsSha256 === undefined - ? legacyCredentialsJson !== previousLegacyKeychain - : legacyCredentialsJson !== null && - createHash('sha256').update(legacyCredentialsJson).digest('hex') !== - previousLegacyCredentialsSha256 - if (legacyCredentialsJson && legacyChanged) { - return legacyCredentialsJson - } - } - const credentialsPath = join(configDir, '.credentials.json') - return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null -} - -function readClaudeOauthAccount(configDir: string): unknown { - for (const configPath of [join(configDir, '.claude.json'), join(configDir, '.config.json')]) { - if (!existsSync(configPath)) { - continue - } - try { - const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Record - if (parsed.oauthAccount) { - return parsed.oauthAccount - } - } catch { - continue - } - } - return null -} - -function resolveClaudeIdentity( - statusOutput: string, - oauthAccount: unknown, - credentialsJson: string -): ClaudeIdentity { - const status = parseJsonObject(statusOutput) - const oauth = asRecord(oauthAccount) - const credentialOauth = asRecord(parseJsonObject(credentialsJson)?.claudeAiOauth) - return { - email: normalizeField( - readString(status, 'email') ?? - readString(oauth, 'emailAddress') ?? - readString(oauth, 'email') ?? - readString(credentialOauth, 'email') - ), - organizationUuid: normalizeField( - readString(status, 'organizationUuid') ?? - readString(status, 'organizationId') ?? - readString(oauth, 'organizationUuid') ?? - readString(oauth, 'organizationId') - ), - organizationName: normalizeField( - readString(status, 'organizationName') ?? readString(oauth, 'organizationName') - ) - } -} - -function parseJsonObject(value: string): Record | null { - try { - return asRecord(JSON.parse(value) as unknown) - } catch { - return null - } -} - -function asRecord(value: unknown): Record | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null -} - -function readString(value: Record | null, key: string): string | null { - return typeof value?.[key] === 'string' ? value[key] : null -} - -function normalizeField(value: string | null | undefined): string | null { - const trimmed = value?.trim() - return trimmed ? trimmed : null -} diff --git a/src/main/claude-accounts/claude-command-process.test.ts b/src/main/claude-accounts/claude-command-process.test.ts new file mode 100644 index 000000000000..514a8ed4d487 --- /dev/null +++ b/src/main/claude-accounts/claude-command-process.test.ts @@ -0,0 +1,75 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' + +type SpawnSpec = { program: string; args?: readonly string[]; env?: NodeJS.ProcessEnv } +const spawned = vi.hoisted(() => { + const specs: SpawnSpec[] = [] + return { specs } +}) + +vi.mock('../../shared/child-process/run-process', () => ({ + spawnProcess: (spec: SpawnSpec) => { + spawned.specs.push(spec) + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + pid: undefined, + kill: vi.fn() + }) + queueMicrotask(() => child.emit('close', 0)) + return child + } +})) +vi.mock('../codex-cli/command', () => ({ resolveClaudeCommand: () => 'claude' })) +vi.mock('../wsl/wsl-executable-path', () => ({ + resolveWslExecutablePath: () => 'C:\\Windows\\System32\\wsl.exe' +})) + +import { buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' +import { runClaudeCommandProcess } from './claude-command-process' + +const originalPlatform = process.platform +afterEach(() => { + spawned.specs.length = 0 + Object.defineProperty(process, 'platform', { value: originalPlatform }) +}) + +describe('runClaudeCommandProcess', () => { + it("logs in on the host with CLAUDE_CONFIG_DIR set to the account's folder", async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + await runClaudeCommandProcess( + ['auth', 'login', '--claudeai'], + { windowsPath: '/data/claude-profiles/a/home', linuxPath: null, wslDistro: null }, + 1000 + ) + expect(spawned.specs[0]).toMatchObject({ + program: 'claude', + args: ['auth', 'login', '--claudeai'] + }) + expect(spawned.specs[0].env?.CLAUDE_CONFIG_DIR).toBe('/data/claude-profiles/a/home') + }) + + it("logs in through the distro's login shell with --exec for a WSL account", async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + await runClaudeCommandProcess( + ['auth', 'login', '--claudeai'], + { + windowsPath: '\\\\wsl.localhost\\Ubuntu\\home\\u\\a', + linuxPath: "/home/u/it's/a", + wslDistro: 'Ubuntu' + }, + 1000 + ) + const spec = spawned.specs[0] + expect(spec.program).toBe('C:\\Windows\\System32\\wsl.exe') + expect(spec.args?.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', '/bin/sh', '-c']) + expect(spec.args?.[5]).toBe( + buildWslLoginShellCommand( + `exec env CLAUDE_CONFIG_DIR='/home/u/it'\\''s/a' claude 'auth' 'login' '--claudeai'` + ) + ) + expect(spec.env?.CLAUDE_CONFIG_DIR).toBe(process.env.CLAUDE_CONFIG_DIR) + }) +}) diff --git a/src/main/claude-accounts/claude-command-process.ts b/src/main/claude-accounts/claude-command-process.ts index 78fe109f79f8..98b867d02a78 100644 --- a/src/main/claude-accounts/claude-command-process.ts +++ b/src/main/claude-accounts/claude-command-process.ts @@ -4,7 +4,13 @@ import { buildWindowsHostInteractiveLoginSpawn, type WindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn' +import { + buildWslExecArgs, + buildWslLoginShellCommand, + quotePosixShell +} from '../../shared/wsl-login-shell-command' import { resolveClaudeCommand } from '../codex-cli/command' +import { resolveWslExecutablePath } from '../wsl/wsl-executable-path' import { buildWindowsCommandInvocation } from './windows-command-invocation' import { terminateClaudeProcess } from './claude-login-process-termination' @@ -19,15 +25,10 @@ export type ClaudeCommandConfig = { } export type ClaudeCommandOptions = { - allowFailure?: boolean signal?: AbortSignal keepStdinOpen?: boolean } -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - export function runClaudeCommandProcess( args: string[], configDir: ClaudeCommandConfig, @@ -149,7 +150,7 @@ export function runClaudeCommandProcess( return } settle(() => { - if (code === 0 || options?.allowFailure) { + if (code === 0) { resolvePromise(output) return } @@ -188,12 +189,9 @@ type ClaudeSpawnConfig = { windowsVerbatimArguments: boolean } +// Why only this variable: account terminals set just CLAUDE_CONFIG_DIR, so Claude keys the login the same way. function claudeConfigDirEnv(configDir: string): NodeJS.ProcessEnv { - return { - CLAUDE_CONFIG_DIR: configDir, - // Why: Claude Code 2.1.220+ hashes this for the Keychain service name. - CLAUDE_SECURESTORAGE_CONFIG_DIR: configDir - } + return { CLAUDE_CONFIG_DIR: configDir } } function resolveClaudeInvocation( @@ -214,15 +212,15 @@ function resolveClaudeInvocation( } : configDir.linuxPath && configDir.wslDistro ? { - command: 'wsl.exe', - args: [ - '-d', - configDir.wslDistro, - '--exec', - 'bash', - '-lc', - `export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; export CLAUDE_SECURESTORAGE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}` - ], + // Same login as the CLI's WSL sign-in: the distro's login shell finds an nvm/mise claude. + command: resolveWslExecutablePath(), + args: buildWslExecArgs(configDir.wslDistro, [ + '/bin/sh', + '-c', + buildWslLoginShellCommand( + `exec env CLAUDE_CONFIG_DIR=${quotePosixShell(configDir.linuxPath)} claude ${args.map(quotePosixShell).join(' ')}` + ) + ]), env: process.env, windowsVerbatimArguments: false } diff --git a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts new file mode 100644 index 000000000000..4156b58ced4b --- /dev/null +++ b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts @@ -0,0 +1,281 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { expect, it } from 'vitest' +import { stripComments } from '../../shared/source-scan/source-tree-scan' + +const src = join(__dirname, '../..') + +/** + * Every place a Claude login could be written from: accounts, usage, launch, hooks, the CLI, the + * relay and the generated shell scripts. Plan §9 lists these; credential checks run on all of them. + */ +const credentialRoots: readonly (string | { root: string; only: RegExp })[] = [ + 'main/claude-accounts', + // Other providers there refresh their own logins; only Claude's usage is in scope. + { root: 'main/rate-limits', only: /[\\/]claude[^\\/]*\.ts$/ }, + 'main/claude', + 'main/native-chat', + 'main/ipc/pty', + 'main/daemon', + 'main/providers', + 'main/macos-keychain', + 'cli', + 'relay', + 'shared' +] + +/** + * Only noncredential profile surfaces may persist bytes, and each only with the primitives it + * uses today; a new primitive in one of them is a review, not a silent pass. + */ +const fsAllowances: Record = { + 'claude-accounts/claude-profile-history.ts': [ + 'mkdirSync', + 'renameSync', + 'rmdirSync', + 'symlinkSync' + ], + 'claude-accounts/claude-profile-paths.ts': ['mkdirSync', 'writeFileAtomically'], + // The which-account file. + 'claude-accounts/claude-profile-router.ts': ['mkdirSync', 'rmSync', 'writeFileAtomically'], + // Removing an account deletes its folder, links first. + 'claude-accounts/claude-account-folder.ts': ['rmSync', 'rmdirSync', 'unlinkSync'], + 'claude-accounts/claude-profile-prompt-history.ts': [ + 'appendFileSync', + 'linkSync', + 'openSync', + 'renameSync', + 'rmSync', + 'symlinkSync', + 'unlinkSync', + 'writeFileSync', + 'writeFileAtomically' + ], + 'claude-accounts/claude-profile-provisioning.ts': ['mkdirSync', 'rmSync', 'writeFileAtomically'], + 'claude-accounts/claude-profile-sharing.ts': [ + 'mkdirSync', + 'rmdirSync', + 'symlinkSync', + 'unlinkSync', + 'writeFileAtomically' + ], + // The guest helper answers the host on stdout. + 'claude-accounts/claude-profile-wsl-entry.ts': ['write'] +} +const mutation = + /^(?:write(?:File|Json|Sync|v|.*Credentials|.*Keychain)?$|write(?:File|Json)|appendFile|copyFile|cp(?:Sync)?$|rename(?:Sync)?$|unlink(?:Sync)?$|rm(?:dir)?(?:Sync)?$|truncate(?:Sync)?$|createWriteStream|symlink(?:Sync)?$|link(?:Sync)?$|chmod(?:Sync)?$|mkdir(?:Sync)?$|mkdtemp(?:Sync)?$|open(?:Sync)?$|delete.*Keychain|refreshClaudeOauth)/ + +function persistencePrimitives(text: string): string[] { + const found = new Set() + // Import-side names survive aliases, including destructuring and wrapped helper calls. + for (const match of text.matchAll(/\b([a-zA-Z_$][\w$]*)\s*(?=\(|as\s|[:,}])/g)) { + if (mutation.test(match[1])) { + found.add(match[1]) + } + } + return [...found] +} + +const WRITE_CALL = + /\b(?:write\w*|append\w*|copy\w*|cp(?:Sync)?|rename\w*|link\w*|symlink\w*|createWriteStream)\s*\(/g +// The one runner that takes `security` argv from its callers; each caller must spell the verb. +const SECURITY_RUNNER = 'main/macos-keychain/generic-password.ts' +// Removing an account deletes the Keychain items Claude made for its folder; nothing adds one. +const KEYCHAIN_DELETE_OWNER = 'main/claude-accounts/claude-account-folder.ts' + +/** Names bound, directly or through another such name, to the credentials file's path. */ +function credentialFileNames(text: string): Set { + const declarations: { name: string; init: string }[] = [] + const lines = text.split('\n') + lines.forEach((line, index) => { + const declared = /^(\s*)(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) + if (!declared) { + return + } + const indent = declared[1].length + let end = index + 1 + while (end < lines.length && /^\s*$|^\s+/.test(lines[end])) { + const lead = /^\s*/.exec(lines[end])?.[0].length ?? 0 + if (lines[end].trim() && lead <= indent && !/^\s*[)\]}]/.test(lines[end])) { + break + } + end += 1 + } + declarations.push({ name: declared[2], init: lines.slice(index, end).join('\n') }) + }) + const names = new Set() + for (let changed = true; changed;) { + changed = false + for (const { name, init } of declarations) { + const tainted = + init.includes('.credentials.json') || + [...names].some((other) => new RegExp(`\\b${other}\\b`).test(init.replace(name, ''))) + if (tainted && !names.has(name)) { + names.add(name) + changed = true + } + } + } + return names +} + +/** Credential writes refused everywhere scanned, whatever form the call takes. */ +function credentialMutations(text: string, file = ''): string[] { + const found: string[] = [] + // A `security` launch whose verb is not spelled out could be a write, so only the runner may + // forward argv, and its callers must spell the verb. + if ( + (file !== SECURITY_RUNNER && + /\b(?:exec(?:File)?(?:Sync|Async)?|spawn(?:Sync)?|runProcess|spawnProcess)\s*\(\s*['"`]security['"`]\s*,\s*(?!\[\s*['"])/.test( + text + )) || + /\bprogram\s*:\s*['"`]security['"`]\s*,\s*args\s*:\s*(?!\[\s*['"])/.test(text) || + /(? new RegExp(`\\b${name}\\b`).test(args))) { + found.push('credentials file write through a name') + break + } + } + // Argv (`['add-generic-password', …]`) and shell (`security add-generic-password`) forms. + if ( + /add-generic-password/.test(text) || + (file !== KEYCHAIN_DELETE_OWNER && /delete-generic-password/.test(text)) + ) { + found.push('keychain write') + } + if (/\b(?:write|delete|store|save)\w*Keychain\w*\b/.test(text)) { + found.push('keychain helper') + } + if (/oauth\/token|grant_type|\brefresh_token\b/.test(text)) { + found.push('token refresh') + } + // A statement that names the credentials file together with anything that writes, copies, + // moves or opens it for writing (TS calls and generated shell alike). + for (const statement of text.split(/;|\n\s*\n/)) { + if ( + statement.includes('.credentials.json') && + /\b(?:write\w*|append\w*|copy\w*|cp(?:Sync)?|rename\w*|link\w*|symlink\w*|createWriteStream|mv|tee|install)\b|\bopen(?:Sync)?\s*\([\s\S]{0,200}?,\s*['"`](?:w|a|r\+|wx|ax)|>\s*["'$]/.test( + statement + ) + ) { + found.push('credentials file write') + } + } + return found +} + +function files(dir: string): string[] { + if (!existsSync(dir)) { + return [] + } + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const file = join(dir, entry.name) + return entry.isDirectory() + ? entry.name === 'node_modules' || entry.name === '__fixtures__' + ? [] + : files(file) + : /\.(?:ts|tsx|mts|cts|js|mjs|cjs|sh)$/.test(entry.name) && + !/\.test\.|\.spec\.|test-harness|harness\.ts|test-fixtures|test-utils/.test(entry.name) + ? [file] + : [] + }) +} + +it('keeps every Claude credential writer removed, in every form and on every launch path', () => { + const violations = credentialRoots + .flatMap((entry) => { + const { root, only } = typeof entry === 'string' ? { root: entry, only: null } : entry + const dir = join(src, root) + const found = statSync(dir, { throwIfNoEntry: false })?.isDirectory() ? files(dir) : [] + return only ? found.filter((file) => only.test(file)) : found + }) + .flatMap((file) => + credentialMutations( + stripComments(readFileSync(file, 'utf8')), + relative(src, file).replaceAll('\\', '/') + ).map((kind) => `${relative(src, file)}: ${kind}`) + ) + expect(violations).toEqual([]) +}) + +it('confines filesystem persistence in accounts and usage to named profile surfaces', () => { + const main = join(src, 'main') + const candidates = [ + ...files(__dirname), + ...files(join(main, 'rate-limits')).filter((file) => /\/claude[^/]*\.ts$/.test(file)) + ] + const violations = candidates.flatMap((file) => { + const name = relative(main, file).replaceAll('\\', '/') + const allowed = new Set(fsAllowances[name] ?? []) + return persistencePrimitives(stripComments(readFileSync(file, 'utf8'))) + .filter((primitive) => !allowed.has(primitive)) + .map((primitive) => `${name}: ${primitive}`) + }) + expect(violations).toEqual([]) + const cli = readFileSync(join(main, '../cli/handlers/account.ts'), 'utf8') + expect(cli).not.toMatch(/Keychain|orca-account-add-claude|addClaudeFromConfigDir/) + const auth = readFileSync(join(__dirname, 'runtime-auth-service.ts'), 'utf8') + expect(auth).not.toMatch(/extends ClaudeRuntimeAuth|snapshot|credentialsJson|readBack/i) +}) + +it.each([ + "import { writeFile as save } from 'node:fs/promises'; save(path, token)", + "import * as fs from 'node:fs'; const save = () => fs.writeFileSync(path, token)", + "import { writeFileAtomically as persist } from '../codex-accounts/fs-utils'; persist(path, token)", + 'fs.copyFile(source, destination)', + 'fs.symlink(credentialPath, destination)', + "const fd = openSync(join(dir, 'x'), 'w'); writeSync(fd, token)", + 'await fs.promises.cp(a, b)', + 'fs.cpSync(a, b, { recursive: true })' +])('mutation control: the filesystem census rejects %s', (source) => { + expect(persistencePrimitives(source).length).toBeGreaterThan(0) +}) + +it.each([ + 'security add-generic-password -s Claude -w token', + "await execFileAsync('security', ['add-generic-password', '-U', '-s', service, '-w', token])", + "execFileSync('security', ['delete-generic-password', '-s', 'Claude Code-credentials'])", + "import { writeKeychainPassword as persist } from '../macos-keychain/generic-password'; persist(service, user, token)", + "const fd = openSync(join(dir, '.credentials.json'), 'w'); writeSync(fd, token)", + "await fs.promises.cp(join(a, '.credentials.json'), join(b, '.credentials.json'))", + "copyFileSync(join(from, '.credentials.json'), join(home, '.credentials.json'))", + "const script = 'cp source .credentials.json'", + 'const script = `printf %s "$token" > "$HOME/.claude/.credentials.json"`', + "fetch('https://api.anthropic.com/v1/oauth/token')", + "body: JSON.stringify({ grant_type: 'refresh_token' })", + "execFile('security', [['add', 'generic', 'password'].join('-'), '-s', service])", + "spawn('security', verbArgs)", + 'execSecurityCommand(args)', + "const CREDENTIALS_FILE = '.credentials.json'\n\ncopyFileSync(join(from, CREDENTIALS_FILE), join(to, CREDENTIALS_FILE))", + "const name = '.credentials.json'\nfunction save(dir) {\n const target = join(dir, name)\n\n writeFileSync(target, token)\n}" +])('mutation control: the credential check rejects %s', (source) => { + expect(credentialMutations(source).length).toBeGreaterThan(0) +}) + +it.each([ + "const file = await readFile(join(home, '.credentials.json'), 'utf8')", + "readKeychainPassword('Claude Code-credentials', user)", + "execSecurityCommand(['find-generic-password', '-s', service, '-w'])", + "const file = join(home, '.credentials.json')\nconst raw = await readFile(file, 'utf8')" +])('the credential check leaves a read alone: %s', (source) => { + expect(credentialMutations(source)).toEqual([]) +}) + +it('lets only account removal delete a Keychain item, and nothing add one', () => { + const remove = "execSecurityCommand(['delete-generic-password', '-s', service])" + expect(credentialMutations(remove, KEYCHAIN_DELETE_OWNER)).toEqual([]) + expect(credentialMutations(remove, 'main/claude-accounts/service.ts')).not.toEqual([]) + expect( + credentialMutations( + "execSecurityCommand(['add-generic-password', '-s', service])", + KEYCHAIN_DELETE_OWNER + ) + ).not.toEqual([]) +}) diff --git a/src/main/claude-accounts/claude-login-completion.oracle.test.ts b/src/main/claude-accounts/claude-login-completion.oracle.test.ts deleted file mode 100644 index 1f2fd963183f..000000000000 --- a/src/main/claude-accounts/claude-login-completion.oracle.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { EventEmitter } from 'node:events' -import { PassThrough } from 'node:stream' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const processMocks = vi.hoisted(() => ({ - spawn: vi.fn() -})) - -vi.mock('node:child_process', () => ({ - execFileSync: vi.fn(), - spawn: processMocks.spawn -})) - -vi.mock('electron', () => ({ - app: { getPath: () => 'C:\\orca-review-11407' } -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: () => 'claude.exe' -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - deleteManagedClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -type CommandConfig = { - windowsPath: string - linuxPath: string | null - wslDistro: string | null -} - -type FakeChild = EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - pid: number -} - -type CommandRunner = { - runClaudeCommand( - args: string[], - config: CommandConfig, - timeoutMs: number, - options?: { keepStdinOpen?: boolean; signal?: AbortSignal } - ): Promise -} - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) -} - -function createChild(): FakeChild { - const child = new EventEmitter() as FakeChild - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - child.pid = 11407 - return child -} - -async function createRunner(): Promise { - const { ClaudeAccountService } = await import('./service') - return new ClaudeAccountService({} as never, {} as never, {} as never) as unknown as CommandRunner -} - -async function flushPromiseCallbacks(): Promise { - await Promise.resolve() - await Promise.resolve() -} - -describe('native Windows Claude login completion oracle', () => { - beforeEach(() => { - vi.useFakeTimers() - processMocks.spawn.mockReset() - }) - - afterEach(() => { - vi.useRealTimers() - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - }) - - it('settles exact native Windows auth login on exit exactly once and cleans resources', async () => { - setPlatform('win32') - const child = createChild() - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const destroyStdout = vi.spyOn(child.stdout, 'destroy') - const destroyStderr = vi.spyOn(child.stderr, 'destroy') - const abortController = new AbortController() - const removeAbortListener = vi.spyOn(abortController.signal, 'removeEventListener') - processMocks.spawn.mockReturnValue(child) - const runner = await createRunner() - let completions = 0 - const command = runner - .runClaudeCommand( - ['auth', 'login', '--claudeai'], - { windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null }, - 1000, - { keepStdinOpen: true, signal: abortController.signal } - ) - .then(() => { - completions += 1 - }) - - child.emit('exit', 0) - await flushPromiseCallbacks() - - try { - expect(completions).toBe(1) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('exit')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(destroyStdout).toHaveBeenCalledTimes(1) - expect(destroyStderr).toHaveBeenCalledTimes(1) - expect(removeAbortListener).toHaveBeenCalledTimes(1) - - child.emit('close', 0) - await vi.advanceTimersByTimeAsync(1001) - - expect(completions).toBe(1) - expect(child.kill).not.toHaveBeenCalled() - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(destroyStdout).toHaveBeenCalledTimes(1) - expect(destroyStderr).toHaveBeenCalledTimes(1) - expect(removeAbortListener).toHaveBeenCalledTimes(1) - } finally { - child.emit('close', 0) - await command - } - }) - - it.each([ - { - name: 'non-Windows auth login', - platform: 'linux' as const, - args: ['auth', 'login', '--claudeai'], - config: { windowsPath: '/isolated-auth', linuxPath: null, wslDistro: null } - }, - { - name: 'unrelated native Windows command containing login', - platform: 'win32' as const, - args: ['config', 'login'], - config: { windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null } - }, - { - name: 'WSL auth login', - platform: 'win32' as const, - args: ['auth', 'login', '--claudeai'], - config: { - windowsPath: 'C:\\isolated-auth', - linuxPath: '/home/orca/isolated-auth', - wslDistro: 'Ubuntu-Oracle' - } - } - ])('preserves close completion for $name', async ({ platform, args, config }) => { - setPlatform(platform) - const child = createChild() - processMocks.spawn.mockReturnValue(child) - const runner = await createRunner() - let completions = 0 - const command = runner.runClaudeCommand(args, config, 1000).then(() => { - completions += 1 - }) - - child.emit('exit', 0) - await flushPromiseCallbacks() - - expect(completions).toBe(0) - expect(child.listenerCount('close')).toBe(1) - - child.emit('close', 0) - await command - - expect(completions).toBe(1) - expect(child.listenerCount('close')).toBe(0) - expect(child.kill).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/claude-accounts/claude-login-session.ts b/src/main/claude-accounts/claude-login-session.ts index ce9a398bb865..47160b506b22 100644 --- a/src/main/claude-accounts/claude-login-session.ts +++ b/src/main/claude-accounts/claude-login-session.ts @@ -1,19 +1,6 @@ -import { mkdtempSync, realpathSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { toWindowsWslPath } from '../wsl' -import { runWslProcess } from '../wsl/wsl-runner' -import type { CapturedClaudeAuth } from './claude-auth-capture' import type { ClaudeCommandConfig, ClaudeCommandOptions } from './claude-command-process' -import type { ClaudeManagedAuthLocation } from './claude-managed-auth-storage' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials -} from './keychain' const LOGIN_TIMEOUT_MS = 180_000 -const STATUS_TIMEOUT_MS = 20_000 type ClaudeLoginSessionDependencies = { runCommand: ( @@ -22,19 +9,14 @@ type ClaudeLoginSessionDependencies = { timeoutMs: number, options?: ClaudeCommandOptions ) => Promise - capture: ( - configDir: string, - statusOutput: string, - previousLegacyKeychain: string | null - ) => Promise setCancel: (cancel: (() => boolean) | null) => void } +/** Runs a hidden `claude auth login` that writes its login straight into the account's folder. */ export async function runClaudeLoginSession( - location: ClaudeManagedAuthLocation, + folder: ClaudeCommandConfig, dependencies: ClaudeLoginSessionDependencies -): Promise { - const tempConfig = await createTemporaryClaudeConfigDir(location) +): Promise { const controller = new AbortController() dependencies.setCancel(() => { if (controller.signal.aborted) { @@ -43,107 +25,12 @@ export async function runClaudeLoginSession( controller.abort() return true }) - const previousLegacyKeychain = await readActiveClaudeKeychainCredentials() - let captured: CapturedClaudeAuth | null = null - let captureError: unknown = null - let cleanupError: unknown = null try { - if (controller.signal.aborted) { - throw new Error('Claude sign-in was cancelled.') - } - await dependencies.runCommand(['auth', 'login', '--claudeai'], tempConfig, LOGIN_TIMEOUT_MS, { + await dependencies.runCommand(['auth', 'login', '--claudeai'], folder, LOGIN_TIMEOUT_MS, { signal: controller.signal, keepStdinOpen: true }) - dependencies.setCancel(null) - const status = await dependencies.runCommand( - ['auth', 'status', '--json'], - tempConfig, - STATUS_TIMEOUT_MS, - { allowFailure: true } - ) - captured = await dependencies.capture(tempConfig.windowsPath, status, previousLegacyKeychain) - } catch (error) { - captureError = error } finally { - if (process.platform === 'darwin') { - try { - await deleteActiveClaudeKeychainCredentialsStrict(tempConfig.windowsPath) - } catch (error) { - console.warn('[claude-accounts] Failed to clean temporary Claude Keychain item:', error) - } - try { - await (previousLegacyKeychain - ? writeActiveClaudeKeychainCredentials(previousLegacyKeychain) - : deleteActiveClaudeKeychainCredentialsStrict()) - } catch (error) { - cleanupError = error - } - } - await removeTemporaryClaudeConfigDir(tempConfig) dependencies.setCancel(null) } - if (captureError) { - throw captureError - } - if (cleanupError) { - throw cleanupError - } - return captured! -} - -async function createTemporaryClaudeConfigDir( - location: ClaudeManagedAuthLocation -): Promise { - if (location.managedAuthRuntime !== 'wsl') { - const created = mkdtempSync(join(tmpdir(), 'orca-claude-login-')) - let windowsPath = created - try { - windowsPath = realpathSync(created) - } catch { - // Keep the mkdtemp path if the temp root cannot be resolved. - } - return { - windowsPath, - linuxPath: null, - wslDistro: null - } - } - if (!location.wslDistro) { - throw new Error('Could not resolve the active WSL distribution for Claude login.') - } - const created = await runWslProcess({ - distro: location.wslDistro, - loginPath: 'none', - shell: 'bash', - script: 'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"', - timeoutMs: 5000 - }) - const linuxPath = created.stdout.replaceAll(String.fromCharCode(0), '').trim() - if (created.code !== 0 || created.timedOut || !linuxPath.startsWith('/')) { - throw new Error('Could not create a temporary WSL Claude login directory.') - } - return { - windowsPath: toWindowsWslPath(linuxPath, location.wslDistro), - linuxPath, - wslDistro: location.wslDistro - } -} - -async function removeTemporaryClaudeConfigDir(config: ClaudeCommandConfig): Promise { - if (config.linuxPath && config.wslDistro) { - try { - await runWslProcess({ - distro: config.wslDistro, - loginPath: 'none', - program: 'rm', - args: ['-rf', '--', config.linuxPath], - timeoutMs: 5000 - }) - } catch { - // Cleanup cannot mask the login result. - } - return - } - rmSync(config.windowsPath, { recursive: true, force: true }) } diff --git a/src/main/claude-accounts/claude-managed-auth-storage.ts b/src/main/claude-accounts/claude-managed-auth-storage.ts deleted file mode 100644 index b430979655c6..000000000000 --- a/src/main/claude-accounts/claude-managed-auth-storage.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' -import { join, relative, resolve, sep } from 'node:path' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { toWindowsWslPath } from '../wsl' -import { runWslProcess } from '../wsl/wsl-runner' -import { stripSharedClaudeCredentialFields } from './shared-credential-fields' -import { - getClaudeManagedAccountsRoot, - readClaudeManagedAuthFile, - resolveOwnedClaudeManagedAuthPath, - writeClaudeManagedAuthFile -} from './managed-auth-path' -import { - deleteManagedClaudeKeychainCredentials, - readManagedClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from './keychain' - -export type ClaudeManagedAuthLocation = { - managedAuthPath: string - managedAuthRuntime: 'host' | 'wsl' - wslDistro: string | null - wslLinuxAuthPath: string | null -} - -export type ClaudeManagedAuthSnapshot = { - credentialsJson: string | null - oauthAccountJson: string | null -} - -export type ClaudeManagedAuthTarget = { - runtime?: 'host' | 'wsl' - wslDistro?: string | null -} - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - -export class ClaudeManagedAuthStorage { - async create( - accountId: string, - target?: ClaudeManagedAuthTarget - ): Promise { - const wslAuth = await this.tryCreateWsl(accountId, target) - if (wslAuth) { - return wslAuth - } - const managedAuthPath = join(this.getRoot(), accountId, 'auth') - mkdirSync(managedAuthPath, { recursive: true, mode: 0o700 }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, { - encoding: 'utf-8', - mode: 0o600 - }) - return { - managedAuthPath: await this.assertOwned(managedAuthPath, accountId), - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null - } - } - - async writeAuth( - accountId: string, - managedAuthPath: string, - captured: { credentialsJson: string; oauthAccount: unknown } - ): Promise { - await this.writeCredentials(accountId, managedAuthPath, captured.credentialsJson) - await this.writeOauthAccount(accountId, managedAuthPath, captured.oauthAccount) - } - - async writeCredentials( - accountId: string, - managedAuthPath: string, - credentialsJson: string - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - if (!parseWslUncPath(trustedPath)) { - credentialsJson = stripSharedClaudeCredentialFields(credentialsJson) - } - if (process.platform === 'darwin') { - await writeManagedClaudeKeychainCredentials(accountId, credentialsJson) - } else { - writeClaudeManagedAuthFile(trustedPath, '.credentials.json', credentialsJson) - } - } - - async writeOauthAccount( - accountId: string, - managedAuthPath: string, - oauthAccount: unknown - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - writeClaudeManagedAuthFile( - trustedPath, - 'oauth-account.json', - `${JSON.stringify(oauthAccount, null, 2)}\n` - ) - } - - async readSnapshot( - accountId: string, - managedAuthPath: string - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - return { - credentialsJson: - process.platform === 'darwin' - ? await readManagedClaudeKeychainCredentials(accountId) - : readClaudeManagedAuthFile(trustedPath, '.credentials.json'), - oauthAccountJson: readClaudeManagedAuthFile(trustedPath, 'oauth-account.json') - } - } - - async restoreCredentials( - accountId: string, - managedAuthPath: string, - snapshot: ClaudeManagedAuthSnapshot - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - if (process.platform === 'darwin') { - await (snapshot.credentialsJson !== null - ? writeManagedClaudeKeychainCredentials(accountId, snapshot.credentialsJson) - : deleteManagedClaudeKeychainCredentials(accountId)) - } else if (snapshot.credentialsJson !== null) { - writeClaudeManagedAuthFile(trustedPath, '.credentials.json', snapshot.credentialsJson) - } else { - rmSync(join(trustedPath, '.credentials.json'), { force: true }) - } - } - - async restoreOauth( - accountId: string, - managedAuthPath: string, - snapshot: ClaudeManagedAuthSnapshot - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - if (snapshot.oauthAccountJson !== null) { - writeClaudeManagedAuthFile(trustedPath, 'oauth-account.json', snapshot.oauthAccountJson) - } else { - rmSync(join(trustedPath, 'oauth-account.json'), { force: true }) - } - } - - async remove(accountId: string, candidatePath: string): Promise { - try { - const managedAuthPath = await this.assertOwned(candidatePath, accountId) - rmSync(resolve(managedAuthPath, '..'), { recursive: true, force: true }) - } catch (error) { - console.warn('[claude-accounts] Refusing to remove untrusted managed auth:', error) - } - await deleteManagedClaudeKeychainCredentials(accountId) - } - - async assertOwned(candidatePath: string, expectedAccountId?: string): Promise { - const wslInfo = parseWslUncPath(candidatePath) - if (wslInfo) { - return this.assertOwnedWsl(candidatePath, wslInfo, expectedAccountId) - } - this.getRoot() - const accountId = expectedAccountId ?? this.readAccountId(candidatePath) - if (!accountId || (expectedAccountId && accountId !== expectedAccountId)) { - throw new Error('Managed Claude auth directory does not exist on disk.') - } - const trustedPath = resolveOwnedClaudeManagedAuthPath(accountId, candidatePath, { - adoptLegacyMarker: true - }) - if (!trustedPath) { - throw new Error('Managed Claude auth storage is not owned by Orca.') - } - return trustedPath - } - - private async tryCreateWsl( - accountId: string, - target?: ClaudeManagedAuthTarget - ): Promise { - if (process.platform !== 'win32' || target?.runtime !== 'wsl') { - return null - } - const requestedDistro = target.wslDistro?.trim() || undefined - const info = await runWslProcess({ - distro: requestedDistro, - loginPath: 'none', - shell: 'bash', - script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"', - timeoutMs: 5000 - }) - const [rawDistro, rawHome] = - info.code === 0 && !info.timedOut - ? info.stdout - .replaceAll(String.fromCharCode(0), '') - .split(/\r?\n/) - .map((line) => line.trim()) - : [] - const distro = requestedDistro || rawDistro - const home = rawHome - if (!distro || !home?.startsWith('/')) { - throw new Error('Could not resolve the active WSL home directory for Claude login.') - } - const linuxPath = `${home.replace(/\/$/, '')}/.local/share/orca/claude-accounts/${accountId}/auth` - const created = await runWslProcess({ - distro, - loginPath: 'none', - shell: 'bash', - script: 'umask 077; mkdir -p "$1" && printf \'%s\\n\' "$2" > "$1/.orca-managed-claude-auth"', - args: [linuxPath, accountId], - timeoutMs: 5000 - }) - if (created.code !== 0 || created.timedOut) { - throw new Error('Could not create the managed WSL Claude auth directory.') - } - const managedAuthPath = toWindowsWslPath(linuxPath, distro) - return { - managedAuthPath: await this.assertOwned(managedAuthPath, accountId), - managedAuthRuntime: 'wsl', - wslDistro: distro, - wslLinuxAuthPath: linuxPath - } - } - - private async assertOwnedWsl( - candidatePath: string, - wslInfo: NonNullable>, - expectedAccountId?: string - ): Promise { - if ( - !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || - !wslInfo.linuxPath.endsWith('/auth') - ) { - throw new Error('Managed WSL Claude auth storage is outside Orca account storage.') - } - if (process.platform !== 'win32') { - if ( - !existsSync(candidatePath) || - !existsSync(join(candidatePath, '.orca-managed-claude-auth')) - ) { - throw new Error('Managed Claude auth storage is not owned by Orca.') - } - return candidatePath - } - try { - const expected = expectedAccountId - ? `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(expectedAccountId)}` - : 'test -n "$(cat "$candidate_real/.orca-managed-claude-auth")"' - const owned = await runWslProcess({ - distro: wslInfo.distro, - loginPath: 'none', - shell: 'bash', - script: [ - 'set -euo pipefail', - `candidate=${shellQuote(wslInfo.linuxPath)}`, - 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', - 'candidate_real=$(readlink -f -- "$candidate")', - 'managed_root_real=$(readlink -f -- "$managed_root")', - 'test -f "$candidate_real/.orca-managed-claude-auth"', - expected, - 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' - ].join('\n'), - timeoutMs: 5000 - }) - const canonicalPath = owned.stdout.trim() - if (owned.code !== 0 || owned.timedOut || !canonicalPath) { - throw new Error('Managed Claude auth directory does not exist on disk.') - } - return toWindowsWslPath(canonicalPath, wslInfo.distro) - } catch (error) { - throw new Error('Managed WSL Claude auth storage is outside Orca account storage.', { - cause: error - }) - } - } - - private getRoot(): string { - const root = getClaudeManagedAccountsRoot() - mkdirSync(root, { recursive: true, mode: 0o700 }) - return root - } - - private readAccountId(candidatePath: string): string | null { - const relativePath = relative(resolve(this.getRoot()), resolve(candidatePath)) - const parts = relativePath.split(sep) - return parts.length === 2 && parts[1] === 'auth' ? parts[0] : null - } -} diff --git a/src/main/claude-accounts/claude-profile-installed-router.ts b/src/main/claude-accounts/claude-profile-installed-router.ts index a0c04e3c9baa..c4efc9f39e42 100644 --- a/src/main/claude-accounts/claude-profile-installed-router.ts +++ b/src/main/claude-accounts/claude-profile-installed-router.ts @@ -26,7 +26,7 @@ export function listClaudeProfileHomes(dataRoot: string): string[] { let installed: ClaudeProfileRouter | undefined -/** Installed by the host runtime only when routing is enabled; workers and child processes have none. */ +/** Installed by the desktop's account services; workers, child processes and orcad have none. */ export function installClaudeProfileRouter(router: ClaudeProfileRouter | undefined): void { installed = router } @@ -46,8 +46,8 @@ export function withClaudeProfileTerminalEnv } /** - * Account `` folders the System default readers cannot see. Step 1 links history into the - * System default on macOS/Linux, so only Windows (or a cross-filesystem refusal) adds any. + * Account `` folders the System default readers cannot see. Setup links history into the + * System default on every platform, so only a folder setup could not link (e.g. across drives) adds any. */ export function claudeProfileHistoryDirs(surface: 'projects' | 'transcripts'): string[] { return (installed?.accountHomes() ?? []).map((home) => join(home, surface)).filter(isDirectory) diff --git a/src/main/claude-accounts/claude-profile-paths.test.ts b/src/main/claude-accounts/claude-profile-paths.test.ts index f0d04f0fd734..070b486eeb20 100644 --- a/src/main/claude-accounts/claude-profile-paths.test.ts +++ b/src/main/claude-accounts/claude-profile-paths.test.ts @@ -12,7 +12,6 @@ import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) import { - assertOutsideDefaultClaudeHomes, describeClaudeProfile, prepareClaudeProfileDirectory, readClaudeProfileObject, @@ -57,10 +56,16 @@ describe('Claude profile namespace', () => { target, home: join(dir, 'claude-profiles/account-a/home') }) - prepareClaudeProfileDirectory(dir, profile, userHome) - expect( - JSON.parse(readFileSync(join(dir, 'claude-profiles/account-a/profile.json'), 'utf8')) - ).toEqual({ version: 1, accountId: 'account-a', runtime: 'wsl', distro: 'Ubuntu' }) + const markSetUp = prepareClaudeProfileDirectory(dir, profile, userHome) + const marker = join(dir, 'claude-profiles/account-a/profile.json') + expect(existsSync(marker)).toBe(false) + markSetUp() + expect(JSON.parse(readFileSync(marker, 'utf8'))).toEqual({ + version: 1, + accountId: 'account-a', + runtime: 'wsl', + distro: 'Ubuntu' + }) // The host id is the caller's view of the host, so another caller's spelling is the same profile. prepareClaudeProfileDirectory( dir, @@ -121,11 +126,9 @@ describe('Claude profile namespace', () => { it.runIf(caseInsensitive)('refuses a case-only alias of the default home', () => { const userHome = root() mkdirSync(join(userHome, '.claude')) - expect(() => assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE'), userHome)).toThrow( - 'separate directories' - ) + const dataRoot = join(userHome, '.CLAUDE', 'orca') expect(() => - assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE', 'nested'), userHome) + prepareClaudeProfileDirectory(dataRoot, describeClaudeProfile(dataRoot, 'a', local), userHome) ).toThrow('separate directories') }) it("reads the user's own CLAUDE_CONFIG_DIR but never Orca's injected one", () => { diff --git a/src/main/claude-accounts/claude-profile-paths.ts b/src/main/claude-accounts/claude-profile-paths.ts index 74ee5222c8e0..ece12937405c 100644 --- a/src/main/claude-accounts/claude-profile-paths.ts +++ b/src/main/claude-accounts/claude-profile-paths.ts @@ -1,6 +1,10 @@ import { lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' import * as hostPath from 'node:path' import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_USER_CONFIG_DIR_ENV +} from '../../shared/claude-profile-routing' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import type { ExecutionHostId } from '../../shared/execution-host' import { writeFileAtomically } from '../codex-accounts/fs-utils' @@ -93,23 +97,23 @@ function readOwnershipMarker(file: string): string | null { return ownershipRecord(version, accountId, runtime, distro) } -/** - * The only gate before writing into a profile: namespace, containment, no linked components, - * outside Claude's default homes, and an ownership marker beside the home. Refuses before creating anything. - */ -// Written by setup's ownership gate when setup starts, not when it completes: its absence means -// setup never started here, and its presence does not prove setup finished. +// Written when a first setup finishes, so its absence means no setup has finished here yet. export function claudeProfileMarkerPath(profile: ClaudeProfileDescriptor): string { const path = profile.target.runtime === 'wsl' ? hostPath.posix : hostPath return path.join(path.dirname(profile.home), 'profile.json') } +/** + * The only gate before writing into a profile: namespace, containment, no linked components, + * outside Claude's default homes, and an ownership marker beside the home. Refuses before creating + * anything. Returns the step that writes the marker, which setup runs last. + */ export function prepareClaudeProfileDirectory( dataRoot: string, profile: ClaudeProfileDescriptor, userHome: string, userConfigDir?: string -): void { +): () => void { let expected: ClaudeProfileDescriptor try { expected = describeClaudeProfile(dataRoot, profile.accountId, profile.target) @@ -135,12 +139,14 @@ export function prepareClaudeProfileDirectory( ) } mkdirSync(profile.home, { recursive: true, mode: 0o700 }) - if (marker === null) { - writeFileAtomically(markerPath, `${record}\n`, { mode: 0o600 }) + return () => { + if (marker === null) { + writeFileAtomically(markerPath, `${record}\n`, { mode: 0o600 }) + } } } -export function assertClaudeProfileDescendant(root: string, destination: string): void { +function assertClaudeProfileDescendant(root: string, destination: string): void { const suffix = relative(resolve(root), resolve(destination)) if (!suffix || suffix === '..' || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) { throw new ClaudeProfileSurfaceError( @@ -184,7 +190,7 @@ function canonicalPath(file: string): string { } } -export function assertDistinctClaudeProfile(profile: string, defaultHome: string): void { +function assertDistinctClaudeProfile(profile: string, defaultHome: string): void { const left = canonicalPath(profile) const right = canonicalPath(defaultHome) for (const [root, destination] of [ @@ -202,7 +208,7 @@ export function assertDistinctClaudeProfile(profile: string, defaultHome: string } /** Claude's default homes: a profile may never be, contain, or sit inside one. */ -export function assertOutsideDefaultClaudeHomes( +function assertOutsideDefaultClaudeHomes( profileHome: string, userHome: string, userConfigDir?: string @@ -214,16 +220,18 @@ export function assertOutsideDefaultClaudeHomes( } } -/** Set beside every CLAUDE_CONFIG_DIR Orca injects, so its own value never reads as the user's. */ -export const CLAUDE_INJECTED_CONFIG_DIR_ENV = 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' - /** The user's own CLAUDE_CONFIG_DIR (their System default), or undefined for `~/.claude`. */ export function readUserClaudeConfigDir(env: NodeJS.ProcessEnv): string | undefined { const configDir = env.CLAUDE_CONFIG_DIR?.trim() const injected = env[CLAUDE_INJECTED_CONFIG_DIR_ENV]?.trim() - if (!configDir || (injected && resolve(injected) === resolve(configDir))) { + if (!configDir) { return undefined } + if (injected && resolve(injected) === resolve(configDir)) { + // Why: an outer Orca's injected value carries the user's own beside it. + const user = env[CLAUDE_USER_CONFIG_DIR_ENV]?.trim() + return user ? resolve(user) : undefined + } return resolve(configDir) } diff --git a/src/main/claude-accounts/claude-profile-provisioning.test.ts b/src/main/claude-accounts/claude-profile-provisioning.test.ts index 66bf0346d90d..4bc12a4738a9 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.test.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.test.ts @@ -13,13 +13,15 @@ vi.mock('../codex-accounts/fs-utils', async (original) => { return { ...actual, writeFileAtomically: vi.fn(actual.writeFileAtomically) } }) import { writeFileAtomically } from '../codex-accounts/fs-utils' -import { CLAUDE_PROFILE_MEMORY_IMPORT, provisionClaudeProfile } from './claude-profile-provisioning' +import { provisionClaudeProfile } from './claude-profile-provisioning' const USER_HOOK = { matcher: '', hooks: [{ type: 'command', command: 'notify-me' }] } const ORCA_HOOK = { matcher: '', hooks: [{ type: 'command', command: '"$HOME/.orca/agent-hooks/claude-hook.sh"' }] } +// What Claude writes on a finished sign-in. +const LOGIN = { emailAddress: 'p@example.com' } const roots: string[] = [] function fixture() { const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'claude-profile-setup-'))) @@ -33,13 +35,8 @@ function fixture() { const read = (file: string): Record => JSON.parse(fs.readFileSync(file, 'utf8')) return { root, userHome, profileHome, source, json, read } } -const provision = (f: { profileHome: string; userHome: string }, trustKeys?: string[]) => - provisionClaudeProfile({ - profileHome: f.profileHome, - userHome: f.userHome, - trustKeys, - platform: 'linux' - }) +const provision = (f: { profileHome: string; userHome: string }) => + provisionClaudeProfile({ profileHome: f.profileHome, userHome: f.userHome, platform: 'linux' }) afterEach(() => { vi.clearAllMocks() for (const dir of roots.splice(0)) { @@ -219,7 +216,7 @@ describe('dormant Claude profile provisioning', () => { const f = fixture() f.json(join(f.source, 'settings.json'), { model: 'a' }) f.json(join(f.userHome, '.claude.json'), { mcpServers: { a: {} }, theme: 'dark' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) await provision(f) fs.writeFileSync(join(f.source, 'settings.json'), '{bad') fs.writeFileSync(join(f.userHome, '.claude.json'), '{bad') @@ -233,17 +230,18 @@ describe('dormant Claude profile provisioning', () => { await provision(f) expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ userID: 'p', + oauthAccount: LOGIN, theme: 'dark', hasCompletedOnboarding: true }) }) - it('requires existing state, merges MCP/theme/onboarding/trust and never copies or writes credentials', async () => { + it('requires existing state, merges MCP/theme/onboarding and never copies or writes credentials', async () => { const f = fixture() fs.writeFileSync(join(f.source, '.credentials.json'), 'SOURCE_CREDENTIAL_BYTES') f.json(join(f.userHome, '.claude.json'), { mcpServers: { local: { command: 'example' } }, theme: 'dark', - oauthAccount: { email: 'source' }, + oauthAccount: { emailAddress: 'source@example.com' }, userID: 'source-id' }) await provision(f) @@ -251,18 +249,18 @@ describe('dormant Claude profile provisioning', () => { expect(fs.existsSync(join(f.profileHome, '.credentials.json'))).toBe(false) fs.writeFileSync(join(f.profileHome, '.credentials.json'), 'PROFILE_CREDENTIAL_BYTES') f.json(join(f.profileHome, '.claude.json'), { - oauthAccount: { email: 'profile' }, + oauthAccount: { emailAddress: 'profile@example.com' }, userID: 'profile-id', projects: { '/work': { allowedTools: ['Read'] } } }) - await provision(f, ['/work']) + await provision(f) expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - oauthAccount: { email: 'profile' }, + oauthAccount: { emailAddress: 'profile@example.com' }, userID: 'profile-id', mcpServers: { local: { command: 'example' } }, theme: 'dark', hasCompletedOnboarding: true, - projects: { '/work': { allowedTools: ['Read'], hasTrustDialogAccepted: true } } + projects: { '/work': { allowedTools: ['Read'] } } }) expect(fs.readFileSync(join(f.source, '.credentials.json'), 'utf8')).toBe( 'SOURCE_CREDENTIAL_BYTES' @@ -277,44 +275,50 @@ describe('dormant Claude profile provisioning', () => { } } }) - it('still forces onboarding and trust when the personal state is unreadable', async () => { + it('leaves a folder Claude has not signed in yet to Claude’s own onboarding', async () => { const f = fixture() - fs.writeFileSync(join(f.userHome, '.claude.json'), '{"theme": "da') - f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) - const report = await provision(f, ['/work']) + f.json(join(f.userHome, '.claude.json'), { theme: 'dark', mcpServers: { a: {} } }) + // Claude writes its state file at the theme pick, before the sign-in. + f.json(join(f.profileHome, '.claude.json'), { theme: 'light' }) + expect((await provision(f)).surfaces['.claude.json']).toBe('absent') + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ theme: 'light' }) + f.json(join(f.profileHome, '.claude.json'), { theme: 'light', oauthAccount: LOGIN }) + expect((await provision(f)).surfaces['.claude.json']).toBe('merged') expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - userID: 'p', - hasCompletedOnboarding: true, - projects: { '/work': { hasTrustDialogAccepted: true } } + theme: 'light', + oauthAccount: LOGIN, + mcpServers: { a: {} }, + hasCompletedOnboarding: true }) - expect(report.warnings).toContainEqual( - expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) - ) }) - it('skips only folder trust when the profile projects value is malformed', async () => { + it('still forces onboarding when the personal state is unreadable', async () => { const f = fixture() - f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p', projects: 'bad' }) - const report = await provision(f, ['/work']) - expect(report.surfaces['.claude.json']).toBe('merged') + fs.writeFileSync(join(f.userHome, '.claude.json'), '{"theme": "da') + f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) + const report = await provision(f) expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ userID: 'p', - projects: 'bad', - theme: 'dark', + oauthAccount: LOGIN, hasCompletedOnboarding: true }) + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) + ) }) it('skips the state write while Claude holds its lock and records nothing for it', async () => { const f = fixture() f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) fs.mkdirSync(join(f.profileHome, '.claude.json.lock')) const report = await provision(f) expect(report.surfaces['.claude.json']).toBe('failed') expect(report.warnings).toContainEqual( expect.objectContaining({ surface: '.claude.json', code: 'locked' }) ) - expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ userID: 'p' }) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + userID: 'p', + oauthAccount: LOGIN + }) fs.rmdirSync(join(f.profileHome, '.claude.json.lock')) expect((await provision(f)).surfaces['.claude.json']).toBe('merged') expect(f.read(join(f.profileHome, '.claude.json')).theme).toBe('dark') @@ -372,9 +376,7 @@ describe('dormant Claude profile provisioning', () => { expect(fs.existsSync(join(f.profileHome, 'CLAUDE.md'))).toBe(false) fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'personal instructions') expect((await provision(f)).surfaces['CLAUDE.md']).toBe('synced') - expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe( - CLAUDE_PROFILE_MEMORY_IMPORT - ) + expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('@~/.claude/CLAUDE.md\n') fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'edited personal instructions') expect((await provision(f)).surfaces['CLAUDE.md']).toBe('unchanged') }) @@ -388,7 +390,7 @@ describe('dormant Claude profile provisioning', () => { f.json(join(userConfigDir, 'settings.json'), { model: 'custom' }) f.json(join(userConfigDir, '.claude.json'), { theme: 'custom' }) f.json(join(f.userHome, '.claude.json'), { theme: 'home' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) await provisionClaudeProfile({ ...f, userConfigDir, platform: 'linux' }) expect(fs.realpathSync(join(f.profileHome, 'skills'))).toBe(join(userConfigDir, 'skills')) expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('custom instructions') diff --git a/src/main/claude-accounts/claude-profile-provisioning.ts b/src/main/claude-accounts/claude-profile-provisioning.ts index 752f7fee3f38..8c72cf291a14 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -2,10 +2,10 @@ import { existsSync } from 'node:fs' import { join, resolve } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { - applyClaudeFolderTrust, resolveClaudeGlobalConfigFile, updateClaudeGlobalConfig } from '../claude/claude-folder-trust-file' +import { claudeStateLogin } from './claude-account-folder' import { readClaudeProfileObject, resolveClaudeDefaultHome } from './claude-profile-paths' import { lstatIfPresent } from './claude-profile-prompt-history' import { @@ -39,7 +39,7 @@ export const CLAUDE_PROFILE_RESOURCE_DIRS = [ ] as const // Copied, not linked: a rename-replace save (Claude's own, or an editor's) would cut a link. export const CLAUDE_PROFILE_RESOURCE_FILES = ['CLAUDE.md', 'keybindings.json'] as const -export const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' +const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' const PRIVATE_KEYS = new Set([ 'apiKeyHelper', 'awsAuthRefresh', @@ -103,7 +103,6 @@ async function mergeState(args: { source: string target: string ledger: ClaudeProfileLedger - trustKeys: readonly string[] report: ClaudeProfileReport }): Promise { if (lstatIfPresent(args.target)?.isSymbolicLink()) { @@ -111,7 +110,7 @@ async function mergeState(args: { } const input = readClaudeProfileObject(args.source) if (input.kind === 'unavailable') { - // Why: onboarding and trust don't depend on the personal state; only its shared keys wait. + // Why: onboarding doesn't depend on the personal state; only its shared keys wait. const error = new ClaudeProfileSurfaceError('unreadable', 'Personal Claude state is unreadable') warnClaudeProfile(args.report, '.claude.json', error) } @@ -120,7 +119,15 @@ async function mergeState(args: { SHARED_STATE_KEYS.filter((key) => key in source).map((key) => [key, source[key]]) ) let written: Record = {} + let signedOut = false const outcome = await updateClaudeGlobalConfig(args.target, (current) => { + // Why oauthAccount: Claude writes it only on a finished sign-in, while it writes this file + // earlier in onboarding (theme pick). It's read under Claude's own lock, on every platform; + // credentials sit in the Keychain on macOS. A flag set before then skips Claude's sign-in. + signedOut = !claudeStateLogin(current) + if (signedOut) { + return { kind: 'unchanged' } + } const config = { ...current } written = { ...args.ledger.keys['.claude.json'] } let changed = mergeClaudeProfileKeys(config, desired, written).length > 0 @@ -135,15 +142,10 @@ async function mergeState(args: { config.hasCompletedOnboarding = true changed = true } - // Why: a malformed `projects` refuses only trust; onboarding and shared keys still apply. - const trust = args.trustKeys.length > 0 ? applyClaudeFolderTrust(config, args.trustKeys) : null - if (trust?.kind === 'changed') { - return trust - } return changed ? { kind: 'changed', config } : { kind: 'unchanged' } }) - if (outcome === 'missing-config') { - // Why: no state file means no completed login; writing one would fabricate an account. + if (outcome === 'missing-config' || (outcome === 'unchanged' && signedOut)) { + // Why: no completed login yet; writing would fabricate an account or skip Claude's sign-in. return 'absent' } if (outcome === 'locked' || outcome === 'unreadable') { @@ -163,7 +165,6 @@ export async function provisionClaudeProfile(args: { /** The user's own CLAUDE_CONFIG_DIR; `~/.claude` when unset. */ userConfigDir?: string platform?: NodeJS.Platform - trustKeys?: readonly string[] }): Promise { const platform = args.platform ?? process.platform const defaultHome = resolveClaudeDefaultHome(args.userHome, args.userConfigDir) @@ -210,7 +211,6 @@ export async function provisionClaudeProfile(args: { source: statePath(args.userConfigDir), target: statePath(args.profileHome), ledger, - trustKeys: args.trustKeys ?? [], report }) ) diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index 5391072d0bbe..3db8218f016f 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -1,4 +1,6 @@ +import { spawnSync } from 'node:child_process' import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -9,22 +11,44 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import type { ClaudeProfileSetupReport } from './claude-profile-setup' +import { + claudeProfileMarkerPath, + describeClaudeProfile, + prepareClaudeProfileDirectory, + type ClaudeProfileDescriptor +} from './claude-profile-paths' import { CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, - ClaudeProfileRouter, - type ClaudeProfileRouterSettings -} from './claude-profile-router' + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE +} from '../../shared/claude-profile-routing' +import { getPosixClaudeShellFunction } from '../../shared/claude-shell-function' +import { ClaudeProfileRouter, type ClaudeProfileRouterSettings } from './claude-profile-router' import { claudeProfileHistoryDirs, installClaudeProfileRouter } from './claude-profile-installed-router' +// Why: removal deletes macOS Keychain items; the test must never reach the real Keychain. +// The login shell exports what a Dock launch's own env lacks. +const shell = vi.hoisted(() => ({ hang: false })) +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: () => + shell.hang + ? new Promise(() => {}) + : Promise.resolve({ CLAUDE_CONFIG_DIR: resolve('/shell/own') }) +})) +vi.mock('../macos-keychain/generic-password', () => ({ + execSecurityCommand: async () => { + throw new Error('The specified item could not be found in the keychain.') + } +})) + const roots: string[] = [] afterEach(() => { + shell.hang = false installClaudeProfileRouter(undefined) roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) }) @@ -52,13 +76,19 @@ function fixture(env: NodeJS.ProcessEnv = {}) { agentStatusHooksEnabled: false, disabledTuiAgents: [] } - // Stands in for the worker; each setup stays pending until the test settles it. + // Stands in for the worker; like it, stays pending until settled and marks the folder last. const setup: { calls: number; outcome: ClaudeProfileSetupReport['outcome']; settle: () => void } = { calls: 0, outcome: 'prepared', settle: () => {} } - const runSetup = () => { + const runSetup = ({ profile }: { profile: ClaudeProfileDescriptor }) => { setup.calls += 1 + mkdirSync(profile.home, { recursive: true }) return new Promise((resolve) => { - setup.settle = () => resolve({ outcome: setup.outcome, warnings: [], surfaces: {} }) + setup.settle = () => { + if (setup.outcome === 'prepared') { + writeFileSync(claudeProfileMarkerPath(profile), '{}') + } + resolve({ outcome: setup.outcome, warnings: [], surfaces: {} }) + } }) } const router = new ClaudeProfileRouter({ @@ -73,13 +103,36 @@ function fixture(env: NodeJS.ProcessEnv = {}) { } describe('ClaudeProfileRouter', () => { - it('publishes the selected folder, System default as empty, and no file without accounts', () => { + it('prepares an account folder for sign-in only when setup succeeds', async () => { + const f = fixture({ CLAUDE_CONFIG_DIR: resolve('/custom/claude') }) + expect(f.router.accountHome('b')).toBe(f.home('b')) + expect(f.router.userConfigDir()).toBe(resolve('/custom/claude')) + const prepared = f.router.prepareAccount('b') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + f.setup.settle() + await expect(prepared).resolves.toBe(f.home('b')) + f.setup.outcome = 'refused' + const refused = f.router.prepareAccount('b') + await vi.waitFor(() => expect(f.setup.calls).toBe(2)) + f.setup.settle() + await expect(refused).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + }) + + it("reads an older Orca's copy-based switching from the System default snapshot it left", () => { + const f = fixture() + expect(f.router.copiedLoginIntoSystemDefault()).toBe(false) + mkdirSync(join(f.dataRoot, 'claude-runtime-auth'), { recursive: true }) + writeFileSync(join(f.dataRoot, 'claude-runtime-auth', 'system-default-auth.json'), '{}') + expect(f.router.copiedLoginIntoSystemDefault()).toBe(true) + }) + + it('publishes the selected folder, System default as empty, and no file without accounts', async () => { const f = fixture() mkdirSync(f.home('a'), { recursive: true }) f.router.publish() expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('a')) // publish returned while its setup is still running in the background. - expect(f.setup.calls).toBe(1) + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) f.settings.activeClaudeManagedAccountId = null f.router.publish() @@ -90,42 +143,108 @@ describe('ClaudeProfileRouter', () => { expect(existsSync(f.router.pointerPath)).toBe(false) }) - it('makes a launch wait only for a folder that was never set up, reusing the running setup', async () => { + it('makes a launch wait for a first setup that never ran or is still running, reusing it', async () => { const f = fixture() mkdirSync(f.home('a'), { recursive: true }) - f.router.publish() let launched = false const launch = f.router.prepareLaunch().then((prepared) => { launched = true return prepared }) - await Promise.resolve() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(launched).toBe(false) + const second = f.router.prepareLaunch() + f.setup.outcome = 'refused' + f.setup.settle() + await expect(launch).rejects.toMatchObject({ reason: 'claudeAccountSetupFailed' }) + await expect(second).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) expect(f.setup.calls).toBe(1) + }) + + it('never makes a launch wait on, or fail with, a re-run of a set-up folder', async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + const first = f.router.prepareLaunch() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) f.setup.settle() - await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) + await expect(first).resolves.toMatchObject({ configDir: f.home('a') }) + // Startup or a switch re-runs setup; it stays pending, then fails. f.setup.outcome = 'refused' - const refused = f.router.prepareLaunch() + f.router.publish() + await vi.waitFor(() => expect(f.setup.calls).toBe(2)) + await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) f.setup.settle() - await expect(refused).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) - - // Set up once (setup's marker exists): launches stop waiting. - writeFileSync(join(f.dataRoot, 'claude-profiles', 'a', 'profile.json'), '{}') + await new Promise((resolve) => setTimeout(resolve, 10)) await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) expect(f.setup.calls).toBe(2) }) - it('names a never-signed-in folder without creating it, and refuses to launch it', async () => { + it('makes a launch redo a first setup that was cut off after its ownership gate', async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + const profile = describeClaudeProfile(f.dataRoot, 'a', { + executionHostId: 'local', + runtime: 'host' + }) + // The worker timed out, or the app quit, after the gate ran. + const cutOff = new ClaudeProfileRouter({ + getSettings: () => f.settings, + dataRoot: f.dataRoot, + userHome: f.userHome, + env: {}, + runSetup: async (args) => { + prepareClaudeProfileDirectory(args.dataRoot, args.profile, args.userHome) + throw new Error('Claude account setup timed out') + } + }) + await expect(cutOff.prepareLaunch()).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + expect(existsSync(claudeProfileMarkerPath(profile))).toBe(false) + + const launch = f.router.prepareLaunch() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + f.setup.settle() + await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) + }) + + it("launches a set-up account without waiting for the login shell's env", async () => { + shell.hang = true + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + writeFileSync(join(f.home('a'), '..', 'profile.json'), '{}') + const router = new ClaudeProfileRouter({ + getSettings: () => f.settings, + dataRoot: f.dataRoot, + userHome: f.userHome + }) + await expect(router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) + }) + + it("sets up a pre-update account's missing folder without a login, for Claude's own first run", async () => { const f = fixture() f.settings.activeClaudeManagedAccountId = 'b' - f.router.publish() - expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('b')) - expect(f.setup.calls).toBe(0) - expect(existsSync(f.home('b'))).toBe(false) + // Before its setup, a terminal still opens; its claude function creates the folder itself. expect(() => f.router.preparation()).toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) - // A terminal still opens; its claude function refuses from the pointer instead. expect(f.router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath }) + f.router.publish() + expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('b')) + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + expect(existsSync(f.home('b'))).toBe(true) + // Claude's first run is clean only without an onboarding-done state file. + expect(existsSync(join(f.home('b'), '.claude.json'))).toBe(false) + const launch = f.router.prepareLaunch() + f.setup.settle() + await expect(launch).resolves.toMatchObject({ configDir: f.home('b') }) + expect(f.setup.calls).toBe(1) + }) + + it('makes a launch set up a missing folder first', async () => { + const f = fixture() + const launch = f.router.prepareLaunch() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + f.setup.settle() + await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) }) it('injects the account with its twin, and nothing over the user’s own System default', () => { @@ -149,12 +268,92 @@ describe('ClaudeProfileRouter', () => { expect(f.router.preparation().envPatch).not.toHaveProperty('CLAUDE_CONFIG_DIR') }) + it('carries the user’s own value beside an account terminal’s injected one', () => { + const f = fixture({ CLAUDE_CONFIG_DIR: resolve('/user/own') }) + mkdirSync(f.home('a'), { recursive: true }) + expect(f.router.terminalEnv()).toEqual({ + ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath, + CLAUDE_CONFIG_DIR: f.home('a'), + ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.home('a'), + ORCA_CLAUDE_USER_CONFIG_DIR: resolve('/user/own') + }) + expect(f.router.preparation().envPatch).not.toHaveProperty('ORCA_CLAUDE_USER_CONFIG_DIR') + f.settings.activeClaudeManagedAccountId = null + expect(f.router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath }) + const none = fixture() + mkdirSync(none.home('a'), { recursive: true }) + expect(none.router.terminalEnv()).not.toHaveProperty('ORCA_CLAUDE_USER_CONFIG_DIR') + }) + + // Why not win32: runs the POSIX claude function under bash. + it.skipIf(process.platform === 'win32')( + 'gives an account terminal back the inherited CLAUDE_CONFIG_DIR, or none, on System default', + () => { + for (const inherited of [{ CLAUDE_CONFIG_DIR: resolve('/user/own') }, {}]) { + const f = fixture(inherited) + mkdirSync(f.home('a'), { recursive: true }) + const bin = join(f.root, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'claude'), '#!/bin/sh\nprintf "%s" "${CLAUDE_CONFIG_DIR-unset}"\n') + chmodSync(join(bin, 'claude'), 0o700) + const claude = (env: Record): string => + spawnSync( + '/bin/bash', + ['--norc', '--noprofile', '-c', `${getPosixClaudeShellFunction()}\nclaude`], + { env: { ...env, PATH: `${bin}:/usr/bin:/bin` }, encoding: 'utf8' } + ).stdout + // The pane opens while account A is selected, so Orca replaces the inherited value. + f.router.publish() + const pane = { ...inherited, ...f.router.terminalEnv() } + expect(claude(pane)).toBe(f.home('a')) + f.settings.activeClaudeManagedAccountId = null + f.router.publish() + expect(claude(pane)).toBe(inherited.CLAUDE_CONFIG_DIR ?? 'unset') + } + } + ) + + it('removes a folder only after its running setup, so the setup cannot bring it back', async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + f.router.publish() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + const removal = f.router.removeAccount('a') + await new Promise((resolve) => setTimeout(resolve, 10)) + // Setup writes into the folder before it settles, as the worker does. + mkdirSync(f.home('a'), { recursive: true }) + writeFileSync(join(f.home('a'), 'settings.json'), '{}') + f.setup.settle() + await removal + expect(existsSync(join(f.dataRoot, 'claude-profiles', 'a'))).toBe(false) + }) + + it('takes System default from the login shell, which a Dock launch does not inherit', async () => { + const f = fixture() + f.settings.activeClaudeManagedAccountId = null + const router = new ClaudeProfileRouter({ + getSettings: () => f.settings, + dataRoot: f.dataRoot, + userHome: f.userHome + }) + await expect(router.prepareLaunch()).resolves.toMatchObject({ + configDir: resolve('/shell/own'), + provenance: 'system' + }) + }) + it('treats a CLAUDE_CONFIG_DIR an outer Orca injected as not the user’s', () => { const f = fixture({ CLAUDE_CONFIG_DIR: '/outer/profile', ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/outer/profile' }) expect(f.router.systemDefaultHome()).toBe(join(f.userHome, '.claude')) + const carried = fixture({ + CLAUDE_CONFIG_DIR: '/outer/profile', + ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/outer/profile', + ORCA_CLAUDE_USER_CONFIG_DIR: '/user/own' + }) + expect(carried.router.systemDefaultHome()).toBe(resolve('/user/own')) }) // Why not win32: creating the link needs privileges there. diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index 3b12f6f7c82d..fa44e53b5630 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -2,13 +2,19 @@ import { existsSync, mkdirSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' -import { CLAUDE_PROFILE_POINTER_ENV } from '../../shared/claude-profile-routing' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_POINTER_ENV, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + CLAUDE_USER_CONFIG_DIR_ENV +} from '../../shared/claude-profile-routing' import type { GlobalSettings } from '../../shared/global-settings-types' import { probeClaudeCliVersion } from '../claude/claude-hook-event-versions' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { resolveClaudeCommand } from '../codex-cli/command' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { - CLAUDE_INJECTED_CONFIG_DIR_ENV, claudeProfileMarkerPath, describeClaudeProfile, type ClaudeProfileDescriptor, @@ -25,6 +31,8 @@ import { } from './runtime-selection' import { wslClaudeProfilePointer } from './claude-profile-wsl-paths' import { isDirectory, listClaudeProfileHomes } from './claude-profile-installed-router' +import { removeClaudeAccountFolder } from './claude-account-folder' +import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' export type ClaudeProfileRouterSettings = Pick< GlobalSettings, @@ -35,12 +43,6 @@ export type ClaudeProfileRouterSettings = Pick< | 'disabledTuiAgents' > -export const CLAUDE_PROFILE_SETUP_FAILED_MESSAGE = - 'The selected Claude account could not be set up. Try again or choose another account.' - -export const CLAUDE_PROFILE_MISSING_MESSAGE = - "The selected Claude account's folder is missing. Sign in to it again or choose another account." - /** * Routes this host's Claude launches to the selected account's folder. Settings own the selection; * the pointer file mirrors it for `claude` typed in an already-open terminal. @@ -48,17 +50,25 @@ export const CLAUDE_PROFILE_MISSING_MESSAGE = export class ClaudeProfileRouter { readonly pointerPath: string private readonly setups = new Map>() + private env: NodeJS.ProcessEnv + private readonly envReady: Promise constructor( private readonly args: { getSettings: () => ClaudeProfileRouterSettings dataRoot: string userHome?: string + /** Tests replace the login shell's env. */ env?: NodeJS.ProcessEnv /** Tests replace the worker. */ runSetup?: typeof runClaudeProfileSetupInWorker } ) { this.pointerPath = join(args.dataRoot, 'claude-profiles', 'selected-host') + this.env = args.env ?? process.env + // Why the login shell's: a Dock launch lacks the CLAUDE_CONFIG_DIR an rc exports. Chats share it. + this.envReady = args.env + ? Promise.resolve() + : resolveLoginShellEnvironment().then((env) => (this.env = env)) } private get userHome(): string { @@ -67,24 +77,19 @@ export class ClaudeProfileRouter { private selectedProfile(): ClaudeProfileDescriptor | null { const id = getSelectedClaudeAccountIdForTarget(this.args.getSettings(), { runtime: 'host' }) - return id - ? describeClaudeProfile(this.args.dataRoot, id, { executionHostId: 'local', runtime: 'host' }) - : null + return id ? this.describe(id) : null } /** The user's System default: their own CLAUDE_CONFIG_DIR, else ~/.claude. */ systemDefaultHome(): string { - return resolveClaudeDefaultHome( - this.userHome, - readUserClaudeConfigDir(this.args.env ?? process.env) - ) + return resolveClaudeDefaultHome(this.userHome, this.userConfigDir()) } /** Null for System default. Throws for a missing folder: falling back would run the wrong account. */ selectedHome(): string | null { const home = this.selectedProfile()?.home ?? null if (home !== null && !isDirectory(home)) { - throw new Error(CLAUDE_PROFILE_MISSING_MESSAGE) + throw claudeProfileMissing() } return home } @@ -98,26 +103,72 @@ export class ClaudeProfileRouter { const profile = this.selectedProfile() mkdirSync(dirname(this.pointerPath), { recursive: true, mode: 0o700 }) writeFileAtomically(this.pointerPath, profile?.home ?? '', { mode: 0o600 }) - // Why the existence check: setup creates the folder, and only sign-in may create an account. - if (profile && isDirectory(profile.home)) { + // Why even a missing folder: setup creates it without a login, so Claude's own first run + // signs in there (an account saved before per-account folders has none yet). + if (profile) { this.setUp(profile).catch((error: unknown) => { console.warn('[claude-profile] Account setup failed:', error) }) } } - /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ + /** Waits for a first setup that never finished, running or not; otherwise launches at once. */ async prepareLaunch(): Promise { const profile = this.selectedProfile() - if (profile && isDirectory(profile.home) && !existsSync(claudeProfileMarkerPath(profile))) { + if (!profile) { + // Only System default reads the login shell's env here; setup awaits it itself. + await this.envReady + return this.preparation() + } + // Why the marker: setup writes it last, so a missing folder is set up too. A re-run of a + // set-up folder never blocks. + if (!existsSync(claudeProfileMarkerPath(profile))) { const report = await this.setUp(profile).catch(() => null) if (report?.outcome !== 'prepared') { - throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + throw claudeProfileSetupFailed() } } return this.preparation() } + /** An account's folder on this host, whether or not it exists yet. */ + accountHome(accountId: string): string { + return this.describe(accountId).home + } + + /** Creates and sets up an account's folder for sign-in; the login itself is Claude's. */ + async prepareAccount(accountId: string): Promise { + const profile = this.describe(accountId) + const report = await this.setUp(profile).catch(() => null) + if (report?.outcome !== 'prepared') { + throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + } + return profile.home + } + + /** Deletes an account's folder after any setup running for it, which would otherwise recreate it. */ + async removeAccount(accountId: string): Promise { + await this.setups.get(accountId)?.catch(() => {}) + await removeClaudeAccountFolder(this.args.dataRoot, accountId) + } + + /** True once an older Orca copied an account's login into System default: it left this snapshot. */ + copiedLoginIntoSystemDefault(): boolean { + return existsSync(join(this.args.dataRoot, 'claude-runtime-auth', 'system-default-auth.json')) + } + + /** The user's own CLAUDE_CONFIG_DIR, which wins over the selection in their terminals. */ + userConfigDir(): string | undefined { + return readUserClaudeConfigDir(this.env) + } + + private describe(accountId: string): ClaudeProfileDescriptor { + return describeClaudeProfile(this.args.dataRoot, accountId, { + executionHostId: 'local', + runtime: 'host' + }) + } + /** One setup per account at a time; a later request reuses the running one. */ private setUp(profile: ClaudeProfileDescriptor): Promise { const running = this.setups.get(profile.accountId) @@ -130,13 +181,14 @@ export class ClaudeProfileRouter { } private async runSetup(profile: ClaudeProfileDescriptor): Promise { + await this.envReady const hooks = isAgentStatusHooksEnabledForAgent(this.args.getSettings(), 'claude') const claudeVersion = hooks ? await probeClaudeCliVersion(resolveClaudeCommand()) : null const report = await (this.args.runSetup ?? runClaudeProfileSetupInWorker)({ dataRoot: this.args.dataRoot, profile, userHome: this.userHome, - userConfigDir: readUserClaudeConfigDir(this.args.env ?? process.env), + userConfigDir: this.userConfigDir(), hooks, claudeVersion: claudeVersion ?? undefined }) @@ -163,7 +215,12 @@ export class ClaudeProfileRouter { return { [CLAUDE_PROFILE_POINTER_ENV]: `~/${wslClaudeProfilePointer(this.args.dataRoot)}` } } try { - return this.launchEnv() + const env = this.launchEnv() + const userConfigDir = this.userConfigDir() + // Why: the injected value replaces the user's own; the claude function restores it on System default. + return env.CLAUDE_CONFIG_DIR && userConfigDir + ? { ...env, [CLAUDE_USER_CONFIG_DIR_ENV]: userConfigDir } + : env } catch { return { [CLAUDE_PROFILE_POINTER_ENV]: this.pointerPath } } @@ -187,3 +244,16 @@ export class ClaudeProfileRouter { return listClaudeProfileHomes(this.args.dataRoot) } } + +// Typed so a chat names the situation; a terminal reads the same message. +export function claudeProfileMissing(): AgentSessionPreSpawnError { + return new AgentSessionPreSpawnError(new Error(CLAUDE_PROFILE_MISSING_MESSAGE), { + reason: 'claudeAccountFolderMissing' + }) +} + +export function claudeProfileSetupFailed(): AgentSessionPreSpawnError { + return new AgentSessionPreSpawnError(new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE), { + reason: 'claudeAccountSetupFailed' + }) +} diff --git a/src/main/claude-accounts/claude-profile-setup.test.ts b/src/main/claude-accounts/claude-profile-setup.test.ts index 0195805eff32..0ead45be168d 100644 --- a/src/main/claude-accounts/claude-profile-setup.test.ts +++ b/src/main/claude-accounts/claude-profile-setup.test.ts @@ -48,7 +48,7 @@ function fixture() { installHooks, platform: 'linux' }) - return { root, defaultHome, dataRoot, service, setup } + return { root, defaultHome, dataRoot, service, installHooks, setup } } // Why: setup runs as 'linux', so it creates real symlinks, which Windows needs privilege for. @@ -83,6 +83,24 @@ describe('Claude account profile setup', () => { expect(existsSync(join(home, '.credentials.json'))).toBe(false) expect((await f.setup()).warnings).toEqual([]) }) + itLinks('marks the folder set up only after its last step', async () => { + const f = fixture() + const marker = join(f.dataRoot, 'claude-profiles/a/profile.json') + let markedDuringHooks: boolean | undefined + const report = await provisionClaudeAccountProfile({ + dataRoot: f.dataRoot, + profile: describeClaudeProfile(f.dataRoot, 'a', local), + userHome: state.home, + installHooks: (target) => { + markedDuringHooks = existsSync(marker) + return f.installHooks(target) + }, + platform: 'linux' + }) + expect(report.outcome).toBe('prepared') + expect(markedDuringHooks).toBe(false) + expect(existsSync(marker)).toBe(true) + }) itLinks("brings the user's later hooks into an account set up while they had none", async () => { const f = fixture() const settings = join(f.defaultHome, 'settings.json') diff --git a/src/main/claude-accounts/claude-profile-setup.ts b/src/main/claude-accounts/claude-profile-setup.ts index 1687d9cf0e8b..7d132e1c86d4 100644 --- a/src/main/claude-accounts/claude-profile-setup.ts +++ b/src/main/claude-accounts/claude-profile-setup.ts @@ -51,16 +51,21 @@ export async function provisionClaudeAccountProfile(args: { userConfigDir?: string /** Null when Orca's Claude hooks are turned off. Runs after the settings merge so its entries survive it. */ installHooks: ((target: { configDir: string }) => AgentHookInstallStatus) | null - trustKeys?: readonly string[] platform?: NodeJS.Platform }): Promise { const platform = args.platform ?? process.platform const report = createClaudeProfileReport() + let markSetUp: () => void try { if (args.profile.target.runtime === 'wsl' && platform === 'win32') { throw new ClaudeProfileSurfaceError('invalid-profile', 'WSL profiles are set up in the guest') } - prepareClaudeProfileDirectory(args.dataRoot, args.profile, args.userHome, args.userConfigDir) + markSetUp = prepareClaudeProfileDirectory( + args.dataRoot, + args.profile, + args.userHome, + args.userConfigDir + ) } catch (error) { report.surfaces.profile = 'failed' warnClaudeProfile(report, 'profile', error) @@ -70,7 +75,7 @@ export async function provisionClaudeAccountProfile(args: { const shared = { profileHome: home, userHome: args.userHome, userConfigDir: args.userConfigDir } for (const step of [ () => shareClaudeProfileHistory({ ...shared, platform }), - () => provisionClaudeProfile({ ...shared, platform, trustKeys: args.trustKeys }) + () => provisionClaudeProfile({ ...shared, platform }) ]) { try { const part = await step() @@ -92,5 +97,7 @@ export async function provisionClaudeAccountProfile(args: { recordInstalledHooks(home) return 'merged' }) + // Last: a setup cut off before here leaves no marker, so the next launch waits for a full one. + markSetUp() return { outcome: 'prepared', ...report } } diff --git a/src/main/claude-accounts/claude-profile-shell-composition.test.ts b/src/main/claude-accounts/claude-profile-shell-composition.test.ts index 4bf99fdac515..fb4ed6255e10 100644 --- a/src/main/claude-accounts/claude-profile-shell-composition.test.ts +++ b/src/main/claude-accounts/claude-profile-shell-composition.test.ts @@ -1,21 +1,13 @@ -import type * as ProfileRouting from '../../shared/claude-profile-routing' import { spawnSync } from 'node:child_process' import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -const gate = vi.hoisted(() => ({ enabled: true })) -vi.mock('../../shared/claude-profile-routing', async (original) => ({ - ...(await original()), - claudeProfileRoutingEnabled: () => gate.enabled -})) +import { afterEach, expect, it } from 'vitest' const FISH = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync) ?? '/usr/bin/fish' const roots: string[] = [] afterEach(() => { - gate.enabled = true - vi.resetModules() roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) }) function fishInit({ args }: { args: string[] | null }): string { @@ -25,9 +17,7 @@ function fishInit({ args }: { args: string[] | null }): string { } return init } -async function composed(enabled: boolean) { - gate.enabled = enabled - vi.resetModules() +async function composed() { const local = await import('../providers/local-pty-shell-ready') const daemon = await import('../daemon/shell-ready') const powershell = await import('../powershell-osc133-bootstrap') @@ -53,35 +43,19 @@ async function composed(enabled: boolean) { } } -it('keeps the composed bash, zsh, fish and PowerShell startup text dormant with the gate off', async () => { - const off = await composed(false) - for (const text of [...off.fish, off.powershell]) { - expect(text).not.toContain('claude') - } - for (const text of [...off.bash, ...off.zsh]) { - expect(text).not.toContain('ORCA_CLAUDE') - } - const on = await composed(true) +it('gives every composed shell startup and the plain fish vendor snippet the claude function', async () => { + const on = await composed() for (const text of [...on.bash, ...on.zsh]) { expect(text).toContain('ORCA_CLAUDE_INJECTED_CONFIG_DIR') } -}) - -it('gives a plain fish tab the claude function through the vendor snippet only with the gate on', async () => { - gate.enabled = false - vi.resetModules() - const off = await import('../fish-xdg-data-dirs-handoff') - expect(off.getFishVendorConfSnippet()).not.toContain('claude') - gate.enabled = true - vi.resetModules() - const on = await import('../fish-xdg-data-dirs-handoff') + const { getFishVendorConfSnippet } = await import('../fish-xdg-data-dirs-handoff') const { getFishClaudeShellFunction } = await import('../../shared/claude-shell-function') expect(getFishClaudeShellFunction()).toContain('function claude') - expect(on.getFishVendorConfSnippet()).toContain(getFishClaudeShellFunction()) + expect(getFishVendorConfSnippet()).toContain(getFishClaudeShellFunction()) }) it('starts the PowerShell claude function on its own line after the codex fragment', async () => { - const on = await composed(true) + const on = await composed() expect(on.powershell).toContain( 'Remove-Variable orcaCodexCommand -ErrorAction SilentlyContinue\n$orcaClaudeCommand' ) @@ -95,7 +69,7 @@ it.skipIf(!existsSync(FISH))( mkdirSync(join(root, 'bin')) writeFileSync(join(root, 'bin', 'claude'), '#!/bin/sh\nexit 0\n') chmodSync(join(root, 'bin', 'claude'), 0o700) - const on = await composed(true) + const on = await composed() for (const init of on.fish) { const result = spawnSync( '/usr/bin/env', diff --git a/src/main/claude-accounts/claude-profile-wsl-paths.ts b/src/main/claude-accounts/claude-profile-wsl-paths.ts index a5096c040eea..39d2bf677282 100644 --- a/src/main/claude-accounts/claude-profile-wsl-paths.ts +++ b/src/main/claude-accounts/claude-profile-wsl-paths.ts @@ -25,3 +25,12 @@ export function wslClaudeProfilePointer(hostDataRoot: string): string { const build = basename(hostDataRoot).replace(/[^\w.-]/g, '_') return `.local/share/orca/claude-profiles/selected-wsl-${build}` } + +/** A saved WSL account's guest folder; null for an older Orca's folder, which holds no login. */ +export function savedWslClaudeAccountHome(account: { + id: string + wslLinuxAuthPath?: string | null +}): string | null { + const home = account.wslLinuxAuthPath + return home?.endsWith(`/claude-profiles/${account.id}/home`) ? home : null +} diff --git a/src/main/claude-accounts/claude-profile-wsl-router.test.ts b/src/main/claude-accounts/claude-profile-wsl-router.test.ts index decde2f9d35a..00c16d881d20 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.test.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -6,6 +6,7 @@ import { mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -37,10 +38,12 @@ vi.mock('../wsl/wsl-runner', () => ({ import { CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, - type ClaudeProfileRouterSettings -} from './claude-profile-router' + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE +} from '../../shared/claude-profile-routing' +import type { ClaudeProfileRouterSettings } from './claude-profile-router' +import { prepareClaudeProfileDirectory } from './claude-profile-paths' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' +import { wslClaudeProfile } from './claude-profile-wsl-paths' // Why skipped on Windows: the guest is Linux; these run its scripts and Node bundle as the guest. const posixHost = process.platform !== 'win32' @@ -75,12 +78,14 @@ function fixture() { const router = new ClaudeWslProfileRouter({ getSettings: () => settings, dataRoot: join(root, 'orca-dev'), + // Like the guest helper, marks the folder only once it finishes. runSetup: async () => { setup.calls += 1 await setup.gate if (setup.fail) { throw new Error('refused') } + mkdirSync(profileHome, { recursive: true }) writeFileSync(join(profileHome, '..', 'profile.json'), '{}') } }) @@ -90,14 +95,10 @@ function fixture() { } describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { - it('writes the guest pointer per build, sets up only a signed-in folder, and removes it with the last account', async () => { + it('writes the guest pointer per build, sets up even a missing folder, and removes it with the last account', async () => { const f = fixture() await f.router.publish('Ubuntu') expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) - expect(f.setup.calls).toBe(0) - - mkdirSync(f.profileHome, { recursive: true }) - await f.router.publish('Ubuntu') await vi.waitFor(() => expect(f.setup.calls).toBe(1)) f.wsl.Ubuntu = null @@ -109,15 +110,21 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { expect(existsSync(f.pointer)).toBe(false) }) - it('refuses a missing folder, waits for a never-set-up one, then launches at once', async () => { + it('sets up a missing folder, waits for a first setup never run or still running, then launches at once', async () => { const f = fixture() - await expect(f.router.prepareLaunch('Ubuntu')).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) - - mkdirSync(f.profileHome, { recursive: true }) + await expect(f.router.preparation('Ubuntu')).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) f.setup.fail = true - await expect(f.router.prepareLaunch('Ubuntu')).rejects.toThrow( - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE - ) + let release = () => {} + f.setup.gate = new Promise((resolve) => (release = resolve)) + const launch = f.router.prepareLaunch('Ubuntu') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + const second = f.router.prepareLaunch('Ubuntu') + await new Promise((resolve) => setTimeout(resolve, 50)) + release() + await expect(launch).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + await expect(second).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + expect(f.setup.calls).toBe(1) + f.setup.fail = false const prepared = await f.router.prepareLaunch('Ubuntu') expect(prepared).toMatchObject({ @@ -131,10 +138,94 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.profileHome } }) + // The failed first setup left no marker, so this launch ran setup again; the next does not. + expect(f.setup.calls).toBe(2) await f.router.prepareLaunch('Ubuntu') expect(f.setup.calls).toBe(2) }) + it('makes a launch redo a first setup that was cut off after its ownership gate', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + // The guest helper failed, or timed out, after the gate ran. + const cutOff = new ClaudeWslProfileRouter({ + getSettings: () => f.settings, + dataRoot: join(guest.home, '..', 'orca-dev'), + runSetup: async (distro, home, accountId) => { + const { dataRoot, profile } = wslClaudeProfile(home, distro, accountId) + prepareClaudeProfileDirectory(dataRoot, profile, home) + throw new Error('timed out') + } + }) + await expect(cutOff.prepareLaunch('Ubuntu')).rejects.toThrow( + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + ) + expect(existsSync(join(f.profileHome, '..', 'profile.json'))).toBe(false) + + await f.router.prepareLaunch('Ubuntu') + expect(f.setup.calls).toBe(1) + }) + + it('never makes a launch wait on, or fail with, a re-run of a set-up folder', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') + f.setup.fail = true + let release = () => {} + f.setup.gate = new Promise((resolve) => (release = resolve)) + await f.router.publish('Ubuntu') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + configDir: f.profileHome + }) + release() + await new Promise((resolve) => setTimeout(resolve, 10)) + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + configDir: f.profileHome + }) + expect(f.setup.calls).toBe(1) + }) + + it('sets up a new account folder for sign-in and deletes it without following its links', async () => { + const f = fixture() + const sharedHistory = join(guest.home, '.claude', 'projects') + mkdirSync(sharedHistory) + writeFileSync(join(sharedHistory, 'chat.jsonl'), '{}') + // The stub setup marks account a's folder. + mkdirSync(f.profileHome, { recursive: true }) + const home = await f.router.prepareAccount('Ubuntu', 'b') + expect(home).toBe(join(guest.home, '.local/share/orca/claude-profiles/b/home')) + expect(f.setup.calls).toBe(1) + mkdirSync(home, { recursive: true }) + symlinkSync(sharedHistory, join(home, 'projects')) + + await f.router.removeAccount('Ubuntu', 'b') + expect(existsSync(join(home, '..'))).toBe(false) + expect(existsSync(join(sharedHistory, 'chat.jsonl'))).toBe(true) + + f.setup.fail = true + await expect(f.router.prepareAccount('Ubuntu', 'c')).rejects.toThrow( + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + ) + }) + + it('removes a guest folder only after its running setup, so the setup cannot bring it back', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + let release = () => {} + // Like the guest helper, setup recreates the folder it writes into. + f.setup.gate = new Promise((resolve) => (release = resolve)).then(() => { + mkdirSync(f.profileHome, { recursive: true }) + }) + await f.router.publish('Ubuntu') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + const removal = f.router.removeAccount('Ubuntu', 'a') + await new Promise((resolve) => setTimeout(resolve, 50)) + release() + await removal + expect(existsSync(join(f.profileHome, '..'))).toBe(false) + }) + it('writes a missing guest pointer before a launch returns', async () => { const f = fixture() mkdirSync(f.profileHome, { recursive: true }) diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index 0fba0eb59a68..10c8273d9195 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -2,21 +2,21 @@ import { existsSync } from 'node:fs' import { lstat, readFile } from 'node:fs/promises' import { join, posix } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' -import { CLAUDE_PROFILE_POINTER_ENV } from '../../shared/claude-profile-routing' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE +} from '../../shared/claude-profile-routing' import { WSL_CLAUDE_PROFILE_HELPER_FILENAME } from '../../shared/relay-artifacts' import { parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { getWslHomeAsync, listRunningWslDistrosAsync } from '../wsl' import { ensureWslPinnedRuntime } from '../wsl/wsl-pinned-runtime' import { relayBundleCandidates } from '../ssh/relay-bundle-paths' import { runWslProcess, type WslSpec } from '../wsl/wsl-runner' +import { claudeProfileMarkerPath, type ClaudeProfileDescriptor } from './claude-profile-paths' import { - CLAUDE_INJECTED_CONFIG_DIR_ENV, - claudeProfileMarkerPath, - type ClaudeProfileDescriptor -} from './claude-profile-paths' -import { - CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + claudeProfileMissing, + claudeProfileSetupFailed, type ClaudeProfileRouterSettings } from './claude-profile-router' import { wslClaudeProfile, wslClaudeProfilePointer } from './claude-profile-wsl-paths' @@ -90,22 +90,23 @@ export class ClaudeWslProfileRouter { return } await writePointer(distro, this.pointerIn(home), profile?.home ?? '') - // Why the existence check: setup creates the folder, and only sign-in may create an account. - if (profile && (await guestStat(distro, profile.home))?.isDirectory()) { + // Why even a missing folder: setup creates it without a login, for Claude's own first run. + if (profile) { this.setUp(distro, home, profile.accountId).catch((error: unknown) => { console.warn('[claude-profile] WSL account setup failed:', error) }) } } - /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ + /** Waits for a first setup that never finished, running or not; otherwise launches at once. */ async prepareLaunch(distro: string): Promise { const { home, profile } = await this.resolve(distro) - await this.assertPresent(distro, profile) - if (profile && !(await guestStat(distro, claudeProfileMarkerPath(profile)))?.isFile()) { + // Why the marker: setup writes it last, so a missing folder is set up too. A re-run of a + // set-up folder never blocks. + if (profile && !(await hasMarker(distro, profile))) { await this.setUp(distro, home, profile.accountId).catch((error: unknown) => { console.warn('[claude-profile] WSL account setup failed:', error) - throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + throw claudeProfileSetupFailed() }) } // Why: a missing or stale guest pointer would run the pane's `claude` under another account. @@ -123,10 +124,32 @@ export class ClaudeWslProfileRouter { return this.preparationFor(distro, home, profile) } + /** Creates and sets up an account's guest folder for sign-in; the login itself is Claude's. */ + async prepareAccount(distro: string, accountId: string): Promise { + const { home } = await this.resolve(distro) + const { profile } = wslClaudeProfile(home, distro, accountId) + await this.setUp(distro, home, accountId).catch((error: unknown) => { + console.warn('[claude-profile] WSL account setup failed:', error) + throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + }) + return profile.home + } + + /** + * Deletes an account's guest folder after any setup running for it, which would otherwise + * recreate it. Linux `rm -r` never follows the history links. + */ + async removeAccount(distro: string, accountId: string): Promise { + await this.setups.get(accountId)?.catch(() => {}) + const { home } = await this.resolve(distro) + const folder = posix.dirname(wslClaudeProfile(home, distro, accountId).profile.home) + await runGuest(distro, { script: 'rm -rf -- "$1"', args: [folder], loginPath: 'none' }) + } + /** Falling back would run the wrong account. */ private async assertPresent(distro: string, profile: ClaudeProfileDescriptor | null) { if (profile && !(await guestStat(distro, profile.home))?.isDirectory()) { - throw new Error(CLAUDE_PROFILE_MISSING_MESSAGE) + throw claudeProfileMissing() } } @@ -172,6 +195,10 @@ async function guestStat(distro: string, linuxPath: string) { return lstat(toWindowsWslPath(linuxPath, distro)).catch(() => null) } +async function hasMarker(distro: string, profile: ClaudeProfileDescriptor): Promise { + return (await guestStat(distro, claudeProfileMarkerPath(profile)))?.isFile() ?? false +} + /** Writes in the guest only when the file differs, so a launch reads it over the share instead. */ async function writePointer(distro: string, pointer: string, home: string): Promise { // Why the timeout: a hung share must not stall startup's serialized publish; the guest write decides. diff --git a/src/main/claude-accounts/claude-windows-interactive-login.test.ts b/src/main/claude-accounts/claude-windows-interactive-login.test.ts deleted file mode 100644 index 69ca24c21a8f..000000000000 --- a/src/main/claude-accounts/claude-windows-interactive-login.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { EventEmitter } from 'node:events' -import { getCmdExePath } from '../../shared/windows-batch-spawn' -import { createService, restorePlatform, setPlatform } from './claude-account-service-test-harness' - -vi.mock('electron', () => ({ - app: { - getPath: () => '/tmp/orca-claude-windows-login-test' - } -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: vi.fn(() => 'C:\\Tools\\claude.cmd') -})) - -describe('Claude Windows host interactive login', () => { - afterEach(() => { - restorePlatform() - }) - - it('resolves a Windows auth login whose child has no piped streams', async () => { - setPlatform('win32') - vi.resetModules() - const child = new EventEmitter() as EventEmitter & { - stdout: null - stderr: null - kill: ReturnType - pid: number - } - child.stdout = null - child.stderr = null - child.kill = vi.fn() - child.pid = 4242 - const spawnMock = vi.fn(() => { - queueMicrotask(() => child.emit('exit', 0)) - return child - }) - const buildInteractiveLoginSpawn = vi.fn(() => ({ - command: getCmdExePath(), - args: [ - '/d', - '/c', - 'start', - '', - '/wait', - 'C:\\Tools\\claude.cmd', - 'auth', - 'login', - '--claudeai' - ], - stdio: 'ignore' as const, - windowsHide: true - })) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ - buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn - })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - await ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number - ): Promise - } - ).runClaudeCommand( - ['auth', 'login', '--claudeai'], - { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, - 1000 - ) - - expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('C:\\Tools\\claude.cmd', [ - 'auth', - 'login', - '--claudeai' - ]) - expect(spawnMock).toHaveBeenCalledWith( - getCmdExePath(), - expect.arrayContaining(['start', '', '/wait']), - expect.objectContaining({ - stdio: 'ignore', - windowsHide: true - }) - ) - expect(child.kill).not.toHaveBeenCalled() - } finally { - vi.doUnmock('node:child_process') - vi.doUnmock('../../shared/windows-interactive-login-spawn') - } - }) - - it('times out a denied Windows login that stays alive without piped output', async () => { - setPlatform('win32') - vi.resetModules() - vi.useFakeTimers() - const child = new EventEmitter() as EventEmitter & { - stdout: null - stderr: null - kill: ReturnType - pid: number - } - child.stdout = null - child.stderr = null - child.kill = vi.fn() - child.pid = 0 - const spawnMock = vi.fn(() => child) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - const login = ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number - ): Promise - } - ).runClaudeCommand( - ['auth', 'login', '--claudeai'], - { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, - 1000 - ) - const rejection = expect(login).rejects.toThrow('Claude sign-in took too long to finish.') - - await vi.advanceTimersByTimeAsync(3_000) - - await rejection - expect(child.kill).toHaveBeenCalledOnce() - } finally { - vi.useRealTimers() - vi.doUnmock('node:child_process') - } - }) -}) diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts index 3fd7871c70d7..918de5250f5e 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -1,4 +1,9 @@ import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV, + CLAUDE_USER_CONFIG_DIR_ENV +} from '../../shared/claude-profile-routing' export const CLAUDE_AUTH_ENV_VARS = [ 'ANTHROPIC_API_KEY', @@ -8,8 +13,9 @@ export const CLAUDE_AUTH_ENV_VARS = [ ] as const export type ClaudeEnvPatch = { - ORCA_CLAUDE_PROFILE_POINTER?: string - ORCA_CLAUDE_INJECTED_CONFIG_DIR?: string + [CLAUDE_PROFILE_POINTER_ENV]?: string + [CLAUDE_INJECTED_CONFIG_DIR_ENV]?: string + [CLAUDE_USER_CONFIG_DIR_ENV]?: string CLAUDE_CONFIG_DIR?: string ANTHROPIC_CUSTOM_HEADERS?: string } @@ -35,11 +41,15 @@ export function applyClaudeEnvPatch( } } - if (patch.ORCA_CLAUDE_PROFILE_POINTER) { - baseEnv.ORCA_CLAUDE_PROFILE_POINTER = patch.ORCA_CLAUDE_PROFILE_POINTER - } - if (patch.ORCA_CLAUDE_INJECTED_CONFIG_DIR) { - baseEnv.ORCA_CLAUDE_INJECTED_CONFIG_DIR = patch.ORCA_CLAUDE_INJECTED_CONFIG_DIR + for (const key of [ + CLAUDE_PROFILE_POINTER_ENV, + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_USER_CONFIG_DIR_ENV + ] as const) { + const value = patch[key] + if (value) { + baseEnv[key] = value + } } if (patch.CLAUDE_CONFIG_DIR) { baseEnv.CLAUDE_CONFIG_DIR = patch.CLAUDE_CONFIG_DIR @@ -56,9 +66,6 @@ export function applyClaudeEnvPatch( export const CLAUDE_AUTH_ENV_CONFLICT_MESSAGE = 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' -export const CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE = - 'A Claude account switch is in progress. Try again after it finishes.' - /** * Whether a launch on the host runtime must drop inherited Anthropic auth. * diff --git a/src/main/claude-accounts/keychain.test.ts b/src/main/claude-accounts/keychain.test.ts index 3ee2c6b77593..3bd725ba068f 100644 --- a/src/main/claude-accounts/keychain.test.ts +++ b/src/main/claude-accounts/keychain.test.ts @@ -1,277 +1,21 @@ -import { createHash } from 'node:crypto' -import { execFile } from 'node:child_process' -import { mkdtempSync, realpathSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - deleteActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict, - writeActiveClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentialsForRuntime -} from './keychain' +import { afterEach, expect, it, vi } from 'vitest' -vi.mock('node:child_process', () => ({ - execFile: vi.fn() -})) +const reads = vi.hoisted(() => vi.fn(async () => null)) +vi.mock('../macos-keychain/generic-password', () => ({ readKeychainPassword: reads })) -const execFileMock = vi.mocked(execFile) -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') -const originalUser = process.env.USER -const originalUsername = process.env.USERNAME -const TEST_USER = 'orca-test-user' -const SSO_USER = 'sso.user@example.com' -let configDir: string - -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -function serviceForConfigDir(configDir: string): string { - const suffix = createHash('sha256').update(configDir).digest('hex').slice(0, 8) - return `Claude Code-credentials-${suffix}` -} - -function invokeExecFileCallback( - callback: unknown, - error: Error | null, - stdout: string, - stderr: string -): void { - const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void - execCallback(error, stdout, stderr) -} - -describe('Claude Keychain credentials', () => { - beforeEach(() => { - configDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-claude-keychain-'))) - setPlatform('darwin') - execFileMock.mockReset() - process.env.USER = TEST_USER - delete process.env.USERNAME - }) - - afterEach(() => { - rmSync(configDir, { recursive: true, force: true }) - vi.useRealTimers() - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - if (originalUser === undefined) { - delete process.env.USER - } else { - process.env.USER = originalUser - } - if (originalUsername === undefined) { - delete process.env.USERNAME - } else { - process.env.USERNAME = originalUsername - } - }) - - it('reads config-scoped Claude Code 2.1 credentials before legacy credentials', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '{"claudeAiOauth":{"accessToken":"scoped"}}\n', '') - return null as never - }) - - await expect(readActiveClaudeKeychainCredentials(configDir)).resolves.toBe( - '{"claudeAiOauth":{"accessToken":"scoped"}}' - ) - - expect(execFileMock).toHaveBeenCalledTimes(1) - expect(execFileMock.mock.calls[0][1]).toEqual([ - 'find-generic-password', - '-s', - scopedService, - '-a', - TEST_USER, - '-w' - ]) - }) - - it('falls back to the legacy unsuffixed Claude Code credentials service', async () => { - const notFound = Object.assign(new Error('not found'), { code: 44 }) - execFileMock - .mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, notFound, '', 'could not be found') - return null as never - }) - .mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, 'legacy\n', '') - return null as never - }) - - await expect(readActiveClaudeKeychainCredentials(configDir)).resolves.toBe('legacy') - - expect(execFileMock.mock.calls[1][1]).toEqual([ - 'find-generic-password', - '-s', - 'Claude Code-credentials', - '-a', - TEST_USER, - '-w' - ]) - }) - - it('writes active credentials to the config-scoped Claude Code service', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '', '') - return null as never - }) - - await writeActiveClaudeKeychainCredentials('credentials-json', configDir) - - expect(execFileMock.mock.calls[0][1]).toEqual([ - 'add-generic-password', - '-U', - '-s', - scopedService, - '-a', - TEST_USER, - '-w', - 'credentials-json' - ]) - }) - - it('writes runtime credentials to scoped and legacy services for old Claude Code compatibility', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementation((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '', '') - return null as never - }) - - await writeActiveClaudeKeychainCredentialsForRuntime('credentials-json', configDir) +import { readActiveClaudeKeychainCredentialsStrict } from './keychain' - expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ - [ - 'add-generic-password', - '-U', - '-s', - scopedService, - '-a', - TEST_USER, - '-w', - 'credentials-json' - ], - [ - 'add-generic-password', - '-U', - '-s', - 'Claude Code-credentials', - '-a', - TEST_USER, - '-w', - 'credentials-json' - ] - ]) - }) - - it('strictly reads only the requested active credentials service', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, 'scoped\n', '') - return null as never - }) - - await expect(readActiveClaudeKeychainCredentialsStrict(configDir)).resolves.toBe('scoped') - - expect(execFileMock).toHaveBeenCalledTimes(1) - expect(execFileMock.mock.calls[0][1]).toEqual([ - 'find-generic-password', - '-s', - scopedService, - '-a', - TEST_USER, - '-w' - ]) - }) - - it('rejects when a keychain read never reports completion', async () => { - vi.useFakeTimers() - const killMock = vi.fn() - execFileMock.mockImplementationOnce(() => ({ kill: killMock }) as never) - - let settled = false - let rejected: unknown - const readPromise = readActiveClaudeKeychainCredentialsStrict(configDir).then( - (credentials) => { - settled = true - return credentials - }, - (error: unknown) => { - settled = true - rejected = error - return null - } - ) - - await vi.advanceTimersByTimeAsync(3000) - - expect(settled).toBe(true) - await readPromise - expect(rejected).toEqual( - expect.objectContaining({ message: 'security timed out after 3000ms' }) - ) - expect(killMock).toHaveBeenCalled() - }) - - it('deletes both scoped and legacy active credentials for config-dir cleanup', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementation((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '', '') - return null as never - }) - - await deleteActiveClaudeKeychainCredentials(configDir) - - expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ - ['delete-generic-password', '-s', scopedService, '-a', TEST_USER], - ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', TEST_USER] - ]) - }) - - it('looks up claude-code-user when $USER contains @ (#12857)', async () => { - process.env.USER = SSO_USER - execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '{"claudeAiOauth":{"accessToken":"ok"}}\n', '') - return null as never - }) - - await expect(readActiveClaudeKeychainCredentials()).resolves.toBe( - '{"claudeAiOauth":{"accessToken":"ok"}}' - ) - expect(execFileMock.mock.calls[0][1]).toEqual([ - 'find-generic-password', - '-s', - 'Claude Code-credentials', - '-a', - 'claude-code-user', - '-w' - ]) - }) - - it('cleans both Claude Code and raw $USER Keychain accounts after a failed SSO login', async () => { - process.env.USER = SSO_USER - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementation((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '', '') - return null as never - }) - - await deleteActiveClaudeKeychainCredentials(configDir) +const originalUser = process.env.USER +afterEach(() => { + process.env.USER = originalUser + reads.mockClear() +}) - expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ - ['delete-generic-password', '-s', scopedService, '-a', 'claude-code-user'], - ['delete-generic-password', '-s', scopedService, '-a', SSO_USER], - ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', 'claude-code-user'], - ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', SSO_USER] - ]) - }) +it('reads the claude-code-user item when $USER is not a valid Keychain account (#12857)', async () => { + process.env.USER = 'first@example.com' + await readActiveClaudeKeychainCredentialsStrict() + expect(reads).toHaveBeenCalledWith('Claude Code-credentials', 'claude-code-user') + process.env.USER = 'first.last' + await readActiveClaudeKeychainCredentialsStrict() + expect(reads).toHaveBeenLastCalledWith('Claude Code-credentials', 'first.last') }) diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts index aa4dcfa64dbc..14070e6bb631 100644 --- a/src/main/claude-accounts/keychain.ts +++ b/src/main/claude-accounts/keychain.ts @@ -2,36 +2,18 @@ import { createHash } from 'node:crypto' import { lstatSync, realpathSync } from 'node:fs' import { userInfo } from 'node:os' import { basename, dirname, join } from 'node:path' -import { - deleteKeychainPassword, - readKeychainPassword, - writeKeychainPassword -} from '../macos-keychain/generic-password' +import { readKeychainPassword } from '../macos-keychain/generic-password' const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials' -const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials' -export async function readActiveClaudeKeychainCredentials( - configDir?: string -): Promise { - for (const service of getActiveClaudeServices(configDir)) { - const credentials = await readKeychainPassword(service, getKeychainUser()) - if (credentials) { - return credentials - } - } - return null -} - export async function readActiveClaudeKeychainCredentialsStrict( configDir?: string ): Promise { if (!configDir) { - return readKeychainPassword(getActiveClaudeService(), getKeychainUser()) + return readKeychainPassword(claudeKeychainService(), getKeychainUser()) } - // Why: macOS tmp is /var → /private/var. Claude hashes the realpath; a - // mkdtemp login dir would miss the Keychain item if we only hashed the raw path. + // Keep both lexical and canonical profile aliases scoped; never try System Default. for (const dir of claudeConfigDirKeychainAliases(configDir)) { - const credentials = await readKeychainPassword(getActiveClaudeService(dir), getKeychainUser()) + const credentials = await readKeychainPassword(claudeKeychainService(dir), getKeychainUser()) if (credentials) { return credentials } @@ -39,63 +21,6 @@ export async function readActiveClaudeKeychainCredentialsStrict( return null } -export async function writeActiveClaudeKeychainCredentials( - contents: string, - configDir?: string -): Promise { - await writeKeychainPassword(getActiveClaudeService(configDir), getKeychainUser(), contents) -} - -export async function writeActiveClaudeKeychainCredentialsForRuntime( - contents: string, - configDir: string -): Promise { - const user = getKeychainUser() - const scopedService = getActiveClaudeService(configDir) - await writeKeychainPassword(scopedService, user, contents) - if (scopedService !== ACTIVE_CLAUDE_SERVICE) { - await writeKeychainPassword(ACTIVE_CLAUDE_SERVICE, user, contents) - } -} - -export async function deleteActiveClaudeKeychainCredentials(configDir?: string): Promise { - for (const service of getActiveClaudeServices(configDir)) { - for (const account of getKeychainUsersForCleanup()) { - await deleteKeychainPassword(service, account) - } - } -} - -export async function deleteActiveClaudeKeychainCredentialsStrict( - configDir?: string -): Promise { - const dirs = configDir ? claudeConfigDirKeychainAliases(configDir) : [undefined] - for (const dir of dirs) { - for (const account of getKeychainUsersForCleanup()) { - await deleteKeychainPassword(getActiveClaudeService(dir), account, { - failOnAccessError: true - }) - } - } -} - -export async function readManagedClaudeKeychainCredentials( - accountId: string -): Promise { - return readKeychainPassword(ORCA_CLAUDE_SERVICE, accountId) -} - -export async function writeManagedClaudeKeychainCredentials( - accountId: string, - contents: string -): Promise { - await writeKeychainPassword(ORCA_CLAUDE_SERVICE, accountId, contents) -} - -export async function deleteManagedClaudeKeychainCredentials(accountId: string): Promise { - await deleteKeychainPassword(ORCA_CLAUDE_SERVICE, accountId) -} - const KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/ const CLAUDE_CODE_FALLBACK_USER = 'claude-code-user' @@ -111,13 +36,7 @@ function getKeychainUser(): string { return KEYCHAIN_ACCOUNT_PATTERN.test(user) ? user : CLAUDE_CODE_FALLBACK_USER } -function getKeychainUsersForCleanup(): string[] { - const derived = getKeychainUser() - const raw = process.env.USER || process.env.USERNAME - return raw && raw !== derived ? [derived, raw] : [derived] -} - -function getActiveClaudeService(configDir?: string): string { +export function claudeKeychainService(configDir?: string): string { if (!configDir) { return ACTIVE_CLAUDE_SERVICE } @@ -173,10 +92,16 @@ export function claudeConfigDirKeychainAliases(configDir: string): string[] { return aliases } -function getActiveClaudeServices(configDir?: string): string[] { - if (!configDir) { - return [ACTIVE_CLAUDE_SERVICE] +/** Every spelling of a folder Claude may have hashed into its Keychain item name (superset U/profiles.ts). */ +export function claudeConfigDirSpellings(configDir: string, userHome: string): string[] { + const spellings = new Set() + for (const dir of claudeConfigDirKeychainAliases(configDir)) { + const trimmed = dir.replace(/[\\/]+$/, '') + const rest = trimmed.startsWith(`${userHome}/`) ? trimmed.slice(userHome.length) : null + for (const spelling of [trimmed, ...(rest ? [`~${rest}`, `$HOME${rest}`] : [])]) { + spellings.add(spelling) + spellings.add(`${spelling}/`) + } } - const scoped = claudeConfigDirKeychainAliases(configDir).map((dir) => getActiveClaudeService(dir)) - return [...new Set([...scoped, ACTIVE_CLAUDE_SERVICE])] + return [...spellings] } diff --git a/src/main/claude-accounts/live-pty-gate.test.ts b/src/main/claude-accounts/live-pty-gate.test.ts deleted file mode 100644 index 7359bbd98055..000000000000 --- a/src/main/claude-accounts/live-pty-gate.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - attachClaudeLivePtyPersistence, - beginClaudeAuthSwitch, - confirmSeededClaudeLivePtys, - endClaudeAuthSwitch, - hasLiveClaudePtys, - isClaudeAuthSwitchInProgress, - markClaudePtyExited, - markClaudePtySpawned, - onLiveClaudePtysDrained, - seedLiveClaudePtysFromPersistence -} from './live-pty-gate' - -describe('Claude live PTY gate', () => { - afterEach(() => { - markClaudePtyExited('live-claude-pty') - markClaudePtyExited('seeded-pty-1') - markClaudePtyExited('seeded-pty-2') - confirmSeededClaudeLivePtys([]) - attachClaudeLivePtyPersistence(null) - endClaudeAuthSwitch() - }) - - it('allows switching while Claude PTYs are live', () => { - markClaudePtySpawned('live-claude-pty') - - beginClaudeAuthSwitch() - - expect(isClaudeAuthSwitchInProgress()).toBe(true) - }) - - it('still rejects overlapping account switches', () => { - beginClaudeAuthSwitch() - - expect(() => beginClaudeAuthSwitch()).toThrow('already in progress') - }) - - it('counts seeded session ids as live until confirmed dead', () => { - seedLiveClaudePtysFromPersistence(['seeded-pty-1', 'seeded-pty-2']) - - expect(hasLiveClaudePtys()).toBe(true) - - confirmSeededClaudeLivePtys(['seeded-pty-1']) - - expect(hasLiveClaudePtys()).toBe(true) - - confirmSeededClaudeLivePtys([]) - - expect(hasLiveClaudePtys()).toBe(true) - - markClaudePtyExited('seeded-pty-1') - - expect(hasLiveClaudePtys()).toBe(false) - }) - - it('releases seeded ids the daemon no longer knows', () => { - const removeClaudeLivePtySessionId = vi.fn() - attachClaudeLivePtyPersistence({ - addClaudeLivePtySessionId: vi.fn(), - removeClaudeLivePtySessionId - }) - seedLiveClaudePtysFromPersistence(['seeded-pty-1', 'seeded-pty-2']) - - confirmSeededClaudeLivePtys(['seeded-pty-2']) - - expect(hasLiveClaudePtys()).toBe(true) - expect(removeClaudeLivePtySessionId).toHaveBeenCalledWith('seeded-pty-1') - expect(removeClaudeLivePtySessionId).not.toHaveBeenCalledWith('seeded-pty-2') - }) - - it('keeps a seeded id confirmed by a real spawn out of later pruning', () => { - seedLiveClaudePtysFromPersistence(['seeded-pty-1']) - markClaudePtySpawned('seeded-pty-1') - - confirmSeededClaudeLivePtys([]) - - expect(hasLiveClaudePtys()).toBe(true) - }) - - it('notifies drain listeners only when the last live Claude PTY exits', () => { - const onDrained = vi.fn() - const unsubscribe = onLiveClaudePtysDrained(onDrained) - try { - markClaudePtySpawned('live-claude-pty') - markClaudePtySpawned('seeded-pty-1') - - markClaudePtyExited('live-claude-pty') - expect(onDrained).not.toHaveBeenCalled() - - markClaudePtyExited('seeded-pty-1') - expect(onDrained).toHaveBeenCalledTimes(1) - - // Why: exits with no live PTYs left must not fire again — the drain - // signal marks the 1 -> 0 transition, not every teardown call. - markClaudePtyExited('seeded-pty-1') - expect(onDrained).toHaveBeenCalledTimes(1) - } finally { - unsubscribe() - } - }) - - it('notifies drain listeners when seed reconciliation releases the last live id', () => { - const onDrained = vi.fn() - const unsubscribe = onLiveClaudePtysDrained(onDrained) - try { - seedLiveClaudePtysFromPersistence(['seeded-pty-1']) - - confirmSeededClaudeLivePtys([]) - - expect(onDrained).toHaveBeenCalledTimes(1) - } finally { - unsubscribe() - } - }) - - it('stops notifying an unsubscribed drain listener', () => { - const onDrained = vi.fn() - const unsubscribe = onLiveClaudePtysDrained(onDrained) - unsubscribe() - - markClaudePtySpawned('live-claude-pty') - markClaudePtyExited('live-claude-pty') - - expect(onDrained).not.toHaveBeenCalled() - }) - - it('persists spawns and exits when persistence is attached', () => { - const addClaudeLivePtySessionId = vi.fn() - const removeClaudeLivePtySessionId = vi.fn() - attachClaudeLivePtyPersistence({ - addClaudeLivePtySessionId, - removeClaudeLivePtySessionId - }) - - markClaudePtySpawned('live-claude-pty') - expect(addClaudeLivePtySessionId).toHaveBeenCalledWith('live-claude-pty') - - markClaudePtyExited('live-claude-pty') - expect(removeClaudeLivePtySessionId).toHaveBeenCalledWith('live-claude-pty') - }) -}) diff --git a/src/main/claude-accounts/live-pty-gate.ts b/src/main/claude-accounts/live-pty-gate.ts deleted file mode 100644 index caab66c34308..000000000000 --- a/src/main/claude-accounts/live-pty-gate.ts +++ /dev/null @@ -1,174 +0,0 @@ -const liveClaudePtyIds = new Set() -// Why: ids restored from persistence at startup, not yet confirmed against the -// daemon. They keep the OAuth refresh gate closed so an early managed refresh -// cannot rotate the single-use refresh token out from under a Claude CLI that -// survived the app restart inside the daemon. -const seededUnconfirmedPtyIds = new Set() -let switchInProgress = false -// Woken by endClaudeAuthSwitch so a caller past the point of no return can wait the -// swap out instead of refusing. See whenClaudeAuthSwitchSettles. -const switchSettledListeners = new Set<() => void>() - -/** A managed account swap is a credential-file rewrite, not a network round trip; - * anything past this is a wedged switch, and refusing beats waiting forever. */ -export const CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS = 15_000 - -export type ClaudeLivePtyPersistence = { - addClaudeLivePtySessionId(sessionId: string): void - removeClaudeLivePtySessionId(sessionId: string): void -} - -let persistence: ClaudeLivePtyPersistence | null = null - -export function attachClaudeLivePtyPersistence(target: ClaudeLivePtyPersistence | null): void { - persistence = target -} - -// Why: a live claude defers the managed OAuth refresh ("Waiting for Claude -// session"); consumers need the 1 -> 0 transition to recover promptly instead -// of waiting out the usage-fetch failure backoff. -type LiveClaudePtyDrainListener = () => void -const drainListeners = new Set() - -export function onLiveClaudePtysDrained(listener: LiveClaudePtyDrainListener): () => void { - drainListeners.add(listener) - return () => drainListeners.delete(listener) -} - -function notifyDrainedOnTransition(hadLivePtys: boolean): void { - if (!hadLivePtys || liveClaudePtyIds.size > 0) { - return - } - for (const listener of drainListeners) { - listener() - } -} - -export function seedLiveClaudePtysFromPersistence(sessionIds: readonly string[]): void { - for (const sessionId of sessionIds) { - liveClaudePtyIds.add(sessionId) - seededUnconfirmedPtyIds.add(sessionId) - } -} - -export function hasSeededUnconfirmedClaudePtys(): boolean { - return seededUnconfirmedPtyIds.size > 0 -} - -/** - * Reconcile seeded ids against the daemon's live session list. Seeded ids the - * daemon no longer knows are dead — release them so they cannot defer OAuth - * refresh forever. Seeded ids that are still alive stay in the gate even if - * their pane never reattaches: that daemon process still owns the credentials. - */ -export function confirmSeededClaudeLivePtys(aliveSessionIds: readonly string[]): void { - const hadLivePtys = liveClaudePtyIds.size > 0 - const alive = new Set(aliveSessionIds) - for (const sessionId of seededUnconfirmedPtyIds) { - if (!alive.has(sessionId)) { - liveClaudePtyIds.delete(sessionId) - persistence?.removeClaudeLivePtySessionId(sessionId) - } - } - seededUnconfirmedPtyIds.clear() - notifyDrainedOnTransition(hadLivePtys) -} - -export function markClaudePtySpawned(ptyId: string): void { - liveClaudePtyIds.add(ptyId) - seededUnconfirmedPtyIds.delete(ptyId) - persistence?.addClaudeLivePtySessionId(ptyId) -} - -export function markClaudePtyExited(ptyId: string): void { - const hadLivePtys = liveClaudePtyIds.size > 0 - liveClaudePtyIds.delete(ptyId) - seededUnconfirmedPtyIds.delete(ptyId) - persistence?.removeClaudeLivePtySessionId(ptyId) - notifyDrainedOnTransition(hadLivePtys) -} - -/** - * Register a structured Claude child with the same gate the terminal path uses. - * - * The gate is what makes the managed OAuth refresh defer instead of rotating a - * single-use refresh token out from under a running Claude (runtime-auth-sync.ts). - * A structured session's child is as much a live Claude as a PTY's is, so it has to - * hold the gate too — otherwise a refresh mid-turn breaks its next API call while an - * identical terminal session is protected. - * - * Deliberately not persisted, unlike markClaudePtySpawned: these children are direct - * children of this process and cannot survive a restart, so seeding them back on the - * next launch would hold the gate closed for a process that is provably gone. - */ -export function markClaudeStructuredChildSpawned(childKey: string): void { - liveClaudePtyIds.add(structuredChildGateId(childKey)) -} - -export function markClaudeStructuredChildExited(childKey: string): void { - const hadLivePtys = liveClaudePtyIds.size > 0 - liveClaudePtyIds.delete(structuredChildGateId(childKey)) - notifyDrainedOnTransition(hadLivePtys) -} - -// Namespaced so a structured child can never collide with a daemon PTY session id, -// which confirmSeededClaudeLivePtys reconciles against the daemon's own list. -function structuredChildGateId(childKey: string): string { - return `claude-structured:${childKey}` -} - -export function hasLiveClaudePtys(): boolean { - return liveClaudePtyIds.size > 0 -} - -export function beginClaudeAuthSwitch(): void { - if (switchInProgress) { - throw new Error('A Claude account switch is already in progress.') - } - switchInProgress = true -} - -export function endClaudeAuthSwitch(): void { - const wasInProgress = switchInProgress - switchInProgress = false - if (!wasInProgress) { - return - } - // Each listener removes itself as it settles; Set iteration is defined over that. - for (const listener of switchSettledListeners) { - listener() - } -} - -/** - * Resolves `true` once no account switch is running, `false` if one is still running - * at the deadline. - * - * Exists for callers that have already done irreversible work — a structured acquire - * has closed the old child by the time it resolves its launch, so turning a switch - * into a refusal there strands the user with a dead session and no replacement. - * Waiting for the swap and then launching against it is the recoverable answer; - * refusing is only correct when nothing has been torn down yet. - */ -export function whenClaudeAuthSwitchSettles( - timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS -): Promise { - if (!switchInProgress) { - return Promise.resolve(true) - } - return new Promise((resolve) => { - const settle = (settled: boolean): void => { - switchSettledListeners.delete(listener) - clearTimeout(timer) - resolve(settled) - } - const listener = (): void => settle(true) - switchSettledListeners.add(listener) - const timer = setTimeout(() => settle(false), timeoutMs) - timer.unref?.() - }) -} - -export function isClaudeAuthSwitchInProgress(): boolean { - return switchInProgress -} diff --git a/src/main/claude-accounts/managed-auth-path.ts b/src/main/claude-accounts/managed-auth-path.ts deleted file mode 100644 index 2914b72e0334..000000000000 --- a/src/main/claude-accounts/managed-auth-path.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' -import { join, relative, resolve, sep } from 'node:path' -import { app } from 'electron' -import { writeFileAtomically } from '../codex-accounts/fs-utils' - -const MANAGED_AUTH_MARKER = '.orca-managed-claude-auth' - -export function getClaudeManagedAccountsRoot(): string { - return join(app.getPath('userData'), 'claude-accounts') -} - -export function resolveOwnedClaudeManagedAuthPath( - accountId: string, - candidatePath: string, - options: { adoptLegacyMarker?: boolean } = {} -): string | null { - const rootPath = getClaudeManagedAccountsRoot() - const resolvedCandidate = resolve(candidatePath) - if (!existsSync(resolvedCandidate) || !existsSync(rootPath)) { - return null - } - try { - if (lstatSync(resolvedCandidate).isSymbolicLink()) { - return null - } - const canonicalCandidate = realpathSync(resolvedCandidate) - const canonicalRoot = realpathSync(rootPath) - if ( - canonicalCandidate === canonicalRoot || - !canonicalCandidate.startsWith(canonicalRoot + sep) - ) { - return null - } - const relativePath = relative(canonicalRoot, canonicalCandidate) - const relativeParts = relativePath.split(sep) - const escaped = relativePath.startsWith('..') || relativePath.includes(`..${sep}`) - if ( - escaped || - relativeParts.length !== 2 || - relativeParts[0] !== accountId || - relativeParts[1] !== 'auth' - ) { - return null - } - const markerPath = join(canonicalCandidate, MANAGED_AUTH_MARKER) - const markerValid = isManagedAuthMarkerValid(markerPath, accountId) - if (!markerValid && options.adoptLegacyMarker) { - writeFileSync(markerPath, `${accountId}\n`, { encoding: 'utf-8', mode: 0o600, flag: 'wx' }) - } - if (!markerValid && !isManagedAuthMarkerValid(markerPath, accountId)) { - return null - } - return canonicalCandidate - } catch { - return null - } -} - -export function readClaudeManagedAuthFile( - managedAuthPath: string, - filename: '.credentials.json' | 'oauth-account.json' -): string | null { - const filePath = resolve(managedAuthPath, filename) - try { - if (!isOwnedChildFile(managedAuthPath, filePath)) { - return null - } - return readFileSync(filePath, 'utf-8') - } catch { - return null - } -} - -export function writeClaudeManagedAuthFile( - managedAuthPath: string, - filename: '.credentials.json' | 'oauth-account.json', - contents: string -): void { - const filePath = resolve(managedAuthPath, filename) - if (existsSync(filePath) && !isOwnedChildFile(managedAuthPath, filePath)) { - throw new Error('Managed Claude auth child file is not owned by Orca.') - } - writeFileAtomically(filePath, contents, { mode: 0o600 }) -} - -function isManagedAuthMarkerValid(markerPath: string, accountId: string): boolean { - try { - if ( - !existsSync(markerPath) || - lstatSync(markerPath).isSymbolicLink() || - !lstatSync(markerPath).isFile() - ) { - return false - } - return readFileSync(markerPath, 'utf-8').trim() === accountId - } catch { - return false - } -} - -function isOwnedChildFile(managedAuthPath: string, filePath: string): boolean { - if ( - !existsSync(filePath) || - lstatSync(filePath).isSymbolicLink() || - !lstatSync(filePath).isFile() - ) { - return false - } - const canonicalAuthPath = realpathSync(managedAuthPath) - const canonicalFilePath = realpathSync(filePath) - return canonicalFilePath.startsWith(canonicalAuthPath + sep) -} diff --git a/src/main/claude-accounts/oauth-refresh.test.ts b/src/main/claude-accounts/oauth-refresh.test.ts deleted file mode 100644 index dd5e01c245a5..000000000000 --- a/src/main/claude-accounts/oauth-refresh.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - applyRefreshedToken, - isOauthTokenExpiring, - parseClaudeOauthBlob, - readRefreshToken, - refreshClaudeOauthCredentials -} from './oauth-refresh' - -const { netFetchMock } = vi.hoisted(() => ({ - netFetchMock: vi.fn() -})) - -vi.mock('electron', () => ({ - net: { fetch: netFetchMock }, - session: { defaultSession: {} } -})) - -vi.mock('../network/proxy-settings', () => ({ - ensureElectronProxyFromEnvironment: vi.fn().mockResolvedValue({ source: 'none' }) -})) - -const NOW = 1_700_000_000_000 - -function credentials(overrides: Record = {}): string { - return JSON.stringify({ - claudeAiOauth: { - accessToken: 'old-access', - refreshToken: 'old-refresh', - expiresAt: NOW + 60 * 60 * 1000, - scopes: ['user:inference', 'user:profile'], - ...overrides - } - }) -} - -describe('parseClaudeOauthBlob', () => { - it('returns the oauth block', () => { - expect(parseClaudeOauthBlob(credentials())?.accessToken).toBe('old-access') - }) - - it('returns null for non-JSON or missing block', () => { - expect(parseClaudeOauthBlob('not json')).toBeNull() - expect(parseClaudeOauthBlob('{}')).toBeNull() - expect(parseClaudeOauthBlob('{"claudeAiOauth":[]}')).toBeNull() - }) -}) - -describe('readRefreshToken', () => { - it('reads a present token', () => { - expect(readRefreshToken(credentials())).toBe('old-refresh') - }) - - it('returns null for blank or missing tokens', () => { - expect(readRefreshToken(credentials({ refreshToken: ' ' }))).toBeNull() - expect(readRefreshToken(credentials({ refreshToken: undefined }))).toBeNull() - }) -}) - -describe('isOauthTokenExpiring', () => { - it('is false when well within validity', () => { - expect(isOauthTokenExpiring(credentials(), NOW)).toBe(false) - }) - - it('is true within the 5-minute buffer', () => { - expect(isOauthTokenExpiring(credentials({ expiresAt: NOW + 60 * 1000 }), NOW)).toBe(true) - }) - - it('is true when already expired', () => { - expect(isOauthTokenExpiring(credentials({ expiresAt: NOW - 1000 }), NOW)).toBe(true) - }) - - it('treats missing/non-numeric expiry as expiring', () => { - expect(isOauthTokenExpiring(credentials({ expiresAt: undefined }), NOW)).toBe(true) - expect(isOauthTokenExpiring(credentials({ expiresAt: 'soon' }), NOW)).toBe(true) - }) - - it('is false for credentials without an oauth block', () => { - expect(isOauthTokenExpiring('{}', NOW)).toBe(false) - }) -}) - -describe('applyRefreshedToken', () => { - it('rotates access + refresh token and recomputes expiry', () => { - const updated = applyRefreshedToken( - credentials(), - { access_token: 'new-access', expires_in: 3600, refresh_token: 'new-refresh' }, - NOW - ) - const oauth = parseClaudeOauthBlob(updated!)! - expect(oauth.accessToken).toBe('new-access') - expect(oauth.refreshToken).toBe('new-refresh') - expect(oauth.expiresAt).toBe(NOW + 3600 * 1000) - }) - - it('keeps the existing refresh token when the server does not rotate it', () => { - const updated = applyRefreshedToken( - credentials(), - { access_token: 'new-access', expires_in: 3600 }, - NOW - ) - expect(parseClaudeOauthBlob(updated!)!.refreshToken).toBe('old-refresh') - }) - - it('preserves unrelated top-level fields', () => { - const raw = JSON.stringify({ - claudeAiOauth: { accessToken: 'a', refreshToken: 'r' }, - somethingElse: { keep: true } - }) - const updated = applyRefreshedToken(raw, { access_token: 'b' }, NOW) - expect(JSON.parse(updated!).somethingElse).toEqual({ keep: true }) - }) - - it('splits scope string into scopes array', () => { - const updated = applyRefreshedToken( - credentials(), - { access_token: 'b', scope: 'user:inference user:profile' }, - NOW - ) - expect(parseClaudeOauthBlob(updated!)!.scopes).toEqual(['user:inference', 'user:profile']) - }) - - it('returns null when the response lacks an access token', () => { - expect(applyRefreshedToken(credentials(), {}, NOW)).toBeNull() - expect(applyRefreshedToken('not json', { access_token: 'b' }, NOW)).toBeNull() - }) -}) - -describe('refreshClaudeOauthCredentials', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('returns null without a refresh token (no network call)', async () => { - const result = await refreshClaudeOauthCredentials( - credentials({ refreshToken: undefined }), - NOW - ) - expect(result).toBeNull() - expect(netFetchMock).not.toHaveBeenCalled() - }) - - it('posts a form-urlencoded refresh grant and persists the rotation', async () => { - netFetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ - access_token: 'fresh-access', - expires_in: 3600, - refresh_token: 'fresh-refresh' - }) - }) - - const result = await refreshClaudeOauthCredentials(credentials(), NOW) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - const [url, init] = netFetchMock.mock.calls[0] - expect(url).toBe('https://platform.claude.com/v1/oauth/token') - expect(init.method).toBe('POST') - expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded') - const body = new URLSearchParams(init.body) - expect(body.get('grant_type')).toBe('refresh_token') - expect(body.get('refresh_token')).toBe('old-refresh') - expect(body.get('client_id')).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e') - - const oauth = parseClaudeOauthBlob(result!)! - expect(oauth.accessToken).toBe('fresh-access') - expect(oauth.refreshToken).toBe('fresh-refresh') - }) - - it('returns null on a non-ok response and logs the status for diagnosability', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - netFetchMock.mockResolvedValue({ ok: false, status: 429, json: async () => ({}) }) - expect(await refreshClaudeOauthCredentials(credentials(), NOW)).toBeNull() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('429')) - warn.mockRestore() - }) - - it('returns null when the request throws (never rejects)', async () => { - netFetchMock.mockRejectedValue(new Error('network down')) - await expect(refreshClaudeOauthCredentials(credentials(), NOW)).resolves.toBeNull() - }) -}) diff --git a/src/main/claude-accounts/oauth-refresh.ts b/src/main/claude-accounts/oauth-refresh.ts deleted file mode 100644 index cc717ef51ee8..000000000000 --- a/src/main/claude-accounts/oauth-refresh.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { net, session } from 'electron' -import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' - -// Why: the OAuth client id and token endpoint are the public Claude Code -// values, verified against the installed `claude` binary (2.1.177) and the -// claude-swap reference tool. Orca owns the refresh so a single-use refresh -// token is rotated and persisted atomically, instead of being scraped back -// after the CLI rotates it (the lossy path that strands stale tokens). -const OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token' -const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' - -// Refresh slightly ahead of expiry so a token doesn't expire mid-launch. The -// CLI uses the same 5-minute skew for its own refresh decision. -const OAUTH_EXPIRY_BUFFER_MS = 5 * 60 * 1000 -const REFRESH_TIMEOUT_MS = 10_000 - -type ClaudeOauthBlob = { - accessToken?: unknown - refreshToken?: unknown - expiresAt?: unknown - scopes?: unknown - [key: string]: unknown -} - -type ClaudeCredentials = { - claudeAiOauth?: ClaudeOauthBlob - [key: string]: unknown -} - -type TokenEndpointResponse = { - access_token?: unknown - expires_in?: unknown - refresh_token?: unknown - scope?: unknown -} - -/** - * Parse the `claudeAiOauth` object from a credentials JSON string. - * Returns null when the string is not parseable or lacks the OAuth block. - */ -export function parseClaudeOauthBlob(credentialsJson: string): ClaudeOauthBlob | null { - try { - const parsed = JSON.parse(credentialsJson) as ClaudeCredentials - const oauth = parsed?.claudeAiOauth - return oauth && typeof oauth === 'object' && !Array.isArray(oauth) ? oauth : null - } catch { - return null - } -} - -/** Read a stored refresh token, or null when absent/blank. */ -export function readRefreshToken(credentialsJson: string): string | null { - const oauth = parseClaudeOauthBlob(credentialsJson) - const token = oauth?.refreshToken - return typeof token === 'string' && token.trim() !== '' ? token.trim() : null -} - -/** - * Whether the stored access token is expired or within the refresh buffer. - * - * A missing/non-numeric `expiresAt` is treated as "needs refresh" so a blob - * with no usable expiry metadata still gets a proactive refresh attempt rather - * than being trusted indefinitely. `now` is injectable for tests. - */ -export function isOauthTokenExpiring(credentialsJson: string, now: number = Date.now()): boolean { - const oauth = parseClaudeOauthBlob(credentialsJson) - if (!oauth) { - return false - } - const expiresAt = oauth.expiresAt - if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) { - return true - } - return now + OAUTH_EXPIRY_BUFFER_MS >= expiresAt -} - -/** - * Merge a token-endpoint response into the stored credentials, returning the - * updated credentials JSON. Preserves every field the caller already had - * (including the refresh token when the server does not rotate it) and only - * overwrites what the response provides. Returns null on malformed input. - */ -export function applyRefreshedToken( - credentialsJson: string, - response: TokenEndpointResponse, - now: number = Date.now() -): string | null { - let parsed: ClaudeCredentials - try { - parsed = JSON.parse(credentialsJson) as ClaudeCredentials - } catch { - return null - } - const accessToken = response.access_token - if (typeof accessToken !== 'string' || accessToken.trim() === '') { - return null - } - const oauth: ClaudeOauthBlob = { ...parsed.claudeAiOauth } - oauth.accessToken = accessToken - if (typeof response.expires_in === 'number' && Number.isFinite(response.expires_in)) { - oauth.expiresAt = now + response.expires_in * 1000 - } - // Rotation: keep the existing refresh token unless the server issued a new - // one. Single-use refresh tokens make persisting the rotated value the whole - // point of owning refresh. - if (typeof response.refresh_token === 'string' && response.refresh_token.trim() !== '') { - oauth.refreshToken = response.refresh_token - } - if (typeof response.scope === 'string' && response.scope.trim() !== '') { - oauth.scopes = response.scope.split(' ') - } - parsed.claudeAiOauth = oauth - return JSON.stringify(parsed) -} - -/** - * Refresh the OAuth token for a stored credentials blob. - * - * Returns the updated credentials JSON (with the rotated refresh token and new - * access token) on success, or null on any failure. Never throws — callers - * treat null as "keep the existing credentials", so a transient network error - * is never worse than today's behavior. - */ -export async function refreshClaudeOauthCredentials( - credentialsJson: string, - now: number = Date.now() -): Promise { - const refreshToken = readRefreshToken(credentialsJson) - if (!refreshToken) { - return null - } - - await ensureElectronProxyFromEnvironment({ - proxySession: session.defaultSession, - probeUrl: OAUTH_TOKEN_URL - }).catch(() => {}) - - try { - // Why: the `claude` CLI posts grant_type=refresh_token as - // application/x-www-form-urlencoded with the public client id. net.fetch - // routes through Chromium's stack so the env proxy bridge above applies. - const res = await net.fetch(OAUTH_TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: OAUTH_CLIENT_ID - }).toString(), - signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS) - }) - if (!res.ok) { - // Why: surface the status (never the token) so a throttle (429) or a - // dead refresh token (400/401 invalid_grant) is diagnosable in the - // field, instead of a silent null that looks identical to success. - // Callers keep the existing credentials on null — a transient 429 just - // means the still-valid token is reused until the next attempt. - console.warn(`[claude-oauth-refresh] token endpoint returned ${res.status}`) - return null - } - const data = (await res.json()) as TokenEndpointResponse - return applyRefreshedToken(credentialsJson, data, now) - } catch (error) { - console.warn( - '[claude-oauth-refresh] token refresh request failed:', - error instanceof Error ? error.message : error - ) - return null - } -} diff --git a/src/main/claude-accounts/runtime-auth-path-materialization.test.ts b/src/main/claude-accounts/runtime-auth-path-materialization.test.ts deleted file mode 100644 index dbccd87cee5e..000000000000 --- a/src/main/claude-accounts/runtime-auth-path-materialization.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getDefaultSettings } from '../../shared/constants' - -const testState = { - fakeHomeDir: '', - previousConfigDir: undefined as string | undefined -} - -vi.mock('electron', () => ({ app: { getPath: () => testState.fakeHomeDir } })) - -vi.mock('node:os', async () => { - // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - const actual = await vi.importActual('node:os') - return { ...actual, homedir: () => testState.fakeHomeDir } -}) - -const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - -beforeEach(() => { - testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-rate-limit-path-')) - testState.previousConfigDir = process.env.CLAUDE_CONFIG_DIR - delete process.env.CLAUDE_CONFIG_DIR -}) - -afterEach(() => { - rmSync(testState.fakeHomeDir, { recursive: true, force: true }) - if (testState.previousConfigDir === undefined) { - delete process.env.CLAUDE_CONFIG_DIR - } else { - process.env.CLAUDE_CONFIG_DIR = testState.previousConfigDir - } - testState.fakeHomeDir = '' -}) - -describe('Claude runtime auth path materialization', () => { - it('does not create the config directory while preparing a background usage fetch', async () => { - const settings = { - ...getDefaultSettings(testState.fakeHomeDir), - disabledTuiAgents: ['claude'] as const, - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn() - } - const service = new ClaudeRuntimeAuthService(store as never) - - const preparation = await service.prepareForRateLimitFetch() - - expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude')) - expect(preparation.provenance).toBe('system') - expect(existsSync(preparation.configDir)).toBe(false) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts b/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts deleted file mode 100644 index 249e403b075b..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createClaudeCredentialsWithoutEmail, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readManagedCredentialsForTest, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('saves a verified file refresh but refuses to overwrite unreadable keychain state', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - testState.throwScopedKeychainRead = true - testState.throwLegacyKeychainRead = true - await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain read failed') - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - expect(testState.scopedKeychainCredentials).toBe(originalCredentials) - expect(testState.legacyKeychainCredentials).toBe(originalCredentials) - warn.mockRestore() - }) - - it('captures a fresh system-default snapshot when re-entering managed mode', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials1 = createClaudeCredentialsJson('system1@example.com', 'system1') - const systemCredentials2 = createClaudeCredentialsJson('system2@example.com', 'system2') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials1, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - writeFileSync(runtimeCredentialsPath, systemCredentials2, 'utf-8') - - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials2) - }) - - it('refreshes keychain and oauth snapshot surfaces when the credentials file is unchanged', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials1 = createClaudeCredentialsJson('system1@example.com', 'system1') - const systemCredentials2 = createClaudeCredentialsJson('system2@example.com', 'system2') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const systemOauthAccount1 = { accountUuid: 'system-account-1' } - const systemOauthAccount2 = { accountUuid: 'system-account-2' } - writeFileSync(runtimeCredentialsPath, systemCredentials1, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount1 })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials1 - testState.legacyKeychainCredentials = systemCredentials1 - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount2 })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials2 - testState.legacyKeychainCredentials = systemCredentials2 - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials1) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount2) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials2) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials2) - }) - - it('reads back refreshed credentials for the outgoing Claude account before switching', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original') - const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Original - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8') - settings.activeClaudeManagedAccountId = 'account-2' - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - }) - - it('switches accounts without persisting unverified live runtime credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original', 'org-a') - const unverifiedLiveCredentials = createClaudeCredentialsWithoutEmail('one-live', 'org-b') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two', 'org-c') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Original - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { - email: 'one@example.com', - organizationUuid: 'org-a' - }), - createClaudeAccount('account-2', managedAuthPath2, { - email: 'two@example.com', - organizationUuid: 'org-c' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - markClaudePtySpawned('live-claude-pty') - try { - writeFileSync(runtimeCredentialsPath, unverifiedLiveCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = 'account-2' - - await service.syncForCurrentSelection() - } finally { - markClaudePtyExited('live-claude-pty') - } - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Original) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - if (process.platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBe(account2Credentials) - } - }) - - it('routes refreshed Claude credentials to the matching managed account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original') - const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Original - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-2' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - // A stale account-1 Claude process refreshed the shared runtime file after - // Orca selected account-2. Persist that refresh to account-1, then restore - // the selected account in the shared Claude runtime credentials. - writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8') - testState.scopedKeychainCredentials = account1Refreshed - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - if (process.platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBe(account2Credentials) - expect(testState.legacyKeychainCredentials).toBe(account2Credentials) - } - }) - - it('rejects stale cold-start read-back for inactive matching account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1ManagedNewer = createClaudeCredentialsJson( - 'one@example.com', - 'one-managed-newer', - null, - 5_000 - ) - const account1RuntimeStale = createClaudeCredentialsJson( - 'one@example.com', - 'one-runtime-stale', - null, - 2_000 - ) - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two', null, 1_000) - writeFileSync(runtimeCredentialsPath, account1RuntimeStale, 'utf-8') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1ManagedNewer - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-2' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1ManagedNewer) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - }) - - it('rejects ambiguous Claude read-back instead of choosing a managed account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('same@example.com', 'same-original') - const refreshedCredentials = createClaudeCredentialsJson('same@example.com', 'same-refreshed') - const activeCredentials = createClaudeCredentialsJson('active@example.com', 'active') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - originalCredentials - ) - const managedAuthPath3 = createManagedClaudeAuth( - testState.userDataDir, - 'account-3', - activeCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'same@example.com' }), - createClaudeAccount('account-3', managedAuthPath3, { email: 'active@example.com' }) - ], - activeClaudeManagedAccountId: 'account-3' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - testState.scopedKeychainCredentials = refreshedCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(originalCredentials) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(activeCredentials) - if (process.platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBe(activeCredentials) - expect(testState.legacyKeychainCredentials).toBe(activeCredentials) - } - }) - - it('rejects same-email read-back when another account needs organization proof', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const noOrgCredentials = createClaudeCredentialsJson('same@example.com', 'no-org') - const orgCredentials = createClaudeCredentialsJson('same@example.com', 'org', 'org-b') - const refreshedWithoutOrg = createClaudeCredentialsJson('same@example.com', 'refreshed') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - noOrgCredentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - orgCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { - email: 'same@example.com', - organizationUuid: 'org-b' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedWithoutOrg, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(noOrgCredentials) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(orgCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(noOrgCredentials) - }) - - it('ignores unrelated org-scoped accounts when reading back no-org credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const account1RefreshedCredentials = createClaudeCredentialsJson( - 'one@example.com', - 'one-refreshed' - ) - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two', 'org-b') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { - email: 'two@example.com', - organizationUuid: 'org-b' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( - account1RefreshedCredentials - ) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) - }) - - it('rejects same-email read-back with conflicting organization for no-org accounts', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const noOrgCredentials = createClaudeCredentialsJson('same@example.com', 'no-org') - const orgCredentials = createClaudeCredentialsJson('same@example.com', 'org', 'org-b') - const conflictingOrgCredentials = createClaudeCredentialsJson( - 'same@example.com', - 'conflicting-org', - 'org-c' - ) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - noOrgCredentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - orgCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { - email: 'same@example.com', - organizationUuid: 'org-b' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, conflictingOrgCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(noOrgCredentials) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(orgCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(noOrgCredentials) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts b/src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts deleted file mode 100644 index efd650618b41..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts +++ /dev/null @@ -1,611 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createClaudeCredentialsWithoutEmail, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readManagedCredentialsForTest, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('restores the system default after rejecting unverifiable managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const originalCredentials = createClaudeCredentialsWithoutEmail('original') - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - }) - - it('restores system default after same-identity managed Claude refresh on deselect', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed', 'org-1') - const externalCredentials = createClaudeCredentialsJson('user@example.com', 'external', 'org-1') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { - organizationUuid: 'org-1' - }) - ] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(externalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - }) - - it('preserves external stale Claude credentials without writing them to managed storage', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const selectedCredentials = createClaudeCredentialsJson('selected@example.com', 'selected') - const staleCredentials = createClaudeCredentialsJson('stale@example.com', 'stale') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'selected@example.com' }) - ] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('does not persist unverifiable stale Claude credentials into another active account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const staleUnverifiableCredentials = createClaudeCredentialsWithoutEmail('stale') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = 'account-2' - await service.syncForCurrentSelection() - writeFileSync(runtimeCredentialsPath, staleUnverifiableCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - }) - - it('materializes only the selected managed account into shared Claude runtime files', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one-token') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two-token') - const account1Oauth = '{"accountUuid":"account-1","emailAddress":"one@example.com"}\n' - const account2Oauth = '{"accountUuid":"account-2","emailAddress":"two@example.com"}\n' - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials, - account1Oauth - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials, - account2Oauth - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ - accountUuid: 'account-1', - emailAddress: 'one@example.com' - }) - - settings.activeClaudeManagedAccountId = 'account-2' - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ - accountUuid: 'account-2', - emailAddress: 'two@example.com' - }) - - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ - accountUuid: 'account-1', - emailAddress: 'one@example.com' - }) - - // Why: switching rewrites only the shared Claude runtime surface; the - // managed account files remain per-account sources of truth. - expect(readFileSync(join(managedAuthPath1, '.credentials.json'), 'utf-8')).toBe( - account1Credentials - ) - expect(readFileSync(join(managedAuthPath2, '.credentials.json'), 'utf-8')).toBe( - account2Credentials - ) - expect(readFileSync(join(managedAuthPath1, 'oauth-account.json'), 'utf-8')).toBe(account1Oauth) - expect(readFileSync(join(managedAuthPath2, 'oauth-account.json'), 'utf-8')).toBe(account2Oauth) - }) - - it('does not carry the reauth read-back skip across Claude account switches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const account2RefreshedCredentials = createClaudeCredentialsJson( - 'two@example.com', - 'two-refreshed' - ) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - service.clearLastWrittenCredentialsJson() - settings.activeClaudeManagedAccountId = 'account-2' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account2RefreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe( - account2RefreshedCredentials - ) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2RefreshedCredentials) - }) - - it('does not apply inactive-account Claude reauth skip to the active account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const account1RefreshedCredentials = createClaudeCredentialsJson( - 'one@example.com', - 'one-refreshed' - ) - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') - service.clearLastWrittenCredentialsJson('account-2') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( - account1RefreshedCredentials - ) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) - }) - - it('keeps external Claude logout when deselecting managed account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - rmSync(runtimeCredentialsPath, { force: true }) - testState.activeKeychainCredentials = null - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - }) - - it('preserves unknown runtime auth when invalid active account has no ownership proof', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: systemOauthAccount, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'missing-account' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - activeClaudeManagedAccountId: 'missing-account' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(store.updateSettings).toHaveBeenCalledWith({ - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - }) - expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude')) - expect(preparation.stripAuthEnv).toBe(false) - expect(preparation.provenance).toBe('system') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'missing-account' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('restores system snapshot when active account credentials are missing but runtime matches account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: systemOauthAccount, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation.provenance).toBe('system') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('keeps missing-managed selection until cleanup can retry after keychain failure', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: systemOauthAccount, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - testState.throwScopedKeychainWrite = true - const failedPreparation = await service.prepareForClaudeLaunch() - - expect(failedPreparation.provenance).toBe('system') - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - - testState.throwScopedKeychainWrite = false - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation.provenance).toBe('system') - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('restores missing-managed oauth metadata when only keychain proves ownership', async () => { - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: systemOauthAccount, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.prepareForClaudeLaunch() - - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('preserves unknown runtime auth when invalid active account has no system snapshot', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'missing-account' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - activeClaudeManagedAccountId: 'missing-account' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation.provenance).toBe('system') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'missing-account' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts b/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts deleted file mode 100644 index 7a80c5be002a..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts +++ /dev/null @@ -1,712 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - expectedRuntimeConfigDir, - readManagedCredentialsForTest, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('reads back refreshed active keychain credentials on macOS', async () => { - if (process.platform !== 'darwin') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - testState.scopedKeychainCredentials = refreshedCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('reads back refreshed legacy keychain credentials on old Claude Code builds', async () => { - if (process.platform !== 'darwin') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - testState.scopedKeychainCredentials = originalCredentials - testState.legacyKeychainCredentials = refreshedCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - expect(testState.scopedKeychainCredentials).toBe(refreshedCredentials) - expect(testState.legacyKeychainCredentials).toBe(refreshedCredentials) - }) - - it('rejects stale legacy keychain credentials after a fresher managed write', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', null, 1_000) - const managedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'managed-newer', - null, - 2_000 - ) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = staleCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(managedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(testState.scopedKeychainCredentials).toBe(managedCredentials) - expect(testState.legacyKeychainCredentials).toBe(managedCredentials) - }) - - it('uses fresher file credentials when scoped keychain is stale', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', null, 1_000) - const managedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'managed', - null, - 2_000 - ) - const refreshedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'refreshed', - null, - 3_000 - ) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - testState.scopedKeychainCredentials = staleCredentials - testState.legacyKeychainCredentials = managedCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - expect(testState.scopedKeychainCredentials).toBe(refreshedCredentials) - expect(testState.legacyKeychainCredentials).toBe(refreshedCredentials) - }) - - it('restores system default when mismatched Claude keychain auth appears before deselect', async () => { - if (process.platform !== 'darwin') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalKeychainCredentials = createClaudeCredentialsJson( - 'external@example.com', - 'external' - ) - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - testState.scopedKeychainCredentials = externalKeychainCredentials - testState.legacyKeychainCredentials = externalKeychainCredentials - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(externalKeychainCredentials) - expect(testState.legacyKeychainCredentials).toBe(externalKeychainCredentials) - }) - - it('restores unchanged scoped keychain while preserving external legacy keychain logout', async () => { - if (process.platform !== 'darwin') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - testState.legacyKeychainCredentials = null - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBeNull() - }) - - it('preserves external scoped keychain login while restoring unchanged legacy keychain', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalScopedCredentials = createClaudeCredentialsJson( - 'external@example.com', - 'external' - ) - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { readActiveClaudeKeychainCredentials } = await import('./keychain') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - expect(readActiveClaudeKeychainCredentials).toHaveBeenCalledWith(expectedRuntimeConfigDir()) - - testState.scopedKeychainCredentials = externalScopedCredentials - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(externalScopedCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('restores oauth metadata when credentials prove managed ownership', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - const externalOauthAccount = { accountUuid: 'external-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: externalOauthAccount })}\n`, - 'utf-8' - ) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores owned oauth metadata when external credentials change but metadata does not', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores owned oauth metadata when only keychain proves managed ownership', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - 'null\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('uses managed credentials as ownership baseline after restart with partial external changes', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalScopedCredentials = createClaudeCredentialsJson( - 'external@example.com', - 'external' - ) - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: null, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - testState.scopedKeychainCredentials = externalScopedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(externalScopedCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('preserves external file and scoped login while restoring unchanged legacy keychain', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - testState.scopedKeychainCredentials = externalCredentials - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) - expect(testState.scopedKeychainCredentials).toBe(externalCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('restores legacy keychain credentials from old system-default snapshots', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: null, - keychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('does not recapture managed file as system default after a partial keychain write failure', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - testState.throwRuntimeKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow('runtime keychain write failed') - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - testState.throwRuntimeKeychainWrite = false - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('restores scoped keychain after legacy runtime keychain write fails', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - testState.throwLegacyRuntimeKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'legacy runtime keychain write failed' - ) - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('keeps managed ownership baseline when keychain restore fails and retries', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - testState.throwScopedKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain write failed') - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(managedCredentials) - testState.throwScopedKeychainWrite = false - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('preserves previous keychain snapshot after restart following partial restore failure', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - let service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - testState.throwScopedKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain write failed') - - testState.throwScopedKeychainWrite = false - service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('does not enter managed mode when keychain snapshot capture fails', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - testState.throwScopedKeychainRead = true - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'Cannot capture current Claude Keychain credentials' - ) - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - warn.mockRestore() - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts b/src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts deleted file mode 100644 index 85d68d30e8b8..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readManagedCredentialsForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from './oauth-refresh' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('does not clobber fresh Claude credentials after clearLastWrittenCredentialsJson', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const reauthedCredentials = createClaudeCredentialsJson('user@example.com', 'reauthed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - testState.managedKeychainCredentials.set('account-1', reauthedCredentials) - writeFileSync(join(managedAuthPath, '.credentials.json'), reauthedCredentials, 'utf-8') - service.clearLastWrittenCredentialsJson() - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(reauthedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(reauthedCredentials) - }) - - it('leaves host system-default credentials untouched before launch', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const expired = createClaudeCredentialsJson('system@example.com', 'system-expired', null, 1_000) - writeFileSync(runtimeCredentialsPath, expired, 'utf-8') - testState.scopedKeychainCredentials = expired - testState.legacyKeychainCredentials = expired - const settings = createSettings({ - activeClaudeManagedAccountId: null - }) - const store = createStore(settings) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(true) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue( - createClaudeCredentialsJson('system@example.com', 'system-refreshed') - ) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(isOauthTokenExpiring).not.toHaveBeenCalled() - expect(refreshClaudeOauthCredentials).not.toHaveBeenCalled() - expect(preparation.provenance).toBe('system') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(expired) - expect(testState.scopedKeychainCredentials).toBe(expired) - expect(testState.legacyKeychainCredentials).toBe(expired) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(false) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null) - }) - - it('proactively refreshes and persists an expiring account on switch-in', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Stale = createClaudeCredentialsJson('one@example.com', 'one-stale', null, 1_000) - const account1Refreshed = createClaudeCredentialsJson( - 'one@example.com', - 'one-refreshed', - null, - 9_999_999_999_999 - ) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Stale - ) - // Start on the system default (no active managed account), then switch in. - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) - ], - activeClaudeManagedAccountId: null - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - // Now switch into account-1: token is expiring, so the service must refresh - // and persist the rotation before materializing. - vi.mocked(isOauthTokenExpiring).mockReturnValueOnce(true) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValueOnce(account1Refreshed) - store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) - await service.syncForCurrentSelection() - - expect(refreshClaudeOauthCredentials).toHaveBeenCalledWith(account1Stale) - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Refreshed) - }) - - it('refreshes the active account with an expired token when no Claude PTY is live', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const expired = createClaudeCredentialsJson('one@example.com', 'one-expired', null, 1_000) - const refreshedCreds = createClaudeCredentialsJson( - 'one@example.com', - 'one-refreshed', - null, - 9_999_999_999_999 - ) - const managedAuthPath1 = createManagedClaudeAuth(testState.userDataDir, 'account-1', expired) - // account-1 is ALREADY the active account (seeded), so this is a re-sync of - // the active account, not a switch-in — the path that was previously missed. - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(true) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(refreshedCreds) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(refreshClaudeOauthCredentials).toHaveBeenCalled() - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(refreshedCreds) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCreds) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(false) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null) - }) - - it('does not refresh the active account while a Claude PTY is live', async () => { - const expired = createClaudeCredentialsJson('one@example.com', 'one-expired', null, 1_000) - const managedAuthPath1 = createManagedClaudeAuth(testState.userDataDir, 'account-1', expired) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(true) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue( - createClaudeCredentialsJson('one@example.com', 'should-not-be-used', null, 9_999_999_999_999) - ) - - const { markClaudePtySpawned, markClaudePtyExited } = await import('./live-pty-gate') - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - - markClaudePtySpawned('pty-live-1') - try { - const preparation = await service.prepareForRateLimitFetch() - // A live Claude owns the credentials; refreshing here would race its - // rotation, so the proactive refresh must be skipped entirely. - expect(refreshClaudeOauthCredentials).not.toHaveBeenCalled() - expect(preparation.managedRefreshDeferredByLivePty).toBe(true) - } finally { - markClaudePtyExited('pty-live-1') - vi.mocked(isOauthTokenExpiring).mockReturnValue(false) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null) - } - }) - - it('adopts a rotated-refresh-token runtime credential on cold-start read-back', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - // Same expiry on both sides (cold start), but the runtime refresh token has - // rotated — proof the CLI refreshed. Must be read back into managed storage. - const managedCredentials = createClaudeCredentialsJson( - 'one@example.com', - 'one-old', - null, - 3_000 - ) - const runtimeRotated = `${JSON.stringify({ - claudeAiOauth: { - email: 'one@example.com', - accessToken: 'one-rotated', - refreshToken: 'one-rotated-refresh', - expiresAt: 3_000 - } - })}\n` - writeFileSync(runtimeCredentialsPath, runtimeRotated, 'utf-8') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(runtimeRotated) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts b/src/main/claude-accounts/runtime-auth-service-materialization.test.ts deleted file mode 100644 index 94bdab9b4d82..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - expectedRuntimeConfigDir, - hostPlatform, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - chmodSync, - existsSync, - lstatSync, - mkdirSync, - readFileSync, - rmSync, - statSync, - symlinkSync, - writeFileSync -} from 'node:fs' -import { join } from 'node:path' -import type { GlobalSettings } from '../../shared/global-settings-types' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('creates and recreates the runtime directory when materializing managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - rmSync(expectedRuntimeConfigDir(), { recursive: true, force: true }) - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - - rmSync(expectedRuntimeConfigDir(), { recursive: true, force: true }) - await service.prepareForClaudeLaunch() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(testState.runtimeWriteConfigDir).toBe(expectedRuntimeConfigDir()) - }) - - it('restores system default instead of materializing corrupt managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth(testState.userDataDir, 'account-1', '{not-json') - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('restores system default instead of materializing wrong-shaped managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth(testState.userDataDir, 'account-1', '{}\n') - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('does not materialize managed credentials from unowned auth paths', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') - mkdirSync(unownedAuthPath, { recursive: true }) - writeFileSync(join(unownedAuthPath, '.credentials.json'), managedCredentials, 'utf-8') - writeFileSync( - join(unownedAuthPath, 'oauth-account.json'), - `${JSON.stringify({ accountUuid: 'account-1' })}\n`, - 'utf-8' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', unownedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - }) - - it('adopts canonical legacy managed auth paths without existing markers', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.credentials.json'), managedCredentials, 'utf-8') - writeFileSync( - join(managedAuthPath, 'oauth-account.json'), - '{"accountUuid":"account-1"}\n', - 'utf-8' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - const markerPath = join(managedAuthPath, '.orca-managed-claude-auth') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(lstatSync(markerPath).isFile()).toBe(true) - expect(readFileSync(markerPath, 'utf-8')).toBe('account-1\n') - }) - - it('rejects symlinked managed credential children', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const escapedCredentials = createClaudeCredentialsJson('user@example.com', 'escaped') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - const escapedCredentialsPath = join(testState.fakeHomeDir, 'escaped-credentials.json') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - join(managedAuthPath, 'oauth-account.json'), - '{"accountUuid":"account-1"}\n', - 'utf-8' - ) - writeFileSync(escapedCredentialsPath, escapedCredentials, 'utf-8') - symlinkSync(escapedCredentialsPath, join(managedAuthPath, '.credentials.json')) - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - }) - - it('restores system auth when switching from an owned account to an unowned account', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const ownedCredentials = createClaudeCredentialsJson('owned@example.com', 'owned') - const unownedCredentials = createClaudeCredentialsJson('unowned@example.com', 'unowned') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const ownedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - ownedCredentials - ) - const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') - mkdirSync(unownedAuthPath, { recursive: true }) - writeFileSync(join(unownedAuthPath, '.credentials.json'), unownedCredentials, 'utf-8') - let settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', ownedAuthPath, { email: 'owned@example.com' }), - createClaudeAccount('account-2', unownedAuthPath, { email: 'unowned@example.com' }) - ], - activeClaudeManagedAccountId: null - }) - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { - ...settings, - ...updates, - notifications: { - ...settings.notifications, - ...updates.notifications - } - } - return settings - }) - } - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(ownedCredentials) - - store.updateSettings({ activeClaudeManagedAccountId: 'account-2' }) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores system auth when the previously synced account is no longer in settings', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const ownedCredentials = createClaudeCredentialsJson('owned@example.com', 'owned') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const ownedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - ownedCredentials - ) - const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') - mkdirSync(unownedAuthPath, { recursive: true }) - let settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', ownedAuthPath, { email: 'owned@example.com' }) - ], - activeClaudeManagedAccountId: null - }) - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { - ...settings, - ...updates, - notifications: { - ...settings.notifications, - ...updates.notifications - } - } - return settings - }) - } - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(ownedCredentials) - - store.updateSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-2', unownedAuthPath, { email: 'unowned@example.com' }) - ], - activeClaudeManagedAccountId: 'account-2' - }) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores system auth when switching from an owned account to missing credentials', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = join(testState.userDataDir, 'claude-accounts', 'account-2', 'auth') - mkdirSync(managedAuthPath2, { recursive: true }) - writeFileSync(join(managedAuthPath2, '.orca-managed-claude-auth'), 'account-2\n', 'utf-8') - writeFileSync( - join(managedAuthPath2, 'oauth-account.json'), - '{"accountUuid":"account-2"}\n', - 'utf-8' - ) - let settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: null - }) - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { - ...settings, - ...updates, - notifications: { - ...settings.notifications, - ...updates.notifications - } - } - return settings - }) - } - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) - - store.updateSettings({ activeClaudeManagedAccountId: 'account-2' }) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('removes runtime credentials when deselecting with a missing system-default snapshot', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(runtimeCredentialsPath)).toBe(false) - if (process.platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - } - }) - - it('preserves unreadable runtime credentials instead of overwriting unknown connector grants', async () => { - if (hostPlatform === 'win32') { - return - } - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const rotatedCredentials = createClaudeCredentialsJson('user@example.com', 'rotated') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - testState.managedKeychainCredentials.set('account-1', rotatedCredentials) - writeFileSync(join(managedAuthPath, '.credentials.json'), rotatedCredentials, 'utf-8') - chmodSync(runtimeCredentialsPath, 0o000) - try { - await expect(service.syncForCurrentSelection()).rejects.toMatchObject({ code: 'EACCES' }) - } finally { - if (existsSync(runtimeCredentialsPath)) { - chmodSync(runtimeCredentialsPath, 0o600) - } - warn.mockRestore() - } - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(testState.scopedKeychainCredentials).toBe(managedCredentials) - }) - - it('tightens credential file permissions when unchanged content is already present', async () => { - if (hostPlatform === 'win32') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - chmodSync(runtimeCredentialsPath, 0o644) - await service.syncForCurrentSelection() - - expect(statSync(runtimeCredentialsPath).mode & 0o777).toBe(0o600) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts b/src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts deleted file mode 100644 index 2806d44e82a5..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts +++ /dev/null @@ -1,607 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createClaudeCredentialsWithoutEmail, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readManagedCredentialsForTest, - resetRuntimeAuthTestState, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('reads back refreshed credentials when the Claude identity still matches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('rejects wrong-shaped refreshed credentials during read-back', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const malformedRefresh = `${JSON.stringify({ - claudeAiOauth: { - email: 'user@example.com', - expiresAt: Date.now() + 120_000 - } - })}\n` - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, malformedRefresh, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) - }) - - it('reads back verified same-account credentials on first sync after restart', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'original', - null, - 1_000 - ) - const refreshedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'refreshed', - null, - 2_000 - ) - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('rejects older same-account Claude credentials on first sync after restart', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const staleRuntimeCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'stale', - null, - 1_000 - ) - const managedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'managed-newer', - null, - 2_000 - ) - writeFileSync(runtimeCredentialsPath, staleRuntimeCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(managedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - }) - - it('rejects runtime read-back from a different Claude identity', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected') - const staleCredentials = createClaudeCredentialsJson('other@example.com', 'stale') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects runtime read-back from the same Claude email in a different organization', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects same-email Claude read-back using stored managed organization identity', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects same-email Claude read-back using stored oauth-account organization identity', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials, - '{"organizationUuid":"org-b"}\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects no-email Claude read-back when organization identity conflicts', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') - const staleCredentials = createClaudeCredentialsWithoutEmail('stale', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects no-email refreshed credentials even when organization identity matches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) - }) - - it('preserves rejected runtime refreshes while a Claude terminal is live on Windows', async () => { - setPlatform('win32') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - markClaudePtySpawned('live-claude-pty') - try { - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - } finally { - markClaudePtyExited('live-claude-pty') - } - - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('does not persist live runtime refreshes with conflicting organization identity', async () => { - setPlatform('win32') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') - const conflictingCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-b') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - markClaudePtySpawned('live-claude-pty') - try { - writeFileSync(runtimeCredentialsPath, conflictingCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(conflictingCredentials) - } finally { - markClaudePtyExited('live-claude-pty') - } - }) - - it('rematerializes managed credentials over a wiped runtime blob while a Claude terminal is live', async () => { - setPlatform('win32') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') - // Why: Claude CLI wipes tokens in place (keeps identity fields) after an - // invalid_grant refresh — the exact blob shape this regression guards. - const parsedOriginal = JSON.parse(originalCredentials) as { - claudeAiOauth: Record - } - const wipedCredentials = `${JSON.stringify({ - claudeAiOauth: { - ...parsedOriginal.claudeAiOauth, - accessToken: '', - refreshToken: '', - expiresAt: 0 - } - })}\n` - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - markClaudePtySpawned('live-claude-pty') - try { - writeFileSync(runtimeCredentialsPath, wipedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) - } finally { - markClaudePtyExited('live-claude-pty') - } - }) - - it('rejects unverifiable refreshed runtime credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsWithoutEmail('original') - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) - }) - - it('reads back identity-less refreshed credentials when the refresh token matches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const refreshToken = 'same-managed-refresh-token' - const originalCredentials = createClaudeCredentialsWithoutEmail('original', null, { - expiresAt: 1_000, - refreshToken - }) - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', null, { - expiresAt: 2_000, - refreshToken - }) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-from-account' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('reads back identity-less refreshed credentials when runtime oauth metadata matches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const originalCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'original', - 'org-a', - 1_000 - ) - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', null, { - expiresAt: 2_000, - refreshToken: 'rotated-refresh-token' - }) - writeFileSync(runtimeConfigPath, '{}\n', 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials, - '{"accountUuid":"account-uuid-1","emailAddress":"user@example.com","organizationUuid":"org-a"}\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { - email: 'user@example.com', - organizationUuid: 'org-a' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('rules out other identity-less accounts with different refresh tokens', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1RefreshToken = 'account-1-refresh-token' - const account1OriginalCredentials = createClaudeCredentialsWithoutEmail('account-1', null, { - expiresAt: 1_000, - refreshToken: account1RefreshToken - }) - const account1RefreshedCredentials = createClaudeCredentialsWithoutEmail( - 'account-1-refreshed', - null, - { - expiresAt: 2_000, - refreshToken: account1RefreshToken - } - ) - const account2Credentials = createClaudeCredentialsWithoutEmail('account-2', null, { - refreshToken: 'account-2-refresh-token' - }) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1OriginalCredentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1), - createClaudeAccount('account-2', managedAuthPath2, { email: 'other@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( - account1RefreshedCredentials - ) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts b/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts deleted file mode 100644 index 4066063c5625..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createElectronMock, - createKeychainMock, - createOauthRefreshMock, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { - createSharedCredentialRuntime, - sharedFields, - withSharedFields -} from './runtime-auth-shared-credentials-fixture' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readFileSync, writeFileSync } from 'node:fs' - -vi.mock('electron', () => createElectronMock()) -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) -vi.mock('./keychain', () => createKeychainMock()) -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { ...actual, homedir: () => testState.fakeHomeDir } -}) - -describe('shared connector credential write failures', () => { - beforeEach(resetRuntimeAuthTestState) - afterEach(cleanupRuntimeAuthTestState) - - it('keeps the committed baseline across a partial rollback so a rotated grant can be retried', async () => { - const { service, settings, runtimePath, first } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } - } - testState.scopedKeychainCredentials = withSharedFields(first, rotated) - settings.activeClaudeManagedAccountId = 'second' - testState.throwLegacyRuntimeKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'legacy runtime keychain write failed' - ) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) - testState.throwLegacyRuntimeKeychainWrite = false - settings.activeClaudeManagedAccountId = 'first' - await service.forceMaterializeCurrentSelectionForRollback() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).claudeAiOauth.accessToken).toBe('first') - expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) - expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) - }) - - it('preserves disjoint live grants when the first switch fails after writing only the scoped item', async () => { - const { service, settings, runtimePath, system } = await createSharedCredentialRuntime() - const figma = sharedFields.mcpOAuth.figma - testState.scopedKeychainCredentials = withSharedFields(system, { - ...sharedFields, - mcpOAuth: { figma } - }) - testState.legacyKeychainCredentials = withSharedFields(system, { - ...sharedFields, - mcpOAuth: { notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } } - }) - writeFileSync(runtimePath, system) - settings.activeClaudeManagedAccountId = 'first' - testState.throwLegacyRuntimeKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'legacy runtime keychain write failed' - ) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).mcpOAuth).toEqual({ - figma, - notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } - }) - testState.throwLegacyRuntimeKeychainWrite = false - await service.syncForCurrentSelection() - const runtime = JSON.parse(readFileSync(runtimePath, 'utf-8')) - expect(runtime.mcpOAuth).toEqual({ - figma, - notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } - }) - expect(runtime.claudeAiOauth.accessToken).toBe('first') - expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) - expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts b/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts deleted file mode 100644 index f3bb1f001378..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createOauthRefreshMock, - createStore, - readManagedCredentialsForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { - createSharedCredentialRuntime, - sharedFields, - withSharedFields -} from './runtime-auth-shared-credentials-fixture' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) -vi.mock('./keychain', () => createKeychainMock()) -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { ...actual, homedir: () => testState.fakeHomeDir } -}) - -describe('shared Claude connector credentials', () => { - beforeEach(resetRuntimeAuthTestState) - afterEach(cleanupRuntimeAuthTestState) - - it.each(['scoped', 'legacy', 'file'] as const)( - 'preserves connector grants stored only in %s when there is no previous Orca write', - async (surface) => { - const { service, settings, runtimePath, system } = await createSharedCredentialRuntime() - testState.scopedKeychainCredentials = system - testState.legacyKeychainCredentials = system - writeFileSync(runtimePath, system) - if (surface === 'scoped') { - testState.scopedKeychainCredentials = withSharedFields(system) - } else if (surface === 'legacy') { - testState.legacyKeychainCredentials = withSharedFields(system) - } else { - writeFileSync(runtimePath, withSharedFields(system)) - } - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) - } - ) - - it.each(['darwin', 'linux', 'win32'] as const)( - 'excludes connector secrets when capturing a managed account on %s', - async (platform) => { - const { firstPath, first } = await createSharedCredentialRuntime(platform) - const { ClaudeManagedAuthStorage } = await import('./claude-managed-auth-storage') - await new ClaudeManagedAuthStorage().writeCredentials( - 'first', - firstPath, - withSharedFields(first) - ) - expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( - JSON.parse(first) - ) - } - ) - - it.each(['scoped', 'legacy', 'file'] as const)( - 'propagates connector revocations from the %s surface and does not resurrect frozen account grants', - async (surface) => { - const { service, settings, runtimePath, first, secondPath, second } = - await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - if (surface === 'scoped') { - testState.scopedKeychainCredentials = first - } else if (surface === 'legacy') { - testState.legacyKeychainCredentials = first - } else { - writeFileSync(runtimePath, first) - } - testState.managedKeychainCredentials.set('second', withSharedFields(second)) - writeFileSync(join(secondPath, '.credentials.json'), withSharedFields(second)) - settings.activeClaudeManagedAccountId = 'second' - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toEqual(JSON.parse(second)) - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toEqual(JSON.parse(second)) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toEqual(JSON.parse(second)) - } - ) - - it('preserves grants refreshed only in the keychain when returning to the system default', async () => { - const { service, settings, runtimePath, first, system } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'rotated', refreshToken: 'rotated' } } - } - testState.legacyKeychainCredentials = withSharedFields(first, rotated) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(rotated) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) - const nextRotation = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'rotated-again', refreshToken: 'rotated-again' } } - } - testState.scopedKeychainCredentials = withSharedFields(system, nextRotation) - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(nextRotation) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(nextRotation) - }) - - it.each(['darwin', 'linux', 'win32'] as const)( - 'keeps connector grants through account switches, syncs, restart, and deselect on %s', - async (platform) => { - const state = await createSharedCredentialRuntime(platform) - const { service, settings, runtimePath, first, second, firstPath, secondPath } = state - for (const id of ['first', 'second', 'first']) { - settings.activeClaudeManagedAccountId = id - await service.syncForCurrentSelection() - await service.syncForCurrentSelection() - const runtime = JSON.parse(readFileSync(runtimePath, 'utf-8')) - expect(runtime).toMatchObject(sharedFields) - expect(runtime.claudeAiOauth.accessToken).toBe(id) - if (platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) - expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) - } - } - expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( - JSON.parse(first) - ) - expect(JSON.parse(readManagedCredentialsForTest('second', secondPath) ?? '')).toEqual( - JSON.parse(second) - ) - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'rotated-access', refreshToken: 'rotated-refresh' } } - } - const live = withSharedFields(first, rotated) - writeFileSync(runtimePath, live) - testState.scopedKeychainCredentials = live - testState.legacyKeychainCredentials = live - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. - const restarted = new ClaudeRuntimeAuthService(createStore(settings) as never) - await restarted.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await restarted.syncForCurrentSelection() - const restored = JSON.parse(readFileSync(runtimePath, 'utf-8')) - expect(restored).toMatchObject(rotated) - expect(restored.claudeAiOauth.accessToken).toBe('system') - if (platform === 'darwin') { - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(rotated) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) - } - } - ) - - it.each(['scoped', 'legacy', 'file'] as const)( - 'preserves MCP rotations written only to the %s surface while adopting a Claude refresh', - async (surface) => { - const { service, settings, runtimePath, firstPath } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const refreshed = createClaudeCredentialsJson( - 'first@example.com', - 'refreshed', - null, - Date.now() + 120_000 - ) - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } - } - const live = withSharedFields(refreshed, rotated) - if (surface === 'scoped') { - testState.scopedKeychainCredentials = live - } - if (surface === 'legacy') { - testState.legacyKeychainCredentials = live - } - if (surface === 'file') { - writeFileSync(runtimePath, live) - } - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) - expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( - JSON.parse(refreshed) - ) - } - ) - - it('preserves conflicting MCP grants after restart even when the Claude account token is newer', async () => { - const { service, settings, runtimePath, firstPath } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const refreshed = createClaudeCredentialsJson( - 'first@example.com', - 'refreshed', - null, - Date.now() + 120_000 - ) - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } - } - testState.legacyKeychainCredentials = withSharedFields(refreshed, rotated) - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. - const restarted = new ClaudeRuntimeAuthService(createStore(settings) as never) - await expect(restarted.syncForCurrentSelection()).rejects.toThrow( - 'live connector credentials conflict' - ) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(sharedFields) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) - expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( - JSON.parse(refreshed) - ) - }) - - it('leaves all credentials untouched when the active keychain cannot be read', async () => { - const { service, settings, runtimePath } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const before = readFileSync(runtimePath, 'utf-8') - settings.activeClaudeManagedAccountId = 'second' - testState.throwScopedKeychainRead = true - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain read failed') - expect(readFileSync(runtimePath, 'utf-8')).toBe(before) - expect(testState.scopedKeychainCredentials).toBe(before) - expect(testState.legacyKeychainCredentials).toBe(before) - testState.throwScopedKeychainRead = false - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).claudeAiOauth.accessToken).toBe('second') - warn.mockRestore() - }) - - it.each(['scoped', 'legacy', 'file'] as const)( - 'refuses to overwrite malformed live credentials in the %s surface', - async (surface) => { - const { service, settings, runtimePath } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - if (surface === 'scoped') { - testState.scopedKeychainCredentials = '{broken' - } else if (surface === 'legacy') { - testState.legacyKeychainCredentials = '{broken' - } else { - writeFileSync(runtimePath, '{broken') - } - const fileBefore = readFileSync(runtimePath, 'utf-8') - const scopedBefore = testState.scopedKeychainCredentials - const legacyBefore = testState.legacyKeychainCredentials - settings.activeClaudeManagedAccountId = 'second' - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'Cannot preserve malformed Claude runtime credentials' - ) - expect(readFileSync(runtimePath, 'utf-8')).toBe(fileBefore) - expect(testState.scopedKeychainCredentials).toBe(scopedBefore) - expect(testState.legacyKeychainCredentials).toBe(legacyBefore) - } - ) - - it('keeps newly authorized MCP grants when returning to a signed-out system default', async () => { - const { service, settings, runtimePath, first } = await createSharedCredentialRuntime() - // A missing system credential is a signed-out default, with no connector grants yet. - rmSync(runtimePath) - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const live = withSharedFields(first) - writeFileSync(runtimePath, live) - testState.scopedKeychainCredentials = live - testState.legacyKeychainCredentials = live - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - expect(existsSync(runtimePath)).toBe(true) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toEqual(sharedFields) - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toEqual(sharedFields) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toEqual(sharedFields) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts b/src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts deleted file mode 100644 index 1df2430b8f6a..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts +++ /dev/null @@ -1,550 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('treats corrupt system-default snapshots as missing and clears owned runtime auth', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync(snapshotPath, '{not-json', 'utf-8') - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(snapshotPath)).toBe(false) - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(readRuntimeOauthAccountForTest()).toBeNull() - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - warn.mockRestore() - }) - - it('treats wrong-shaped system-default snapshots as missing and clears owned runtime auth', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: { token: 'system' }, - keychainCredentialsJson: managedCredentials, - scopedKeychainCredentialsJson: { token: 'scoped' }, - legacyKeychainCredentialsJson: managedCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(snapshotPath)).toBe(false) - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(readRuntimeOauthAccountForTest()).toBeNull() - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - warn.mockRestore() - }) - - it('treats snapshots missing all keychain credential fields as invalid', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: null, - configOauthAccount: null, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(snapshotPath)).toBe(false) - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - warn.mockRestore() - }) - - it('treats snapshots missing credentialsJson as invalid and clears missing-managed runtime auth', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - configOauthAccount: null, - keychainCredentialsJson: null, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.prepareForClaudeLaunch() - - expect(existsSync(snapshotPath)).toBe(true) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - warn.mockRestore() - }) - - it('clears missing-managed oauth metadata when only keychain proves ownership', async () => { - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.prepareForClaudeLaunch() - - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('preserves missing-managed oauth metadata without credential ownership proof', async () => { - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - const managedOauthAccount = { accountUuid: 'account-1' } - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: managedOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = externalCredentials - testState.legacyKeychainCredentials = externalCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.prepareForClaudeLaunch() - - expect(readRuntimeOauthAccountForTest()).toEqual(managedOauthAccount) - expect(testState.scopedKeychainCredentials).toBe(externalCredentials) - expect(testState.legacyKeychainCredentials).toBe(externalCredentials) - }) - - it('preserves invalid external runtime oauth metadata when deselecting', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync(runtimeConfigPath, `${JSON.stringify({})}\n`, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - 'null\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') - }) - - it('preserves invalid runtime config while materializing managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(testState.scopedKeychainCredentials).toBe(managedCredentials) - expect(testState.legacyKeychainCredentials).toBe(managedCredentials) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') - }) - - it('preserves non-object runtime config while materializing managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync(runtimeConfigPath, '[]', 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('[]') - }) - - it('does not use skipped oauth writes as ownership proof on deselect', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - const managedOauthAccount = { accountUuid: 'account-1' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - `${JSON.stringify(managedOauthAccount)}\n` - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: managedOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = externalCredentials - testState.legacyKeychainCredentials = externalCredentials - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(managedOauthAccount) - expect(testState.scopedKeychainCredentials).toBe(externalCredentials) - expect(testState.legacyKeychainCredentials).toBe(externalCredentials) - }) - - it('preserves external oauth logout when managed oauth metadata is null', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'system-account' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - 'null\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - rmSync(runtimeCredentialsPath, { force: true }) - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - writeFileSync(runtimeConfigPath, `${JSON.stringify({})}\n`, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(readRuntimeOauthAccountForTest()).toBeNull() - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - }) - - it('restores reordered owned oauth metadata using stable json equality', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account', emailAddress: 'system@example.com' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - '{"accountUuid":"account-1","emailAddress":"user@example.com"}\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync( - runtimeConfigPath, - '{"oauthAccount":{"emailAddress":"user@example.com","accountUuid":"account-1"}}\n', - 'utf-8' - ) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores owned oauth metadata during rollback after removing the added account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - '{"accountUuid":"account-1"}\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - settings.claudeManagedAccounts = [] - await service.forceMaterializeCurrentSelectionForRollback() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-test-harness.ts b/src/main/claude-accounts/runtime-auth-service-test-harness.ts deleted file mode 100644 index 874e7534ff1a..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-test-harness.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { getDefaultSettings } from '../../shared/constants' -import type { GlobalSettings } from '../../shared/global-settings-types' -import type { ClaudeManagedAccount } from '../../shared/managed-account-types' - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') -export const hostPlatform = process.platform -export const testState = { - userDataDir: '', - fakeHomeDir: '', - activeKeychainCredentials: null as string | null, - scopedKeychainCredentials: null as string | null, - legacyKeychainCredentials: null as string | null, - throwScopedKeychainRead: false, - throwLegacyKeychainRead: false, - throwRuntimeKeychainWrite: false, - throwLegacyRuntimeKeychainWrite: false, - throwScopedKeychainWrite: false, - runtimeWriteConfigDir: null as string | null, - managedKeychainCredentials: new Map() -} - -export function expectedRuntimeConfigDir(): string { - return join(testState.fakeHomeDir, '.claude') -} - -export function createElectronMock() { - return { - app: { - getPath: () => testState.userDataDir - } - } -} - -// Why: these tests exercise materialize/read-back/snapshot logic, not the -// network OAuth refresh (covered by oauth-refresh.test.ts). Default the token -// to "not expiring" so the proactive switch-in refresh never fires here and -// existing expectations hold; individual tests can override these mocks. -export function createOauthRefreshMock() { - return { - isOauthTokenExpiring: vi.fn(() => false), - refreshClaudeOauthCredentials: vi.fn(async () => null) - } -} - -export function createKeychainMock() { - return { - readActiveClaudeKeychainCredentials: vi.fn(async (configDir?: string) => { - if (configDir) { - if (configDir !== expectedRuntimeConfigDir()) { - return testState.legacyKeychainCredentials - } - return testState.scopedKeychainCredentials ?? testState.legacyKeychainCredentials - } - return testState.legacyKeychainCredentials - }), - writeActiveClaudeKeychainCredentials: vi.fn(async (contents: string, configDir?: string) => { - if (configDir) { - if (configDir !== expectedRuntimeConfigDir()) { - throw new Error(`Unexpected Claude config dir: ${configDir}`) - } - if (testState.throwScopedKeychainWrite) { - throw new Error('scoped keychain write failed') - } - testState.scopedKeychainCredentials = contents - } else { - testState.legacyKeychainCredentials = contents - } - testState.activeKeychainCredentials = contents - }), - deleteActiveClaudeKeychainCredentials: vi.fn(async () => { - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - testState.activeKeychainCredentials = null - }), - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async (configDir?: string) => { - if (configDir) { - if (configDir !== expectedRuntimeConfigDir()) { - throw new Error(`Unexpected Claude config dir: ${configDir}`) - } - testState.scopedKeychainCredentials = null - } else { - testState.legacyKeychainCredentials = null - } - testState.activeKeychainCredentials = null - }), - readActiveClaudeKeychainCredentialsStrict: vi.fn(async (configDir?: string) => - configDir - ? (() => { - if (testState.throwScopedKeychainRead) { - throw new Error('scoped keychain read failed') - } - return configDir === expectedRuntimeConfigDir() - ? testState.scopedKeychainCredentials - : null - })() - : (() => { - if (testState.throwLegacyKeychainRead) { - throw new Error('legacy keychain read failed') - } - return testState.legacyKeychainCredentials - })() - ), - writeActiveClaudeKeychainCredentialsForRuntime: vi.fn( - async (contents: string, configDir: string) => { - if (configDir !== expectedRuntimeConfigDir()) { - throw new Error(`Unexpected Claude config dir: ${configDir}`) - } - if (testState.throwRuntimeKeychainWrite) { - throw new Error('runtime keychain write failed') - } - testState.runtimeWriteConfigDir = configDir - testState.scopedKeychainCredentials = contents - if (testState.throwLegacyRuntimeKeychainWrite) { - throw new Error('legacy runtime keychain write failed') - } - testState.legacyKeychainCredentials = contents - testState.activeKeychainCredentials = contents - } - ), - readManagedClaudeKeychainCredentials: vi.fn( - async (accountId: string) => testState.managedKeychainCredentials.get(accountId) ?? null - ), - writeManagedClaudeKeychainCredentials: vi.fn(async (accountId: string, contents: string) => { - testState.managedKeychainCredentials.set(accountId, contents) - }) - } -} - -export function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -/** Shared `beforeEach` body: fresh platform, module registry, and temp homes. */ -export function resetRuntimeAuthTestState(): void { - setPlatform('darwin') - vi.resetModules() - vi.clearAllMocks() - testState.activeKeychainCredentials = null - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - testState.throwScopedKeychainRead = false - testState.throwLegacyKeychainRead = false - testState.throwRuntimeKeychainWrite = false - testState.throwLegacyRuntimeKeychainWrite = false - testState.throwScopedKeychainWrite = false - testState.runtimeWriteConfigDir = null - testState.managedKeychainCredentials.clear() - testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-claude-runtime-')) - testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-home-')) - mkdirSync(join(testState.fakeHomeDir, '.claude'), { recursive: true }) -} - -/** Shared `afterEach` body. */ -export function cleanupRuntimeAuthTestState(): void { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - rmSync(testState.userDataDir, { recursive: true, force: true }) - rmSync(testState.fakeHomeDir, { recursive: true, force: true }) -} - -export function createSettings(overrides: Partial = {}): GlobalSettings { - return { - ...getDefaultSettings(testState.fakeHomeDir), - ...overrides - } -} - -export function createStore(settings: GlobalSettings) { - return { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { - ...settings, - ...updates, - notifications: { - ...settings.notifications, - ...updates.notifications - } - } - return settings - }) - } -} - -export function createManagedClaudeAuth( - rootDir: string, - accountId: string, - credentialsJson: string, - oauthAccountJson = `{"accountUuid":"${accountId}"}\n` -): string { - const managedAuthPath = join(rootDir, 'claude-accounts', accountId, 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), credentialsJson, 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), oauthAccountJson, 'utf-8') - testState.managedKeychainCredentials.set(accountId, credentialsJson) - return managedAuthPath -} - -export function createClaudeAccount( - id: string, - managedAuthPath: string, - overrides: Partial = {} -): ClaudeManagedAccount { - return { - id, - email: 'user@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1, - ...overrides - } -} - -export function createClaudeCredentialsJson( - email: string, - accessToken: string, - organizationUuid: string | null = null, - expiresAt = Date.now() + 60_000 -): string { - return `${JSON.stringify({ - claudeAiOauth: { - email, - ...(organizationUuid ? { organizationUuid } : {}), - accessToken, - refreshToken: `${accessToken}-refresh`, - expiresAt - } - })}\n` -} - -export function createClaudeCredentialsWithoutEmail( - accessToken: string, - organizationUuid: string | null = null, - options: { expiresAt?: number; refreshToken?: string } = {} -): string { - return `${JSON.stringify({ - claudeAiOauth: { - ...(organizationUuid ? { organizationUuid } : {}), - accessToken, - refreshToken: options.refreshToken ?? `${accessToken}-refresh`, - expiresAt: options.expiresAt ?? Date.now() + 60_000 - } - })}\n` -} - -export function readManagedCredentialsForTest( - accountId: string, - managedAuthPath: string -): string | null { - if (process.platform === 'darwin') { - return testState.managedKeychainCredentials.get(accountId) ?? null - } - return readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8') -} - -export function readRuntimeOauthAccountForTest(): unknown { - const configPath = join(testState.fakeHomeDir, '.claude.json') - if (!existsSync(configPath)) { - return null - } - return ( - (JSON.parse(readFileSync(configPath, 'utf-8')) as Record).oauthAccount ?? null - ) -} diff --git a/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts b/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts deleted file mode 100644 index 61c3323b6327..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - resetRuntimeAuthTestState, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import type * as ClaudeProfileRouting from '../../shared/claude-profile-routing' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('uses account WSL runtime for untargeted Claude preparation instead of stale terminal WSL settings', async () => { - setPlatform('win32') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => null, - toWindowsWslPath: (value: string) => value - })) - const ubuntuAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'ubuntu-account', - createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') - ) - const settings = createSettings({ - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Ubuntu', - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Debian', - claudeManagedAccounts: [ - createClaudeAccount('ubuntu-account', ubuntuAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' - }) - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'ubuntu-account' } - } - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - expect(service.getRuntimeConfigDir({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - ubuntuAuthPath - ) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation).toMatchObject({ - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth', - provenance: 'managed:ubuntu-account:wsl:Ubuntu', - stripAuthEnv: true - }) - }) - - it('uses the global WSL runtime for untargeted Claude preparation under auto', async () => { - setPlatform('win32') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => null, - toWindowsWslPath: (value: string) => value - })) - const ubuntuAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'ubuntu-account', - createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') - ) - const settings = createSettings({ - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, - claudeManagedAccounts: [ - createClaudeAccount('ubuntu-account', ubuntuAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' - }) - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'ubuntu-account' } - } - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation).toMatchObject({ - runtime: 'wsl', - wslDistro: 'Ubuntu', - provenance: 'managed:ubuntu-account:wsl:Ubuntu' - }) - }) - - it('ignores a persisted WSL account-runtime pin on non-Windows hosts', async () => { - setPlatform('darwin') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => null, - toWindowsWslPath: (value: string) => value - })) - const settings = createSettings({ - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Ubuntu' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation).toMatchObject({ - runtime: 'host', - wslDistro: null, - provenance: 'system', - stripAuthEnv: false - }) - }) - - it('keeps untargeted Claude preparation on host when account runtime is host', async () => { - setPlatform('win32') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => null, - toWindowsWslPath: (value: string) => value - })) - const settings = createSettings({ - localAccountRuntime: 'host', - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Debian' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation).toMatchObject({ - runtime: 'host', - wslDistro: null, - provenance: 'system', - stripAuthEnv: false - }) - }) - - it('clears a selected WSL managed account when its credentials are missing', async () => { - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/account-1/auth' - }) - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch({ - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - - expect(store.updateSettings).toHaveBeenCalledWith({ - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } - }) - expect(preparation.runtime).toBe('wsl') - expect(preparation.provenance).toBe('wsl:Ubuntu:system') - expect(preparation.stripAuthEnv).toBe(true) - }) - - it('uses the default distro selection for WSL-default Claude preparation', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => join(testState.userDataDir, 'wsl-home'), - toWindowsWslPath: (value: string) => value - })) - const ubuntuAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'ubuntu-account', - createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') - ) - const debianAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'debian-account', - createClaudeCredentialsJson('debian@example.com', 'debian-token') - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('ubuntu-account', ubuntuAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' - }), - createClaudeAccount('debian-account', debianAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Debian', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/debian/auth' - }) - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'ubuntu-account', Debian: 'debian-account' } - } - }) - const store = createStore(settings) - - try { - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch({ - runtime: 'wsl', - wslDistro: null - }) - - expect(preparation).toMatchObject({ - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth', - provenance: 'managed:ubuntu-account:wsl:Ubuntu', - stripAuthEnv: true - }) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('routes a WSL distro through its guest router, and a failed guest publish never fails a select', async () => { - setPlatform('win32') - const wslRouter = { - prepareLaunch: vi.fn(async (distro: string) => ({ runtime: 'wsl', wslDistro: distro })), - publish: vi.fn(async () => { - throw new Error('distro is gone') - }), - runningDistros: vi.fn(async () => []) - } - vi.doMock('../../shared/claude-profile-routing', async (original) => ({ - ...(await original()), - claudeProfileRoutingEnabled: () => true - })) - vi.doMock('./claude-profile-wsl-router', () => ({ - ClaudeWslProfileRouter: function ClaudeWslProfileRouter() { - return wslRouter - } - })) - try { - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const store = createStore(createSettings()) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only getSettings/updateSettings, which the harness store implements. - const service = new ClaudeRuntimeAuthService(store as never) - await expect( - service.prepareForClaudeLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - ).resolves.toMatchObject({ wslDistro: 'Ubuntu' }) - await expect( - service.syncForCurrentSelection({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - ).resolves.toBeUndefined() - expect(wslRouter.publish).toHaveBeenCalledWith('Ubuntu') - } finally { - vi.doUnmock('../../shared/claude-profile-routing') - vi.doUnmock('./claude-profile-wsl-router') - } - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts new file mode 100644 index 000000000000..1cfc4c7d3857 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/global-settings-types' + +const fakes = vi.hoisted(() => ({ + host: { + prepareLaunch: async () => ({ runtime: 'host' }), + publish: () => {}, + systemDefaultHome: () => '/host/.claude' + }, + wsl: { + prepareLaunch: async (distro: string) => ({ runtime: 'wsl', wslDistro: distro }), + publish: async (_distro: string): Promise => {}, + runningDistros: async (): Promise => [] + } +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getPath: () => '/data/orca' }) +})) +vi.mock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: (distro: string) => `/wsl/${distro}/home` +})) +vi.mock('./claude-profile-router', () => ({ + ClaudeProfileRouter: function ClaudeProfileRouter() { + return fakes.host + } +})) +vi.mock('./claude-profile-wsl-router', () => ({ + ClaudeWslProfileRouter: function ClaudeWslProfileRouter() { + return fakes.wsl + } +})) +vi.mock('./claude-profile-installed-router', () => ({ installClaudeProfileRouter: () => {} })) + +import { ClaudeRuntimeAuthService } from './runtime-auth-service' + +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +function service(platformName: NodeJS.Platform, overrides: Partial = {}) { + Object.defineProperty(process, 'platform', { configurable: true, value: platformName }) + const settings = { ...getDefaultSettings('/home/user'), ...overrides } + return new ClaudeRuntimeAuthService({ getSettings: () => settings }) +} + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) +afterEach(() => { + Object.defineProperty(process, 'platform', platform) + vi.restoreAllMocks() +}) + +describe('ClaudeRuntimeAuthService', () => { + it('keeps a stale WSL account setting on the host off Windows', async () => { + const auth = service('darwin', { localAccountRuntime: 'wsl', localAccountWslDistro: 'Ubuntu' }) + expect(auth.getRuntimeConfigDir()).toBe('/host/.claude') + await expect(auth.prepareForClaudeLaunch()).resolves.toEqual({ runtime: 'host' }) + }) + + it('routes a WSL target that names no distro to the default distro', async () => { + const auth = service('win32') + await expect(auth.prepareForClaudeLaunch({ runtime: 'wsl', wslDistro: null })).resolves.toEqual( + { runtime: 'wsl', wslDistro: 'Ubuntu' } + ) + expect(auth.getRuntimeConfigDir({ runtime: 'wsl', wslDistro: null })).toBe( + '/wsl/Ubuntu/home/.claude' + ) + }) + + it('never fails a select over a failed guest publish, and publishes one pass at a time', async () => { + const auth = service('win32') + vi.spyOn(fakes.wsl, 'publish').mockRejectedValue(new Error('distro is gone')) + await expect( + auth.syncForCurrentSelection({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ).resolves.toBeUndefined() + + const order: string[] = [] + let release = () => {} + const gate = new Promise((resolve) => (release = resolve)) + vi.spyOn(fakes.host, 'publish').mockImplementation(() => order.push('host')) + vi.spyOn(fakes.wsl, 'runningDistros') + .mockImplementationOnce(async () => { + await gate + order.push('first guests') + return [] + }) + .mockResolvedValue([]) + const first = auth.publishAll() + const second = auth.publishAll() + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(order).toEqual(['host']) + release() + await Promise.all([first, second]) + expect(order).toEqual(['host', 'first guests', 'host']) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index adbbce30da01..b15bffbc4829 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,164 +1,156 @@ +import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' -import { claudeProfileRoutingEnabled } from '../../shared/claude-profile-routing' +import { toWindowsWslPath } from '../../shared/wsl-paths' +import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' +import type { Store } from '../persistence' +import { getDefaultWslDistro, getWslHome } from '../wsl' import { ClaudeProfileRouter } from './claude-profile-router' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' -import { - getClaudeProfileRouter, - installClaudeProfileRouter -} from './claude-profile-installed-router' -import type { Store } from '../persistence' -import { - getSelectedClaudeAccountIdForTarget, - type ClaudeAccountSelectionTarget -} from './runtime-selection' -import { ClaudeRuntimeAuthSync } from './runtime-auth/runtime-auth-sync' +import { installClaudeProfileRouter } from './claude-profile-installed-router' +import type { ClaudeAccountSelectionTarget } from './runtime-selection' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' -function routerFor(target: ClaudeAccountSelectionTarget): ClaudeProfileRouter | undefined { - return target.runtime === 'wsl' ? undefined : getClaudeProfileRouter() -} +/** `configDir` is the CLAUDE_CONFIG_DIR value; `readPath` is where this host reads it (UNC for WSL). */ +export type ClaudeAccountFolder = { configDir: string; readPath: string } -export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { +/** Routes Claude launches on this host and its WSL distros to the selected account folder. */ +export class ClaudeRuntimeAuthService { + readonly router: ClaudeProfileRouter private readonly wslRouter?: ClaudeWslProfileRouter - - constructor(store: Store) { - super(store) - if (claudeProfileRoutingEnabled()) { - const args = { - getSettings: () => store.getSettings(), - dataRoot: getAppEnvironment().getPath('userData') - } - installClaudeProfileRouter(new ClaudeProfileRouter(args)) - this.wslRouter = process.platform === 'win32' ? new ClaudeWslProfileRouter(args) : undefined - } - this.initializeLastSyncedState() - void this.safeSyncForCurrentSelection() + private readonly dataRoot = getAppEnvironment().getPath('userData') + private mutationQueue: Promise = Promise.resolve() + + constructor(private readonly store: Pick) { + const args = { getSettings: () => store.getSettings(), dataRoot: this.dataRoot } + this.router = new ClaudeProfileRouter(args) + installClaudeProfileRouter(this.router) + this.wslRouter = process.platform === 'win32' ? new ClaudeWslProfileRouter(args) : undefined + void this.publishAll().catch((error: unknown) => { + console.warn('[claude-runtime-auth] Failed to publish the Claude account selection:', error) + }) } - async prepareForClaudeLaunch( + prepareForClaudeLaunch( target?: ClaudeAccountSelectionTarget ): Promise { - const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() - const wsl = this.wslRouteFor(effectiveTarget) - if (wsl) { - return wsl.router.prepareLaunch(wsl.distro) - } - const router = routerFor(effectiveTarget) - if (router) { - return router.prepareLaunch() - } - await this.syncForCurrentSelection(effectiveTarget) - return this.getPreparation(effectiveTarget) + const wsl = this.wslRouteFor(target) + return wsl ? wsl.router.prepareLaunch(wsl.distro) : this.router.prepareLaunch() } async prepareForRateLimitFetch( target?: ClaudeAccountSelectionTarget ): Promise { - const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() - const wsl = this.wslRouteFor(effectiveTarget) - if (wsl) { - return wsl.router.preparation(wsl.distro) - } - const router = routerFor(effectiveTarget) - if (router) { - return router.preparation() + const wsl = this.wslRouteFor(target) + try { + return await (wsl ? wsl.router.preparation(wsl.distro) : this.router.preparation()) + } catch (error) { + // Why not thrown: one usage cycle polls every provider, so one account must not stop it. + return { + configDir: '', + envPatch: {}, + stripAuthEnv: true, + provenance: 'profile:unavailable', + usageError: error instanceof Error ? error.message : String(error) + } } - await this.syncForCurrentSelection(effectiveTarget) - return this.getPreparation(effectiveTarget) } + /** Rewrites the which-account file for `target` after its selection changed. */ async syncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { await this.serializeMutation(async () => { - const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() - const wsl = this.wslRouteFor(effectiveTarget) - const router = routerFor(effectiveTarget) + const wsl = this.wslRouteFor(target) if (wsl) { await this.publishWsl(wsl.router, wsl.distro) - } else if (router) { - router.publish() } else { - await this.doSyncForCurrentSelection(effectiveTarget) + this.router.publish() } }) } - /** Null when the target is not a WSL distro routed by account folders. */ - private wslRouteFor( - target: ClaudeAccountSelectionTarget - ): { router: ClaudeWslProfileRouter; distro: string } | null { - const distro = - target.runtime === 'wsl' ? this.resolveWslDefaultTarget(target).wslDistro?.trim() : null - return this.wslRouter && distro ? { router: this.wslRouter, distro } : null - } - - // Why never thrown: a guest Orca cannot reach also cannot run a pane, and a deleted distro must - // not block removing its accounts. The next select, or a start while it runs, rewrites it. - private async publishWsl(router: ClaudeWslProfileRouter, distro: string): Promise { - await router.publish(distro).catch((error: unknown) => { - console.warn(`[claude-profile] Could not update the Claude account in WSL ${distro}:`, error) + /** Startup and rollback republish only running distros: neither may boot a stopped one. */ + async publishAll(): Promise { + await this.serializeMutation(async () => { + this.router.publish() + const router = this.wslRouter + if (router) { + const distros = await router.runningDistros() + await Promise.all(distros.map((distro) => this.publishWsl(router, distro))) + } }) } - /** Startup and rollback republish only running distros: neither may boot a stopped one. */ - private async publishRunningWslDistros(): Promise { - const router = this.wslRouter - if (router) { - const distros = await router.runningDistros() - await Promise.all(distros.map((distro) => this.publishWsl(router, distro))) + /** Creates and sets up an account's folder so Claude can sign in to it. */ + async prepareAccountFolder( + accountId: string, + target: ClaudeAccountSelectionTarget + ): Promise { + const wsl = this.wslRouteFor(target) + if (!wsl) { + const home = await this.router.prepareAccount(accountId) + return { configDir: home, readPath: home } } + const home = await wsl.router.prepareAccount(wsl.distro, accountId) + return { configDir: home, readPath: toWindowsWslPath(home, wsl.distro) } } - async forceMaterializeCurrentSelectionForRollback(): Promise { - await this.serializeMutation(async () => { - const router = getClaudeProfileRouter() - if (router) { - router.publish() - await this.publishRunningWslDistros() - return - } - const settings = this.store.getSettings() - if (!settings.activeClaudeManagedAccountId) { - const previousAccount = this.getActiveAccount( - settings.claudeManagedAccounts, - this.lastSyncedAccountId - ) - await this.restoreSystemDefaultSnapshot( - previousAccount ? await this.readManagedCredentials(previousAccount) : null, - previousAccount ? await this.readManagedOauthAccount(previousAccount) : undefined - ) - this.lastSyncedAccountId = null - return - } - await this.doSyncForCurrentSelection() + async removeAccountFolder( + accountId: string, + target: ClaudeAccountSelectionTarget + ): Promise { + const wsl = this.wslRouteFor(target) + if (!wsl) { + await this.router.removeAccount(accountId) + return + } + // Why never thrown: a deleted distro must not block removing its accounts. + await wsl.router.removeAccount(wsl.distro, accountId).catch((error: unknown) => { + console.warn( + `[claude-profile] Could not delete the Claude account folder in WSL ${wsl.distro}:`, + error + ) }) } + /** System default's folder: skills and plugins install there and are linked into accounts. */ getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { - return this.getPreparation(target).configDir + const effective = this.resolveTarget(target) + const home = + effective.runtime === 'wsl' && effective.wslDistro ? getWslHome(effective.wslDistro) : null + return home ? join(home, '.claude') : this.router.systemDefaultHome() } - private initializeLastSyncedState(): void { - const settings = this.store.getSettings() - this.lastSyncedAccountId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) + // Why never thrown: a guest Orca cannot reach also cannot run a pane, and a deleted distro must + // not block removing its accounts. The next select, or a start while it runs, rewrites it. + private async publishWsl(router: ClaudeWslProfileRouter, distro: string): Promise { + await router.publish(distro).catch((error: unknown) => { + console.warn(`[claude-profile] Could not update the Claude account in WSL ${distro}:`, error) + }) } - private async safeSyncForCurrentSelection(): Promise { - try { - const router = getClaudeProfileRouter() - if (!router) { - await this.syncForCurrentSelection() - return - } - // Why serialized: an account change during startup must not be overwritten by this older read. - await this.serializeMutation(async () => { - router.publish() - await this.publishRunningWslDistros() - }) - } catch (error) { - console.warn('[claude-runtime-auth] Failed to sync runtime auth state:', error) + /** Null when the target is not a WSL distro this host can route. */ + private wslRouteFor( + target?: ClaudeAccountSelectionTarget + ): { router: ClaudeWslProfileRouter; distro: string } | null { + const effective = this.resolveTarget(target) + const distro = effective.runtime === 'wsl' ? effective.wslDistro?.trim() : null + return this.wslRouter && distro ? { router: this.wslRouter, distro } : null + } + + /** A WSL target that names no distro means the default distro, as Claude launches it there. */ + private resolveTarget(target?: ClaudeAccountSelectionTarget): ClaudeAccountSelectionTarget { + const effective = + target ?? + // Why: stale cross-platform WSL pins must stay on the host off Windows. + (process.platform === 'win32' + ? resolveLocalAccountRuntimeTarget(this.store.getSettings()) + : { runtime: 'host' as const }) + if (effective.runtime !== 'wsl' || effective.wslDistro?.trim()) { + return effective } + const defaultDistro = getDefaultWslDistro() + return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : effective } private serializeMutation(fn: () => Promise): Promise { @@ -166,14 +158,4 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { this.mutationQueue = next.catch(() => {}) return next } - - // Why: re-auth/add-account write fresh managed tokens; skip the next read-back so stale runtime tokens can't overwrite them. - clearLastWrittenCredentialsJson( - accountId = this.store.getSettings().activeClaudeManagedAccountId - ): void { - if (accountId === this.store.getSettings().activeClaudeManagedAccountId) { - this.lastWrittenCredentialsJson = null - } - this.skipNextReadBackForAccountId = accountId - } } diff --git a/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts b/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts deleted file mode 100644 index 8cb00420dc65..000000000000 --- a/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - createClaudeAccount, - createClaudeCredentialsJson, - createManagedClaudeAuth, - createSettings, - createStore, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { writeFileSync } from 'node:fs' -import { join } from 'node:path' - -export const sharedFields = { - mcpOAuth: { figma: { accessToken: 'mcp-access', refreshToken: 'mcp-refresh' } }, - mcpOAuthClientConfig: { figma: { clientId: 'figma-client' } }, - mcpXaaIdp: { token: 'idp-token' }, - mcpXaaIdpConfig: { issuer: 'idp-issuer' }, - pluginSecrets: { plugin: 'secret' } -} - -export function withSharedFields( - credentials: string, - fields: Record = sharedFields -): string { - return JSON.stringify({ ...JSON.parse(credentials), ...fields }) -} - -export async function createSharedCredentialRuntime(platform: NodeJS.Platform = 'darwin') { - setPlatform(platform) - const runtimePath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const system = createClaudeCredentialsJson('system@example.com', 'system') - const first = createClaudeCredentialsJson('first@example.com', 'first') - const second = createClaudeCredentialsJson('second@example.com', 'second') - const firstPath = createManagedClaudeAuth(testState.userDataDir, 'first', first) - const secondPath = createManagedClaudeAuth(testState.userDataDir, 'second', second) - writeFileSync(runtimePath, withSharedFields(system)) - testState.scopedKeychainCredentials = withSharedFields(system) - testState.legacyKeychainCredentials = withSharedFields(system) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('first', firstPath, { email: 'first@example.com' }), - createClaudeAccount('second', secondPath, { email: 'second@example.com' }) - ] - }) - const store = createStore(settings) - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - return { service, settings, runtimePath, first, second, system, firstPath, secondPath } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts deleted file mode 100644 index d029ae0d6992..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { ClaudeRuntimeAuthFileStorage } from './runtime-auth-file-storage' -import { stripSharedClaudeCredentialFields } from '../shared-credential-fields' -import type { - ClaudeAuthIdentity, - ClaudeReadBackMatch, - ClaudeRefreshTokenComparison -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthCredentialIdentity extends ClaudeRuntimeAuthFileStorage { - protected accountCredentialFieldsEqual(left: string | null, right: string | null): boolean { - if (left === right) { - return true - } - if (left === null || right === null) { - return false - } - try { - const leftAccount = this.asRecord(JSON.parse(stripSharedClaudeCredentialFields(left))) - const rightAccount = this.asRecord(JSON.parse(stripSharedClaudeCredentialFields(right))) - return ( - leftAccount !== null && - rightAccount !== null && - this.jsonValuesEqual(leftAccount, rightAccount) - ) - } catch { - return false - } - } - - protected readIdentityFromCredentials(credentialsJson: string): ClaudeAuthIdentity | null { - let parsed: Record - try { - parsed = JSON.parse(credentialsJson) as Record - } catch { - return null - } - const oauth = this.asRecord(parsed.claudeAiOauth) - return { - accountUuid: this.normalizeField( - this.readString(oauth, 'accountUuid') ?? this.readString(oauth, 'accountId') - ), - email: this.normalizeField(this.readString(oauth, 'email')), - organizationUuid: this.normalizeField( - this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId') - ) - } - } - - protected isValidCredentialsJsonObject(credentialsJson: string): boolean { - try { - const parsed = this.asRecord(JSON.parse(credentialsJson)) - const oauth = this.asRecord(parsed?.claudeAiOauth) - return this.normalizeField(this.readString(oauth, 'accessToken')) !== null - } catch { - return false - } - } - - protected runtimeCredentialsAreFresher( - runtimeCredentialsJson: string, - managedCredentialsJson: string - ): boolean { - const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson) - const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson) - return ( - runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness > managedFreshness - ) - } - - protected runtimeCredentialsAreOlder( - runtimeCredentialsJson: string, - managedCredentialsJson: string - ): boolean { - const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson) - const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson) - return ( - runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness < managedFreshness - ) - } - - protected chooseFreshestReadBackCandidate( - candidates: { - credentialsJson: string - match: Extract - }[] - ): { - credentialsJson: string - match: Extract - } { - return candidates.reduce((freshest, candidate) => { - const candidateFreshness = this.readFreshnessFromCredentials(candidate.credentialsJson) - const freshestFreshness = this.readFreshnessFromCredentials(freshest.credentialsJson) - if ( - candidateFreshness !== null && - (freshestFreshness === null || candidateFreshness > freshestFreshness) - ) { - return candidate - } - return freshest - }) - } - - protected readFreshnessFromCredentials(credentialsJson: string): number | null { - let parsed: Record - try { - parsed = JSON.parse(credentialsJson) as Record - } catch { - return null - } - const oauth = this.asRecord(parsed.claudeAiOauth) - return ( - this.readNumber(oauth, 'expiresAt') ?? - this.readNumber(oauth, 'expires_at') ?? - this.readNumber(oauth, 'expiry') ?? - this.readNumber(oauth, 'expires') - ) - } - - protected compareRefreshTokens( - runtimeCredentialsJson: string, - managedCredentialsJson: string - ): ClaudeRefreshTokenComparison { - const runtimeRefreshToken = this.readRefreshTokenFromCredentials(runtimeCredentialsJson) - const managedRefreshToken = this.readRefreshTokenFromCredentials(managedCredentialsJson) - if (!runtimeRefreshToken || !managedRefreshToken) { - return 'missing' - } - return runtimeRefreshToken === managedRefreshToken ? 'same' : 'different' - } - - protected readRefreshTokenFromCredentials(credentialsJson: string): string | null { - try { - const parsed = JSON.parse(credentialsJson) as Record - const oauth = this.asRecord(parsed.claudeAiOauth) - return this.normalizeField(this.readString(oauth, 'refreshToken')) - } catch { - return null - } - } - - protected readIdentityFromOauthAccount(oauthAccount: unknown): ClaudeAuthIdentity { - const oauth = this.asRecord(oauthAccount) - return { - accountUuid: this.normalizeField( - this.readString(oauth, 'accountUuid') ?? this.readString(oauth, 'accountId') - ), - email: this.normalizeField( - this.readString(oauth, 'emailAddress') ?? this.readString(oauth, 'email') - ), - organizationUuid: this.normalizeField( - this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId') - ) - } - } - - protected asRecord(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - return value as Record - } - - protected readString(value: Record | null, key: string): string | null { - const candidate = value?.[key] - return typeof candidate === 'string' ? candidate : null - } - - protected readNumber(value: Record | null, key: string): number | null { - const candidate = value?.[key] - if (typeof candidate === 'number' && Number.isFinite(candidate)) { - return candidate - } - if (typeof candidate === 'string') { - const parsed = Number(candidate) - return Number.isFinite(parsed) ? parsed : null - } - return null - } - - protected normalizeField(value: string | null | undefined): string | null { - if (!value) { - return null - } - const trimmed = value.trim() - return trimmed === '' ? null : trimmed - } - - protected jsonValuesEqual(left: unknown, right: unknown): boolean { - return ( - JSON.stringify(this.sortJsonValue(left ?? null)) === - JSON.stringify(this.sortJsonValue(right ?? null)) - ) - } - - protected sortJsonValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((item) => this.sortJsonValue(item)) - } - const record = this.asRecord(value) - if (!record) { - return value - } - return Object.fromEntries( - Object.entries(record) - .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) - .map(([key, nestedValue]) => [key, this.sortJsonValue(nestedValue)]) - ) - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts deleted file mode 100644 index 390ec57a8b2f..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { ClaudeRuntimeAuthRuntimeState } from './runtime-auth-runtime-state' -import type { ClaudeReadBackMatch } from './runtime-auth-types' - -export class ClaudeRuntimeAuthCredentialMatching extends ClaudeRuntimeAuthRuntimeState { - protected async findManagedAccountForRuntimeCredentials( - runtimeCredentialsJson: string, - runtimeOauthAccount: unknown - ): Promise { - const matches: { account: ClaudeManagedAccount; managedCredentialsJson: string }[] = [] - let unverifiableCount = 0 - for (const account of this.store.getSettings().claudeManagedAccounts) { - const managedCredentialsJson = await this.readManagedCredentials(account) - if (!managedCredentialsJson) { - continue - } - const match = this.runtimeCredentialsMatchAccount( - runtimeCredentialsJson, - runtimeOauthAccount, - account, - managedCredentialsJson, - await this.readManagedOauthAccount(account) - ) - if (match === 'match') { - matches.push({ account, managedCredentialsJson }) - } else if (match === 'unverifiable') { - unverifiableCount += 1 - } - } - - if (matches.length === 1 && unverifiableCount === 0) { - return { kind: 'matched', ...matches[0] } - } - return { kind: matches.length === 0 && unverifiableCount === 0 ? 'none' : 'ambiguous' } - } - - protected runtimeCredentialsMatchAccount( - runtimeCredentialsJson: string, - runtimeOauthAccount: unknown, - account: ClaudeManagedAccount, - managedCredentialsJson: string, - managedOauthAccount: unknown - ): 'match' | 'mismatch' | 'unverifiable' { - const identity = this.readIdentityFromCredentials(runtimeCredentialsJson) - if (!identity) { - return 'mismatch' - } - const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson) - const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) - const runtimeOauthIdentity = this.readIdentityFromOauthAccount(runtimeOauthAccount) - const credentialOauthConflict = - (identity.accountUuid && - runtimeOauthIdentity.accountUuid && - identity.accountUuid !== runtimeOauthIdentity.accountUuid) || - (identity.email && - runtimeOauthIdentity.email && - identity.email !== runtimeOauthIdentity.email) || - (identity.organizationUuid && - runtimeOauthIdentity.organizationUuid && - identity.organizationUuid !== runtimeOauthIdentity.organizationUuid) - if (credentialOauthConflict) { - return 'mismatch' - } - - // Why: mirrors the Codex runtime-home guard; don't persist shared runtime creds into the managed account if another login rewrote them. - const selectedOrganizationUuid = this.normalizeField( - account.organizationUuid ?? - managedIdentity?.organizationUuid ?? - managedOauthIdentity.organizationUuid - ) - const oauthAccountMatches = - Boolean(managedOauthIdentity.accountUuid) && - managedOauthIdentity.accountUuid === runtimeOauthIdentity.accountUuid && - Boolean(runtimeOauthIdentity.email || runtimeOauthIdentity.organizationUuid) - const runtimeEmail = identity.email ?? runtimeOauthIdentity.email - const runtimeOrganizationUuid = - identity.organizationUuid ?? runtimeOauthIdentity.organizationUuid - const refreshTokenComparison = this.compareRefreshTokens( - runtimeCredentialsJson, - managedCredentialsJson - ) - if (!runtimeEmail) { - if (refreshTokenComparison === 'same') { - return 'match' - } - if (identity.organizationUuid) { - if (selectedOrganizationUuid && selectedOrganizationUuid !== identity.organizationUuid) { - return 'mismatch' - } - return 'unverifiable' - } - if (oauthAccountMatches) { - return 'match' - } - if (!runtimeOrganizationUuid && refreshTokenComparison === 'different') { - return 'mismatch' - } - return 'unverifiable' - } - if (account.email && this.normalizeField(account.email) !== runtimeEmail) { - return 'mismatch' - } - if (selectedOrganizationUuid && !runtimeOrganizationUuid) { - return refreshTokenComparison === 'same' || oauthAccountMatches ? 'match' : 'unverifiable' - } - if ( - selectedOrganizationUuid && - runtimeOrganizationUuid && - selectedOrganizationUuid !== runtimeOrganizationUuid - ) { - return 'mismatch' - } - if (!selectedOrganizationUuid && runtimeOrganizationUuid) { - return refreshTokenComparison === 'same' ? 'match' : 'unverifiable' - } - - return 'match' - } - - protected liveRuntimeCredentialsCanUpdateActiveAccount( - runtimeCredentialsJson: string, - account: ClaudeManagedAccount, - managedCredentialsJson: string, - managedOauthAccount: unknown - ): boolean { - const match = this.runtimeCredentialsMatchAccount( - runtimeCredentialsJson, - this.readRuntimeOauthAccount(), - account, - managedCredentialsJson, - managedOauthAccount - ) - if (match === 'match') { - return true - } - const identity = this.readIdentityFromCredentials(runtimeCredentialsJson) - const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson) - const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) - const runtimeOauthIdentity = this.readIdentityFromOauthAccount(this.readRuntimeOauthAccount()) - const selectedOrganizationUuid = this.normalizeField( - account.organizationUuid ?? - managedIdentity?.organizationUuid ?? - managedOauthIdentity.organizationUuid - ) - return ( - match === 'unverifiable' && - Boolean(selectedOrganizationUuid) && - (identity?.organizationUuid ?? runtimeOauthIdentity.organizationUuid) === - selectedOrganizationUuid - ) - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts deleted file mode 100644 index 8aa33a88c609..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { app } from 'electron' -import { writeFileAtomically } from '../../codex-accounts/fs-utils' -import { ClaudeRuntimeAuthState } from './runtime-auth-state' - -export class ClaudeRuntimeAuthFileStorage extends ClaudeRuntimeAuthState { - protected writeRuntimeCredentials(contents: string): void { - const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath - mkdirSync(dirname(credentialsPath), { recursive: true }) - // Why: skip unchanged rewrites to dodge Windows EPERM contention (#1507); re-verify the file since another Claude may have rewritten it. - if ( - this.lastWrittenCredentialsJson === contents && - this.fileContentsEqual(credentialsPath, contents) - ) { - this.ensureOwnerOnlyMode(credentialsPath) - return - } - if (this.fileContentsEqual(credentialsPath, contents)) { - this.ensureOwnerOnlyMode(credentialsPath) - this.lastWrittenCredentialsJson = contents - return - } - writeFileAtomically(credentialsPath, contents, { mode: 0o600 }) - this.lastWrittenCredentialsJson = contents - } - - protected writeJson(targetPath: string, value: unknown): void { - const serialized = `${JSON.stringify(value, null, 2)}\n` - mkdirSync(dirname(targetPath), { recursive: true }) - // Why: same Windows contention reason as writeRuntimeCredentials. - if (this.fileContentsEqual(targetPath, serialized)) { - return - } - writeFileAtomically(targetPath, serialized, { mode: 0o600 }) - } - - protected fileContentsEqual(targetPath: string, contents: string): boolean { - try { - return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents - } catch { - return false - } - } - - protected ensureOwnerOnlyMode(targetPath: string): void { - if (process.platform === 'win32') { - return - } - try { - chmodSync(targetPath, 0o600) - } catch { - /* Best effort: the next atomic write will set the restrictive mode. */ - } - } - - protected readJsonObject(targetPath: string): Record | null { - if (!existsSync(targetPath)) { - return {} - } - try { - const parsed = JSON.parse(readFileSync(targetPath, 'utf-8')) as unknown - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record - } - } catch { - // Why: invalid config is unknown external state; return null so we don't erase user or Claude-owned settings. - return null - } - return null - } - - protected getRuntimeMetadataDir(): string { - const metadataDir = join(app.getPath('userData'), 'claude-runtime-auth') - mkdirSync(metadataDir, { recursive: true }) - return metadataDir - } - - protected getSystemDefaultSnapshotPath(): string { - return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts deleted file mode 100644 index ef4c54acab83..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict -} from '../keychain' -import { ClaudeRuntimeAuthManagedCredentials } from './runtime-auth-managed-credentials' -import type { - ClaudeKeychainReadResult, - ClaudeKeychainSnapshotValue, - ClaudeSystemDefaultSnapshot -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthKeychainSnapshots extends ClaudeRuntimeAuthManagedCredentials { - protected isSystemDefaultSnapshot(value: unknown): value is ClaudeSystemDefaultSnapshot { - const snapshot = this.asRecord(value) - return ( - snapshot !== null && - Object.hasOwn(snapshot, 'credentialsJson') && - this.isOptionalNullableString(snapshot.credentialsJson) && - this.isOptionalNullableString(snapshot.keychainCredentialsJson) && - this.isOptionalNullableString(snapshot.scopedKeychainCredentialsJson) && - this.isOptionalNullableString(snapshot.legacyKeychainCredentialsJson) && - this.isOptionalBoolean(snapshot.scopedKeychainCredentialsCaptured) && - this.isOptionalBoolean(snapshot.legacyKeychainCredentialsCaptured) && - this.hasValidKeychainSnapshotValue(snapshot, 'scoped') && - this.hasValidKeychainSnapshotValue(snapshot, 'legacy') && - (snapshot.capturedAt === undefined || typeof snapshot.capturedAt === 'number') - ) - } - - protected isOptionalNullableString(value: unknown): boolean { - return value === undefined || value === null || typeof value === 'string' - } - - protected isOptionalBoolean(value: unknown): boolean { - return value === undefined || typeof value === 'boolean' - } - - protected snapshotKeychainCredentials( - credentialsJson: string | null, - previousSnapshot: ClaudeSystemDefaultSnapshot | null | undefined, - service: 'scoped' | 'legacy', - managedCredentialsJson: string | undefined - ): string | null { - if ( - managedCredentialsJson && - this.accountCredentialFieldsEqual(credentialsJson, managedCredentialsJson) && - previousSnapshot - ) { - const previousValue = this.readKeychainSnapshotValue(previousSnapshot, service) - if (previousValue.status === 'captured') { - return previousValue.credentialsJson - } - } - return credentialsJson - } - - protected hasValidKeychainSnapshotValue( - snapshot: Record, - service: 'scoped' | 'legacy' - ): boolean { - const capturedKey = - service === 'scoped' - ? 'scopedKeychainCredentialsCaptured' - : 'legacyKeychainCredentialsCaptured' - if (snapshot[capturedKey] === false) { - return true - } - const credentialsKey = - service === 'scoped' ? 'scopedKeychainCredentialsJson' : 'legacyKeychainCredentialsJson' - return ( - Object.hasOwn(snapshot, credentialsKey) || Object.hasOwn(snapshot, 'keychainCredentialsJson') - ) - } - - protected readKeychainSnapshotValue( - snapshot: ClaudeSystemDefaultSnapshot | null, - service: 'scoped' | 'legacy' - ): ClaudeKeychainSnapshotValue { - if (!snapshot) { - return { status: 'captured', credentialsJson: null } - } - const capturedKey = - service === 'scoped' - ? 'scopedKeychainCredentialsCaptured' - : 'legacyKeychainCredentialsCaptured' - if (snapshot[capturedKey] === false) { - return { status: 'unknown' } - } - const credentialsKey = - service === 'scoped' ? 'scopedKeychainCredentialsJson' : 'legacyKeychainCredentialsJson' - if (Object.hasOwn(snapshot, credentialsKey)) { - return { - status: 'captured', - credentialsJson: snapshot[credentialsKey] ?? null - } - } - return { status: 'captured', credentialsJson: snapshot.keychainCredentialsJson } - } - - protected async readAggregateClaudeKeychainCredentialsBestEffort( - configDir: string - ): Promise { - try { - return await readActiveClaudeKeychainCredentials(configDir) - } catch (error) { - console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) - return null - } - } - - protected async readActiveClaudeKeychainCredentialsBestEffort( - configDir?: string - ): Promise { - try { - return await readActiveClaudeKeychainCredentialsStrict(configDir) - } catch (error) { - console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) - return null - } - } - - protected async readActiveClaudeKeychainCredentialsForSnapshot( - configDir?: string - ): Promise { - try { - return { - status: 'captured', - credentialsJson: await readActiveClaudeKeychainCredentialsStrict(configDir) - } - } catch (error) { - console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) - return { status: 'failed' } - } - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts deleted file mode 100644 index 3a93f63d4dd6..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { existsSync } from 'node:fs' -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { parseWslUncPath } from '../../../shared/wsl-paths' -import { toWindowsWslPath } from '../../wsl' -import { runWslProcess } from '../../wsl/wsl-runner' -import { - readClaudeManagedAuthFile, - resolveOwnedClaudeManagedAuthPath, - writeClaudeManagedAuthFile -} from '../managed-auth-path' -import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from '../oauth-refresh' -import { - readManagedClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from '../keychain' -import { ClaudeRuntimeAuthCredentialIdentity } from './runtime-auth-credential-identity' -import { stripSharedClaudeCredentialFields } from '../shared-credential-fields' - -const OWNERSHIP_PROBE_TIMEOUT = 'orca-wsl-ownership-probe-timeout' - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - -export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCredentialIdentity { - protected async readManagedCredentials(account: ClaudeManagedAccount): Promise { - const managedAuthPath = await this.getOwnedManagedAuthPath(account) - if (!managedAuthPath) { - return null - } - if (process.platform === 'darwin') { - const credentials = await readManagedClaudeKeychainCredentials(account.id) - return credentials === null ? null : stripSharedClaudeCredentialFields(credentials) - } - const credentials = readClaudeManagedAuthFile(managedAuthPath, '.credentials.json') - return credentials === null || account.managedAuthRuntime === 'wsl' - ? credentials - : stripSharedClaudeCredentialFields(credentials) - } - - protected async writeManagedCredentials( - account: ClaudeManagedAccount, - credentialsJson: string - ): Promise { - const managedAuthPath = await this.getOwnedManagedAuthPath(account) - if (!managedAuthPath) { - throw new Error('Managed Claude auth storage is not owned by Orca.') - } - if (account.managedAuthRuntime !== 'wsl') { - credentialsJson = stripSharedClaudeCredentialFields(credentialsJson) - } - if (process.platform === 'darwin') { - await writeManagedClaudeKeychainCredentials(account.id, credentialsJson) - return - } - writeClaudeManagedAuthFile(managedAuthPath, '.credentials.json', credentialsJson) - } - - /** - * Proactively refresh an account's OAuth token and persist the rotation to - * managed storage. Returns the refreshed credentials JSON, or null when no - * refresh happened (token valid, no refresh token, or network failure). - * - * Caller guarantees this account isn't the live/active one and runs inside the - * serialized mutation queue, so the single-use refresh token can't rotate concurrently. - */ - protected async refreshManagedAccountTokenIfNeeded( - account: ClaudeManagedAccount, - credentialsJson: string - ): Promise { - if (!isOauthTokenExpiring(credentialsJson)) { - return null - } - const refreshed = await refreshClaudeOauthCredentials(credentialsJson) - if (!refreshed || !this.isValidCredentialsJsonObject(refreshed)) { - return null - } - try { - await this.writeManagedCredentials(account, refreshed) - } catch (error) { - console.warn('[claude-runtime-auth] Failed to persist refreshed Claude token:', error) - return null - } - return refreshed - } - - protected async readManagedOauthAccount(account: ClaudeManagedAccount): Promise { - const managedAuthPath = await this.getOwnedManagedAuthPath(account) - if (!managedAuthPath) { - return null - } - try { - const contents = readClaudeManagedAuthFile(managedAuthPath, 'oauth-account.json') - return contents ? (JSON.parse(contents) as unknown) : null - } catch { - return null - } - } - - protected async getOwnedManagedAuthPath(account: ClaudeManagedAccount): Promise { - const wslInfo = parseWslUncPath(account.managedAuthPath) - if (wslInfo) { - if ( - !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || - !wslInfo.linuxPath.endsWith('/auth') - ) { - return null - } - if (process.platform === 'win32') { - try { - const owned = await runWslProcess({ - distro: wslInfo.distro, - loginPath: 'none', - shell: 'bash', - script: [ - 'set -euo pipefail', - `candidate=${shellQuote(wslInfo.linuxPath)}`, - 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', - 'candidate_real=$(readlink -f -- "$candidate")', - 'managed_root_real=$(readlink -f -- "$managed_root")', - 'test -f "$candidate_real/.orca-managed-claude-auth"', - `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(account.id)}`, - 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' - ].join('\n'), - timeoutMs: 5000 - }) - if (owned.timedOut) { - throw new Error(OWNERSHIP_PROBE_TIMEOUT) - } - if (owned.code !== 0) { - return null - } - const canonicalLinuxPath = owned.stdout.trim() - return canonicalLinuxPath ? toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) : null - } catch (error) { - // Why rethrow a timeout: null means "not owned by Orca", and the - // caller persists that -- clearing the user's account selection. A - // slow distro must not decide ownership. Swallowing it here is what - // made the previous guard dead code. - if (error instanceof Error && error.message === OWNERSHIP_PROBE_TIMEOUT) { - throw error - } - return null - } - } - return existsSync(account.managedAuthPath) ? account.managedAuthPath : null - } - return resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { - adoptLegacyMarker: true - }) - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts deleted file mode 100644 index dabcd9d472f3..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { join } from 'node:path' -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { resolveLocalAccountRuntimeTarget } from '../../../shared/local-account-runtime' -import { parseWslUncPath } from '../../../shared/wsl-paths' -import { shouldStripClaudeAuthEnvForAccount } from '../environment' -import { getDefaultWslDistro, getWslHome } from '../../wsl' -import { - getSelectedClaudeAccountIdForTarget, - normalizeClaudeAccountSelectionTarget, - type ClaudeAccountSelectionTarget -} from '../runtime-selection' -import { ClaudeRuntimeAuthSnapshotRestore } from './runtime-auth-snapshot-restore' -import type { ClaudeRuntimeAuthPreparation } from './runtime-auth-types' - -export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapshotRestore { - protected getPreparation(target?: ClaudeAccountSelectionTarget): ClaudeRuntimeAuthPreparation { - const settings = this.store.getSettings() - const paths = this.pathResolver.getRuntimePaths() - const normalizedTarget = this.resolveWslDefaultTarget( - target ?? this.getDefaultAccountSelectionTarget(settings) - ) - const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) - const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) - if ( - normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl' && - activeAccount?.managedAuthRuntime === 'wsl' && - activeAccount.wslLinuxAuthPath - ) { - return { - configDir: activeAccount.managedAuthPath, - runtime: 'wsl', - wslDistro: activeAccount.wslDistro ?? null, - wslLinuxConfigDir: activeAccount.wslLinuxAuthPath, - envPatch: { CLAUDE_CONFIG_DIR: activeAccount.wslLinuxAuthPath }, - stripAuthEnv: true, - provenance: `managed:${activeAccount.id}:wsl:${activeAccount.wslDistro ?? ''}` - } - } - if (normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl') { - const distro = - normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? getDefaultWslDistro() - const wslHome = distro ? getWslHome(distro) : null - const wslHomeInfo = wslHome ? parseWslUncPath(wslHome) : null - if (distro && wslHome && wslHomeInfo) { - const windowsConfigDir = join(wslHome, '.claude') - const linuxConfigDir = `${wslHomeInfo.linuxPath.replace(/\/$/, '')}/.claude` - return { - configDir: windowsConfigDir, - runtime: 'wsl', - wslDistro: distro, - wslLinuxConfigDir: linuxConfigDir, - envPatch: {}, - stripAuthEnv: true, - provenance: `wsl:${distro}:system` - } - } - return { - configDir: paths.configDir, - runtime: 'wsl', - wslDistro: normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro, - wslLinuxConfigDir: null, - envPatch: {}, - stripAuthEnv: true, - provenance: `wsl:${normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? '__default__'}:system` - } - } - return { - configDir: paths.configDir, - runtime: 'host', - wslDistro: null, - wslLinuxConfigDir: null, - envPatch: paths.envPatch, - stripAuthEnv: shouldStripClaudeAuthEnvForAccount( - settings.claudeManagedAccounts, - activeAccountId - ), - managedRefreshDeferredByLivePty: Boolean( - activeAccountId && - activeAccount?.managedAuthRuntime !== 'wsl' && - this.managedRefreshDeferredByLivePtyAccountId === activeAccountId - ), - provenance: - activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl' - ? `managed:${activeAccountId}` - : 'system' - } - } - - protected getActiveAccount( - accounts: ClaudeManagedAccount[], - activeAccountId: string | null - ): ClaudeManagedAccount | null { - if (!activeAccountId) { - return null - } - return accounts.find((account) => account.id === activeAccountId) ?? null - } - - protected getDefaultAccountSelectionTarget( - settings = this.store.getSettings() - ): ClaudeAccountSelectionTarget { - // Why: Windows auth follows the resolved account runtime; stale cross-platform WSL pins must stay local-host. - const resolved = resolveLocalAccountRuntimeTarget(settings) - if (process.platform === 'win32' && resolved.runtime === 'wsl') { - return { runtime: 'wsl', wslDistro: resolved.wslDistro } - } - return { runtime: 'host' } - } - - protected resolveWslDefaultTarget( - target?: ClaudeAccountSelectionTarget - ): ClaudeAccountSelectionTarget { - if (target?.runtime !== 'wsl' || target.wslDistro?.trim()) { - return target ?? { runtime: 'host' } - } - const defaultDistro = getDefaultWslDistro() - return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts deleted file mode 100644 index 848ee093dfbc..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain' -import { ClaudeRuntimeAuthCredentialMatching } from './runtime-auth-credential-matching' -import type { - ClaudeReadBackMatch, - ClaudeReadBackResult, - ClaudeRuntimeCredentialCandidate -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthReadback extends ClaudeRuntimeAuthCredentialMatching { - protected async readBackRefreshedTokens( - baselineCredentialsJson: string, - options: { updateLastWrittenCredentialsJson: boolean } - ): Promise { - try { - const candidates = - await this.readRuntimeCredentialCandidatesForReadBack(baselineCredentialsJson) - if (candidates.length === 0) { - return { status: 'unchanged' } - } - const changedCandidates = - this.lastWrittenCredentialsJson === null - ? candidates - : candidates.filter( - (candidate) => - !this.accountCredentialFieldsEqual( - candidate.credentialsJson, - this.lastWrittenCredentialsJson - ) - ) - if (changedCandidates.length === 0) { - return { status: 'unchanged' } - } - - const acceptedCandidates: { - credentialsJson: string - match: Extract - }[] = [] - const ambiguousCandidates: string[] = [] - let sawAmbiguousCandidate = false - let sawValidChangedCandidate = false - for (const runtimeContents of changedCandidates) { - if (!this.isValidCredentialsJsonObject(runtimeContents.credentialsJson)) { - continue - } - sawValidChangedCandidate = true - const match = await this.findManagedAccountForRuntimeCredentials( - runtimeContents.credentialsJson, - runtimeContents.runtimeOauthAccount - ) - if (match.kind === 'ambiguous') { - sawAmbiguousCandidate = true - ambiguousCandidates.push(runtimeContents.credentialsJson) - continue - } - if (match.kind !== 'matched') { - continue - } - // Why: on cold start we can't tell a fresh CLI refresh from stale runtime creds; adopt only when expiry or a rotated refresh token proves runtime is newer than managed. - if (this.lastWrittenCredentialsJson === null) { - const fresher = this.runtimeCredentialsAreFresher( - runtimeContents.credentialsJson, - match.managedCredentialsJson - ) - const refreshTokenRotated = - this.compareRefreshTokens( - runtimeContents.credentialsJson, - match.managedCredentialsJson - ) === 'different' - const older = this.runtimeCredentialsAreOlder( - runtimeContents.credentialsJson, - match.managedCredentialsJson - ) - if (!fresher && !(refreshTokenRotated && !older)) { - continue - } - } else if ( - this.runtimeCredentialsAreOlder( - runtimeContents.credentialsJson, - match.managedCredentialsJson - ) - ) { - continue - } - acceptedCandidates.push({ credentialsJson: runtimeContents.credentialsJson, match }) - } - if (acceptedCandidates.length === 0) { - if (sawAmbiguousCandidate) { - console.warn('[claude-runtime-auth] Refusing ambiguous Claude auth read-back') - } - return { - status: 'rejected', - runtimeCredentialsChanged: true, - hasValidChangedRuntimeCredentials: sawValidChangedCandidate, - runtimeCredentialsJson: - ambiguousCandidates.length === 1 ? ambiguousCandidates[0] : undefined - } - } - const { credentialsJson: runtimeContents, match } = - this.chooseFreshestReadBackCandidate(acceptedCandidates) - - await this.writeManagedCredentials(match.account, runtimeContents) - if (options.updateLastWrittenCredentialsJson) { - const merged = await this.mergeLiveRuntimeSharedCredentials(runtimeContents) - this.writeRuntimeCredentials(merged) - if (process.platform === 'darwin') { - const paths = this.pathResolver.getRuntimePaths() - await writeActiveClaudeKeychainCredentialsForRuntime(merged, paths.configDir) - } - this.lastWrittenSharedCredentialsJson = merged - } - return { status: 'persisted' } - } catch (error) { - // Why: read-back is best-effort; a transient fs error must not block forward sync (worst case: one more stale-token cycle). - console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error) - return { - status: 'rejected', - runtimeCredentialsChanged: - this.runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson), - // Why: an fs error hides whether a live session's refresh is present, so err toward preserving runtime state. - hasValidChangedRuntimeCredentials: true - } - } - } - - protected async readRuntimeCredentialCandidatesForReadBack( - baselineCredentialsJson: string - ): Promise { - const paths = this.pathResolver.getRuntimePaths() - const fileCredentials = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - const runtimeOauthAccount = this.readRuntimeOauthAccount() - const candidates: ClaudeRuntimeCredentialCandidate[] = [] - const pushCandidate = (credentialsJson: string | null): void => { - if ( - credentialsJson && - !candidates.some((candidate) => candidate.credentialsJson === credentialsJson) - ) { - candidates.push({ credentialsJson, runtimeOauthAccount }) - } - } - if (process.platform === 'darwin') { - const scopedKeychainCredentials = await this.readActiveClaudeKeychainCredentialsBestEffort( - paths.configDir - ) - const legacyKeychainCredentials = await this.readActiveClaudeKeychainCredentialsBestEffort() - if (this.lastWrittenCredentialsJson === null) { - pushCandidate(scopedKeychainCredentials) - pushCandidate(legacyKeychainCredentials) - pushCandidate(fileCredentials) - return candidates.filter( - (candidate) => - !this.accountCredentialFieldsEqual(candidate.credentialsJson, baselineCredentialsJson) - ) - } - pushCandidate(scopedKeychainCredentials) - pushCandidate(legacyKeychainCredentials) - } - pushCandidate(fileCredentials) - return candidates - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts deleted file mode 100644 index 40d4d45395a2..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { existsSync, readFileSync, rmSync } from 'node:fs' -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentialsStrict, - writeActiveClaudeKeychainCredentials -} from '../keychain' -import { - mergeSharedClaudeCredentialFields, - reconcileSharedClaudeCredentialFields -} from '../shared-credential-fields' -import { ClaudeRuntimeAuthKeychainSnapshots } from './runtime-auth-keychain-snapshots' -import { - RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR, - type ClaudeKeychainSnapshotValue -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthRuntimeState extends ClaudeRuntimeAuthKeychainSnapshots { - protected async mergeLiveRuntimeSharedCredentials(credentialsJson: string): Promise { - const paths = this.pathResolver.getRuntimePaths() - const candidates: string[] = [] - if (process.platform === 'darwin') { - // A failed read must stop the switch before any shared tokens are overwritten. - const scoped = await readActiveClaudeKeychainCredentialsStrict(paths.configDir) - const legacy = await readActiveClaudeKeychainCredentialsStrict() - if (scoped !== null) { - candidates.push(scoped) - } - if (legacy !== null) { - candidates.push(legacy) - } - } - const file = this.readRuntimeCredentialsFile() - if (file !== null) { - candidates.push(file) - } - const shared = reconcileSharedClaudeCredentialFields( - candidates, - this.lastWrittenSharedCredentialsJson - ) - return mergeSharedClaudeCredentialFields(credentialsJson, shared) - } - - protected readRuntimeCredentialsFile(): string | null { - const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath - return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null - } - - protected runtimeCredentialsBelongToAccount( - credentialsJson: string | null, - account: ClaudeManagedAccount, - managedOauthAccount: unknown - ): boolean { - if (!credentialsJson) { - return false - } - const identity = this.readIdentityFromCredentials(credentialsJson) - if ( - !identity?.email || - (account.email && this.normalizeField(account.email) !== identity.email) - ) { - return false - } - const oauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) - const selectedOrganizationUuid = this.normalizeField( - account.organizationUuid ?? oauthIdentity.organizationUuid - ) - if (selectedOrganizationUuid) { - return identity.organizationUuid === selectedOrganizationUuid - } - return !identity.organizationUuid - } - - protected clearLastWrittenRuntimeState(): void { - this.lastWrittenCredentialsJson = null - this.lastWrittenOauthAccount = null - this.hasLastWrittenOauthAccount = false - this.hasMaterializedRuntimeAuth = false - } - - protected hasUnchangedRuntimeCredentials( - previouslyWrittenCredentialsJson: string | null - ): boolean { - if (previouslyWrittenCredentialsJson === null) { - return false - } - const paths = this.pathResolver.getRuntimePaths() - const currentCredentialsJson = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - return this.accountCredentialFieldsEqual( - currentCredentialsJson, - previouslyWrittenCredentialsJson - ) - } - - protected runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson: string): boolean { - const paths = this.pathResolver.getRuntimePaths() - try { - const currentCredentialsJson = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - return ( - currentCredentialsJson !== null && - currentCredentialsJson !== (this.lastWrittenCredentialsJson ?? baselineCredentialsJson) - ) - } catch { - return false - } - } - - protected restoreRuntimeCredentials( - credentialsJson: string | null, - sharedCredentialsJson?: string - ): void { - const paths = this.pathResolver.getRuntimePaths() - const restored = mergeSharedClaudeCredentialFields( - credentialsJson ?? '{}', - sharedCredentialsJson ?? this.readRuntimeCredentialsFile() - ) - if (credentialsJson !== null || restored !== '{}') { - this.writeRuntimeCredentials(restored) - } else { - rmSync(paths.credentialsPath, { force: true }) - } - } - - protected restoreRuntimeOauthAccountIfOwned( - oauthAccount: unknown, - ownedOauthAccount: unknown, - options: { allowCredentialSurfaceOwnership: boolean } - ): void { - const currentOauthAccount = this.readRuntimeOauthAccount() - if (currentOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR) { - return - } - if (options.allowCredentialSurfaceOwnership) { - this.writeRuntimeOauthAccount(oauthAccount) - return - } - if ( - (ownedOauthAccount === null || ownedOauthAccount === undefined) && - !options.allowCredentialSurfaceOwnership - ) { - return - } - if (!this.jsonValuesEqual(currentOauthAccount, ownedOauthAccount)) { - return - } - this.writeRuntimeOauthAccount(oauthAccount) - } - - protected async hasUnchangedActiveClaudeKeychainCredentials( - snapshotValue: ClaudeKeychainSnapshotValue, - previouslyWrittenCredentialsJson: string | null, - configDir?: string - ): Promise { - if (snapshotValue.status === 'unknown') { - return false - } - const currentCredentialsJson = - await this.readActiveClaudeKeychainCredentialsBestEffort(configDir) - return ( - previouslyWrittenCredentialsJson !== null && - this.accountCredentialFieldsEqual(currentCredentialsJson, previouslyWrittenCredentialsJson) - ) - } - - protected async restoreActiveClaudeKeychainCredentials( - credentialsJson: string | null, - configDir?: string, - sharedCredentialsJson?: string - ): Promise { - const live = - sharedCredentialsJson ?? (await readActiveClaudeKeychainCredentialsStrict(configDir)) - const restored = mergeSharedClaudeCredentialFields(credentialsJson ?? '{}', live) - await (credentialsJson !== null || restored !== '{}' - ? writeActiveClaudeKeychainCredentials(restored, configDir) - : deleteActiveClaudeKeychainCredentialsStrict(configDir)) - } - - protected async hasActiveKeychainCredentialsForAccount( - account: ClaudeManagedAccount, - managedOauthAccount: unknown, - configDir?: string - ): Promise { - const currentCredentialsJson = - await this.readActiveClaudeKeychainCredentialsBestEffort(configDir) - return this.runtimeCredentialsBelongToAccount( - currentCredentialsJson, - account, - managedOauthAccount - ) - } - - protected readRuntimeOauthAccount(): unknown { - const configPath = this.pathResolver.getRuntimePaths().configPath - if (!existsSync(configPath)) { - return null - } - try { - const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as unknown - const record = this.asRecord(parsed) - if (!record) { - return RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR - } - return record.oauthAccount ?? null - } catch { - return RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR - } - } - - protected runtimeOauthAccountMatches(managedOauthAccount: unknown): boolean { - if (managedOauthAccount === null || managedOauthAccount === undefined) { - return false - } - const currentOauthAccount = this.readRuntimeOauthAccount() - if (currentOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR) { - return false - } - return this.jsonValuesEqual(currentOauthAccount, managedOauthAccount) - } - - protected writeRuntimeOauthAccount(oauthAccount: unknown): boolean { - const configPath = this.pathResolver.getRuntimePaths().configPath - const existing = this.readJsonObject(configPath) - if (existing === null) { - return false - } - if (oauthAccount === null || oauthAccount === undefined) { - delete existing.oauthAccount - } else { - existing.oauthAccount = oauthAccount - } - this.writeJson(configPath, existing) - return true - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts deleted file mode 100644 index e184ec4e7517..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { existsSync, readFileSync, rmSync } from 'node:fs' -import { ClaudeRuntimeAuthReadback } from './runtime-auth-readback' -import { - RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR, - type ClaudeSystemDefaultSnapshot -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthSnapshotCapture extends ClaudeRuntimeAuthReadback { - protected async captureSystemDefaultSnapshotForManagedEntry( - runtimeCredentialsJson: string | null, - managedCredentialsJson: string - ): Promise { - const snapshotPath = this.getSystemDefaultSnapshotPath() - const existingSnapshot = this.readSystemDefaultSnapshot(snapshotPath) - if (!this.accountCredentialFieldsEqual(runtimeCredentialsJson, managedCredentialsJson)) { - await this.captureSystemDefaultSnapshot({ - force: true, - previousSnapshot: existingSnapshot, - managedCredentialsJson - }) - return - } - if (existingSnapshot) { - await this.captureSystemDefaultSnapshot({ - force: true, - credentialsJsonOverride: existingSnapshot.credentialsJson, - previousSnapshot: existingSnapshot, - managedCredentialsJson - }) - return - } - await this.captureSystemDefaultSnapshot({ force: false }) - } - - protected async captureSystemDefaultSnapshot(options: { - force: boolean - credentialsJsonOverride?: string | null - previousSnapshot?: ClaudeSystemDefaultSnapshot | null - managedCredentialsJson?: string - }): Promise { - const snapshotPath = this.getSystemDefaultSnapshotPath() - if (!options.force && existsSync(snapshotPath)) { - return - } - - const paths = this.pathResolver.getRuntimePaths() - const credentialsJson = - options.credentialsJsonOverride !== undefined - ? options.credentialsJsonOverride - : existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - const keychainCredentialsJson = await this.readAggregateClaudeKeychainCredentialsBestEffort( - paths.configDir - ) - const scopedKeychainCredentials = - process.platform === 'darwin' - ? await this.readActiveClaudeKeychainCredentialsForSnapshot(paths.configDir) - : ({ status: 'captured', credentialsJson: null } as const) - const legacyKeychainCredentialsJson = - process.platform === 'darwin' - ? await this.readActiveClaudeKeychainCredentialsForSnapshot() - : ({ status: 'captured', credentialsJson: null } as const) - if ( - scopedKeychainCredentials.status === 'failed' || - legacyKeychainCredentialsJson.status === 'failed' - ) { - throw new Error('Cannot capture current Claude Keychain credentials') - } - const scopedKeychainCredentialsJson = - scopedKeychainCredentials.status === 'captured' - ? this.snapshotKeychainCredentials( - scopedKeychainCredentials.credentialsJson, - options.previousSnapshot, - 'scoped', - options.managedCredentialsJson - ) - : undefined - const legacyKeychainSnapshotJson = - legacyKeychainCredentialsJson.status === 'captured' - ? this.snapshotKeychainCredentials( - legacyKeychainCredentialsJson.credentialsJson, - options.previousSnapshot, - 'legacy', - options.managedCredentialsJson - ) - : undefined - const configOauthAccount = this.readRuntimeOauthAccount() - const snapshot: ClaudeSystemDefaultSnapshot = { - credentialsJson, - configOauthAccount: - configOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR ? null : configOauthAccount, - keychainCredentialsJson, - scopedKeychainCredentialsJson, - legacyKeychainCredentialsJson: legacyKeychainSnapshotJson, - scopedKeychainCredentialsCaptured: scopedKeychainCredentials.status === 'captured', - legacyKeychainCredentialsCaptured: legacyKeychainCredentialsJson.status === 'captured', - capturedAt: Date.now() - } - this.writeJson(snapshotPath, snapshot) - } - - protected readSystemDefaultSnapshot(snapshotPath: string): ClaudeSystemDefaultSnapshot | null { - if (!existsSync(snapshotPath)) { - return null - } - try { - const parsed = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown - if (this.isSystemDefaultSnapshot(parsed)) { - return parsed - } - throw new Error('Invalid Claude system-default auth snapshot shape') - } catch (error) { - console.warn('[claude-runtime-auth] Ignoring invalid system-default auth snapshot:', error) - rmSync(snapshotPath, { force: true }) - return null - } - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts deleted file mode 100644 index bb2158d9464b..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { ClaudeRuntimeAuthSnapshotCapture } from './runtime-auth-snapshot-capture' -import type { ClaudeKeychainSnapshotValue } from './runtime-auth-types' - -export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotCapture { - protected async restoreSystemDefaultSnapshot( - ownedCredentialsJson?: string | null, - ownedOauthAccount?: unknown - ): Promise { - const snapshotPath = this.getSystemDefaultSnapshotPath() - const paths = this.pathResolver.getRuntimePaths() - const previouslyWrittenCredentialsJson = - this.lastWrittenCredentialsJson ?? ownedCredentialsJson ?? null - const snapshot = this.readSystemDefaultSnapshot(snapshotPath) - - const fileCredentialsOwned = this.hasUnchangedRuntimeCredentials( - previouslyWrittenCredentialsJson - ) - let hasCredentialSurfaceOwnership = fileCredentialsOwned - // Why: prove ownership before mutating anything, and restore OAuth first so a failure leaves the credential proof intact for retry. - this.lastWrittenCredentialsJson = previouslyWrittenCredentialsJson - let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null - let legacySnapshot: ClaudeKeychainSnapshotValue | null = null - let scopedKeychainOwned = false - let legacyKeychainOwned = false - if (process.platform === 'darwin') { - scopedSnapshot = this.readKeychainSnapshotValue(snapshot, 'scoped') - legacySnapshot = this.readKeychainSnapshotValue(snapshot, 'legacy') - scopedKeychainOwned = await this.hasUnchangedActiveClaudeKeychainCredentials( - scopedSnapshot, - previouslyWrittenCredentialsJson, - paths.configDir - ) - legacyKeychainOwned = await this.hasUnchangedActiveClaudeKeychainCredentials( - legacySnapshot, - previouslyWrittenCredentialsJson - ) - hasCredentialSurfaceOwnership = - fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned - } - const sharedCredentialsJson = hasCredentialSurfaceOwnership - ? await this.mergeLiveRuntimeSharedCredentials('{}') - : undefined - this.restoreRuntimeOauthAccountIfOwned( - snapshot?.configOauthAccount ?? null, - this.getOwnedRuntimeOauthBaseline(ownedOauthAccount, hasCredentialSurfaceOwnership), - { allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership } - ) - if (fileCredentialsOwned) { - this.restoreRuntimeCredentials(snapshot?.credentialsJson ?? null, sharedCredentialsJson) - } - if (process.platform === 'darwin') { - if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - scopedSnapshot.credentialsJson, - paths.configDir, - sharedCredentialsJson - ) - } - if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - legacySnapshot.credentialsJson, - undefined, - sharedCredentialsJson - ) - } - } - this.recordRestoredSharedCredentials( - sharedCredentialsJson, - fileCredentialsOwned, - scopedSnapshot?.status === 'captured' && scopedKeychainOwned, - legacySnapshot?.status === 'captured' && legacyKeychainOwned - ) - this.lastWrittenCredentialsJson = null - this.lastWrittenOauthAccount = null - this.hasLastWrittenOauthAccount = false - this.hasMaterializedRuntimeAuth = false - } - - protected getOwnedRuntimeOauthBaseline( - ownedOauthAccount: unknown, - hasCredentialSurfaceOwnership: boolean - ): unknown { - if (this.hasLastWrittenOauthAccount) { - return this.lastWrittenOauthAccount - } - // Why: managed metadata hints identity but isn't proof Orca wrote .claude.json; use only after a credential surface proves ownership. - if (hasCredentialSurfaceOwnership && ownedOauthAccount !== undefined) { - return ownedOauthAccount - } - return null - } - - private recordRestoredSharedCredentials( - credentialsJson: string | undefined, - fileRestored: boolean, - scopedRestored: boolean, - legacyRestored: boolean - ): void { - if ( - credentialsJson !== undefined && - fileRestored && - (process.platform !== 'darwin' || (scopedRestored && legacyRestored)) - ) { - this.lastWrittenSharedCredentialsJson = credentialsJson - } - } - - protected async clearRuntimeAuthForAccount( - account: ClaudeManagedAccount, - managedOauthAccount: unknown - ): Promise { - const paths = this.pathResolver.getRuntimePaths() - const fileCredentialsOwned = this.runtimeCredentialsBelongToAccount( - this.readRuntimeCredentialsFile(), - account, - managedOauthAccount - ) - let scopedKeychainOwned = false - let legacyKeychainOwned = false - if (process.platform === 'darwin') { - scopedKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( - account, - managedOauthAccount, - paths.configDir - ) - legacyKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( - account, - managedOauthAccount - ) - } - const hasCredentialSurfaceOwnership = - fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned - const sharedCredentialsJson = hasCredentialSurfaceOwnership - ? await this.mergeLiveRuntimeSharedCredentials('{}') - : undefined - this.restoreRuntimeOauthAccountIfOwned( - null, - this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership), - { - allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership - } - ) - if (fileCredentialsOwned) { - this.restoreRuntimeCredentials(null, sharedCredentialsJson) - } - if (process.platform === 'darwin') { - if (scopedKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - null, - paths.configDir, - sharedCredentialsJson - ) - } - if (legacyKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials(null, undefined, sharedCredentialsJson) - } - } - this.recordRestoredSharedCredentials( - sharedCredentialsJson, - fileCredentialsOwned, - scopedKeychainOwned, - legacyKeychainOwned - ) - } - - protected async restoreSystemDefaultSnapshotForMissingManagedCredentials( - account: ClaudeManagedAccount, - managedOauthAccount: unknown - ): Promise { - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - if (!snapshot) { - await this.clearRuntimeAuthForAccount(account, managedOauthAccount) - this.clearLastWrittenRuntimeState() - return - } - const paths = this.pathResolver.getRuntimePaths() - const fileCredentialsOwned = this.runtimeCredentialsBelongToAccount( - this.readRuntimeCredentialsFile(), - account, - managedOauthAccount - ) - let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null - let legacySnapshot: ClaudeKeychainSnapshotValue | null = null - let scopedKeychainOwned = false - let legacyKeychainOwned = false - if (process.platform === 'darwin') { - scopedSnapshot = this.readKeychainSnapshotValue(snapshot, 'scoped') - legacySnapshot = this.readKeychainSnapshotValue(snapshot, 'legacy') - scopedKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( - account, - managedOauthAccount, - paths.configDir - ) - legacyKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( - account, - managedOauthAccount - ) - } - const hasCredentialSurfaceOwnership = - fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned - const sharedCredentialsJson = hasCredentialSurfaceOwnership - ? await this.mergeLiveRuntimeSharedCredentials('{}') - : undefined - this.restoreRuntimeOauthAccountIfOwned( - snapshot.configOauthAccount, - this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership), - { - allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership - } - ) - if (fileCredentialsOwned) { - this.restoreRuntimeCredentials(snapshot.credentialsJson, sharedCredentialsJson) - } - if (process.platform === 'darwin') { - if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - scopedSnapshot.credentialsJson, - paths.configDir, - sharedCredentialsJson - ) - } - if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - legacySnapshot.credentialsJson, - undefined, - sharedCredentialsJson - ) - } - } - this.recordRestoredSharedCredentials( - sharedCredentialsJson, - fileCredentialsOwned, - scopedSnapshot?.status === 'captured' && scopedKeychainOwned, - legacySnapshot?.status === 'captured' && legacyKeychainOwned - ) - this.clearLastWrittenRuntimeState() - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts deleted file mode 100644 index 6d6e5e47a489..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Store } from '../../persistence' -import { ClaudeRuntimePathResolver } from '../runtime-paths' - -export class ClaudeRuntimeAuthState { - protected readonly pathResolver = new ClaudeRuntimePathResolver() - protected mutationQueue: Promise = Promise.resolve() - protected lastSyncedAccountId: string | null = null - // Why: creds Orca last wrote to the shared file; a mismatch on managed→default transition means an external login overwrote it, so adopt it as the new default. - protected lastWrittenCredentialsJson: string | null = null - // Connector revocations require a baseline that reached every runtime store, not a partial file write. - protected lastWrittenSharedCredentialsJson: string | null = null - protected hasMaterializedRuntimeAuth = false - protected hasLastWrittenOauthAccount = false - protected lastWrittenOauthAccount: unknown = null - protected skipNextReadBackForAccountId: string | null = null - protected managedRefreshDeferredByLivePtyAccountId: string | null = null - - protected constructor(protected readonly store: Store) {} -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts deleted file mode 100644 index 23e9112d7ed7..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import { - getSelectedClaudeAccountIdForTarget, - normalizeClaudeAccountSelectionTarget, - normalizeClaudeRuntimeSelection, - setSelectedClaudeAccountIdForTarget, - type ClaudeAccountSelectionTarget -} from '../runtime-selection' -import { hasLiveClaudePtys } from '../live-pty-gate' -import { isOauthTokenExpiring } from '../oauth-refresh' -import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain' -import { ClaudeRuntimeAuthPreparationService } from './runtime-auth-preparation' - -export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService { - protected async doSyncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { - const settings = this.store.getSettings() - const effectiveTarget = this.resolveWslDefaultTarget(target) - const normalizedTarget = normalizeClaudeAccountSelectionTarget(effectiveTarget) - const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) - const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) - const previousAccount = this.getActiveAccount( - settings.claudeManagedAccounts, - this.lastSyncedAccountId - ) - this.managedRefreshDeferredByLivePtyAccountId = null - const previousManagedCredentialsJson = previousAccount - ? await this.readManagedCredentials(previousAccount) - : null - const previousManagedOauthAccount = previousAccount - ? await this.readManagedOauthAccount(previousAccount) - : null - if (previousAccount && previousAccount.id !== activeAccount?.id) { - if (previousManagedCredentialsJson) { - const outgoingReadBackResult = await this.readBackRefreshedTokens( - previousManagedCredentialsJson, - { - updateLastWrittenCredentialsJson: true - } - ) - if ( - outgoingReadBackResult.status === 'rejected' && - outgoingReadBackResult.runtimeCredentialsChanged && - hasLiveClaudePtys() - ) { - if ( - outgoingReadBackResult.runtimeCredentialsJson && - this.liveRuntimeCredentialsCanUpdateActiveAccount( - outgoingReadBackResult.runtimeCredentialsJson, - previousAccount, - previousManagedCredentialsJson, - previousManagedOauthAccount - ) - ) { - // Why: switching away while Claude is live must preserve verified token refreshes before replacing shared runtime credentials. - await this.writeManagedCredentials( - previousAccount, - outgoingReadBackResult.runtimeCredentialsJson - ) - } else { - // Why: the runtime blob may lack identity proof for a live-session refresh; skip persisting it, but still let new terminals move to the account. - console.warn( - '[claude-runtime-auth] Skipping unverified live Claude auth read-back while switching accounts' - ) - } - } - } - } - if (!activeAccount) { - if (activeAccountId) { - const nextSelection = setSelectedClaudeAccountIdForTarget( - normalizeClaudeRuntimeSelection(settings), - null, - normalizedTarget - ) - this.store.updateSettings({ - activeClaudeManagedAccountId: - normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - } - if (normalizedTarget.runtime === 'wsl') { - return - } - if (this.lastSyncedAccountId !== null) { - await (previousAccount - ? this.restoreSystemDefaultSnapshot( - previousManagedCredentialsJson, - previousManagedOauthAccount - ) - : this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined)) - this.lastSyncedAccountId = null - } - return - } - - if (activeAccount.managedAuthRuntime === 'wsl') { - if (!(await this.getOwnedManagedAuthPath(activeAccount))) { - console.warn( - '[claude-runtime-auth] Active WSL managed account is not owned by Orca, restoring system default' - ) - const nextSelection = setSelectedClaudeAccountIdForTarget( - normalizeClaudeRuntimeSelection(settings), - null, - normalizedTarget - ) - this.store.updateSettings({ - activeClaudeManagedAccountId: - normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - return - } - const credentialsJson = await this.readManagedCredentials(activeAccount) - if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { - console.warn( - '[claude-runtime-auth] Active WSL managed account is missing or has invalid credentials, restoring system default' - ) - const nextSelection = setSelectedClaudeAccountIdForTarget( - normalizeClaudeRuntimeSelection(settings), - null, - normalizedTarget - ) - this.store.updateSettings({ - activeClaudeManagedAccountId: - normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - return - } - // Why: WSL managed accounts are isolated by their Linux CLAUDE_CONFIG_DIR; materializing into Windows ~/.claude would mix two auth stores. - this.clearLastWrittenRuntimeState() - return - } - - if (!(await this.getOwnedManagedAuthPath(activeAccount))) { - console.warn( - '[claude-runtime-auth] Active managed account is not owned by Orca, restoring system default' - ) - if (this.lastSyncedAccountId !== null) { - if ( - previousAccount && - (previousAccount.id !== activeAccount.id || - this.hasMaterializedRuntimeAuth || - this.runtimeOauthAccountMatches(await this.readManagedOauthAccount(previousAccount))) - ) { - await this.restoreSystemDefaultSnapshotForMissingManagedCredentials( - previousAccount, - previousManagedOauthAccount - ) - } else if (!previousAccount && this.hasMaterializedRuntimeAuth) { - await this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined) - } - } - this.store.updateSettings({ activeClaudeManagedAccountId: null }) - this.lastSyncedAccountId = null - return - } - - let credentialsJson = await this.readManagedCredentials(activeAccount) - if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { - console.warn( - '[claude-runtime-auth] Active managed account is missing or has invalid credentials, restoring system default' - ) - if (this.lastSyncedAccountId !== null) { - if ( - previousAccount && - (previousAccount.id !== activeAccount.id || - this.hasMaterializedRuntimeAuth || - this.runtimeOauthAccountMatches(previousManagedOauthAccount)) - ) { - await this.restoreSystemDefaultSnapshotForMissingManagedCredentials( - previousAccount, - previousManagedOauthAccount - ) - } else if (!previousAccount && this.hasMaterializedRuntimeAuth) { - await this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined) - } - } - this.store.updateSettings({ activeClaudeManagedAccountId: null }) - this.lastSyncedAccountId = null - return - } - - if (this.lastSyncedAccountId === null) { - const paths = this.pathResolver.getRuntimePaths() - const runtimeCredentialsJson = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - await this.captureSystemDefaultSnapshotForManagedEntry( - runtimeCredentialsJson, - credentialsJson - ) - } - - // Why: the CLI writes refreshed tokens to .credentials.json; if runtime differs from our last write, preserve them to managed storage before overwriting. - if (this.lastSyncedAccountId === activeAccount.id) { - if (this.skipNextReadBackForAccountId === activeAccount.id) { - this.skipNextReadBackForAccountId = null - } else { - const readBackResult = await this.readBackRefreshedTokens(credentialsJson, { - updateLastWrittenCredentialsJson: true - }) - if (readBackResult.status === 'persisted') { - const updatedCredentialsJson = await this.readManagedCredentials(activeAccount) - if (updatedCredentialsJson && this.isValidCredentialsJsonObject(updatedCredentialsJson)) { - credentialsJson = updatedCredentialsJson - } - } else if ( - readBackResult.status === 'rejected' && - readBackResult.runtimeCredentialsChanged && - // Why: a live Claude that lost a refresh race can wipe its runtime blob (empty tokens); preserving that would log out every new session. - readBackResult.hasValidChangedRuntimeCredentials && - hasLiveClaudePtys() - ) { - if ( - readBackResult.runtimeCredentialsJson && - this.liveRuntimeCredentialsCanUpdateActiveAccount( - readBackResult.runtimeCredentialsJson, - activeAccount, - credentialsJson, - await this.readManagedOauthAccount(activeAccount) - ) - ) { - // Why: this Claude launched under the active managed account, but persistence still needs positive account proof. - await this.writeManagedCredentials(activeAccount, readBackResult.runtimeCredentialsJson) - credentialsJson = readBackResult.runtimeCredentialsJson - } else { - // Why: while Claude runs, an unknown refresh may belong to a live session; rewriting stale managed auth logs it out. - console.warn( - '[claude-runtime-auth] Preserving changed Claude runtime credentials while live Claude terminals are running' - ) - this.lastSyncedAccountId = activeAccount.id - this.hasMaterializedRuntimeAuth = true - return - } - } - } - } - - if (this.lastSyncedAccountId !== activeAccount.id) { - this.skipNextReadBackForAccountId = null - } - - // Why: rotate+persist the single-use token to managed storage before materializing (else runtime gets a stale token that fails invalid_grant); skip while a live PTY owns the creds since refreshing would double-rotate it (invalidating one copy) — read-back preserves its refresh instead. - const liveClaudePtys = hasLiveClaudePtys() - if (liveClaudePtys && isOauthTokenExpiring(credentialsJson)) { - this.managedRefreshDeferredByLivePtyAccountId = activeAccount.id - } - if (!liveClaudePtys) { - const refreshed = await this.refreshManagedAccountTokenIfNeeded( - activeAccount, - credentialsJson - ) - if (refreshed) { - credentialsJson = refreshed - } - } - - const paths = this.pathResolver.getRuntimePaths() - const runtimeCredentialsJson = await this.mergeLiveRuntimeSharedCredentials(credentialsJson) - this.writeRuntimeCredentials(runtimeCredentialsJson) - if (process.platform === 'darwin') { - // Why: Claude Code 2.1+ reads the scoped service, older builds the legacy unsuffixed one; runtime switching must satisfy both. - try { - await writeActiveClaudeKeychainCredentialsForRuntime( - runtimeCredentialsJson, - paths.configDir - ) - } catch (error) { - await this.restoreSystemDefaultSnapshot( - credentialsJson, - await this.readManagedOauthAccount(activeAccount) - ) - throw error - } - } - this.lastWrittenSharedCredentialsJson = runtimeCredentialsJson - const managedOauthAccount = await this.readManagedOauthAccount(activeAccount) - if (this.writeRuntimeOauthAccount(managedOauthAccount)) { - this.lastWrittenOauthAccount = managedOauthAccount - this.hasLastWrittenOauthAccount = true - } else { - this.lastWrittenOauthAccount = null - this.hasLastWrittenOauthAccount = false - } - this.lastSyncedAccountId = activeAccount.id - this.hasMaterializedRuntimeAuth = true - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts index 77f0394e5719..a6a099dad194 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts @@ -1,4 +1,3 @@ -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import type { ClaudeEnvPatch } from '../environment' export type ClaudeRuntimeAuthPreparation = { @@ -8,48 +7,7 @@ export type ClaudeRuntimeAuthPreparation = { wslLinuxConfigDir?: string | null envPatch: ClaudeEnvPatch stripAuthEnv: boolean - managedRefreshDeferredByLivePty?: boolean provenance: string + /** Usage only: why the selected account cannot be read; usage polling must not throw for it. */ + usageError?: string } - -export type ClaudeSystemDefaultSnapshot = { - credentialsJson: string | null - configOauthAccount: unknown - keychainCredentialsJson: string | null - scopedKeychainCredentialsJson?: string | null - legacyKeychainCredentialsJson?: string | null - scopedKeychainCredentialsCaptured?: boolean - legacyKeychainCredentialsCaptured?: boolean - capturedAt: number -} - -export type ClaudeAuthIdentity = { - accountUuid: string | null - email: string | null - organizationUuid: string | null -} - -export type ClaudeReadBackResult = - | { status: 'unchanged' | 'persisted' } - | { - status: 'rejected' - runtimeCredentialsChanged: boolean - hasValidChangedRuntimeCredentials: boolean - runtimeCredentialsJson?: string - } -export type ClaudeReadBackMatch = - | { kind: 'matched'; account: ClaudeManagedAccount; managedCredentialsJson: string } - | { kind: 'none' | 'ambiguous' } -export type ClaudeKeychainReadResult = - | { status: 'captured'; credentialsJson: string | null } - | { status: 'failed' } -export type ClaudeKeychainSnapshotValue = - | { status: 'captured'; credentialsJson: string | null } - | { status: 'unknown' } -export type ClaudeRefreshTokenComparison = 'same' | 'different' | 'missing' -export type ClaudeRuntimeCredentialCandidate = { - credentialsJson: string - runtimeOauthAccount: unknown -} - -export const RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR = Symbol('runtime-oauth-account-parse-error') diff --git a/src/main/claude-accounts/runtime-paths.test.ts b/src/main/claude-accounts/runtime-paths.test.ts index cd1607e5a319..aa2f168b6eac 100644 --- a/src/main/claude-accounts/runtime-paths.test.ts +++ b/src/main/claude-accounts/runtime-paths.test.ts @@ -79,12 +79,6 @@ describe('ClaudeRuntimePathResolver', () => { expect(paths.envPatch).toEqual({ CLAUDE_CONFIG_DIR: inherited }) }) - it('resolves credentials next to the config directory', () => { - const paths = new ClaudeRuntimePathResolver().getRuntimePaths() - - expect(paths.credentialsPath).toBe(join(testState.fakeHomeDir, '.claude', '.credentials.json')) - }) - it('falls back to the home config file when no colocated config exists', () => { const paths = new ClaudeRuntimePathResolver().getRuntimePaths() diff --git a/src/main/claude-accounts/runtime-paths.ts b/src/main/claude-accounts/runtime-paths.ts index d9ff5701f262..c164df91d9c6 100644 --- a/src/main/claude-accounts/runtime-paths.ts +++ b/src/main/claude-accounts/runtime-paths.ts @@ -5,7 +5,6 @@ import type { ClaudeEnvPatch } from './environment' export type ClaudeRuntimePaths = { configDir: string - credentialsPath: string configPath: string envPatch: ClaudeEnvPatch } @@ -18,7 +17,6 @@ export class ClaudeRuntimePathResolver { return { configDir, - credentialsPath: join(configDir, '.credentials.json'), configPath: this.resolveConfigPath(configDir, inheritedConfigDir), envPatch: inheritedConfigDir ? { CLAUDE_CONFIG_DIR: configDir } : {} } diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index d1c79eaf17d5..9d64480cfd4a 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -1,73 +1,39 @@ -import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' -import type { Store } from '../persistence' -import type { RateLimitService } from '../rate-limits/service' +import type { + ClaudeAccountSignIn, + ClaudeRateLimitAccountsState, + ClaudeSignInRequest +} from '../../shared/managed-account-types' import { ClaudeAccountRegistration } from './claude-account-registration' -import { ClaudeAccountSelection } from './claude-account-selection' import { - captureClaudeAuthFromConfigDir, - captureClaudeAuthFromExistingConfigDir, - readCapturedClaudeCredentials, - type CapturedClaudeAuth -} from './claude-auth-capture' -import { - runClaudeCommandProcess, - type ClaudeCommandConfig, - type ClaudeCommandOptions -} from './claude-command-process' + ClaudeAccountSelection, + type ClaudeAccountRuntime, + type ClaudeAccountStore, + type ClaudeAccountUsage +} from './claude-account-selection' +import { runClaudeCommandProcess } from './claude-command-process' import { runClaudeLoginSession } from './claude-login-session' -import { - ClaudeManagedAuthStorage, - type ClaudeManagedAuthLocation -} from './claude-managed-auth-storage' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import type { ClaudeAccountSelectionTarget } from './runtime-selection' -export type ClaudeAccountAddTarget = { - runtime?: 'host' | 'wsl' - wslDistro?: string | null -} - -export type ClaudeAccountImportOptions = ClaudeAccountAddTarget & { - previousLegacyCredentialsSha256?: string | null -} - export class ClaudeAccountService { private mutationQueue: Promise = Promise.resolve() private cancelPendingClaudeLogin: (() => boolean) | null = null - private readonly storage = new ClaudeManagedAuthStorage() private readonly selection: ClaudeAccountSelection private readonly registration: ClaudeAccountRegistration constructor( - store: Store, - rateLimits: RateLimitService, - private readonly runtimeAuth: ClaudeRuntimeAuthService + store: ClaudeAccountStore, + rateLimits: ClaudeAccountUsage, + private readonly runtimeAuth: ClaudeAccountRuntime & + Pick, + private readonly runLoginCommand = runClaudeCommandProcess ) { - this.selection = new ClaudeAccountSelection(store, rateLimits, runtimeAuth, (accountId, path) => - this.safeRemoveManagedAuth(accountId, path) - ) + this.selection = new ClaudeAccountSelection(store, rateLimits, runtimeAuth) this.registration = new ClaudeAccountRegistration({ store, rateLimits, runtimeAuth, - selection: this.selection, - createManagedAuth: (accountId, target) => this.storage.create(accountId, target), - assertManagedAuth: (path, accountId) => this.storage.assertOwned(path, accountId), - removeManagedAuth: (accountId, path) => this.safeRemoveManagedAuth(accountId, path), - writeManagedAuth: (accountId, path, captured) => - this.writeManagedAuth(accountId, path, captured), - writeCredentials: (accountId, path, value) => - this.storage.writeCredentials(accountId, path, value), - writeOauth: (accountId, path, value) => - this.storage.writeOauthAccount(accountId, path, value), - readSnapshot: (accountId, path) => this.storage.readSnapshot(accountId, path), - restoreCredentials: (accountId, path, snapshot) => - this.storage.restoreCredentials(accountId, path, snapshot), - restoreOauth: (accountId, path, snapshot) => - this.storage.restoreOauth(accountId, path, snapshot), - login: (location) => this.runClaudeLoginAndCapture(location), - captureExisting: (configDir, previousDigest) => - this.captureFromExistingConfigDir(configDir, previousDigest) + selection: this.selection }) } @@ -75,34 +41,45 @@ export class ClaudeAccountService { return this.selection.list() } - async addAccount(target?: ClaudeAccountAddTarget): Promise { - this.supersedePendingLogin() - return this.serializeMutation(() => this.registration.add(target)) + /** A hidden `claude auth login` opens the browser and writes into the new account's folder. */ + addAccount(target: ClaudeAccountSelectionTarget = {}): Promise { + return this.signInHidden({ runtime: target.runtime, wslDistro: target.wslDistro }) + } + + reauthenticateAccount(accountId: string): Promise { + return this.signInHidden({ accountId }) } - async addAccountFromConfigDir( - configDir: string, - options?: ClaudeAccountImportOptions + cancelPendingLogin(): boolean { + return this.cancelPendingClaudeLogin?.() ?? false + } + + beginSignIn(request: ClaudeSignInRequest = {}): Promise { + return this.serializeMutation(() => this.registration.begin(request)) + } + + finishSignIn( + signIn: Omit ): Promise { - return this.serializeMutation(() => this.registration.addFromConfigDir(configDir, options)) + return this.serializeMutation(() => this.registration.finish(signIn)) } - async reauthenticateAccount(accountId: string): Promise { - this.supersedePendingLogin() - return this.serializeMutation(() => this.registration.reauthenticate(accountId)) + /** Deletes the folder of a sign-in that never became an account; a saved account's is kept. */ + cancelSignIn(signIn: Omit): Promise { + return this.serializeMutation(() => this.registration.cancel(signIn)) } - async removeAccount(accountId: string): Promise { + removeAccount(accountId: string): Promise { this.supersedePendingLogin() return this.serializeMutation(() => this.selection.remove(accountId)) } - async selectAccount(accountId: string | null): Promise { + selectAccount(accountId: string | null): Promise { this.supersedePendingLogin() return this.serializeMutation(() => this.selection.select(accountId)) } - async selectAccountForTarget( + selectAccountForTarget( accountId: string | null, target?: ClaudeAccountSelectionTarget ): Promise { @@ -110,13 +87,26 @@ export class ClaudeAccountService { return this.serializeMutation(() => this.selection.select(accountId, target)) } - cancelPendingLogin(): boolean { - return this.cancelPendingClaudeLogin?.() ?? false + getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { + return this.runtimeAuth.getRuntimeConfigDir(target) } - // Why before the queue, not inside it: the abandoned login owns the queue slot - // every later account action waits for. Called from the four the user drives, - // never from serializeMutation, which background work also uses. + private signInHidden(request: ClaudeSignInRequest): Promise { + this.supersedePendingLogin() + return this.serializeMutation(() => + this.registration.signIn(request, (folder) => + runClaudeLoginSession(folder, { + runCommand: this.runLoginCommand, + setCancel: (cancel) => { + this.cancelPendingClaudeLogin = cancel + } + }) + ) + ) + } + + // Why before the queue, not inside it: the abandoned login owns the queue slot every later + // account action waits for. Only user-driven actions supersede it. private supersedePendingLogin(): void { if (this.cancelPendingLogin()) { console.info( @@ -125,91 +115,9 @@ export class ClaudeAccountService { } } - getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { - return this.runtimeAuth.getRuntimeConfigDir(target) - } - private serializeMutation(operation: () => Promise): Promise { const next = this.mutationQueue.then(operation, operation) this.mutationQueue = next.catch(() => {}) return next } - - private runClaudeLoginAndCapture( - location: ClaudeManagedAuthLocation = { - managedAuthPath: '', - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null - } - ): Promise { - return runClaudeLoginSession(location, { - runCommand: (args, config, timeoutMs, options) => - this.runClaudeCommand(args, config, timeoutMs, options), - capture: (configDir, status, previousLegacy) => - this.captureAuthFromConfigDir(configDir, status, previousLegacy), - setCancel: (cancel) => { - this.cancelPendingClaudeLogin = cancel - } - }) - } - - private runClaudeCommand( - args: string[], - configDir: ClaudeCommandConfig, - timeoutMs: number, - options?: ClaudeCommandOptions - ): Promise { - return runClaudeCommandProcess(args, configDir, timeoutMs, options) - } - - private captureFromExistingConfigDir( - configDir: string, - previousLegacyCredentialsSha256?: string | null - ): Promise { - return captureClaudeAuthFromExistingConfigDir( - configDir, - previousLegacyCredentialsSha256, - (args, config, timeoutMs, options) => this.runClaudeCommand(args, config, timeoutMs, options) - ) - } - - private captureAuthFromConfigDir( - configDir: string, - statusOutput: string, - previousLegacyKeychain: string | null, - previousLegacyCredentialsSha256?: string | null - ): Promise { - return captureClaudeAuthFromConfigDir( - configDir, - statusOutput, - previousLegacyKeychain, - previousLegacyCredentialsSha256, - (path, previous, digest) => this.readCapturedCredentials(path, previous, digest) - ) - } - - private readCapturedCredentials( - configDir: string, - previousLegacyKeychain: string | null, - previousLegacyCredentialsSha256?: string | null - ): Promise { - return readCapturedClaudeCredentials( - configDir, - previousLegacyKeychain, - previousLegacyCredentialsSha256 - ) - } - - private writeManagedAuth( - accountId: string, - managedAuthPath: string, - captured: CapturedClaudeAuth - ): Promise { - return this.storage.writeAuth(accountId, managedAuthPath, captured) - } - - private safeRemoveManagedAuth(accountId: string, path: string): Promise { - return this.storage.remove(accountId, path) - } } diff --git a/src/main/claude-accounts/shared-credential-fields.test.ts b/src/main/claude-accounts/shared-credential-fields.test.ts deleted file mode 100644 index 097e1725d128..000000000000 --- a/src/main/claude-accounts/shared-credential-fields.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - mergeSharedClaudeCredentialFields, - SHARED_CLAUDE_CREDENTIAL_KEYS -} from './shared-credential-fields' - -describe('mergeSharedClaudeCredentialFields', () => { - it('merges the live credential shared fields into the target credential', () => { - const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) - const live = JSON.stringify({ - claudeAiOauth: { accessToken: 'live-token' }, - mcpOAuth: { conn1: 'v1' }, - pluginSecrets: { s: 1 } - }) - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) - expect(result.claudeAiOauth).toEqual({ accessToken: 'target-token' }) - expect(result.mcpOAuth).toEqual({ conn1: 'v1' }) - expect(result.pluginSecrets).toEqual({ s: 1 }) - }) - - it('is absence-authoritative: a shared key missing on live is not carried from the target', () => { - const target = JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token' }, - mcpOAuth: { stale: 'rotated-out' } - }) - const live = JSON.stringify({ claudeAiOauth: { accessToken: 'live-token' } }) - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) - expect(result.mcpOAuth).toBeUndefined() - }) - - it('leaves account-scoped sibling keys on the target untouched', () => { - const target = JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token' }, - trustedDeviceToken: 'target-device-token' - }) - const live = JSON.stringify({ - claudeAiOauth: { accessToken: 'live-token' }, - mcpOAuth: { conn1: 'v1' } - }) - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) - expect(result.trustedDeviceToken).toBe('target-device-token') - expect(result.mcpOAuth).toEqual({ conn1: 'v1' }) - }) - - it('returns the target unchanged when there is no live credential to merge from', () => { - const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) - expect(mergeSharedClaudeCredentialFields(target, null)).toBe(target) - }) - - it('returns the target unchanged when the live value is not a JSON object (e.g. a raw managed API key)', () => { - const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) - expect(mergeSharedClaudeCredentialFields(target, 'sk-ant-api-not-json')).toBe(target) - }) - - it('returns the target unchanged when the target itself is not a Claude OAuth credential object (managed API key)', () => { - const target = 'sk-ant-api-a-raw-managed-key' - const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('returns the target unchanged when the target JSON is malformed', () => { - const target = '{not valid json' - const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('returns the target unchanged when claudeAiOauth is null rather than an object', () => { - const target = JSON.stringify({ claudeAiOauth: null }) - const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('returns the target unchanged when claudeAiOauth is a primitive rather than an object', () => { - const target = JSON.stringify({ claudeAiOauth: 'not-an-object' }) - const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('returns the target byte-for-byte unchanged when neither side has any shared key (no-op merge)', () => { - // Why: a no-op reformat (e.g. dropped trailing newline) reads as an external refresh downstream. - const target = `${JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token', refreshToken: 'target-refresh' } - })}\n` - const live = JSON.stringify({ claudeAiOauth: { accessToken: 'live-token' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('covers every documented shared key, not just mcpOAuth', () => { - // Why: pins the actual key names, so removing one from the constant fails this test. - expect(SHARED_CLAUDE_CREDENTIAL_KEYS).toEqual([ - 'mcpOAuth', - 'mcpOAuthClientConfig', - 'mcpXaaIdp', - 'mcpXaaIdpConfig', - 'pluginSecrets' - ]) - const target = JSON.stringify({ claudeAiOauth: {} }) - const liveObj: Record = { claudeAiOauth: {} } - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - liveObj[key] = { present: true } - } - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, JSON.stringify(liveObj))) - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - expect(result[key]).toEqual({ present: true }) - } - }) - - it('returns the target byte-for-byte unchanged when an existing shared key keeps its value (interleaved order)', () => { - // Why: rebuilding via key-order iteration used to move an existing shared key to the - // end even when its value did not change, causing a spurious formatting-only rewrite. - const target = `${JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token' }, - mcpOAuth: { conn1: 'v1' }, - trustedDeviceToken: 'target-device-token' - })}\n` - const live = JSON.stringify({ - claudeAiOauth: { accessToken: 'live-token' }, - mcpOAuth: { conn1: 'v1' } - }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('still updates an interleaved shared key in place when its live value actually differs', () => { - const target = JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token' }, - mcpOAuth: { conn1: 'stale' }, - trustedDeviceToken: 'target-device-token' - }) - const live = JSON.stringify({ - claudeAiOauth: { accessToken: 'live-token' }, - mcpOAuth: { conn1: 'fresh' } - }) - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) - expect(result.mcpOAuth).toEqual({ conn1: 'fresh' }) - expect(result.trustedDeviceToken).toBe('target-device-token') - }) -}) diff --git a/src/main/claude-accounts/shared-credential-fields.ts b/src/main/claude-accounts/shared-credential-fields.ts deleted file mode 100644 index e0014f25c4b8..000000000000 --- a/src/main/claude-accounts/shared-credential-fields.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { isDeepStrictEqual } from 'node:util' - -export const SHARED_CLAUDE_CREDENTIAL_KEYS = [ - 'mcpOAuth', - 'mcpOAuthClientConfig', - 'mcpXaaIdp', - 'mcpXaaIdpConfig', - 'pluginSecrets' -] as const - -function parseCredentialObject(credentialsJson: string | null): Record | null { - if (!credentialsJson) { - return null - } - let parsed: unknown - try { - parsed = JSON.parse(credentialsJson) - } catch { - return null - } - return isCredentialObject(parsed) ? parsed : null -} - -function isCredentialObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -export function stripSharedClaudeCredentialFields(credentialsJson: string): string { - const credential = parseCredentialObject(credentialsJson) - if (!credential) { - return credentialsJson - } - let changed = false - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - if (Object.hasOwn(credential, key)) { - delete credential[key] - changed = true - } - } - return changed ? JSON.stringify(credential) : credentialsJson -} - -// Shared connector state follows the live runtime, including revocations, rather than frozen account snapshots. -export function mergeSharedClaudeCredentialFields( - targetCredentialsJson: string, - liveCredentialsJson: string | null -): string { - const target = parseCredentialObject(targetCredentialsJson) - const live = parseCredentialObject(liveCredentialsJson) - if ( - !target || - !live || - (Object.hasOwn(target, 'claudeAiOauth') && !isCredentialObject(target.claudeAiOauth)) - ) { - return targetCredentialsJson - } - - let changed = false - const merged: Record = { ...target } - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - const targetHasKey = Object.hasOwn(target, key) - if (Object.hasOwn(live, key)) { - if (!targetHasKey || JSON.stringify(live[key]) !== JSON.stringify(target[key])) { - merged[key] = live[key] - changed = true - } - } else if (targetHasKey) { - delete merged[key] - changed = true - } - } - return changed ? JSON.stringify(merged) : targetCredentialsJson -} - -type CredentialField = { present: boolean; value: unknown } - -function credentialField(record: Record, key: string): CredentialField { - const present = Object.hasOwn(record, key) - return { present, value: present ? record[key] : undefined } -} - -function resolveCredentialField( - candidates: CredentialField[], - baseline: CredentialField | null -): CredentialField { - const changes = candidates.filter((candidate) => - baseline === null ? candidate.present : !isDeepStrictEqual(candidate, baseline) - ) - const first = changes[0] - if (first && changes.some((candidate) => !isDeepStrictEqual(candidate, first))) { - throw new Error( - 'Cannot switch Claude accounts: live connector credentials conflict; existing authorizations were preserved' - ) - } - return first ?? baseline ?? { present: false, value: undefined } -} - -function resolveServerGrants( - sources: Record[], - baseline: Record | null, - key: string -): CredentialField | null { - const fields = sources.map((source) => credentialField(source, key)) - const previous = baseline === null ? null : credentialField(baseline, key) - if ( - fields.some((field) => field.present && !isCredentialObject(field.value)) || - (previous?.present && !isCredentialObject(previous.value)) - ) { - return null - } - const maps = fields.map((field) => (isCredentialObject(field.value) ? field.value : {})) - const previousMap = - previous === null ? null : isCredentialObject(previous.value) ? previous.value : {} - const names = new Set([ - ...maps.flatMap((map) => Object.keys(map)), - ...Object.keys(previousMap ?? {}) - ]) - const entries: [string, unknown][] = [] - for (const name of names) { - // Keep a server's access/refresh token pair atomic while combining independent server updates. - const grant = resolveCredentialField( - maps.map((map) => credentialField(map, name)), - previousMap === null ? null : credentialField(previousMap, name) - ) - if (grant.present) { - entries.push([name, grant.value]) - } - } - const present = - entries.length > 0 || - (fields.some((field) => field.present) && - !(baseline !== null && fields.some((field) => !field.present))) - return { present, value: present ? Object.fromEntries(entries) : undefined } -} - -export function reconcileSharedClaudeCredentialFields( - liveCredentials: string[], - lastWrittenCredentials: string | null -): string { - const sources = liveCredentials.map((credentials) => { - const parsed = parseCredentialObject(credentials) - if (!parsed) { - throw new Error('Cannot preserve malformed Claude runtime credentials') - } - return parsed - }) - if (sources.length === 0) { - return '{}' - } - const baseline = parseCredentialObject(lastWrittenCredentials) - const entries: [string, unknown][] = [] - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - const field = - (key === 'mcpOAuth' || key === 'mcpOAuthClientConfig' - ? resolveServerGrants(sources, baseline, key) - : null) ?? - resolveCredentialField( - sources.map((source) => credentialField(source, key)), - baseline === null ? null : credentialField(baseline, key) - ) - if (field.present) { - entries.push([key, field.value]) - } - } - return JSON.stringify(Object.fromEntries(entries)) -} diff --git a/src/main/claude-accounts/shared-credential-reconciliation.test.ts b/src/main/claude-accounts/shared-credential-reconciliation.test.ts deleted file mode 100644 index f772b5634c99..000000000000 --- a/src/main/claude-accounts/shared-credential-reconciliation.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { reconcileSharedClaudeCredentialFields } from './shared-credential-fields' - -const original = { accessToken: 'access', refreshToken: 'refresh' } -const rotated = { accessToken: 'rotated-access', refreshToken: 'rotated-refresh' } -const baseline = JSON.stringify({ - mcpOAuth: { figma: original }, - pluginSecrets: { plugin: 'secret' } -}) - -describe('live Claude connector reconciliation', () => { - it('combines independent server grants on startup without importing account identity', () => { - const sources = [ - JSON.stringify({ claudeAiOauth: { accessToken: 'account' }, mcpOAuth: { figma: original } }), - JSON.stringify({ - mcpOAuth: { notion: rotated }, - mcpOAuthClientConfig: { notion: { clientId: 'client' } } - }), - '{}' - ] - const expected = { - mcpOAuth: { figma: original, notion: rotated }, - mcpOAuthClientConfig: { notion: { clientId: 'client' } } - } - expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, null))).toEqual(expected) - expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources.toReversed(), null))).toEqual( - expected - ) - }) - - it('combines independent rotations and additions against the previous write', () => { - const sources = [ - JSON.stringify({ mcpOAuth: { figma: rotated }, pluginSecrets: { plugin: 'secret' } }), - JSON.stringify({ - mcpOAuth: { figma: original, notion: original }, - pluginSecrets: { plugin: 'new-secret' } - }), - baseline - ] - const expected = { - mcpOAuth: { figma: rotated, notion: original }, - pluginSecrets: { plugin: 'new-secret' } - } - expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, baseline))).toEqual(expected) - expect( - JSON.parse(reconcileSharedClaudeCredentialFields(sources.toReversed(), baseline)) - ).toEqual(expected) - }) - - it('keeps a revocation while another store adds an unrelated server', () => { - const sources = [ - JSON.stringify({ mcpOAuth: {}, pluginSecrets: { plugin: 'secret' } }), - JSON.stringify({ - mcpOAuth: { figma: original, notion: rotated }, - pluginSecrets: { plugin: 'secret' } - }), - baseline - ] - expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, baseline))).toEqual({ - mcpOAuth: { notion: rotated }, - pluginSecrets: { plugin: 'secret' } - }) - }) - - it.each([null, baseline])( - 'rejects conflicting server token pairs with baseline %s', - (previous) => { - const sources = [ - JSON.stringify({ mcpOAuth: { figma: rotated } }), - JSON.stringify({ - mcpOAuth: { figma: { accessToken: 'other-access', refreshToken: 'other-refresh' } } - }) - ] - expect(() => reconcileSharedClaudeCredentialFields(sources, previous)).toThrow( - 'live connector credentials conflict' - ) - expect(() => reconcileSharedClaudeCredentialFields(sources.toReversed(), previous)).toThrow( - 'live connector credentials conflict' - ) - } - ) - - it('does not recombine access and refresh tokens from different writes', () => { - const sources = [ - JSON.stringify({ mcpOAuth: { figma: { ...original, accessToken: 'new-access' } } }), - JSON.stringify({ mcpOAuth: { figma: { ...original, refreshToken: 'new-refresh' } } }) - ] - expect(() => reconcileSharedClaudeCredentialFields(sources, baseline)).toThrow( - 'live connector credentials conflict' - ) - }) - - it('does not infer MCP freshness from Claude account-token expiry', () => { - const sources = [ - JSON.stringify({ claudeAiOauth: { expiresAt: 1 }, mcpOAuth: { figma: original } }), - JSON.stringify({ claudeAiOauth: { expiresAt: 9999999999999 }, mcpOAuth: { figma: rotated } }) - ] - expect(() => reconcileSharedClaudeCredentialFields(sources, null)).toThrow( - 'live connector credentials conflict' - ) - }) - - it('rejects conflicting non-server fields instead of combining their secrets', () => { - expect(() => - reconcileSharedClaudeCredentialFields( - [ - JSON.stringify({ pluginSecrets: { plugin: 'one' } }), - JSON.stringify({ pluginSecrets: { plugin: 'two' } }) - ], - null - ) - ).toThrow('live connector credentials conflict') - }) - - it('does not resurrect the last-written grants when every live store is missing', () => { - expect(reconcileSharedClaudeCredentialFields([], baseline)).toBe('{}') - }) - - it('treats an explicit null as an authoritative field removal after a known write', () => { - expect( - JSON.parse( - reconcileSharedClaudeCredentialFields( - [JSON.stringify({ mcpOAuth: null, pluginSecrets: { plugin: 'secret' } }), baseline], - baseline - ) - ) - ).toEqual({ mcpOAuth: null, pluginSecrets: { plugin: 'secret' } }) - }) -}) diff --git a/src/main/claude/claude-at-rest-commands.ts b/src/main/claude/claude-at-rest-commands.ts index 02bf2fc17a05..47d4198a4fac 100644 --- a/src/main/claude/claude-at-rest-commands.ts +++ b/src/main/claude/claude-at-rest-commands.ts @@ -1,5 +1,9 @@ import { join } from 'node:path' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + isLegacyAgentSessionAccountHome, + requireLegacyAgentSessionAccountHome +} from '../../shared/agent-session-account-home' import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire' import { structuredSlashCommands } from '../../shared/structured-agent-session-composer' import { discoverSkills } from '../skills/discovery' @@ -45,7 +49,11 @@ export class ClaudeAtRestCommandCatalog { read = (record: AgentSessionRecord): AgentSessionSlashCommand[] | undefined => { // Another host's folders are only readable there, so this host never answers for them. - if (record.provider !== 'claude' || !supportsClaudeStructuredLocation(record.location)) { + if ( + record.provider !== 'claude' || + !supportsClaudeStructuredLocation(record.location) || + !isLegacyAgentSessionAccountHome(record.accountHome) + ) { return undefined } const key = JSON.stringify([record.location.workspaceId, record.accountHome.path]) @@ -115,7 +123,7 @@ export class ClaudeAtRestCommandCatalog { const cwd = agentSessionPinnedLaunchDirectory(record) ?? (await this.deps.resolveWorkspacePath(record.location.workspaceId)) - const account = record.accountHome.path + const account = requireLegacyAgentSessionAccountHome(record.accountHome).path const [custom, discovered] = await Promise.all([ scanClaudeCommandFolders([join(cwd, '.claude', 'commands'), join(account, 'commands')]), (this.deps.discover ?? discoverSkills)({ diff --git a/src/main/claude/claude-child-process-environment.ts b/src/main/claude/claude-child-process-environment.ts index 58f00c5c1b8a..a75d4f01921e 100644 --- a/src/main/claude/claude-child-process-environment.ts +++ b/src/main/claude/claude-child-process-environment.ts @@ -1,4 +1,5 @@ import { CLAUDE_AUTH_ENV_VARS, applyClaudeEnvPatch } from '../claude-accounts/environment' +import { NATIVE_CHAT_VISUALS_DIR_ENV } from '../native-chat/native-chat-visuals-delivery' const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ 'CLAUDE_CODE_CHILD_SESSION', @@ -16,11 +17,12 @@ function cloneProcessEnv(source: NodeJS.ProcessEnv): Record { return env } -function stripClaudeChildSessionStamps( +function stripEnvKeys( env: Record, + keys: readonly string[], platform: NodeJS.Platform ): Record { - for (const key of CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS) { + for (const key of keys) { for (const envKey of Object.keys(env)) { if (envKey === key || (platform === 'win32' && envKey.toUpperCase() === key)) { delete env[envKey] @@ -30,6 +32,13 @@ function stripClaudeChildSessionStamps( return env } +function stripClaudeChildSessionStamps( + env: Record, + platform: NodeJS.Platform +): Record { + return stripEnvKeys(env, CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS, platform) +} + export function buildClaudeChildProcessEnv( configuredEnv: Record = {}, options: { @@ -61,6 +70,8 @@ export function buildClaudeChildProcessEnv( } } } + // Only the launch names a chat's visuals folder: an inherited one belongs to another chat. + stripEnvKeys(env, [NATIVE_CHAT_VISUALS_DIR_ENV], platform) if (options.scrubConfiguredChildSessionStamps) { return stripClaudeChildSessionStamps({ ...env, ...configuredEnv }, platform) } diff --git a/src/main/claude/claude-cli-flag-prewarm.test.ts b/src/main/claude/claude-cli-flag-prewarm.test.ts new file mode 100644 index 000000000000..cfc03250f44e --- /dev/null +++ b/src/main/claude/claude-cli-flag-prewarm.test.ts @@ -0,0 +1,71 @@ +import { homedir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' +import { prewarmClaudeCliFlags } from './claude-cli-flag-prewarm' + +const record = ( + sessionId: string, + overrides: { + provider?: string + host?: ExecutionHostId + wslDistro?: string | null + dir?: string + } = {} +): AgentSessionRecord => + agentSessionRecordFixture({ + sessionId, + provider: overrides.provider ?? 'claude', + location: { + executionHostId: overrides.host ?? LOCAL_EXECUTION_HOST_ID, + wslDistro: overrides.wslDistro ?? null + }, + ...(overrides.dir ? { launchDirectory: overrides.dir } : {}) + }) + +function prewarmWith(records: AgentSessionRecord[], resolveCommand = () => '/bin/claude') { + const prewarm = vi.fn() + const done = prewarmClaudeCliFlags({ + cliFlags: { prewarm }, + store: { + listVisibleSessionIds: () => records.map((r) => r.sessionId), + getRecord: (id) => records.find((r) => r.sessionId === id) ?? null + }, + resolveCommand, + resolveEnv: () => ({ PATH: '/shims' }), + resolveInheritedEnv: async () => ({ PATH: '/usr/bin' }) + }) + return { prewarm, done } +} + +describe('prewarming the Claude version check at startup', () => { + it("probes the home folder and each open local Claude chat's own folder, a few at most", async () => { + const { prewarm, done } = prewarmWith([ + record('a', { dir: '/scratch/a' }), + record('codex', { provider: 'codex', dir: '/scratch/codex' }), + record('remote', { host: 'ssh:box', dir: '/scratch/remote' }), + record('wsl', { wslDistro: 'Ubuntu', dir: '/scratch/wsl' }), + record('b', { dir: '/scratch/b' }), + record('unpinned'), + record('c', { dir: '/scratch/c' }), + record('d', { dir: '/scratch/d' }) + ]) + await done + expect(prewarm.mock.calls.map(([launch]) => launch.cwd)).toEqual([ + homedir(), + '/scratch/a', + '/scratch/b', + '/scratch/c' + ]) + expect(prewarm.mock.calls[0]?.[0]).toMatchObject({ command: '/bin/claude' }) + }) + + it('never throws when the CLI cannot be resolved', async () => { + const { prewarm, done } = prewarmWith([], () => { + throw new Error('the claude Command setting is not a runnable program') + }) + await expect(done).resolves.toBeUndefined() + expect(prewarm).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/claude/claude-cli-flag-prewarm.ts b/src/main/claude/claude-cli-flag-prewarm.ts new file mode 100644 index 000000000000..7278f5ad430a --- /dev/null +++ b/src/main/claude/claude-cli-flag-prewarm.ts @@ -0,0 +1,52 @@ +// Asks the Claude CLI for its version when native chat starts, so a chat's launch usually finds the +// answer already known instead of waiting on a probe of a cold binary. + +import { homedir } from 'node:os' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import type { ClaudeCliFlagSupport } from './claude-cli-flag-support' +import { + claudeProbeEnv, + resolveClaudeChildEnvSources, + type ClaudeEnvDeps +} from './claude-structured-child-env' + +// Why a few: each is one short `--version` at startup; enough for the chats the user has open. +const MAX_PREWARMED_FOLDERS = 4 + +/** + * Probes in the home folder, which loads the binary from a cold disk, and in the folder of each open + * Claude chat pinned to one (the probe answer is per folder: a version manager's shim can pick a + * different CLI per project). Never resolves a workspace or lists worktrees, and never throws. + */ +export async function prewarmClaudeCliFlags( + deps: ClaudeEnvDeps & { + cliFlags: Pick + store: Pick + } +): Promise { + try { + const sources = await resolveClaudeChildEnvSources(deps) + const folders = new Set([homedir()]) + for (const sessionId of deps.store.listVisibleSessionIds()) { + if (folders.size >= MAX_PREWARMED_FOLDERS) { + break + } + const record = deps.store.getRecord(sessionId) + if ( + record?.provider === 'claude' && + record.location.executionHostId === LOCAL_EXECUTION_HOST_ID && + record.location.wslDistro === null && + record.launchDirectory + ) { + folders.add(record.launchDirectory) + } + } + const env = claudeProbeEnv(sources) + for (const cwd of folders) { + deps.cliFlags.prewarm({ command: sources.command, cwd, env }) + } + } catch { + // A launch probes for itself; this only saves it the wait. + } +} diff --git a/src/main/claude/claude-cli-flag-support.test.ts b/src/main/claude/claude-cli-flag-support.test.ts new file mode 100644 index 000000000000..f61dda478f38 --- /dev/null +++ b/src/main/claude/claude-cli-flag-support.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it, vi } from 'vitest' +import { + CLAUDE_PLUGIN_DIR_FLAG, + CLAUDE_THINKING_DISPLAY_FLAG, + createClaudeCliFlagSupport +} from './claude-cli-flag-support' + +const LAUNCH = { command: '/bin/claude', cwd: '/repo', env: { PATH: '/shims' } } +const UNKNOWN_FLAG = new Error( + "claude stream-json exited (code 1): error: unknown option '--thinking-display'" +) + +type Support = ReturnType + +const thinking = (support: Support, launch: typeof LAUNCH): Promise => + support.supports(CLAUDE_THINKING_DISPLAY_FLAG, launch) + +type Probe = ( + command: string, + launch: { cwd: string; env: Record; timeoutMs: number } +) => Promise + +function supportWith( + probe: Probe, + budgetMs = 20, + keyOf = async (command: string, cwd: string): Promise => `${command}\n${cwd}` +) { + const calls = vi.fn(probe) + // Real time plus whatever a test skips ahead. + let skippedMs = 0 + const support = createClaudeCliFlagSupport({ + probe: calls, + keyOf, + budgetMs, + now: () => performance.now() + skippedMs + }) + return { support, calls, skip: (ms: number) => (skippedMs += ms) } +} + +/** A probe the test answers by hand. */ +function heldProbe() { + let answer: (version: string | null) => void = () => {} + const probe: Probe = () => + new Promise((resolve) => { + answer = resolve + }) + return { probe, answer: (version: string | null) => answer(version) } +} + +describe('whether a launch passes a Claude CLI flag', () => { + it("probes with the launch's own cwd and env and its own kill timeout, then remembers", async () => { + const { support, calls } = supportWith(async () => '2.1.280') + await expect(thinking(support, LAUNCH)).resolves.toBe(true) + await expect(thinking(support, LAUNCH)).resolves.toBe(true) + expect(calls).toHaveBeenCalledTimes(1) + expect(calls).toHaveBeenCalledWith('/bin/claude', { + cwd: '/repo', + env: { PATH: '/shims' }, + timeoutMs: 10_000 + }) + }) + + it('asks again per workspace: a shim can pick a different CLI there', async () => { + const { support, calls } = supportWith(async () => '2.1.280') + await thinking(support, LAUNCH) + await thinking(support, { ...LAUNCH, cwd: '/other' }) + expect(calls).toHaveBeenCalledTimes(2) + }) + + it('passes nothing to a CLI older than the flag, and keeps that answer for good', async () => { + const { support, calls, skip } = supportWith(async () => '2.1.92') + await expect(thinking(support, LAUNCH)).resolves.toBe(false) + skip(60 * 60_000) + await expect(thinking(support, LAUNCH)).resolves.toBe(false) + expect(calls).toHaveBeenCalledTimes(1) + }) + + it('never remembers a probe that printed no version, failed or was killed: the next launch asks', async () => { + for (const failure of [async () => null, () => Promise.reject(new Error('EMFILE'))]) { + let answer: () => Promise = failure + const { support, calls } = supportWith(() => answer()) + await expect(thinking(support, LAUNCH)).resolves.toBe(false) + // A loaded boot is exactly when probes fail; the next launch must not inherit that. + answer = async () => '2.1.280' + await expect(thinking(support, LAUNCH)).resolves.toBe(true) + expect(calls).toHaveBeenCalledTimes(2) + } + }) + + it('waits as long as the caller allows for a probe slower than the default budget', async () => { + const { support, calls } = supportWith( + () => new Promise((resolve) => setTimeout(() => resolve('2.1.280'), 60)), + 20 + ) + const [plugins, thinkingDisplay] = await Promise.all([ + support.supports(CLAUDE_PLUGIN_DIR_FLAG, LAUNCH, 1_000), + thinking(support, LAUNCH) + ]) + // One probe answers both; only the caller that allowed for it waited. + expect(plugins).toBe(true) + expect(thinkingDisplay).toBe(false) + expect(calls).toHaveBeenCalledTimes(1) + await expect(thinking(support, LAUNCH)).resolves.toBe(true) + }) + + it('bounds even a long budget: a probe that never answers costs at most that budget', async () => { + const held = heldProbe() + const { support } = supportWith(held.probe, 20) + const started = performance.now() + await expect(support.supports(CLAUDE_PLUGIN_DIR_FLAG, LAUNCH, 80)).resolves.toBe(false) + expect(performance.now() - started).toBeLessThan(500) + }) + + it('waits for a probe that answers within the budget', async () => { + const { support } = supportWith( + () => new Promise((resolve) => setTimeout(() => resolve('2.1.280'), 5)), + 1_000 + ) + await expect(thinking(support, LAUNCH)).resolves.toBe(true) + }) + + it('does not wait again on a probe already past its budget, and keeps its late answer', async () => { + const held = heldProbe() + const { support, calls } = supportWith(held.probe, 30) + await expect(thinking(support, LAUNCH)).resolves.toBe(false) + const started = performance.now() + await expect(thinking(support, LAUNCH)).resolves.toBe(false) + expect(performance.now() - started).toBeLessThan(25) + held.answer('2.1.280') + await vi.waitFor(async () => expect(await thinking(support, LAUNCH)).toBe(true)) + expect(calls).toHaveBeenCalledTimes(1) + }) + + it('stops passing the flag to a binary that exited refusing it', async () => { + const { support, calls } = supportWith(async () => '2.1.280') + await thinking(support, LAUNCH) + support.observeExit(LAUNCH, UNKNOWN_FLAG) + await vi.waitFor(async () => expect(await thinking(support, LAUNCH)).toBe(false)) + expect(calls).toHaveBeenCalledTimes(1) + }) + + it('keeps a refusal seen while a probe was still running', async () => { + const held = heldProbe() + const { support } = supportWith(held.probe, 10) + await thinking(support, LAUNCH) + support.observeExit(LAUNCH, UNKNOWN_FLAG) + await new Promise((resolve) => setTimeout(resolve, 0)) + held.answer('2.1.280') + await new Promise((resolve) => setTimeout(resolve, 0)) + await expect(thinking(support, LAUNCH)).resolves.toBe(false) + }) + + it("keeps the flag through another failure or another option's refusal", async () => { + const { support } = supportWith(async () => '2.1.280') + await thinking(support, LAUNCH) + support.observeExit(LAUNCH, new Error('claude stream-json exited (code 1): not signed in')) + support.observeExit(LAUNCH, new Error("error: unknown option '--thinking'")) + await new Promise((resolve) => setTimeout(resolve, 0)) + await expect(thinking(support, LAUNCH)).resolves.toBe(true) + }) + + it('spends the budget finding the binary too, and caches nothing when that runs out', async () => { + let slow = true + const { support, calls } = supportWith( + async () => '2.1.280', + 30, + async (command, cwd) => { + if (slow) { + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + return `${command}\n${cwd}` + } + ) + const started = performance.now() + await expect(thinking(support, LAUNCH)).resolves.toBe(false) + expect(performance.now() - started).toBeLessThan(500) + expect(calls).not.toHaveBeenCalled() + slow = false + await expect(thinking(support, LAUNCH)).resolves.toBe(true) + }) + + it('forgets the binaries launched least recently, not the ones in use', async () => { + const { support, calls } = supportWith(async () => '2.1.280') + const at = (index: number) => ({ ...LAUNCH, cwd: `/repo-${index}` }) + for (let index = 1; index <= 32; index += 1) { + await thinking(support, at(index)) + } + await thinking(support, at(1)) + await thinking(support, at(33)) + expect(calls).toHaveBeenCalledTimes(33) + await thinking(support, at(1)) + expect(calls).toHaveBeenCalledTimes(33) + await thinking(support, at(2)) + expect(calls).toHaveBeenCalledTimes(34) + }) + + it('answers every flag from one probe, each against its own first version', async () => { + const { support, calls } = supportWith(async () => '2.1.0') + await expect(support.supports(CLAUDE_PLUGIN_DIR_FLAG, LAUNCH)).resolves.toBe(true) + await expect(thinking(support, LAUNCH)).resolves.toBe(false) + expect(calls).toHaveBeenCalledTimes(1) + }) + + it('shares a probe still running between flags', async () => { + const held = heldProbe() + const { support, calls } = supportWith(held.probe, 1_000) + const both = Promise.all([ + thinking(support, LAUNCH), + support.supports(CLAUDE_PLUGIN_DIR_FLAG, LAUNCH) + ]) + await vi.waitFor(() => expect(calls).toHaveBeenCalled()) + held.answer('2.1.280') + await expect(both).resolves.toEqual([true, true]) + expect(calls).toHaveBeenCalledTimes(1) + }) + + it('withdraws only the flag a binary refused', async () => { + const { support } = supportWith(async () => '2.1.280') + await thinking(support, LAUNCH) + support.observeExit(LAUNCH, new Error("error: unknown option '--plugin-dir'")) + await vi.waitFor(async () => + expect(await support.supports(CLAUDE_PLUGIN_DIR_FLAG, LAUNCH)).toBe(false) + ) + await expect(thinking(support, LAUNCH)).resolves.toBe(true) + }) + + it('keeps a refusal through a failed probe and a later one that answers', async () => { + let version: string | null = null + const { support } = supportWith(async () => version) + await thinking(support, LAUNCH) + support.observeExit(LAUNCH, UNKNOWN_FLAG) + await new Promise((resolve) => setTimeout(resolve, 0)) + version = '2.1.280' + await expect(support.supports(CLAUDE_PLUGIN_DIR_FLAG, LAUNCH)).resolves.toBe(true) + await expect(thinking(support, LAUNCH)).resolves.toBe(false) + }) + + it('prewarms without waiting, so a later launch finds the answer and spawns nothing', async () => { + const held = heldProbe() + const { support, calls } = supportWith(held.probe, 20) + support.prewarm(LAUNCH) + await vi.waitFor(() => expect(calls).toHaveBeenCalledOnce()) + held.answer('2.1.280') + await expect(support.supports(CLAUDE_PLUGIN_DIR_FLAG, LAUNCH)).resolves.toBe(true) + support.prewarm(LAUNCH) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(calls).toHaveBeenCalledOnce() + }) + + it('a prewarm that finds no binary probes nothing and throws nothing', async () => { + const { support, calls } = supportWith( + async () => '2.1.280', + 20, + async () => { + throw new Error('EACCES') + } + ) + support.prewarm(LAUNCH) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(calls).not.toHaveBeenCalled() + }) + + it('answers from what is known without starting a probe when asked not to', async () => { + const { support, calls } = supportWith(async () => '2.1.280') + await expect(support.supports(CLAUDE_THINKING_DISPLAY_FLAG, LAUNCH, 250, false)).resolves.toBe( + false + ) + expect(calls).not.toHaveBeenCalled() + await support.supports(CLAUDE_PLUGIN_DIR_FLAG, LAUNCH) + await expect(support.supports(CLAUDE_THINKING_DISPLAY_FLAG, LAUNCH, 250, false)).resolves.toBe( + true + ) + expect(calls).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/claude/claude-cli-flag-support.ts b/src/main/claude/claude-cli-flag-support.ts new file mode 100644 index 000000000000..2b68c66fcb1e --- /dev/null +++ b/src/main/claude/claude-cli-flag-support.ts @@ -0,0 +1,201 @@ +import { realpath, stat } from 'node:fs/promises' +import { claudeVersionReaches, probeClaudeCliVersion } from './claude-hook-event-versions' + +/** A launch flag an older Claude CLI exits on, and the first published CLI whose parser defines it. + * Versions were found by reading published packages, not by running them. */ +export type ClaudeCliFlag = { readonly option: string; readonly firstVersion: string } + +// Why 2.1.94: 2.1.93 was never published. +export const CLAUDE_THINKING_DISPLAY_FLAG: ClaudeCliFlag = { + option: '--thinking-display', + firstVersion: '2.1.94' +} + +// Why 2.0.25: 2.0.24's parser has no such option. +export const CLAUDE_PLUGIN_DIR_FLAG: ClaudeCliFlag = { + option: '--plugin-dir', + firstVersion: '2.0.25' +} + +/** How long a launch waits on a binary nothing is known about yet. A warm probe answers in tens of + * milliseconds; this covers a cold disk, a node install and an antivirus scan, paid once per key + * during a start that already takes seconds. */ +export const CLAUDE_CLI_FLAG_PROBE_BUDGET_MS = 1_500 + +/** A probe still running by now is killed, and its binary gets no flag. */ +export const CLAUDE_CLI_FLAG_PROBE_KILL_AFTER_MS = 10_000 + +/** Commander's refusal, exactly: any other startup failure says nothing about a flag. */ +const UNKNOWN_OPTION_DIAGNOSTIC = /unknown option '(--[a-z][a-z0-9-]*)'/ + +// One small entry per binary per workspace it launched in; enough for every worktree in active use. +const MAX_REMEMBERED = 32 + +export type ClaudeCliLaunch = { + command: string + cwd: string + env: Record +} + +type ClaudeVersionProbe = ( + command: string, + launch: { cwd: string; env: Record; timeoutMs: number } +) => Promise + +export type ClaudeCliFlagSupport = { + /** + * Whether this launch's CLI takes `flag`. A binary not yet known is probed once for its version + * with the launch's own cwd and env, waited on for at most the budget (the caller's, or the + * default) from when its probe began; past it the answer is no for this launch only. Every flag + * shares that one probe. + */ + supports: ( + flag: ClaudeCliFlag, + launch: ClaudeCliLaunch, + budgetMs?: number, + /** False answers from what is known or already being asked, never starting a new probe. */ + startProbe?: boolean + ) => Promise + /** Starts the version probe for a binary nothing is known about yet, without waiting on it, so a + * later launch finds the answer. Never throws. */ + prewarm: (launch: ClaudeCliLaunch) => void + /** A child that exited refusing a flag: that binary, in that workspace, never gets it again. */ + observeExit: (launch: Pick, error: Error) => void +} + +/** Which binary a command is in a workspace right now: a shim answers per project, and a + * self-update swaps the link's target or the file. */ +async function claudeBinaryKey(command: string, cwd: string): Promise { + try { + const target = await realpath(command) + return `${target}\n${(await stat(target)).mtimeMs}\n${cwd}` + } catch { + return null + } +} + +/** The promise's value if it settles within `ms`, else undefined. */ +async function within(pending: Promise, ms: number): Promise { + let timer: ReturnType | undefined + const expired = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), Math.max(0, ms)) + timer.unref?.() + }) + try { + return await Promise.race([pending, expired]) + } finally { + clearTimeout(timer) + } +} + +/** `version` undefined: not known yet. */ +type BinaryFacts = { version?: string; refused: Set } + +export function createClaudeCliFlagSupport( + deps: { + probe: ClaudeVersionProbe + keyOf: (command: string, cwd: string) => Promise + budgetMs: number + now: () => number + } = { + probe: probeClaudeCliVersion, + keyOf: claudeBinaryKey, + budgetMs: CLAUDE_CLI_FLAG_PROBE_BUDGET_MS, + now: () => performance.now() + } +): ClaudeCliFlagSupport { + const known = new Map() + const probing = new Map; startedAt: number }>() + /** The key's facts, marked most recently used; the bound drops the binaries launched least recently. */ + const touch = (key: string): BinaryFacts => { + const facts = known.get(key) ?? { refused: new Set() } + known.delete(key) + known.set(key, facts) + for (const stale of known.keys()) { + if (known.size <= MAX_REMEMBERED) { + break + } + known.delete(stale) + } + return facts + } + // Only a version is remembered, for the binary's life. A probe that gave none (killed, failed to + // spawn, unparseable) is forgotten, so the next launch asks again: a latched failure would cost + // the flag exactly when probes are slowest, at boot. A refusal is remembered on its own. + const settle = (key: string, version: string | null): void => { + if (version !== null) { + touch(key).version = version + } + } + const versionOf = (key: string): string | undefined => + known.has(key) ? touch(key).version : undefined + const answer = (key: string, flag: ClaudeCliFlag): boolean => { + const facts = known.get(key) + return ( + facts?.version !== undefined && + !facts.refused.has(flag.option) && + claudeVersionReaches(facts.version, flag.firstVersion) + ) + } + const probe = (key: string, launch: ClaudeCliLaunch) => { + const settled = deps + .probe(launch.command, { + cwd: launch.cwd, + env: launch.env, + timeoutMs: CLAUDE_CLI_FLAG_PROBE_KILL_AFTER_MS + }) + .then( + (version) => settle(key, version), + () => settle(key, null) + ) + .finally(() => probing.delete(key)) + const started = { settled, startedAt: deps.now() } + probing.set(key, started) + return started + } + + return { + supports: async (flag, launch, budgetMs = deps.budgetMs, startProbe = true) => { + // The launch never waits longer than the budget, finding the binary included. + const deadline = deps.now() + budgetMs + const key = await within(deps.keyOf(launch.command, launch.cwd), budgetMs) + if (key === undefined || key === null) { + return false + } + if (versionOf(key) !== undefined) { + return answer(key, flag) + } + const running = probing.get(key) ?? (startProbe ? probe(key, launch) : undefined) + if (!running) { + return false + } + // The probe's own budget, too: one already past it is not waited on again. + await within(running.settled, Math.min(running.startedAt + budgetMs, deadline) - deps.now()) + return answer(key, flag) + }, + prewarm: (launch) => { + void deps.keyOf(launch.command, launch.cwd).then( + (key) => { + if (key !== null && versionOf(key) === undefined && !probing.has(key)) { + probe(key, launch) + } + }, + () => {} + ) + }, + observeExit: (launch, error) => { + const option = UNKNOWN_OPTION_DIAGNOSTIC.exec(error.message)?.[1] + if (!option) { + return + } + void deps.keyOf(launch.command, launch.cwd).then((key) => { + if (key !== null) { + touch(key).refused.add(option) + } + }) + } + } +} + +/** One per process: every structured launch on this host shares what it learned. */ +export const claudeCliFlagSupport = createClaudeCliFlagSupport() diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index f3a2646107cf..26dd3c601b03 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -15,7 +15,6 @@ export type ClaudeModelCatalogProbeDeps = Pick< ClaudeStructuredLaunchResolverDeps, 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' | 'resolveAuthPolicy' > & { - authSwitchSettleTimeoutMs?: number /** Test seams; production runs the one-shot listing child. */ discover?: typeof discoverModelsLocal spawnAgent?: typeof spawnSourceControlAgent diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index f9c61b9b8e4b..e23154524815 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -5,7 +5,6 @@ import { join } from 'node:path' import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' -import { hasLiveClaudePtys } from '../claude-accounts/live-pty-gate' import type { ProcessSpec } from '../../shared/child-process/process-spec' import { query, type CanUseTool, type Options } from '@anthropic-ai/claude-agent-sdk' import { @@ -757,85 +756,3 @@ describe('Claude stream-json connection', () => { await expect(connection.close()).resolves.toBe(true) }, 20_000) }) - -// A structured Claude child owns the account's credentials while it runs, exactly as -// a Claude PTY does. The gate is what makes runtime-auth-sync defer the managed OAuth -// refresh instead of rotating the single-use token out from under a live session, and -// structured sessions used to be invisible to it. -describe('the managed-auth live gate', () => { - it('holds while a structured child runs and releases when it ends', async () => { - // The gate is a process-wide singleton and a sibling test's release lands on its - // child's 'close' event, which can settle after that test's close() resolved. - await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') - const scenario = scriptScenario([ - { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, - { wait: HOLD_OPEN } - ]) - const connection = await open(launchFor(scenario)) - - expect(hasLiveClaudePtys()).toBe(true) - - await connection.close() - - await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain') - expect(hasLiveClaudePtys()).toBe(false) - }, 30_000) - - it('releases when the child dies on its own rather than through close()', async () => { - await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') - const scenario = scriptScenario([ - { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, - { wait: HOLD_OPEN } - ]) - await open(launchFor(scenario)) - expect(hasLiveClaudePtys()).toBe(true) - - spawnedChildren.at(-1)?.kill('SIGKILL') - - await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain') - expect(hasLiveClaudePtys()).toBe(false) - }, 30_000) - - // The gate entry is deliberately unpersisted, so confirmSeededClaudeLivePtys can never - // reconcile a stray one: a leak here defers the managed OAuth refresh for the life of - // the process. Entering the gate only after the release handlers are attached makes - // that unreachable regardless of what the setup in between does. - it('leaks no gate entry when setup throws between spawn and handler attachment', async () => { - await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') - const scenario = scriptScenario([ - { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, - { wait: HOLD_OPEN } - ]) - let started: SpawnedProcess | null = null - - try { - await expect( - openClaudeStreamJsonConnection(launchFor(scenario), {}, (spec) => { - const child = spawnProcess(spec) - started = child - const attach = child.stderr.on.bind(child.stderr) - // Measured attach order: the SDK binds stderr 'data' from inside query(), - // before the child is even assigned. The SECOND bind is this connection's own - // armTreeOnOutput — the first statement that runs after the child exists and - // before its 'exit'/'close' release handlers. Throwing on the first is - // vacuous: it escapes before any gate entry could have happened. - let dataAttaches = 0 - child.stderr.on = ((event: string, listener: (...args: unknown[]) => void) => { - if (event === 'data') { - dataAttaches += 1 - if (dataAttaches === 2) { - throw new Error('stderr listener attach failed') - } - } - return attach(event, listener) - }) as typeof child.stderr.on - return child - }) - ).rejects.toThrow('stderr listener attach failed') - - expect(hasLiveClaudePtys()).toBe(false) - } finally { - ;(started as SpawnedProcess | null)?.kill('SIGKILL') - } - }, 30_000) -}) diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index e8ada34f0b91..bbf0db1c115d 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -1,12 +1,7 @@ -import { randomUUID } from 'node:crypto' import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk' import type { CanUseTool, OnUserDialog, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' import { spawnProcess } from '../../shared/child-process/run-process' -import { - markClaudeStructuredChildExited, - markClaudeStructuredChildSpawned -} from '../claude-accounts/live-pty-gate' import { buildClaudeChildProcessEnv } from './claude-child-process-environment' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' import { @@ -155,12 +150,6 @@ export async function openClaudeStreamJsonConnection( if (!child || !managed) { throw new Error('the claude agent SDK returned without spawning a child') } - // This child owns the account's credentials for as long as it runs, exactly as a - // Claude PTY does — hold the OAuth-refresh gate so a managed refresh cannot rotate - // the single-use token out from under it mid-turn. Entered below, once a release - // path exists. - const authGateKey = randomUUID() - const releaseAuthGate = (): void => markClaudeStructuredChildExited(authGateKey) let exitStatus: ExitStatus | null = null let closing = false let terminalError: Error | null = null @@ -256,7 +245,6 @@ export async function openClaudeStreamJsonConnection( managed.onExit((exit) => { exitStatus = exit - releaseAuthGate() handleUnexpectedEnd() }) child.on('error', (error) => { @@ -266,7 +254,6 @@ export async function openClaudeStreamJsonConnection( handleUnexpectedEnd(error) }) child.on('close', () => { - releaseAuthGate() handleUnexpectedEnd() }) child.stdin.on('error', (error) => { @@ -275,13 +262,6 @@ export async function openClaudeStreamJsonConnection( handleUnexpectedEnd(error) } }) - // Why here and not at spawn: a structured gate entry is deliberately unpersisted, so - // confirmSeededClaudeLivePtys can never reconcile a stray one and a leak defers the - // managed OAuth refresh for the life of the process. Entering only after 'exit' and - // 'close' are attached makes that unreachable — any later throw still leaves a - // listener that releases. Nothing between spawn and here can yield, so the child - // cannot end before the gate is entered. - markClaudeStructuredChildSpawned(authGateKey) const send: ClaudeStreamJsonConnection['send'] = (message, beforeDispatch) => { if ( diff --git a/src/main/claude/claude-structured-agent-definition.ts b/src/main/claude/claude-structured-agent-definition.ts index 991d681c988c..447ecb79a3be 100644 --- a/src/main/claude/claude-structured-agent-definition.ts +++ b/src/main/claude/claude-structured-agent-definition.ts @@ -1,9 +1,9 @@ -import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import type { DirectoryAccountAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import { CLAUDE_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' import { isClaudeStructuredOptionKey } from './claude-structured-options' import { claudeFallbackModelOptions } from './claude-structured-session-options' -export const CLAUDE_STRUCTURED_AGENT: StructuredAgentDefinition = { +export const CLAUDE_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { agent: 'claude', handleTransport: CLAUDE_STRUCTURED_HANDLE_NAMESPACE.transport, accountHomeVariable: 'CLAUDE_CONFIG_DIR', diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index 227fb117914c..2a22fae853ae 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -1,20 +1,8 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' -import { - CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE -} from '../claude-accounts/environment' +import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE } from '../claude-accounts/environment' import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' -import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' -import { - PROVIDER_SESSION_ID, - adapterFor, - fakeClaude, - identityFor -} from './claude-structured-session-test-support' -import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'orca-session-auth' const IDENTITY = { sessionId: SESSION_ID } as Parameters< @@ -39,7 +27,6 @@ function record(): AgentSessionRecord { function resolverFor(options: { stripAuthEnv: boolean overlay?: Record - authSwitchSettleTimeoutMs?: number }): ReturnType { return createClaudeStructuredLaunchResolver({ resolveLaunchArgs: () => [], @@ -47,41 +34,10 @@ function resolverFor(options: { resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: options.stripAuthEnv }), - authSwitchSettleTimeoutMs: options.authSwitchSettleTimeoutMs ?? 20, ...(options.overlay ? { resolveEnv: () => options.overlay as Record } : {}) }) } -/** - * An adapter driven by the REAL launch resolver, not the stub in the shared test - * support — the stub has no auth guard at all, so a teardown-window test built on it - * would pass whatever the guard did. - */ -function realResolverAdapter( - claude: ReturnType, - authSwitchSettleTimeoutMs: number -): ClaudeStructuredSessionAdapter { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only the chain head's handle from this partial record. - const resumable = { - ...record(), - providerHandleChain: [{ handle: claudeProviderHandle(PROVIDER_SESSION_ID, null) }] - } as unknown as AgentSessionRecord - return new ClaudeStructuredSessionAdapter({ - resolveLaunch: createClaudeStructuredLaunchResolver({ - resolveLaunchArgs: () => [], - store: { getRecord: () => resumable, pinLaunchDirectory: vi.fn() }, - resolveWorkspacePath: async (id) => `/repos/${id}`, - resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), - authSwitchSettleTimeoutMs - }), - openConnection: claude.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => 1_700_000_000_500, - persistHandle: async () => {} - }) -} - function withAmbientAuth(value: string, run: () => Promise): Promise { const restore = process.env.ANTHROPIC_API_KEY process.env.ANTHROPIC_API_KEY = value @@ -95,10 +51,6 @@ function withAmbientAuth(value: string, run: () => Promise): Promise { } describe('claude structured auth parity with the terminal preflight', () => { - afterEach(() => { - endClaudeAuthSwitch() - }) - // Task 1 — the terminal preflight refuses this at spawn-env.ts:25 and // runtime/spawn-preflight.ts:139; the structured path used to let the override win. it('refuses an explicit Anthropic auth override while a managed account is pinned', async () => { @@ -161,91 +113,4 @@ describe('claude structured auth parity with the terminal preflight', () => { expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined() }) }) - - // Task 3 — the terminal preflight guards this at four sites; the structured path had none. - it('refuses launch resolution when an account switch never settles', async () => { - beginClaudeAuthSwitch() - - await expect( - resolverFor({ stripAuthEnv: true, authSwitchSettleTimeoutMs: 20 })({ identity: IDENTITY }) - ).rejects.toMatchObject({ - message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, - reason: 'accountSwitchInProgress' - }) - }) - - it('waits a settling account switch out rather than refusing a resolved launch', async () => { - beginClaudeAuthSwitch() - setTimeout(() => endClaudeAuthSwitch(), 20) - - const launch = await resolverFor({ - stripAuthEnv: true, - authSwitchSettleTimeoutMs: 5_000 - })({ identity: IDENTITY }) - - expect(launch.claudeConfigDir).toBe('/home/work/.claude') - }) - - it('refuses an acquire before it tears the previous session down', async () => { - const claude = fakeClaude() - const adapter = adapterFor(claude) - beginClaudeAuthSwitch() - - await expect( - adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - ).rejects.toMatchObject({ - message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, - reason: 'accountSwitchInProgress' - }) - // Nothing was spawned, so the refusal must not have opened a connection. - expect(claude.connections).toHaveLength(0) - }) - - // The teardown between the entry guard and launch resolution closes the live child - // and proves its tree — seconds, not milliseconds. A switch that begins inside it - // has already cost the user their session, so refusing there produces exactly the - // outcome the entry guard advertises against: a dead chat and no replacement. - it('replaces the session when a switch begins inside the acquire teardown', async () => { - const claude = fakeClaude() - const adapter = realResolverAdapter(claude, 5_000) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - const live = claude.connections[0]! - const closeWithSwitch = live.close - live.close = async () => { - beginClaudeAuthSwitch() - setTimeout(() => endClaudeAuthSwitch(), 20) - return closeWithSwitch() - } - - await expect( - adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) - ).resolves.toMatchObject({ process: { spawnToken: 'spawn-10' } }) - expect(live.closed).toBe(true) - // The replacement child exists: the user's chat came back. - expect(claude.connections).toHaveLength(2) - expect(claude.connections[1]!.closed).toBe(false) - await adapter.closeAll() - }) - - it('still refuses a mid-teardown switch that never settles, leaving nothing half-open', async () => { - const claude = fakeClaude() - const adapter = realResolverAdapter(claude, 20) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - const live = claude.connections[0]! - const closeWithSwitch = live.close - live.close = async () => { - beginClaudeAuthSwitch() - return closeWithSwitch() - } - - await expect( - adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) - ).rejects.toMatchObject({ - message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, - reason: 'accountSwitchInProgress' - }) - // No replacement child was opened, so nothing is left running unowned. - expect(claude.connections).toHaveLength(1) - await adapter.closeAll() - }) }) diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index dcdad5b868bf..2d14a9e0c047 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -131,6 +131,8 @@ function restingRecord(options: Record): AgentSessionRecord { provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, location: { wslDistro: null }, + // Why released: a chat at rest has no live claim, so its next start reads the current account. + lease: { claimStatus: 'released' }, options } as unknown as AgentSessionRecord } diff --git a/src/main/claude/claude-structured-history-window.test.ts b/src/main/claude/claude-structured-history-window.test.ts index e5ed29561cbc..f0a2e78841c7 100644 --- a/src/main/claude/claude-structured-history-window.test.ts +++ b/src/main/claude/claude-structured-history-window.test.ts @@ -5,7 +5,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { structuredAgentSessionSendBody } from '../../shared/structured-agent-session-outbox' +import { structuredAgentSessionSendBody } from '../../shared/structured-agent-session-send-mutation' import { structuredAgentSessionPayloadFingerprint } from '../../shared/structured-agent-session-mutation' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { reconcileSubmissions } from '../native-chat/agent-session-journal/journal-submission-reconciler' diff --git a/src/main/claude/claude-structured-launch-account-routing.test.ts b/src/main/claude/claude-structured-launch-account-routing.test.ts index 031559a44387..0d7babfb8f5d 100644 --- a/src/main/claude/claude-structured-launch-account-routing.test.ts +++ b/src/main/claude/claude-structured-launch-account-routing.test.ts @@ -4,8 +4,8 @@ import { join, resolve as resolvePath } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { CLAUDE_PROFILE_SETUP_FAILED_MESSAGE } from '../../shared/claude-profile-routing' import { - CLAUDE_PROFILE_MISSING_MESSAGE, ClaudeProfileRouter, type ClaudeProfileRouterSettings } from '../claude-accounts/claude-profile-router' @@ -32,7 +32,10 @@ it('launches each acquisition under the current selection, not the account it wa getSettings: () => settings, dataRoot: root, userHome: join(root, 'personal'), - env: { CLAUDE_CONFIG_DIR: resolvePath('/user/own') } + env: { CLAUDE_CONFIG_DIR: resolvePath('/user/own') }, + runSetup: async () => { + throw new Error('setup refused') + } }) installClaudeProfileRouter(router) const record = { @@ -56,7 +59,12 @@ it('launches each acquisition under the current selection, not the account it wa providerHandle: claudeProviderHandle('unused', null) } - await expect(resolve({ identity })).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) + // A missing folder is set up first; typed when that fails, so the chat names the situation. + await expect(resolve({ identity })).rejects.toMatchObject({ + name: 'AgentSessionPreSpawnError', + reason: 'claudeAccountSetupFailed', + message: CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + }) const home = join(root, 'claude-profiles', 'a', 'home') mkdirSync(home, { recursive: true }) diff --git a/src/main/claude/claude-structured-launch-flags.ts b/src/main/claude/claude-structured-launch-flags.ts new file mode 100644 index 000000000000..32ccef04dff3 --- /dev/null +++ b/src/main/claude/claude-structured-launch-flags.ts @@ -0,0 +1,64 @@ +// The version-gated parts of a structured Claude launch: readable thinking, and the chat's visuals +// folder with its skill plugin. Both ask one shared version probe of the launch's own CLI. + +import type { + NativeChatVisualsLaunch, + PrepareNativeChatVisuals +} from '../native-chat/native-chat-visuals-delivery' +import { + CLAUDE_CLI_FLAG_PROBE_KILL_AFTER_MS, + CLAUDE_PLUGIN_DIR_FLAG, + CLAUDE_THINKING_DISPLAY_FLAG, + type ClaudeCliFlagSupport, + type ClaudeCliLaunch +} from './claude-cli-flag-support' + +const THINKING_DISPLAY_ARGS: Readonly> = { 'thinking-display': 'summarized' } + +/** How long the thinking flag is asked again once the visuals check has waited for the version: + * enough to find the binary and read the now-known answer. */ +const THINKING_RECHECK_BUDGET_MS = 250 + +export type ClaudeLaunchFlags = { + /** Readable thinking: the CLI otherwise streams thinking blocks with no text under Orca's + * launch. Only the display is set, never `--thinking`, so a user who turned thinking off keeps + * it off. */ + thinkingDisplayArgs: Readonly> + visuals: { visuals: NativeChatVisualsLaunch; pluginDir: string | null } | null +} + +export async function resolveClaudeLaunchFlags( + deps: { + cliFlags?: Pick + prepareVisuals?: PrepareNativeChatVisuals + }, + sessionId: string, + launch: ClaudeCliLaunch +): Promise { + const thinking = (budgetMs?: number, startProbe?: boolean) => + deps.cliFlags?.supports(CLAUDE_THINKING_DISPLAY_FLAG, launch, budgetMs, startProbe) ?? false + // Unlike the thinking display, a missed answer costs the chat its skill for its whole life, so + // the plugin check waits for the probe up to its own kill time: bounded, and instant once known. + const preparedVisuals = deps.prepareVisuals?.(sessionId) ?? Promise.resolve(null) + const [thinksFirst, visuals, loadsPlugins] = await Promise.all([ + thinking(), + preparedVisuals, + preparedVisuals.then((visuals) => + visuals + ? (deps.cliFlags?.supports( + CLAUDE_PLUGIN_DIR_FLAG, + launch, + CLAUDE_CLI_FLAG_PROBE_KILL_AFTER_MS + ) ?? false) + : false + ) + ]) + // The plugin check may have outwaited the thinking budget and learned the version meanwhile; a + // probe that gave none is not asked again here. + const thinks = + thinksFirst || (visuals ? await thinking(THINKING_RECHECK_BUDGET_MS, false) : false) + return { + thinkingDisplayArgs: thinks ? THINKING_DISPLAY_ARGS : {}, + visuals: visuals ? { visuals, pluginDir: loadsPlugins ? visuals.skill.pluginDir : null } : null + } +} diff --git a/src/main/claude/claude-structured-launch-home.ts b/src/main/claude/claude-structured-launch-home.ts index 0df1b2f599c1..14afad9b5c43 100644 --- a/src/main/claude/claude-structured-launch-home.ts +++ b/src/main/claude/claude-structured-launch-home.ts @@ -32,7 +32,7 @@ export async function resolveClaudeStructuredLaunchHome( } /** Whether Claude wrote a transcript for this id under the given config folder. */ -export async function claudeTranscriptExists(input: { +async function claudeTranscriptExists(input: { providerSessionId: string claudeConfigDir: string }): Promise { diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 5427756baf2c..19067d65a4c5 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -5,9 +5,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' -import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' -import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { CLAUDE_DEFAULT_SETTING_SOURCES, CLAUDE_SESSION_STATE_EVENTS_ENV, @@ -17,7 +14,7 @@ import { type ClaudeStructuredLaunchResolverDeps } from './claude-structured-launch-resolution' import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode' -import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' +import { CLAUDE_THINKING_DISPLAY_FLAG, type ClaudeCliFlag } from './claude-cli-flag-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'orca-session-1' @@ -63,7 +60,8 @@ function resolverFor( // Manual by default so a test that is not about permissions is not silently about them. agentDefaultArgs: Record = { claude: '' }, hasTranscript: () => Promise = async () => true, - resolveLaunchArgs?: () => string[] + resolveLaunchArgs?: () => string[], + attachmentDirectory?: string ) { return createClaudeStructuredLaunchResolver({ store: { getRecord: () => value, pinLaunchDirectory: vi.fn() }, @@ -73,37 +71,11 @@ function resolverFor( resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }), hasTranscript, resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []), - ...(resolveEnv ? { resolveEnv } : {}) + ...(resolveEnv ? { resolveEnv } : {}), + ...(attachmentDirectory ? { attachmentDirectory } : {}) }) } -function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { - return { - id, - email: `${id}@example.com`, - managedAuthPath: `/managed/${id}`, - managedAuthRuntime, - authMethod: 'subscription-oauth' as const, - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0 - } -} - -const HOST_SELECTED: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('host-1', 'host')], - activeClaudeManagedAccountId: 'host-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } -} - -/** The normalized steady state of a Windows user whose only Claude account is WSL-managed: the - * prune drops the WSL account out of the host slot and persists that. */ -const WSL_ONLY_NORMALIZED: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } -} - const RESUMABLE = record({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. providerHandleChain: [ @@ -424,6 +396,38 @@ describe('claude structured launch resolution', () => { } }) + it("lets the agent read the host's chat attachment store, outside the workspace", async () => { + const launch = await resolverFor( + record(), + undefined, + false, + { claude: '' }, + async () => true, + undefined, + '/state/agent-session-attachments' + )({ identity: IDENTITY }) + + expect(launch.options.additionalDirectories).toEqual(['/state/agent-session-attachments']) + expect(launch.cwd).toBe('/repos/workspace-1') + }) + + it('grants the attachment store beside folders the saved Arguments add', async () => { + const launch = await resolverFor( + record(), + undefined, + false, + { claude: '' }, + async () => true, + () => ['--add-dir', '/extra'], + '/state/agent-session-attachments' + )({ identity: IDENTITY }) + + expect(launch.options.additionalDirectories).toEqual([ + '/extra', + '/state/agent-session-attachments' + ]) + }) + it('builds on the supplied inherited env instead of Orca process env', async () => { const launch = await createClaudeStructuredLaunchResolver({ resolveLaunchArgs: () => [], @@ -546,66 +550,11 @@ describe('claude structured launch resolution', () => { }) ).rejects.toThrow(/CLAUDE_CONFIG_DIR/) }) - - /** The account state can change while a session lives, and a reacquire after an unexpected child - * exit re-resolves the launch. Without the gate here, that reacquire spawns under whatever the - * account state has become. */ - describe('managed-account gate on every acquisition', () => { - function resolverWithGate(read: () => ClaudeManagedAccountGateSettings | null) { - return createClaudeStructuredLaunchResolver({ - resolveLaunchArgs: () => [], - store: { getRecord: () => RESUMABLE, pinLaunchDirectory: vi.fn() }, - resolveWorkspacePath: async (id) => `/repos/${id}`, - resolveCommand: () => '/usr/local/bin/claude', - // Derived, not a literal: the gate and the policy must read the SAME account state, so a - // hardcoded value could assert a pairing production cannot produce. - resolveAuthPolicy: () => { - const settings = read() - if (!settings) { - throw new Error('the gate refuses before the auth policy is computed') - } - return claudeStructuredAuthPolicyForSettings(settings) - }, - readManagedAccountGate: read - }) - } - - it('refuses a reacquire once the account state becomes the refused shape', async () => { - let gate: ClaudeManagedAccountGateSettings | null = HOST_SELECTED - const resolve = resolverWithGate(() => gate) - - // Created while supported: the launch resolves and would spawn. - await expect(resolve({ identity: identityAt('leaf-current') })).resolves.toMatchObject({ - providerSessionId: 'provider-current' - }) - - gate = WSL_ONLY_NORMALIZED - - // Reacquire after the account state changed: refused before anything spawns, naming the - // account shape a person can change. - const refused = resolve({ identity: identityAt('leaf-current') }) - await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) - await expect(refused).rejects.toMatchObject({ reason: 'managedAccountUnsupported' }) - }) - - it('fails closed when the account state cannot be read, naming no situation', async () => { - const refused = resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) - await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) - await expect(refused).rejects.toMatchObject({ reason: undefined }) - }) - - it('keeps resolving when no gate is wired, so other embedders are unaffected', async () => { - await expect( - resolverFor(RESUMABLE)({ identity: identityAt('leaf-current') }) - ).resolves.toMatchObject({ providerSessionId: 'provider-current' }) - }) - }) }) describe('readable Claude thinking', () => { const launchWith = ( - thinkingDisplay?: ClaudeStructuredLaunchResolverDeps['thinkingDisplay'], - authSwitchSettleTimeoutMs?: number, + cliFlags?: ClaudeStructuredLaunchResolverDeps['cliFlags'], command = '/usr/local/bin/claude', launchArgs: string[] = [] ) => @@ -617,8 +566,7 @@ describe('readable Claude thinking', () => { resolveAuthPolicy: () => ({ stripAuthEnv: false }), resolveEnv: () => ({ PROJECT_SHIM: '1', ANTHROPIC_API_KEY: 'sk-user' }), hasTranscript: async () => false, - ...(thinkingDisplay ? { thinkingDisplay } : {}), - ...(authSwitchSettleTimeoutMs === undefined ? {} : { authSwitchSettleTimeoutMs }) + ...(cliFlags ? { cliFlags } : {}) })({ identity: IDENTITY }) // Whether the CLI's directory holds a `node` decides if the runtime pairing puts that directory @@ -629,10 +577,11 @@ describe('readable Claude thinking', () => { ])( 'probes the CLI the launch runs, on its PATH and shims, without its credentials (%s)', async (_, sibling) => { - const argsFor = vi.fn( - async (_launch: { command: string; cwd: string; env: Record }) => ({ - 'thinking-display': 'summarized' - }) + const supports = vi.fn( + async ( + _flag: ClaudeCliFlag, + _launch: { command: string; cwd: string; env: Record } + ) => true ) const binDir = join(mkdtempSync(join(tmpdir(), 'orca-claude-probe-')), 'bin') const command = join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude') @@ -640,8 +589,8 @@ describe('readable Claude thinking', () => { if (sibling) { makeExecutable(join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node')) } - const launch = await launchWith({ argsFor }, undefined, command) - const asked = argsFor.mock.calls[0]?.[0] + const launch = await launchWith({ supports }, command) + const asked = supports.mock.calls[0]?.[1] expect(asked).toMatchObject({ command, cwd: '/repos/workspace-1' }) const segments = (env: Record | undefined) => (env?.PATH ?? env?.Path ?? '').split(delimiter) @@ -663,15 +612,14 @@ describe('readable Claude thinking', () => { ) it('passes nothing when the CLI is not known to take the flag, or nothing can say', async () => { - const launch = await launchWith({ argsFor: async () => ({}) }) + const launch = await launchWith({ supports: async () => false }) expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null }) expect((await launchWith()).options.extraArgs).toEqual({ 'replay-user-messages': null }) }) it('keeps saved Arguments beside readable thinking, with the display left to Orca', async () => { const launch = await launchWith( - { argsFor: async () => ({ 'thinking-display': 'summarized' }) }, - undefined, + { supports: async (flag) => flag === CLAUDE_THINKING_DISPLAY_FLAG }, undefined, ['--effort', 'high', '--thinking-display', 'omitted'] ) @@ -681,21 +629,4 @@ describe('readable Claude thinking', () => { 'thinking-display': 'summarized' }) }) - - it('still rechecks an account switch that began while the probe ran', async () => { - try { - const launch = launchWith( - { - argsFor: async () => { - beginClaudeAuthSwitch() - return {} - } - }, - 10 - ) - await expect(launch).rejects.toMatchObject({ reason: 'accountSwitchInProgress' }) - } finally { - endClaudeAuthSwitch() - } - }) }) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index de64c7e648d1..98ec3d971add 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -1,5 +1,5 @@ import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' -import { createHash } from 'node:crypto' +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { Options as ClaudeAgentSdkOptions, PermissionMode @@ -14,20 +14,10 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' -import { - CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS, - whenClaudeAuthSwitchSettles -} from '../claude-accounts/live-pty-gate' import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { - hasWslBoundClaudeAccount, - structuredClaudeMatchesActiveManagedAccount, - type ClaudeManagedAccountGateSettings -} from '../native-chat/claude-structured-managed-account-support' import { claudeChildEnv, claudeProbeEnv, @@ -36,14 +26,22 @@ import { type ClaudeEnvDeps } from './claude-structured-child-env' import { claudeStructuredLaunchArgs } from './claude-structured-launch-args' +import type { ClaudeCliFlagSupport } from './claude-cli-flag-support' +import { resolveClaudeLaunchFlags } from './claude-structured-launch-flags' +import { + withNativeChatVisualsEnv, + type PrepareNativeChatVisuals +} from '../native-chat/native-chat-visuals-delivery' import { claudeLaunchResumesTranscript, resolveClaudeStructuredLaunchHome } from './claude-structured-launch-home' -import type { ClaudeThinkingDisplaySupport } from './claude-thinking-display-support' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory' import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' +import { claudeSessionIdForOrcaSession } from './claude-structured-session-id' + +export { claudeSessionIdForOrcaSession } export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const export const CLAUDE_SESSION_STATE_EVENTS_ENV = 'CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS' @@ -56,6 +54,7 @@ export type ClaudeStructuredSdkOptions = Pick< | 'supportedDialogKinds' | 'extraArgs' | 'additionalDirectories' + | 'plugins' | 'model' | 'effort' | 'permissionMode' @@ -131,12 +130,13 @@ export type ClaudeStructuredLaunchResolverDeps = { resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy /** The user's Agent Permissions setting, re-read per acquisition. Absent means prompting. */ resolvePermissionMode?: () => Promise | PermissionMode - /** How long an in-flight account switch may hold a launch before it is refused. */ - authSwitchSettleTimeoutMs?: number - /** Account state for the managed-account gate; null when it cannot be read, which refuses. */ - readManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null - /** Whether this CLI takes the thinking-display flag. Absent ⇒ the flag is never passed. */ - thinkingDisplay?: Pick + /** The host's chat attachment store: files a client attached live there, outside the workspace, + * and the agent reads them without asking. */ + attachmentDirectory?: string + /** Which version-gated flags this CLI takes. Absent ⇒ none is ever passed. */ + cliFlags?: Pick + /** This chat's visuals folder and skill; absent or null ⇒ the chat gets neither. */ + prepareVisuals?: PrepareNativeChatVisuals /** Whether Claude wrote a transcript for this id; defaults to the transcript resolver. */ hasTranscript?: (input: { providerSessionId: string @@ -154,19 +154,13 @@ export type ClaudeStructuredInvocation = { command: string; env: Record & { - authSwitchSettleTimeoutMs?: number - }, + deps: ClaudeEnvDeps & Pick, decorateEnv: (env: Record) => Record = (env) => env, /** Already resolved by a caller that needed them earlier; read again otherwise. */ resolvedSources?: ClaudeChildEnvSources ): Promise { const sources = resolvedSources ?? (await resolveClaudeChildEnvSources(deps)) const auth = await deps.resolveAuthPolicy() - // A switch can begin while the policy and overlay resolve, exactly as it can - // during the terminal preflight's prepareClaudeAuth — recheck after the awaits. - await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs) // Under a managed account the pinned credential is the only auth this launch may // use, so an explicit override is refused rather than silently beating the pin. if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(sources.overlay)) { @@ -180,37 +174,10 @@ export async function resolveClaudeStructuredInvocation( } } -/** - * Wait a running account switch out, and refuse only if it never settles. - * - * Launch resolution is reached from `acquireClaudeSession` *after* the old child has - * been closed and proved, so a plain refusal here would leave the user with a dead - * chat and no replacement — the very harm the acquire-entry guard exists to prevent. - * The entry guard still refuses outright, because nothing has been torn down yet. - */ -export async function assertClaudeAuthSwitchSettled( - timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS -): Promise { - if (!(await whenClaudeAuthSwitchSettles(timeoutMs))) { - throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { - reason: 'accountSwitchInProgress' - }) - } -} - -export function claudeSessionIdForOrcaSession(sessionId: string): string { - const bytes = createHash('sha256').update(`orca-claude:${sessionId}`).digest().subarray(0, 16) - bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 - bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 - const hex = bytes.toString('hex') - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` -} - export function createClaudeStructuredLaunchResolver( deps: ClaudeStructuredLaunchResolverDeps ): (input: { identity: AgentSessionJournalIdentity }) => Promise { return async ({ identity }) => { - await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs) const record = deps.store.getRecord(identity.sessionId) if (!record) { throw new Error(`no durable agent-session record for ${identity.sessionId}`) @@ -226,22 +193,12 @@ export function createClaudeStructuredLaunchResolver( `claude structured sessions run on the local host, not ${record.location.executionHostId}` ) } + const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome) const pinned = CLAUDE_STRUCTURED_AGENT.accountHomeVariable - if (record.accountHome.variable !== pinned) { - throw new Error(`claude sessions pin ${pinned}, not ${record.accountHome.variable}`) + if (accountHome.variable !== pinned) { + throw new Error(`claude sessions pin ${pinned}, not ${accountHome.variable}`) } - // Every acquisition, not just the first: the account state can change under a live session, and - // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. - // Codex has no gate here — it resolves its account on a different path. const router = getClaudeProfileRouter() - const gate = router ? undefined : deps.readManagedAccountGate?.() - if (gate !== undefined && !structuredClaudeMatchesActiveManagedAccount(gate)) { - // Unreadable account state names no situation a person can act on, so only the log reads it. - throw new AgentSessionPreSpawnError( - 'structured Claude is not offered under the active managed Claude account', - gate && hasWslBoundClaudeAccount(gate) ? { reason: 'managedAccountUnsupported' } : {} - ) - } // A Claude record's chain holds only Claude handles; the attach admission refuses anything else. const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null if ( @@ -259,51 +216,55 @@ export function createClaudeStructuredLaunchResolver( const cwd = await resolveAgentSessionLaunchDirectory(deps, record) const sources = await resolveClaudeChildEnvSources(deps) // Asked as soon as the spawn's cwd and PATH are known, so it overlaps what is left to resolve. - const thinkingDisplay = deps.thinkingDisplay?.argsFor({ - command: sources.command, - cwd, - env: claudeProbeEnv(sources) - }) + const probeLaunch = { command: sources.command, cwd, env: claudeProbeEnv(sources) } + const launchFlags = resolveClaudeLaunchFlags(deps, record.sessionId, probeLaunch) const configured = claudeStructuredLaunchArgs(await deps.resolveLaunchArgs()) - const { additionalDirectories } = configured + const { thinkingDisplayArgs, visuals } = await launchFlags + const additionalDirectories = [ + ...configured.additionalDirectories, + ...(deps.attachmentDirectory ? [deps.attachmentDirectory] : []), + ...(visuals ? [visuals.visuals.folder] : []) + ] const permission = claudeStructuredPermissionOptions( (await deps.resolvePermissionMode?.()) ?? 'default' ) - const thinkingDisplayArgs = (await thinkingDisplay) ?? {} + const { command, env } = await resolveClaudeStructuredInvocation( + deps, + (base) => + // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. + structuredSessionChildIdentityEnv( + record.sessionId, + withNativeChatVisualsEnv( + { + ...base, + // The turn translator relies on Claude's authoritative idle frame when no result arrives. + [CLAUDE_SESSION_STATE_EVENTS_ENV]: '1' + }, + visuals?.visuals ?? null + ) + ), + sources + ) + const launchHome = await resolveClaudeStructuredLaunchHome(router, env, accountHome.path) // A start that failed before its first turn wrote no transcript, and `--resume` of an absent // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. const leafUuid = head ? claudeProviderHandleLeafUuid(head) : null - const resumes = async (claudeConfigDir: string): Promise => + const resumesTranscript = head !== null && (await claudeLaunchResumesTranscript({ router, leafUuid, providerSessionId, - claudeConfigDir, + claudeConfigDir: launchHome, hasTranscript: deps.hasTranscript })) - // Why: without a router the home is fixed, so check it before the recheck that must stay last. - const resumedWithoutRouter = router ? undefined : await resumes(record.accountHome.path) - // Last: it rechecks the account switch, which may have begun during any await above. - const { command, env } = await resolveClaudeStructuredInvocation( - deps, - (base) => - // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. - structuredSessionChildIdentityEnv(record.sessionId, { - ...base, - // The turn translator relies on Claude's authoritative idle frame when no result arrives. - [CLAUDE_SESSION_STATE_EVENTS_ENV]: '1' - }), - sources - ) - const launchHome = await resolveClaudeStructuredLaunchHome(router, env, record.accountHome.path) - const resumesTranscript = resumedWithoutRouter ?? (await resumes(launchHome)) return { pathToClaudeCodeExecutable: command, options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, ...permission, ...(additionalDirectories.length ? { additionalDirectories } : {}), + ...(visuals?.pluginDir ? { plugins: [{ type: 'local', path: visuals.pluginDir }] } : {}), extraArgs: { ...configured.extraArgs, ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, diff --git a/src/main/claude/claude-structured-launch-visuals.test.ts b/src/main/claude/claude-structured-launch-visuals.test.ts new file mode 100644 index 000000000000..0411523b191c --- /dev/null +++ b/src/main/claude/claude-structured-launch-visuals.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { + NATIVE_CHAT_VISUALS_DIR_ENV, + type NativeChatVisualsLaunch +} from '../native-chat/native-chat-visuals-delivery' +import { + CLAUDE_CLI_FLAG_PROBE_KILL_AFTER_MS, + CLAUDE_PLUGIN_DIR_FLAG, + type ClaudeCliFlag +} from './claude-cli-flag-support' +import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' + +const SESSION_ID = 'orca-session-visuals' +const VISUALS: NativeChatVisualsLaunch = { + folder: '/state/native-chat-visuals/abc', + skill: { pluginDir: '/app/native-chat-visuals', skillsRoot: '/app/native-chat-visuals/skills' } +} + +const record = agentSessionRecordFixture({ sessionId: SESSION_ID }) +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: SESSION_ID, + workspaceId: 'workspace-1', + hostId: LOCAL_EXECUTION_HOST_ID, + agent: 'claude', + providerHandle: null +} + +function launch(options: { + prepareVisuals?: (sessionId: string) => Promise + supports?: (flag: ClaudeCliFlag, launch: unknown, budgetMs?: number) => Promise + launchArgs?: string[] + env?: Record + attachmentDirectory?: string +}) { + return createClaudeStructuredLaunchResolver({ + store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveLaunchArgs: () => options.launchArgs ?? [], + resolveEnv: () => options.env ?? {}, + hasTranscript: async () => false, + ...(options.attachmentDirectory ? { attachmentDirectory: options.attachmentDirectory } : {}), + ...(options.supports ? { cliFlags: { supports: options.supports } } : {}), + ...(options.prepareVisuals ? { prepareVisuals: options.prepareVisuals } : {}) + })({ identity: IDENTITY }) +} + +describe('a Claude chat launch with inline visuals', () => { + it('loads the skill plugin, grants the chat folder beside the user directories, and names it', async () => { + const prepareVisuals = vi.fn(async () => VISUALS) + const resolved = await launch({ + prepareVisuals, + supports: async (flag) => flag === CLAUDE_PLUGIN_DIR_FLAG, + launchArgs: ['--add-dir', '/shared/notes'] + }) + expect(prepareVisuals).toHaveBeenCalledWith(SESSION_ID) + expect(resolved.options.plugins).toEqual([{ type: 'local', path: VISUALS.skill.pluginDir }]) + expect(resolved.options.additionalDirectories).toEqual(['/shared/notes', VISUALS.folder]) + expect(resolved.env?.[NATIVE_CHAT_VISUALS_DIR_ENV]).toBe(VISUALS.folder) + }) + + it.each([ + { + name: 'prepared visuals', + visuals: VISUALS, + directories: ['/shared/notes', '/state/agent-session-attachments', VISUALS.folder] + }, + { + name: 'unavailable visuals', + visuals: null, + directories: ['/shared/notes', '/state/agent-session-attachments'] + } + ])('keeps user folders and attachment access with $name', async ({ visuals, directories }) => { + const resolved = await launch({ + prepareVisuals: async () => visuals, + supports: async (flag) => flag === CLAUDE_PLUGIN_DIR_FLAG, + launchArgs: ['--add-dir', '/shared/notes'], + attachmentDirectory: '/state/agent-session-attachments' + }) + expect(resolved.options.additionalDirectories).toEqual(directories) + }) + + it('still grants and names the folder when the CLI cannot load a plugin by path', async () => { + const resolved = await launch({ + prepareVisuals: async () => VISUALS, + supports: async () => false + }) + expect(resolved.options).not.toHaveProperty('plugins') + expect(resolved.options.additionalDirectories).toEqual([VISUALS.folder]) + expect(resolved.env?.[NATIVE_CHAT_VISUALS_DIR_ENV]).toBe(VISUALS.folder) + }) + + it('without a prepared folder, grants nothing and drops a folder inherited from another chat', async () => { + const resolved = await launch({ + prepareVisuals: async () => null, + supports: async () => true, + env: { [NATIVE_CHAT_VISUALS_DIR_ENV]: '/state/native-chat-visuals/other-chat' } + }) + expect(resolved.options).not.toHaveProperty('plugins') + expect(resolved.options).not.toHaveProperty('additionalDirectories') + expect(resolved.env).not.toHaveProperty(NATIVE_CHAT_VISUALS_DIR_ENV) + }) + + it('never asks about the plugin flag on a host that delivers no visuals', async () => { + const supports = vi.fn(async (_flag: ClaudeCliFlag) => true) + const resolved = await launch({ supports }) + expect(supports.mock.calls.map(([flag]) => flag)).not.toContain(CLAUDE_PLUGIN_DIR_FLAG) + expect(resolved.options).not.toHaveProperty('plugins') + }) + + it('waits for the version up to the probe kill time for the plugin, never the short budget', async () => { + const supports = vi.fn( + async (_flag: ClaudeCliFlag, _launch: unknown, _budgetMs?: number) => true + ) + await launch({ prepareVisuals: async () => VISUALS, supports }) + expect(supports).toHaveBeenCalledWith( + CLAUDE_PLUGIN_DIR_FLAG, + expect.objectContaining({ command: '/usr/local/bin/claude' }), + CLAUDE_CLI_FLAG_PROBE_KILL_AFTER_MS + ) + }) + + it('asks for readable thinking again once the plugin check has learned the version', async () => { + let versionKnown = false + const supports = vi.fn(async (flag: ClaudeCliFlag, _launch: unknown, budgetMs?: number) => { + if (flag === CLAUDE_PLUGIN_DIR_FLAG) { + versionKnown = true + return true + } + // The first thinking check gave up at its short budget; the re-check reads the cached answer. + return versionKnown && budgetMs !== undefined + }) + const resolved = await launch({ prepareVisuals: async () => VISUALS, supports }) + expect(resolved.options.plugins).toHaveLength(1) + expect(resolved.options.extraArgs).toMatchObject({ 'thinking-display': 'summarized' }) + }) + + it('never re-asks for thinking on a host without visuals', async () => { + const supports = vi.fn( + async (_flag: ClaudeCliFlag, _launch: unknown, _budgetMs?: number) => false + ) + await launch({ supports }) + expect(supports).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/claude/claude-structured-real-cli-visuals.test.ts b/src/main/claude/claude-structured-real-cli-visuals.test.ts new file mode 100644 index 000000000000..a74677ddacf6 --- /dev/null +++ b/src/main/claude/claude-structured-real-cli-visuals.test.ts @@ -0,0 +1,133 @@ +// The real Claude CLI, launched through Orca's own launch resolution with a chat's visuals: the +// CLI must report the bundled skill and its plugin in its own init frame. Opt-in, like every +// real-CLI suite: it spends one short turn on the signed-in account. +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { createNativeChatVisualsDelivery } from '../native-chat/native-chat-visuals-delivery' +import { nativeChatVisualsFolderFor } from '../native-chat/native-chat-visuals-folder' +import { NATIVE_CHAT_VISUALS_SKILL_NAME } from '../native-chat/native-chat-visuals-skill-location' +import { createClaudeCliFlagSupport } from './claude-cli-flag-support' +import { readClaudeInit } from './claude-structured-init-proof' +import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' +import { + realClaudeAuthenticated, + realClaudeAvailable, + realClaudeCliGate, + realClaudeCommand, + realClaudeLaunchHome +} from './claude-real-cli-availability-test-support' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' + +const SESSION_ID = 'real-cli-visuals' +const listed = (value: unknown): unknown[] => (Array.isArray(value) ? value : []) +const suiteTitle = `Claude real CLI chat visuals${realClaudeCliGate.skipReason ? ` (skipped: ${realClaudeCliGate.skipReason})` : ''}` + +describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { + it.skipIf(!realClaudeAuthenticated)( + "loads the visuals skill through Orca's launch and grants the chat folder", + async () => { + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const scratch = await mkdtemp(join(tmpdir(), 'orca-real-cli-visuals-')) + const cwd = join(scratch, 'workspace') + const stateDirectory = join(scratch, 'state') + const record = agentSessionRecordFixture({ + sessionId: SESSION_ID, + location: { workspaceId: 'real-cli-visuals-workspace' }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: claudeConfigDir }, + launchDirectory: cwd + }) + const logger = { warn: vi.fn(), error: vi.fn() } + const resolveLaunch = createClaudeStructuredLaunchResolver({ + store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, + resolveWorkspacePath: async () => cwd, + resolveCommand: () => realClaudeCommand, + resolveLaunchArgs: () => [], + resolveEnv: () => realClaudeLaunchHome().env, + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + hasTranscript: async () => false, + cliFlags: createClaudeCliFlagSupport(), + prepareVisuals: createNativeChatVisualsDelivery({ stateDirectory, logger }) + }) + const identity: AgentSessionJournalIdentity = { + sessionId: SESSION_ID, + workspaceId: 'real-cli-visuals-workspace', + hostId: 'local', + agent: 'claude', + providerHandle: null + } + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch, + onEvent: (event) => events.push(event), + readProcessStartTime: async () => 1, + now: () => 2 + }) + const frames = (): Record[] => + events.flatMap((event) => (event.type === 'message' ? [event.message] : [])) + try { + await mkdir(cwd, { recursive: true }) + const launch = await resolveLaunch({ identity }) + const folder = nativeChatVisualsFolderFor(stateDirectory, SESSION_ID) + expect(launch.options.additionalDirectories).toEqual([folder]) + expect(launch.options.plugins).toHaveLength(1) + await adapter.acquire({ + identity: { + ...identity, + providerHandle: claudeProviderHandle(launch.providerSessionId, null) + }, + fence: 1, + spawnToken: 'real-cli-visuals' + }) + await adapter.dispatch({ + sessionId: SESSION_ID, + clientMessageId: 'real-cli-visuals-1', + body: { + kind: 'message', + role: 'user', + blocks: [ + { + type: 'text', + text: 'Without using any tool, reply with only the exact names of the skills available to you whose name contains "visuals", comma separated, or NONE.' + } + ] + }, + fence: 1 + }) + await vi.waitFor( + () => expect(frames()).toContainEqual(expect.objectContaining({ type: 'result' })), + { timeout: 120_000, interval: 200 } + ) + const init = frames().find( + (frame) => readClaudeInit(frame) !== null && frame.subtype === 'init' + ) + // The model's own skill catalog lists it, though it is kept out of the `/` menu (and + // so out of the init frame's skill and command lists). + const answer = frames().find((frame) => frame.type === 'result')?.result + expect(String(answer)).toContain(NATIVE_CHAT_VISUALS_SKILL_NAME) + expect( + [...listed(init?.skills), ...listed(init?.slash_commands)].some( + (name) => typeof name === 'string' && name.endsWith(NATIVE_CHAT_VISUALS_SKILL_NAME) + ) + ).toBe(false) + expect(init?.plugins).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: launch.options.plugins?.[0]?.path }) + ]) + ) + expect(logger.warn).not.toHaveBeenCalled() + } finally { + await adapter.closeAll() + await rm(scratch, { recursive: true, force: true }) + } + }, + 150_000 + ) +}) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 0e10d929fa05..b10af618808b 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -1,10 +1,7 @@ -import { - AgentSessionPreSpawnError, - type AgentSessionAcquisition, - type StructuredAgentSessionAcquireInput +import type { + AgentSessionAcquisition, + StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../claude-accounts/environment' -import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate' import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' @@ -62,13 +59,6 @@ export async function acquireClaudeSession({ exits: Map callbacks: ClaudeAcquireCallbacks }): Promise { - // A managed-account switch is mid-swap of the pinned credential home; refuse here, - // before this acquisition cancels the previous attempt and closes the live session. - if (isClaudeAuthSwitchInProgress()) { - throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { - reason: 'accountSwitchInProgress' - }) - } const sessionId = input.identity.sessionId const prompts = new ClaudePromptRegistry() const { previous, attempt } = acquisitions.start(sessionId, prompts) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index e79f3cab5d25..6f8bc901989e 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,3 +1,4 @@ +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, @@ -131,7 +132,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ) => resolveClaudeProviderHistoryWindow({ identity: input.identity, - accountHomePath: input.accountHome.path, + accountHomePath: requireLegacyAgentSessionAccountHome(input.accountHome).path, hasLiveSession: this.sessions.has(input.identity.sessionId) || this.exits.has(input.identity.sessionId) }) diff --git a/src/main/claude/claude-structured-session-id.ts b/src/main/claude/claude-structured-session-id.ts new file mode 100644 index 000000000000..abdda0721f8e --- /dev/null +++ b/src/main/claude/claude-structured-session-id.ts @@ -0,0 +1,10 @@ +import { createHash } from 'node:crypto' + +/** A stable UUID (v4 layout) for the Claude conversation behind an Orca chat. */ +export function claudeSessionIdForOrcaSession(sessionId: string): string { + const bytes = createHash('sha256').update(`orca-claude:${sessionId}`).digest().subarray(0, 16) + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 + const hex = bytes.toString('hex') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} diff --git a/src/main/claude/claude-thinking-display-support.test.ts b/src/main/claude/claude-thinking-display-support.test.ts deleted file mode 100644 index a586a65d70a6..000000000000 --- a/src/main/claude/claude-thinking-display-support.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createClaudeThinkingDisplaySupport } from './claude-thinking-display-support' - -const LAUNCH = { command: '/bin/claude', cwd: '/repo', env: { PATH: '/shims' } } -const UNKNOWN_FLAG = new Error( - "claude stream-json exited (code 1): error: unknown option '--thinking-display'" -) - -type Probe = ( - command: string, - launch: { cwd: string; env: Record; timeoutMs: number } -) => Promise - -function supportWith( - probe: Probe, - budgetMs = 20, - keyOf = async (command: string, cwd: string): Promise => `${command}\n${cwd}` -) { - const calls = vi.fn(probe) - // Real time plus whatever a test skips ahead. - let skippedMs = 0 - const support = createClaudeThinkingDisplaySupport({ - probe: calls, - keyOf, - budgetMs, - now: () => performance.now() + skippedMs - }) - return { support, calls, skip: (ms: number) => (skippedMs += ms) } -} - -/** A probe the test answers by hand. */ -function heldProbe() { - let answer: (version: string | null) => void = () => {} - const probe: Probe = () => - new Promise((resolve) => { - answer = resolve - }) - return { probe, answer: (version: string | null) => answer(version) } -} - -describe('the thinking-display flag a launch passes', () => { - it("probes with the launch's own cwd and env and its own kill timeout, then remembers", async () => { - const { support, calls } = supportWith(async () => '2.1.280') - await expect(support.argsFor(LAUNCH)).resolves.toEqual({ 'thinking-display': 'summarized' }) - await expect(support.argsFor(LAUNCH)).resolves.toEqual({ 'thinking-display': 'summarized' }) - expect(calls).toHaveBeenCalledTimes(1) - expect(calls).toHaveBeenCalledWith('/bin/claude', { - cwd: '/repo', - env: { PATH: '/shims' }, - timeoutMs: 10_000 - }) - }) - - it('asks again per workspace: a shim can pick a different CLI there', async () => { - const { support, calls } = supportWith(async () => '2.1.280') - await support.argsFor(LAUNCH) - await support.argsFor({ ...LAUNCH, cwd: '/other' }) - expect(calls).toHaveBeenCalledTimes(2) - }) - - it('passes nothing to a CLI older than the flag, and keeps that answer for good', async () => { - const { support, calls, skip } = supportWith(async () => '2.1.92') - await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) - skip(60 * 60_000) - await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) - expect(calls).toHaveBeenCalledTimes(1) - }) - - it('asks again after a while when a probe printed no version, failed or was killed', async () => { - for (const probe of [async () => null, () => Promise.reject(new Error('EMFILE'))]) { - const { support, calls, skip } = supportWith(probe) - await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) - // Within the window a hung or broken probe costs no further spawn or wait. - skip(9 * 60_000) - await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) - expect(calls).toHaveBeenCalledTimes(1) - // A failure from a loaded boot heals. - skip(2 * 60_000) - await support.argsFor(LAUNCH) - expect(calls).toHaveBeenCalledTimes(2) - } - }) - - it('waits for a probe that answers within the budget', async () => { - const { support } = supportWith( - () => new Promise((resolve) => setTimeout(() => resolve('2.1.280'), 5)), - 1_000 - ) - await expect(support.argsFor(LAUNCH)).resolves.toEqual({ 'thinking-display': 'summarized' }) - }) - - it('does not wait again on a probe already past its budget, and keeps its late answer', async () => { - const held = heldProbe() - const { support, calls } = supportWith(held.probe, 30) - await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) - const started = performance.now() - await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) - expect(performance.now() - started).toBeLessThan(25) - held.answer('2.1.280') - await vi.waitFor(async () => - expect(await support.argsFor(LAUNCH)).toEqual({ 'thinking-display': 'summarized' }) - ) - expect(calls).toHaveBeenCalledTimes(1) - }) - - it('stops passing the flag to a binary that exited refusing it', async () => { - const { support, calls } = supportWith(async () => '2.1.280') - await support.argsFor(LAUNCH) - support.observeExit(LAUNCH, UNKNOWN_FLAG) - await vi.waitFor(async () => expect(await support.argsFor(LAUNCH)).toEqual({})) - expect(calls).toHaveBeenCalledTimes(1) - }) - - it('keeps a refusal seen while a probe was still running', async () => { - const held = heldProbe() - const { support } = supportWith(held.probe, 10) - await support.argsFor(LAUNCH) - support.observeExit(LAUNCH, UNKNOWN_FLAG) - await new Promise((resolve) => setTimeout(resolve, 0)) - held.answer('2.1.280') - await new Promise((resolve) => setTimeout(resolve, 0)) - await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) - }) - - it('records nothing for any other startup failure', async () => { - const { support } = supportWith(async () => '2.1.280') - await support.argsFor(LAUNCH) - support.observeExit(LAUNCH, new Error('claude stream-json exited (code 1): not signed in')) - support.observeExit(LAUNCH, new Error("error: unknown option '--thinking'")) - await new Promise((resolve) => setTimeout(resolve, 0)) - await expect(support.argsFor(LAUNCH)).resolves.toEqual({ 'thinking-display': 'summarized' }) - }) - - it('spends the budget finding the binary too, and caches nothing when that runs out', async () => { - let slow = true - const { support, calls } = supportWith( - async () => '2.1.280', - 30, - async (command, cwd) => { - if (slow) { - await new Promise((resolve) => setTimeout(resolve, 1_000)) - } - return `${command}\n${cwd}` - } - ) - const started = performance.now() - await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) - expect(performance.now() - started).toBeLessThan(500) - expect(calls).not.toHaveBeenCalled() - slow = false - await expect(support.argsFor(LAUNCH)).resolves.toEqual({ 'thinking-display': 'summarized' }) - }) - - it('forgets the binaries launched least recently, not the ones in use', async () => { - const { support, calls } = supportWith(async () => '2.1.280') - const at = (index: number) => ({ ...LAUNCH, cwd: `/repo-${index}` }) - for (let index = 1; index <= 32; index += 1) { - await support.argsFor(at(index)) - } - await support.argsFor(at(1)) - await support.argsFor(at(33)) - expect(calls).toHaveBeenCalledTimes(33) - await support.argsFor(at(1)) - expect(calls).toHaveBeenCalledTimes(33) - await support.argsFor(at(2)) - expect(calls).toHaveBeenCalledTimes(34) - }) -}) diff --git a/src/main/claude/claude-thinking-display-support.ts b/src/main/claude/claude-thinking-display-support.ts deleted file mode 100644 index f7ad53d44339..000000000000 --- a/src/main/claude/claude-thinking-display-support.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { realpath, stat } from 'node:fs/promises' -import { claudeVersionReaches, probeClaudeCliVersion } from './claude-hook-event-versions' - -// Why: the first CLI whose parser defines the flag (2.1.93 was never published); an older one exits -// on it before the session starts. Found by reading published packages, not by running them. -const CLAUDE_THINKING_DISPLAY_FIRST_VERSION = '2.1.94' - -/** How long a launch waits on a binary nothing is known about yet. A warm probe answers in tens of - * milliseconds; this covers a cold disk, a node install and an antivirus scan, paid once per key - * during a start that already takes seconds. */ -export const CLAUDE_THINKING_DISPLAY_PROBE_BUDGET_MS = 1_500 - -/** A probe still running by now is killed, and its binary gets no flag. */ -const PROBE_KILL_AFTER_MS = 10_000 - -/** How long a probe that gave no version (killed, failed to spawn, unparseable) means "no flag": - * long enough that a hung `--version` costs one wait per stretch, short enough that a failure - * from a loaded boot heals. */ -const FAILED_PROBE_RETRY_AFTER_MS = 10 * 60_000 - -/** Commander's refusal, exactly: any other startup failure says nothing about the flag. */ -const UNKNOWN_FLAG_DIAGNOSTIC = "unknown option '--thinking-display'" - -// One small entry per binary per workspace it launched in; enough for every worktree in active use. -const MAX_REMEMBERED = 32 - -const SUMMARIZED: Readonly> = { 'thinking-display': 'summarized' } - -export type ClaudeThinkingDisplayLaunch = { - command: string - cwd: string - env: Record -} - -type ClaudeVersionProbe = ( - command: string, - launch: { cwd: string; env: Record; timeoutMs: number } -) => Promise - -export type ClaudeThinkingDisplaySupport = { - /** - * Asks for readable thinking: under Orca's launch the CLI otherwise streams thinking blocks with - * no text. Only the display is set, never `--thinking`, so a user who turned thinking off keeps - * it off. A binary not yet known is probed with the launch's own cwd and env, waited on for at - * most the budget from when its probe began; past it the launch goes without the flag. - */ - argsFor: (launch: ClaudeThinkingDisplayLaunch) => Promise>> - /** A child that exited refusing the flag: that binary, in that workspace, never gets it again. */ - observeExit: (launch: Pick, error: Error) => void -} - -/** Which binary a command is in a workspace right now: a shim answers per project, and a - * self-update swaps the link's target or the file. */ -async function claudeBinaryKey(command: string, cwd: string): Promise { - try { - const target = await realpath(command) - return `${target}\n${(await stat(target)).mtimeMs}\n${cwd}` - } catch { - return null - } -} - -/** The promise's value if it settles within `ms`, else undefined. */ -async function within(pending: Promise, ms: number): Promise { - let timer: ReturnType | undefined - const expired = new Promise((resolve) => { - timer = setTimeout(() => resolve(undefined), Math.max(0, ms)) - timer.unref?.() - }) - try { - return await Promise.race([pending, expired]) - } finally { - clearTimeout(timer) - } -} - -export function createClaudeThinkingDisplaySupport( - deps: { - probe: ClaudeVersionProbe - keyOf: (command: string, cwd: string) => Promise - budgetMs: number - now: () => number - } = { - probe: probeClaudeCliVersion, - keyOf: claudeBinaryKey, - budgetMs: CLAUDE_THINKING_DISPLAY_PROBE_BUDGET_MS, - now: () => performance.now() - } -): ClaudeThinkingDisplaySupport { - /** `expiresAt` only on an answer that was no answer: a version or a refusal is final. */ - const known = new Map() - const probing = new Map; startedAt: number }>() - const remember = (key: string, supported: boolean, expiresAt?: number): void => { - known.delete(key) - known.set(key, { supported, ...(expiresAt === undefined ? {} : { expiresAt }) }) - for (const stale of known.keys()) { - if (known.size <= MAX_REMEMBERED) { - break - } - known.delete(stale) - } - } - // A version is kept for the binary's life. A probe that gave none is kept only for a while, so - // a hung CLI costs one wait per stretch and a boot-time failure heals. A refusal seen meanwhile - // wins over either. - const settle = (key: string, supported: boolean, expiresAt?: number): void => { - if (!known.has(key)) { - remember(key, supported, expiresAt) - } - } - const settleWithoutVersion = (key: string): void => - settle(key, false, deps.now() + FAILED_PROBE_RETRY_AFTER_MS) - const lookup = (key: string): boolean | undefined => { - const entry = known.get(key) - if (entry?.expiresAt !== undefined && entry.expiresAt <= deps.now()) { - known.delete(key) - return undefined - } - if (entry) { - // Read as used: the bound drops the binaries launched least recently. - remember(key, entry.supported, entry.expiresAt) - } - return entry?.supported - } - const probe = (key: string, launch: ClaudeThinkingDisplayLaunch) => { - const settled = deps - .probe(launch.command, { cwd: launch.cwd, env: launch.env, timeoutMs: PROBE_KILL_AFTER_MS }) - .then( - (version) => - version === null - ? settleWithoutVersion(key) - : settle(key, claudeVersionReaches(version, CLAUDE_THINKING_DISPLAY_FIRST_VERSION)), - () => settleWithoutVersion(key) - ) - .finally(() => probing.delete(key)) - const started = { settled, startedAt: deps.now() } - probing.set(key, started) - return started - } - - return { - argsFor: async (launch) => { - // The launch never waits longer than the budget, finding the binary included. - const deadline = deps.now() + deps.budgetMs - const key = await within(deps.keyOf(launch.command, launch.cwd), deps.budgetMs) - if (key === undefined || key === null) { - return {} - } - const supported = lookup(key) - if (supported !== undefined) { - return supported ? SUMMARIZED : {} - } - const running = probing.get(key) ?? probe(key, launch) - // The probe's own budget, too: one already past it is not waited on again. - await within( - running.settled, - Math.min(running.startedAt + deps.budgetMs, deadline) - deps.now() - ) - return known.get(key)?.supported === true ? SUMMARIZED : {} - }, - observeExit: (launch, error) => { - if (!error.message.includes(UNKNOWN_FLAG_DIAGNOSTIC)) { - return - } - void deps.keyOf(launch.command, launch.cwd).then((key) => { - if (key !== null) { - remember(key, false) - } - }) - } - } -} - -/** One per process: every structured launch on this host shares what it learned. */ -export const claudeThinkingDisplaySupport = createClaudeThinkingDisplaySupport() diff --git a/src/main/codex/codex-structured-agent-definition.ts b/src/main/codex/codex-structured-agent-definition.ts index 317acdbd3a35..1f06c603000b 100644 --- a/src/main/codex/codex-structured-agent-definition.ts +++ b/src/main/codex/codex-structured-agent-definition.ts @@ -1,8 +1,8 @@ -import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import type { DirectoryAccountAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import { CODEX_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' import { isCodexTurnOptionKey } from './codex-structured-turn-start' -export const CODEX_STRUCTURED_AGENT: StructuredAgentDefinition = { +export const CODEX_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { agent: 'codex', handleTransport: CODEX_STRUCTURED_HANDLE_NAMESPACE.transport, accountHomeVariable: 'CODEX_HOME', diff --git a/src/main/codex/codex-structured-child-environment.ts b/src/main/codex/codex-structured-child-environment.ts index 38ea91abe268..c34e0b3a3c87 100644 --- a/src/main/codex/codex-structured-child-environment.ts +++ b/src/main/codex/codex-structured-child-environment.ts @@ -1,6 +1,10 @@ import type { CodexStructuredLaunch } from './codex-structured-session-state' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' +import { + NATIVE_CHAT_VISUALS_DIR_ENV, + withNativeChatVisualsEnv +} from '../native-chat/native-chat-visuals-delivery' export function buildCodexStructuredChildEnvironment( launch: CodexStructuredLaunch, @@ -9,10 +13,29 @@ export function buildCodexStructuredChildEnvironment( ): Record { return { // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. - ...structuredSessionChildIdentityEnv(sessionId, { - ...launch.env, - ...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}) - }), + ...structuredSessionChildIdentityEnv( + sessionId, + withNativeChatVisualsEnv( + { + ...launch.env, + ...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}) + }, + launch.visuals ?? null + ) + ), [CODEX_SPAWN_TOKEN_ENV]: spawnToken } } + +/** The child's env overlay, and the keys removed from what it inherits: without visuals, a folder + * Orca itself inherited (started from a chat) names another chat's folder. */ +export function codexStructuredChildEnvironment( + launch: CodexStructuredLaunch, + spawnToken: string, + sessionId: string +): { env: Record; envToDelete?: readonly string[] } { + return { + env: buildCodexStructuredChildEnvironment(launch, spawnToken, sessionId), + ...(launch.visuals ? {} : { envToDelete: [NATIVE_CHAT_VISUALS_DIR_ENV] }) + } +} diff --git a/src/main/codex/codex-structured-item-streams.ts b/src/main/codex/codex-structured-item-streams.ts index 291ff544c528..c783458ca298 100644 --- a/src/main/codex/codex-structured-item-streams.ts +++ b/src/main/codex/codex-structured-item-streams.ts @@ -122,10 +122,14 @@ export function createCodexStructuredItemStreams( }).accepted } - const persist = (key: string, text: string, force: boolean): boolean => { + const shouldPersist = (key: string, textLength: number): boolean => { const checkpointLength = checkpointLengths.get(key) ?? 0 const nextLength = Math.max(checkpointLength + 32, Math.ceil(checkpointLength * 1.125)) - if (!force && checkpointLength > 0 && text.length < nextLength) { + return !(checkpointLength > 0 && textLength < nextLength) + } + + const persist = (key: string, text: string, force: boolean): boolean => { + if (!force && !shouldPersist(key, text.length)) { return true } const state = states.get(key) @@ -143,6 +147,7 @@ export function createCodexStructuredItemStreams( maxTotalRetainedBytes: deps.maxTotalRetainedBytes, isProtected: (key) => states.isPersistent(key), schedule: deps.schedule, + shouldEmit: shouldPersist, emit: (key, text) => { return persist(key, text, false) } diff --git a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts index c5dccffdcd73..fd3453fed655 100644 --- a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts +++ b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts @@ -98,7 +98,7 @@ describe('grouping a Codex thread restored from full history', () => { draws: true, outlivesTurn: false, reportsFailure: false, - reportsCompaction: false + explainsTurn: false })), settledTurnKeys: new Set(bars.settledTurns.keys()), expandedTurnKeys: new Set() diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index 5cb1110a5f8e..d98ccba5f35e 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -750,3 +750,77 @@ describe('notice journal pipeline', () => { translator.dispose() }) }) + +describe('Codex streamed checkpoint admission', () => { + it('skips joining a below-threshold prefix and still writes its final text', () => { + const { translator, tap, window } = translatorWith() + const prefix = 'checkpoint '.repeat(12) + translator.handle(notification('item/agentMessage/delta', { itemId: 'reply', delta: prefix })) + window.fire() + const rows = tap.rows.length + const publications = tap.publishes() + const join = vi.spyOn(Array.prototype, 'join') + const prefixJoins = () => + join.mock.contexts.filter( + (chunks) => + Array.isArray(chunks) && chunks.length === 2 && chunks[0] === prefix && chunks[1] === 'x' + ) + try { + translator.handle(notification('item/agentMessage/delta', { itemId: 'reply', delta: 'x' })) + window.fire() + expect(prefixJoins()).toHaveLength(0) + expect(tap.rows).toHaveLength(rows) + expect(tap.publishes()).toBe(publications) + expect(window.idle()).toBe(true) + + translator.flush() + expect(prefixJoins()).toHaveLength(1) + expect(tap.rows.at(-1)?.body).toMatchObject({ + kind: 'message', + blocks: [{ type: 'text', text: `${prefix}x` }] + }) + expect(tap.publishes()).toBe(publications + 1) + } finally { + join.mockRestore() + translator.dispose() + } + }) + + it('retries a refused checkpoint before forcing a smaller final increment', () => { + const { translator, tap, window } = translatorWith() + let reject = false + tap.sink.tryAppendItem = (identity, body, options) => { + if (reject) { + return { accepted: false, reason: 'backpressure' } + } + tap.sink.appendItem(identity, body, options) + return { accepted: true } + } + const delta = (text: string) => + translator.handle(notification('item/agentMessage/delta', { itemId: 'reply', delta: text })) + delta('a'.repeat(128)) + window.fire() + const rows = tap.rows.length + reject = true + delta('b'.repeat(32)) + window.fire() + window.fire() + expect(tap.rows).toHaveLength(rows) + expect(window.idle()).toBe(false) + + reject = false + window.fire() + expect(window.idle()).toBe(true) + expect(tap.rows.at(-1)?.body).toMatchObject({ + blocks: [{ type: 'text', text: `${'a'.repeat(128)}${'b'.repeat(32)}` }] + }) + delta('x') + window.fire() + expect(tap.rows).toHaveLength(rows + 1) + translator.flush() + expect(tap.rows.at(-1)?.body).toMatchObject({ + blocks: [{ type: 'text', text: `${'a'.repeat(128)}${'b'.repeat(32)}x` }] + }) + translator.dispose() + }) +}) diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 183f765f5c86..8016c938ae83 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -7,6 +7,7 @@ // for, which is how a resume becomes a fork wearing a resume's name. import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { resolveCodexCommand } from '../codex-cli/command' @@ -17,6 +18,7 @@ import type { CodexStructuredPermissionPolicy } from './codex-structured-permiss import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' import { codexStructuredLaunchArgs } from './codex-structured-launch-args' import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' +import type { PrepareNativeChatVisuals } from '../native-chat/native-chat-visuals-delivery' export type CodexStructuredLaunchResolverDeps = { store: Pick @@ -32,6 +34,8 @@ export type CodexStructuredLaunchResolverDeps = { /** The user's Agent Permissions setting as thread policy, re-read per acquisition. * States both postures outright — a resume inherits the last one for any field left absent. */ resolvePermissionPolicy?: () => CodexStructuredPermissionPolicy + /** This chat's visuals folder and skill; absent or null ⇒ the chat gets neither. */ + prepareVisuals?: PrepareNativeChatVisuals } export type CodexStructuredInvocation = { @@ -67,7 +71,8 @@ export function createCodexStructuredLaunchResolver( if (!record) { throw new Error(`no durable agent-session record for ${identity.sessionId}`) } - const { location, accountHome } = record + const { location } = record + const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome) if (record.provider !== 'codex') { throw new Error(`session ${identity.sessionId} is a ${record.provider} session`) } @@ -91,6 +96,7 @@ export function createCodexStructuredLaunchResolver( const resumeThreadId = head?.handle.nativeId ?? null // The same saved options every turn sends, so the thread and its turns name one model. const model = record.options?.model + const visuals = (await deps.prepareVisuals?.(record.sessionId)) ?? null return { command, args: [...args, 'app-server'], @@ -105,6 +111,7 @@ export function createCodexStructuredLaunchResolver( ...(resumeThreadId && head?.origin === 'created' ? { supersedeIfUnsaved: true } : {}), ...(permissionPolicy ? { permissionPolicy } : {}), ...(model ? { model } : {}), + ...(visuals ? { visuals } : {}), ...(resumeThreadId ? { resumePath: await (deps.resolveRollout ?? resolvePinnedCodexRolloutProof)( diff --git a/src/main/codex/codex-structured-real-cli-visuals.test.ts b/src/main/codex/codex-structured-real-cli-visuals.test.ts new file mode 100644 index 000000000000..c9b2e9869b7b --- /dev/null +++ b/src/main/codex/codex-structured-real-cli-visuals.test.ts @@ -0,0 +1,69 @@ +// The installed Codex, driven through Orca's own visuals setup in an isolated CODEX_HOME: Codex must +// list the bundled skill and open the thread with the chat folder beside the user's own writable +// root. Opt-in (ORCA_REAL_CODEX_CLI_TEST=1); no account is used, nothing reaches a model. +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { resolveCodexCommand } from '../codex-cli/command' +import { NATIVE_CHAT_VISUALS_SKILL_NAME } from '../native-chat/native-chat-visuals-skill-location' +import { openCodexAppServerConnection } from './codex-app-server-connection' +import { withCodexVisualsThreadConfig } from './codex-structured-visuals' + +const enabled = process.env.ORCA_REAL_CODEX_CLI_TEST === '1' +const MANUAL = { approvalPolicy: 'on-request', sandbox: 'workspace-write' } as const + +describe.skipIf(!enabled)('Codex real app-server chat visuals', () => { + it('lists the visuals skill and grants the chat folder beside the user writable roots', async () => { + const base = mkdtempSync(join(tmpdir(), 'orca-real-codex-visuals-')) + try { + const codexHome = join(base, 'codex-home') + const cwd = join(base, 'workspace') + const userRoot = join(base, 'user-root') + const folder = join(base, 'state', 'native-chat-visuals', 'chat') + for (const dir of [codexHome, cwd, folder]) { + mkdirSync(dir, { recursive: true }) + } + writeFileSync( + join(codexHome, 'config.toml'), + `[sandbox_workspace_write]\nwritable_roots = [${JSON.stringify(userRoot)}]\n` + ) + const pluginDir = join(__dirname, '..', '..', '..', 'resources', 'native-chat-visuals') + const connection = await openCodexAppServerConnection({ + command: resolveCodexCommand(), + args: ['app-server'], + cwd, + env: { HOME: base, CODEX_HOME: codexHome } + }) + try { + const launch = await withCodexVisualsThreadConfig( + connection, + { + cwd, + permissionPolicy: MANUAL, + visuals: { folder, skill: { pluginDir, skillsRoot: join(pluginDir, 'skills') } } + }, + { sessionId: 'real-codex-visuals' } + ) + const skills = JSON.stringify( + await connection.request('skills/list', { cwds: [cwd] }, { timeoutMs: 10_000 }) + ) + expect(skills).toMatch( + new RegExp(`"name":"(?:[a-z0-9-]+:)?${NATIVE_CHAT_VISUALS_SKILL_NAME}"`) + ) + const opened = await connection.request( + 'thread/start', + { cwd, ...MANUAL, ...(launch.threadConfig ? { config: launch.threadConfig } : {}) }, + { timeoutMs: 20_000 } + ) + expect(opened).toMatchObject({ + sandbox: { type: 'workspaceWrite', writableRoots: [userRoot, folder] } + }) + } finally { + await connection.close() + } + } finally { + rmSync(base, { recursive: true, force: true }) + } + }, 60_000) +}) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index b34aa335cfc8..1dbdde663b41 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -21,8 +21,9 @@ import { codexProviderHandleLink, codexSpawnedProcessIdentity } from './codex-structured-owner-identity' -import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment' +import { codexStructuredChildEnvironment } from './codex-structured-child-environment' import { openCodexThread } from './codex-structured-thread-open' +import { withCodexVisualsThreadConfig } from './codex-structured-visuals' import { closeCodexPublishedSession, handleCodexSessionExit @@ -139,7 +140,7 @@ export async function acquireCodexStructuredSession(input: { command: launch.command, args: launch.args, cwd: launch.cwd, - env: buildCodexStructuredChildEnvironment(launch, acquireInput.spawnToken, sessionId) + ...codexStructuredChildEnvironment(launch, acquireInput.spawnToken, sessionId) }, { onNotification: codexAcquisitionNotificationHandler({ @@ -195,7 +196,12 @@ export async function acquireCodexStructuredSession(input: { }) } acquisitions.assertCurrent(sessionId, attempt) - const opened = await openCodexThread(connection, launch, deps.requestTimeoutMs) + const threadLaunch = await withCodexVisualsThreadConfig(connection, launch, { + sessionId, + ...(deps.logger ? { logger: deps.logger } : {}) + }) + acquisitions.assertCurrent(sessionId, attempt) + const opened = await openCodexThread(connection, threadLaunch, deps.requestTimeoutMs) acquisitions.assertCurrent(sessionId, attempt) primaryThreadId = opened.threadId const restoreAdmission = translator?.restoreThread(opened.threadId, opened.thread ?? {}) diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 83cd87a3b420..fc2c505e9fb5 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -26,6 +26,7 @@ import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { NativeChatVisualsLaunch } from '../native-chat/native-chat-visuals-delivery' export type CodexSessionCatalogAccess = AgentModelCatalogSessionAccess @@ -43,6 +44,8 @@ export type CodexStructuredLaunch = { /** The model the session chose; the thread opens on it so its first turn is not a switch. */ model?: string env?: Record + /** This chat's visuals folder and skill; absent when the chat has no visuals. */ + visuals?: NativeChatVisualsLaunch } /** Turn and item boundaries, timed by when the host received them, never by when a buffered or diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 19a3980cb208..6fd58ba17ecd 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -91,11 +91,17 @@ export async function openCodexThread( /** Why: Codex renders a thread's base instructions for its opening model; a first turn on * another model reads as a mid-conversation switch and injects a second full prompt. */ model?: string + /** Config overrides for this thread, on start and resume alike. */ + threadConfig?: Record }, timeoutMs: number | undefined ): Promise { const resumeThreadId = launch.resumeThreadId - const threadSettings = { cwd: launch.cwd, ...launch.permissionPolicy } + const threadSettings = { + cwd: launch.cwd, + ...launch.permissionPolicy, + ...(launch.threadConfig ? { config: launch.threadConfig } : {}) + } const startThread = (): Promise => connection.request( 'thread/start', diff --git a/src/main/codex/codex-structured-visuals.test.ts b/src/main/codex/codex-structured-visuals.test.ts new file mode 100644 index 000000000000..93430c3d6af7 --- /dev/null +++ b/src/main/codex/codex-structured-visuals.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi } from 'vitest' +import { + NATIVE_CHAT_VISUALS_DIR_ENV, + type NativeChatVisualsLaunch +} from '../native-chat/native-chat-visuals-delivery' +import { CodexAppServerRequestError } from './codex-app-server-request-error' +import { CodexAppServerTimeoutError } from './codex-app-server-session' +import { acquired, fakeCodex, THREAD_ID } from './codex-structured-session-adapter-fixture' +import { + CODEX_VISUALS_SETUP_BUDGET_MS, + prepareCodexThreadForVisuals +} from './codex-structured-visuals' + +const VISUALS: NativeChatVisualsLaunch = { + folder: '/state/native-chat-visuals/abc', + skill: { pluginDir: '/app/native-chat-visuals', skillsRoot: '/app/native-chat-visuals/skills' } +} +const MANUAL = { approvalPolicy: 'on-request', sandbox: 'workspace-write' } as const +const YOLO = { approvalPolicy: 'never', sandbox: 'danger-full-access' } as const + +type Call = { method: string; params?: Record; timeoutMs?: number } + +function connectionAnswering(routes: Record unknown>) { + const calls: Call[] = [] + const request = vi.fn( + async (method: string, params?: Record, options?: { timeoutMs?: number }) => { + calls.push({ method, params, timeoutMs: options?.timeoutMs }) + const route = routes[method] + return route ? route() : {} + } + ) + return { calls, connection: { request } } +} + +const configWithRoots = (roots: unknown) => () => ({ + config: { sandbox_workspace_write: { writable_roots: roots, network_access: true } } +}) + +describe('setting up a Codex app-server for a chat with visuals', () => { + it("names the skill root and adds the chat folder to the user's writable roots", async () => { + const { calls, connection } = connectionAnswering({ + 'config/read': configWithRoots(['/home/me/scratch']) + }) + const config = await prepareCodexThreadForVisuals(connection, { + cwd: '/work/repo', + visuals: VISUALS, + permissionPolicy: MANUAL + }) + expect(config).toEqual({ + 'sandbox_workspace_write.writable_roots': ['/home/me/scratch', VISUALS.folder] + }) + expect(calls).toEqual( + expect.arrayContaining([ + { + method: 'skills/extraRoots/set', + params: { extraRoots: [VISUALS.skill.skillsRoot] }, + timeoutMs: CODEX_VISUALS_SETUP_BUDGET_MS + }, + { + method: 'config/read', + params: { cwd: '/work/repo' }, + timeoutMs: CODEX_VISUALS_SETUP_BUDGET_MS + } + ]) + ) + }) + + it('grants just the folder when the user has no writable roots of their own', async () => { + for (const answer of [() => ({ config: {} }), configWithRoots([])]) { + const { connection } = connectionAnswering({ 'config/read': answer }) + await expect( + prepareCodexThreadForVisuals(connection, { cwd: '/w', visuals: VISUALS }) + ).resolves.toEqual({ 'sandbox_workspace_write.writable_roots': [VISUALS.folder] }) + } + }) + + it('overrides nothing when the user roots cannot be read, so none are dropped', async () => { + const logger = { warn: vi.fn(), error: vi.fn() } + for (const route of [ + () => Promise.reject(new CodexAppServerTimeoutError('config/read exceeded 2000ms')), + configWithRoots([42]), + () => ({ nothing: true }) + ]) { + const { connection } = connectionAnswering({ 'config/read': route }) + await expect( + prepareCodexThreadForVisuals( + connection, + { cwd: '/w', visuals: VISUALS, permissionPolicy: MANUAL }, + { logger, sessionId: 's-1' } + ) + ).resolves.toBeNull() + } + expect(logger.warn).toHaveBeenCalledTimes(3) + }) + + it('asks for no writable root under full access, and still names the skill root', async () => { + const { calls, connection } = connectionAnswering({}) + await expect( + prepareCodexThreadForVisuals(connection, { + cwd: '/w', + visuals: VISUALS, + permissionPolicy: YOLO + }) + ).resolves.toBeNull() + expect(calls.map((call) => call.method)).toEqual(['skills/extraRoots/set']) + }) + + it('opens without the skill on a Codex that predates skill roots, or one that fails or hangs', async () => { + const failures = [ + new CodexAppServerRequestError( + 'skills/extraRoots/set', + -32600, + 'Invalid request: unknown variant `skills/extraRoots/set`, expected one of `initialize`' + ), + new CodexAppServerRequestError('skills/extraRoots/set', -32601, 'method not found'), + new CodexAppServerTimeoutError('codex app-server skills/extraRoots/set exceeded 2000ms'), + new Error('broken pipe') + ] + for (const failure of failures) { + const logger = { warn: vi.fn(), error: vi.fn() } + const { connection } = connectionAnswering({ + 'skills/extraRoots/set': () => Promise.reject(failure), + 'config/read': configWithRoots([]) + }) + await expect( + prepareCodexThreadForVisuals( + connection, + { cwd: '/w', visuals: VISUALS }, + { logger, sessionId: 's-1' } + ) + ).resolves.toEqual({ 'sandbox_workspace_write.writable_roots': [VISUALS.folder] }) + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching( + failure.message.includes('variant') || failure.message.includes('not found') + ? /cannot load skills/ + : /did not take/ + ), + expect.objectContaining({ scope: 'nativeChatVisuals.codex', error: failure }) + ) + } + }) + + it('sends nothing for a chat without visuals', async () => { + const { calls, connection } = connectionAnswering({}) + await expect(prepareCodexThreadForVisuals(connection, { cwd: '/w' })).resolves.toBeNull() + expect(calls).toEqual([]) + }) +}) + +describe('a Codex chat acquisition with visuals', () => { + it('sets up skills and the folder after initialize and before the thread opens, on start and resume', async () => { + for (const resumeThreadId of [null, THREAD_ID]) { + const codex = fakeCodex({ 'config/read': configWithRoots(['/home/me/scratch']) }) + await acquired(codex, { visuals: VISUALS, permissionPolicy: MANUAL, resumeThreadId }) + const connection = codex.connections[0]! + const methods = connection.calls.map((call) => call.method) + const open = resumeThreadId ? 'thread/resume' : 'thread/start' + expect(methods.indexOf('skills/extraRoots/set')).toBeLessThan(methods.indexOf(open)) + expect(methods.indexOf('config/read')).toBeLessThan(methods.indexOf(open)) + expect(connection.calls.find((call) => call.method === open)?.params).toMatchObject({ + ...MANUAL, + config: { 'sandbox_workspace_write.writable_roots': ['/home/me/scratch', VISUALS.folder] } + }) + expect(connection.launch.env?.[NATIVE_CHAT_VISUALS_DIR_ENV]).toBe(VISUALS.folder) + } + }) + + it('opens the thread exactly as before for a chat without visuals', async () => { + const codex = fakeCodex() + await acquired(codex, { + permissionPolicy: MANUAL, + env: { [NATIVE_CHAT_VISUALS_DIR_ENV]: '/other' } + }) + const connection = codex.connections[0]! + const methods = connection.calls.map((call) => call.method) + expect(methods[0]).toBe('thread/start') + expect(methods).not.toContain('skills/extraRoots/set') + expect(connection.calls[0]?.params).not.toHaveProperty('config') + expect(connection.launch.env).not.toHaveProperty(NATIVE_CHAT_VISUALS_DIR_ENV) + }) +}) diff --git a/src/main/codex/codex-structured-visuals.ts b/src/main/codex/codex-structured-visuals.ts new file mode 100644 index 000000000000..20962b349ccd --- /dev/null +++ b/src/main/codex/codex-structured-visuals.ts @@ -0,0 +1,153 @@ +// Inline visuals for a Codex chat, set up on its app-server between initialize and the thread +// open: the skill root Codex scans, and the chat's folder as a writable root. Both are best effort +// under one short budget; a chat whose setup fails, hangs or is unsupported opens without visuals. + +import type { NativeChatVisualsLaunch } from '../native-chat/native-chat-visuals-delivery' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { CodexAppServerConnection } from './codex-app-server-connection' +import { isCodexAppServerRequestError } from './codex-app-server-request-error' +import { isCodexMethodNotFoundError } from './codex-app-server-session' +import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' + +/** Why short: both requests answer from local state in milliseconds, and the user's first prompt + * waits behind them; past this the thread opens without whatever has not answered. */ +export const CODEX_VISUALS_SETUP_BUDGET_MS = 2_000 + +const SKILL_ROOTS_METHOD = 'skills/extraRoots/set' +const WRITABLE_ROOTS_KEY = 'sandbox_workspace_write.writable_roots' + +type CodexVisualsConnection = Pick + +/** A Codex that predates the method: older builds refuse it as an unknown request variant. */ +function isSkillRootsUnsupported(error: unknown): boolean { + return ( + isCodexMethodNotFoundError(error) || + (isCodexAppServerRequestError(error) && + error.code === -32600 && + error.message.includes(`unknown variant \`${SKILL_ROOTS_METHOD}\``)) + ) +} + +/** + * The skill roots Orca hands this app-server. The request replaces the process's whole list, so + * every root Orca supplies is named here; each chat has its own app-server, and this runs on every + * acquisition, so a respawned or resumed chat gets the list again. + */ +function orcaSkillRoots(visuals: NativeChatVisualsLaunch): string[] { + return [visuals.skill.skillsRoot] +} + +async function setSkillRoots( + connection: CodexVisualsConnection, + visuals: NativeChatVisualsLaunch, + report: (message: string, error: unknown) => void +): Promise { + try { + await connection.request( + SKILL_ROOTS_METHOD, + { extraRoots: orcaSkillRoots(visuals) }, + { timeoutMs: CODEX_VISUALS_SETUP_BUDGET_MS } + ) + } catch (error) { + report( + isSkillRootsUnsupported(error) + ? 'codex app-server cannot load skills by path; the chat has no visuals skill' + : 'codex app-server did not take the visuals skill root', + error + ) + } +} + +function writableRootsOf(response: unknown): string[] | null { + if (typeof response !== 'object' || response === null || !('config' in response)) { + return null + } + const { config } = response + if (typeof config !== 'object' || config === null) { + return null + } + const section = 'sandbox_workspace_write' in config ? config.sandbox_workspace_write : null + if (section === null || section === undefined) { + return [] + } + if (typeof section !== 'object' || !('writable_roots' in section)) { + return [] + } + const roots = section.writable_roots + return Array.isArray(roots) && roots.every((root) => typeof root === 'string') ? roots : null +} + +/** + * The thread config adding the chat's folder to the writable roots the user already has. The + * override replaces Codex's list rather than appending to it, so the user's own roots are read + * first (with the project layers this cwd sees) and kept; when they can't be read, nothing is + * overridden and a write there asks for approval like any other path outside the workspace. + */ +async function writableRootsConfig( + connection: CodexVisualsConnection, + input: { cwd: string; folder: string }, + report: (message: string, error: unknown) => void +): Promise | null> { + try { + const roots = writableRootsOf( + await connection.request( + 'config/read', + { cwd: input.cwd }, + { timeoutMs: CODEX_VISUALS_SETUP_BUDGET_MS } + ) + ) + if (!roots) { + report('codex config/read gave no readable writable roots', null) + return null + } + return { [WRITABLE_ROOTS_KEY]: [...new Set([...roots, input.folder])] } + } catch (error) { + report('codex config/read failed; the chat folder is not a writable root', error) + return null + } +} + +/** + * Sets this app-server up for the chat's visuals and returns the thread config the thread open + * carries, or null. Never throws. Full access needs no writable root: everything is writable. + */ +export async function prepareCodexThreadForVisuals( + connection: CodexVisualsConnection, + launch: { + cwd: string + visuals?: NativeChatVisualsLaunch | null + permissionPolicy?: CodexStructuredPermissionPolicy + }, + log?: { logger?: StructuredAgentSessionLogger; sessionId: string } +): Promise | null> { + const { visuals } = launch + if (!visuals) { + return null + } + const report = (message: string, error: unknown): void => + log?.logger?.warn(message, { + scope: 'nativeChatVisuals.codex', + sessionId: log.sessionId, + ...(error === null ? {} : { error }) + }) + const [, threadConfig] = await Promise.all([ + setSkillRoots(connection, visuals, report), + launch.permissionPolicy?.sandbox === 'danger-full-access' + ? null + : writableRootsConfig(connection, { cwd: launch.cwd, folder: visuals.folder }, report) + ]) + return threadConfig +} + +/** `launch` carrying its visuals thread config, set up on this app-server first. Codex reads skill + * roots and writable roots when a thread opens, so this runs between initialize and the open. */ +export async function withCodexVisualsThreadConfig< + T extends Parameters[1] +>( + connection: CodexVisualsConnection, + launch: T, + log: { logger?: StructuredAgentSessionLogger; sessionId: string } +): Promise }> { + const threadConfig = await prepareCodexThreadForVisuals(connection, launch, log) + return threadConfig ? { ...launch, threadConfig } : launch +} diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index c712d0e8c9dd..53e3d13744f7 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(41) + expect(PROTOCOL_VERSION).toBe(42) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index 423ee9ee34f2..2de5fd5be84e 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -125,26 +125,6 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(setLocalPtyProviderMock).not.toHaveBeenCalled() }) - it('prunes seeded Claude live-PTY ids against daemon sessions after init', async () => { - const mod = await importFresh() - // Why: live-pty-gate is intentionally unmocked — import from the same fresh registry so gate state matches daemon-init's. - const gate = await import('../claude-accounts/live-pty-gate') - defaultListSessionsSessions.push({ sessionId: 'claude-alive' }) - gate.seedLiveClaudePtysFromPersistence(['claude-alive', 'claude-dead']) - try { - await mod.initDaemonPtyProvider() - - expect(gate.hasLiveClaudePtys()).toBe(true) - - gate.markClaudePtyExited('claude-alive') - // Why: proves 'claude-dead' was released by the daemon reconcile — the surviving session held the gate alone. - expect(gate.hasLiveClaudePtys()).toBe(false) - } finally { - gate.markClaudePtyExited('claude-alive') - gate.markClaudePtyExited('claude-dead') - } - }) - it('does not install a late daemon provider after startup fallback aborts the init attempt', async () => { const mod = await importFresh() let resolveEnsureRunning!: (value: { socketPath: string; tokenPath: string }) => void diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 68eae86f58b2..8967d0cae513 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,7 +1,9 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v41 stages long startup commands as sourced scripts; v40 rolled the #25130/#24636 shell-wrapper -// changes and the wider agent list into a fresh daemon; older owners stay attachable. -export const PROTOCOL_VERSION = 41 +// v42 gives every pane the `claude` function that follows the selected Claude account; v41 stages +// long startup commands as sourced scripts; v40 rolled the #25130/#24636 shell-wrapper changes and +// the wider agent list into a fresh daemon; older owners stay attachable. +export const PROTOCOL_VERSION = 42 +export const CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION = 42 // v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS. export const CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION = 39 // Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. @@ -36,7 +38,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41 ] as const export function supportsColorQueryReplyColors(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index 9d9a1b5c3e11..dd2d2f951368 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -1,8 +1,4 @@ import { getLocalPtyProvider, rebindLocalProviderListeners } from '../ipc/pty' -import { - confirmSeededClaudeLivePtys, - hasSeededUnconfirmedClaudePtys -} from '../claude-accounts/live-pty-gate' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics' import { checkDaemonHealth } from './daemon-health' import { collectPinnedDaemonVersions, pruneOldDaemonHosts } from './daemon-host-relocation' @@ -163,7 +159,6 @@ export async function initDaemonPtyProvider( if (process.platform === 'darwin' && newSpawner.getHandle()?.adopted) { void reportDaemonAdoption(runtimeDir, info.socketPath, info.tokenPath, newAdapter) } - await reconcileSeededClaudeLivePtys(routedAdapter) } // Why off the init path: this is measurement of an adopted daemon (#17696), and neither its probes nor their failure may delay or fail startup. @@ -190,29 +185,3 @@ async function reportDaemonAdoption( // Best-effort measurement only. } } - -// Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token. -async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise { - if (!hasSeededUnconfirmedClaudePtys()) { - return - } - try { - const adapters = - provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider - ? provider.getAllAdapters() - : [provider] - const results = await Promise.allSettled(adapters.map((entry) => entry.listSessions())) - if (results.some((result) => result.status === 'rejected')) { - console.warn('[daemon] Keeping seeded Claude live-PTY gate — session listing failed') - return - } - confirmSeededClaudeLivePtys( - results.flatMap((result) => - result.status === 'fulfilled' ? result.value.map((session) => session.sessionId) : [] - ) - ) - } catch (error) { - // Why: gate bookkeeping must never fail daemon init; stale seeds only defer a usage refresh until next restart. - console.warn('[daemon] Failed to reconcile seeded Claude live-PTY gate:', error) - } -} diff --git a/src/main/daemon/daemon-provider-state.ts b/src/main/daemon/daemon-provider-state.ts index 30eff6691f99..7599ee9cd03c 100644 --- a/src/main/daemon/daemon-provider-state.ts +++ b/src/main/daemon/daemon-provider-state.ts @@ -3,7 +3,7 @@ import { setLocalPtyProvider } from '../ipc/pty' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import { getDaemonRuntimeDir as getRuntimeDir } from './daemon-launch-paths' import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' -import type { DaemonProvider } from './daemon-provider-routing' +import { getLegacyDaemonAdapters, type DaemonProvider } from './daemon-provider-routing' import { DaemonPtyRouter } from './daemon-pty-router' import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner' import type { DaemonSpawner } from './daemon-spawner' @@ -88,6 +88,18 @@ export function getDaemonProvider(): DaemonProvider | null { return adapter } +/** True for a terminal on a daemon kept alive from before `protocolVersion`; never for an SSH pane. */ +export function isTerminalFromBeforeDaemonProtocol( + ptyId: string, + protocolVersion: number +): boolean { + return adapter + ? getLegacyDaemonAdapters(adapter).some( + (legacy) => legacy.protocolVersion < protocolVersion && legacy.hasPty(ptyId) + ) + : false +} + // Why: computed from the pid record on demand (not cached at adoption) so the Settings // remedy surface always reflects the daemon actually serving terminals right now. export async function getCurrentDaemonMacTccAttributionHealth(): Promise { diff --git a/src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts b/src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts index b7ce586668b5..a4faede7cae5 100644 --- a/src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts +++ b/src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts @@ -192,7 +192,9 @@ describe.each(['darwin', 'linux', 'win32'] as const)('%s native-handle contract' expect(host.listSessions()).toHaveLength(1) expect(fixture.proc.destroy).not.toHaveBeenCalled() expect(onExit).not.toHaveBeenCalled() - await vi.waitFor(() => expect(signal).toHaveBeenCalledWith(4242, 'SIGKILL')) + await vi.waitFor(() => expect(signal).toHaveBeenCalledWith(4242, 'SIGKILL'), { + interval: 1 + }) } finally { fixture.proc._simulateExit(137) killFailure = await settled diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts index e29fff5330ab..e3c1638ff1fe 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -34,6 +34,10 @@ import { type RecognizedAgentProcess } from '../../../shared/agent-process-recognition' import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV +} from '../../../shared/claude-profile-routing' import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell' import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready' import { resolveWslSessionContext } from '../wsl-session-context' @@ -169,8 +173,8 @@ export function createPtyShellLaunchPlan( if (env.CLAUDE_CONFIG_DIR) { addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } - if (env.ORCA_CLAUDE_PROFILE_POINTER) { - addWslEnvKeys(env, ['ORCA_CLAUDE_PROFILE_POINTER', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR']) + if (env[CLAUDE_PROFILE_POINTER_ENV]) { + addWslEnvKeys(env, [CLAUDE_PROFILE_POINTER_ENV, CLAUDE_INJECTED_CONFIG_DIR_ENV]) } if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) diff --git a/src/main/daemon/serialize-grid-cell-descriptors.ts b/src/main/daemon/serialize-grid-cell-descriptors.ts index 9adbbc6c694c..9efc74221f72 100644 --- a/src/main/daemon/serialize-grid-cell-descriptors.ts +++ b/src/main/daemon/serialize-grid-cell-descriptors.ts @@ -132,6 +132,29 @@ function sameCellFields(expected: Cell, actual: Cell): boolean { ) } +// Match the frozen default-blank descriptor without formatting every trailing cell. +function isDefaultBlank(line: BufferLine | undefined, x: number, scratch: Cell): boolean { + if (!line || x >= line.length) { + return true + } + const cell = line.getCell(x, scratch) + if (!cell) { + return true + } + const chars = cell.getChars() + return ( + cell.getWidth() === 1 && + cell.getBgColorMode() === 0 && + cell.getBgColor() === -1 && + cell.isInverse() === 0 && + (chars === '' || + (chars === ' ' && + cell.isUnderline() === 0 && + cell.isStrikethrough() === 0 && + cell.isOverline() === 0)) + ) +} + function trimmedEnd( buffer: Buffer, start: number, @@ -143,7 +166,7 @@ function trimmedEnd( const line = buffer.getLine(end - 1) let blank = true for (let x = 0; x < cols; x++) { - if (cellDescriptor(line, x, cols, scratch) !== DEFAULT_BLANK) { + if (!isDefaultBlank(line, x, scratch)) { blank = false break } diff --git a/src/main/daemon/terminal-checkpoint-serializer.test.ts b/src/main/daemon/terminal-checkpoint-serializer.test.ts index ca12dd1d9893..1308402ae4d3 100644 --- a/src/main/daemon/terminal-checkpoint-serializer.test.ts +++ b/src/main/daemon/terminal-checkpoint-serializer.test.ts @@ -92,29 +92,36 @@ describe('terminal checkpoint serializer', () => { expect(Buffer.byteLength(serialized, 'utf8')).toBeLessThanOrEqual(maxBytes) }) - it('does not materialize and rescan a passing candidate', async () => { + it('keeps native conversion bounded without materializing the whole candidate', async () => { let reads = 0 const input = snapshot() Object.defineProperty(input, 'snapshotAnsi', { enumerable: true, get: () => { reads += 1 - return 'visible' + return 'visible'.repeat(20_000) } }) const stringify = vi.spyOn(JSON, 'stringify') const byteLength = vi.spyOn(Buffer, 'byteLength') try { - await serializeTerminalCheckpointWithinLimit(input, metadata, 20 * 1024) - - expect({ - fullCandidateStringifies: stringify.mock.calls.filter( - ([value]) => typeof value === 'object' && value !== null - ).length, - byteLengthCalls: byteLength.mock.calls.length, - snapshotReads: reads - }).toEqual({ fullCandidateStringifies: 0, byteLengthCalls: 0, snapshotReads: 1 }) + await serializeTerminalCheckpointWithinLimit(input, metadata, 200 * 1024) + + expect( + stringify.mock.calls.some(([value]) => typeof value === 'object' && value !== null) + ).toBe(false) + for (const [value] of stringify.mock.calls) { + if (typeof value === 'string') { + expect(value.length).toBeLessThanOrEqual(64 * 1024) + } + } + for (const [value] of byteLength.mock.calls) { + if (typeof value === 'string') { + expect(value.length).toBeLessThanOrEqual(128 * 1024) + } + } + expect(reads).toBe(1) } finally { stringify.mockRestore() byteLength.mockRestore() @@ -171,4 +178,53 @@ describe('terminal checkpoint serializer', () => { stringify.mockRestore() } }) + it.each([16 * 1024 - 1, 16 * 1024])( + 'preserves hardcoded control and surrogate JSON after %i code units', + async (prefixLength) => { + const prefix = 'x'.repeat(prefixLength) + const input = snapshot({ + snapshotAnsi: `${prefix}😀\ud800x\udc00"\b\t\n\f\r\\\0` + }) + const expectedPrefix = + `{"snapshotAnsi":"${prefix}😀\\ud800x\\udc00\\"\\b\\t\\n\\f\\r\\\\\\u0000",` + + '"scrollbackAnsi":""' + const serialized = await serializeTerminalCheckpointWithinLimit(input, metadata, 40 * 1024) + expect(serialized.startsWith(expectedPrefix)).toBe(true) + expect(JSON.parse(serialized).snapshotAnsi).toBe(input.snapshotAnsi) + const exactBytes = Buffer.byteLength(serialized, 'utf8') + await expect( + serializeTerminalCheckpointWithinLimit(input, metadata, exactBytes) + ).resolves.toBe(serialized) + const trimmed = await serializeTerminalCheckpointWithinLimit(input, metadata, exactBytes - 1) + expect(Buffer.byteLength(trimmed, 'utf8')).toBeLessThan(exactBytes) + } + ) + + it('rejects huge escaped metadata using bounded native conversions near the byte cap', async () => { + const stringify = vi.spyOn(JSON, 'stringify') + const byteLength = vi.spyOn(Buffer, 'byteLength') + try { + await expect( + serializeTerminalCheckpointWithinLimit( + snapshot(), + { ...metadata, cwd: String.fromCharCode(0).repeat(1_000_000) }, + 512 + ) + ).rejects.toThrow('Terminal checkpoint metadata exceeds byte limit') + for (const [value] of stringify.mock.calls) { + expect(typeof value === 'object' && value !== null).toBe(false) + if (typeof value === 'string') { + expect(value.length).toBeLessThanOrEqual(512) + } + } + for (const [value] of byteLength.mock.calls) { + if (typeof value === 'string') { + expect(value.length).toBeLessThanOrEqual(512 * 6) + } + } + } finally { + stringify.mockRestore() + byteLength.mockRestore() + } + }) }) diff --git a/src/main/daemon/terminal-checkpoint-serializer.ts b/src/main/daemon/terminal-checkpoint-serializer.ts index 8a686cf29f87..60ba778ef3ec 100644 --- a/src/main/daemon/terminal-checkpoint-serializer.ts +++ b/src/main/daemon/terminal-checkpoint-serializer.ts @@ -1,3 +1,4 @@ +import { clampToSafeSplitIndex } from './daemon-stream-data-split' import type { TerminalCheckpointFile, TerminalSnapshot } from './types' import { ColdRestoreReplayWriter } from './cold-restore-replay-writer' import { HeadlessEmulator } from './headless-emulator' @@ -60,29 +61,12 @@ class BoundedJsonWriter { return true } - result(): string | null { - return this.exceeded ? null : this.output + this.chunk + remainingBytes(): number { + return this.maxBytes - this.bytes } -} -function escapedCodeUnit(codeUnit: number): string | null { - switch (codeUnit) { - case 0x08: - return '\\b' - case 0x09: - return '\\t' - case 0x0a: - return '\\n' - case 0x0c: - return '\\f' - case 0x0d: - return '\\r' - case 0x22: - return '\\"' - case 0x5c: - return '\\\\' - default: - return codeUnit < 0x20 ? `\\u${codeUnit.toString(16).padStart(4, '0')}` : null + result(): string | null { + return this.exceeded ? null : this.output + this.chunk } } @@ -90,44 +74,17 @@ function appendJsonString(writer: BoundedJsonWriter, value: string): boolean { if (!writer.append('"', 1)) { return false } - let spanStart = 0 - let spanBytes = 0 - for (let index = 0; index < value.length; index += 1) { - const codeUnit = value.charCodeAt(index) - let escaped = escapedCodeUnit(codeUnit) - if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - const next = value.charCodeAt(index + 1) - if (next >= 0xdc00 && next <= 0xdfff) { - spanBytes += 4 - index += 1 - } else { - escaped = `\\u${codeUnit.toString(16)}` - } - } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { - escaped = `\\u${codeUnit.toString(16)}` - } else if (escaped === null) { - spanBytes += codeUnit < 0x80 ? 1 : codeUnit < 0x800 ? 2 : 3 - } - - if (escaped !== null) { - if ( - (index > spanStart && !writer.append(value.slice(spanStart, index), spanBytes)) || - !writer.append(escaped, escaped.length) - ) { - return false - } - spanStart = index + 1 - spanBytes = 0 - } else if (index + 1 - spanStart >= 16 * 1024) { - if (!writer.append(value.slice(spanStart, index + 1), spanBytes)) { - return false - } - spanStart = index + 1 - spanBytes = 0 + let start = 0 + while (start < value.length) { + // Keep a surrogate pair together even when only one byte remains. + const remainingBytes = writer.remainingBytes() + const chunkLength = remainingBytes < 16 * 1024 ? Math.max(2, remainingBytes) : 16 * 1024 + const end = clampToSafeSplitIndex(value, start, Math.min(value.length, start + chunkLength)) + const json = JSON.stringify(value.slice(start, end)).slice(1, -1) + if (!writer.append(json, Buffer.byteLength(json, 'utf8'))) { + return false } - } - if (spanStart < value.length && !writer.append(value.slice(spanStart), spanBytes)) { - return false + start = end } return writer.append('"', 1) } diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.test.ts new file mode 100644 index 000000000000..9b1c6fd63b4e --- /dev/null +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.test.ts @@ -0,0 +1,90 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import * as fsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('node:fs/promises', { spy: true }) + +const reads = vi.hoisted(() => ({ + binder: vi.fn<() => Promise>(), + client: vi.fn() +})) + +vi.mock('./foreign-sqlite-reader-client', () => ({ + ForeignSqliteReaderClient: class { + constructor() { + reads.client() + } + + readOpenCodeBinderSessions = reads.binder + } +})) + +import { readOpenCodeBinderSessions } from './foreign-sqlite-reader-spawn' + +const CURSOR = { ms: 0, id: '' } +let root = '' + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-binder-preflight-')) + reads.binder.mockReset().mockResolvedValue([]) + reads.client.mockClear() +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +describe('OpenCode binder worker admission', () => { + it.each(['ENOENT', 'ENOTDIR'])('does not dispatch a worker read for %s', async (code) => { + const parent = join(root, 'parent') + if (code === 'ENOTDIR') { + writeFileSync(parent, '') + } + const path = join(parent, 'opencode.db') + + expect(await readOpenCodeBinderSessions(path, CURSOR)).toEqual([]) + expect(await readOpenCodeBinderSessions(path, CURSOR)).toEqual([]) + expect(reads.binder).not.toHaveBeenCalled() + expect(reads.client).not.toHaveBeenCalled() + }) + + it('dispatches once the previously absent database appears', async () => { + const path = join(root, 'later', 'opencode.db') + expect(await readOpenCodeBinderSessions(path, CURSOR)).toEqual([]) + expect(reads.binder).not.toHaveBeenCalled() + + mkdirSync(join(root, 'later')) + writeFileSync(path, '') + const rows = [{ id: 'session', directory: root, createdAtMs: 1, parentId: null }] + reads.binder.mockResolvedValue(rows) + + expect(await readOpenCodeBinderSessions(path, CURSOR)).toEqual(rows) + expect(reads.binder).toHaveBeenCalledExactlyOnceWith(path, CURSOR) + }) + + it.each(['EACCES', 'EIO'])( + 'leaves %s failures to the existing worker error handling', + async (code) => { + const path = join(root, 'opencode.db') + vi.mocked(fsPromises.stat).mockRejectedValueOnce(Object.assign(new Error(code), { code })) + + expect(await readOpenCodeBinderSessions(path, CURSOR)).toEqual([]) + expect(reads.binder).toHaveBeenCalledExactlyOnceWith(path, CURSOR) + } + ) + + it('keeps the worker result authoritative when the database changes after the preflight', async () => { + const path = join(root, 'opencode.db') + writeFileSync(path, '') + reads.binder.mockImplementation(async () => { + rmSync(path) + return [] + }) + + expect(await readOpenCodeBinderSessions(path, CURSOR)).toEqual([]) + expect(reads.binder).toHaveBeenCalledExactlyOnceWith(path, CURSOR) + }) +}) diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts index de4654d1160c..043a317130d8 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts @@ -1,4 +1,5 @@ import { existsSync } from 'node:fs' +import { stat } from 'node:fs/promises' import { Worker } from 'node:worker_threads' import { currentWorkerEntryLayout } from '../worker-thread-entry-path' import type { CursorDesktopProfileReadResult } from './cursor-profile-result' @@ -40,9 +41,22 @@ export function readCursorDesktopProfile(dbPath: string): Promise { + try { + // Missing stores must not keep an otherwise unused reader worker alive on every binder poll. + await stat(dbPath) + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ) { + return [] + } + // The worker still owns reporting other failures and checking for changes after this probe. + } return getSharedClient().readOpenCodeBinderSessions(dbPath, cursor) } diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index 82a02b295754..f2b9dc39ad16 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -61,7 +61,7 @@ vi.mock('./github-enterprise-repository', () => ({ isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) })) -import { createGitHubPullRequest } from './client' +import { createGitHubPullRequest } from './client/create/create-github-pull-request' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' diff --git a/src/main/github/client-file-viewed.test.ts b/src/main/github/client-file-viewed.test.ts index a52a479e9f59..ba0a7fb291cf 100644 --- a/src/main/github/client-file-viewed.test.ts +++ b/src/main/github/client-file-viewed.test.ts @@ -53,7 +53,7 @@ vi.mock('./github-enterprise-repository', () => ({ isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) })) -import { setPRFileViewed } from './client' +import { setPRFileViewed } from './client/update/pr-file-viewed' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' diff --git a/src/main/github/client-issue-origin-preference.test.ts b/src/main/github/client-issue-origin-preference.test.ts index 951962a3c255..d2e7317abe6c 100644 --- a/src/main/github/client-issue-origin-preference.test.ts +++ b/src/main/github/client-issue-origin-preference.test.ts @@ -85,7 +85,8 @@ vi.mock('./github-api-repository', async (importOriginal) => { } }) -import { getWorkItem, _resetOwnerRepoCache } from './client' +import { getWorkItem } from './client/fetch/get-work-item' +import { _resetOwnerRepoCache } from './gh-utils' describe('GitHub issue open-by-number origin preference', () => { beforeEach(() => { diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index a4f8b6468314..57ad87387e2a 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -92,7 +92,10 @@ vi.mock('./github-api-repository', async (importOriginal) => { } }) -import { countWorkItems, getWorkItem, listWorkItems, _resetOwnerRepoCache } from './client' +import { countWorkItems } from './client/list/count-work-items' +import { getWorkItem } from './client/fetch/get-work-item' +import { listWorkItems } from './client/list/list-work-items' +import { _resetOwnerRepoCache } from './gh-utils' const PR_LIST_FIELDS = 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' 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 d099408d022f..b6fe0d535d50 100644 --- a/src/main/github/client-merge-queue-auto-merge.test.ts +++ b/src/main/github/client-merge-queue-auto-merge.test.ts @@ -26,15 +26,13 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { - getPRForBranch, - getWorkItemByOwnerRepo, - mergePR, - resolveReviewThread, - setPRAutoMerge, - updatePRTitle, - _getMergeQueueCacheSizeForTests -} from './client' +import { getPRForBranch } from './client/lookup/get-pr-for-branch' +import { getWorkItemByOwnerRepo } from './client/fetch/get-work-item' +import { mergePR } from './client/merge/merge-pr' +import { resolveReviewThread } from './client/update/resolve-review-thread' +import { setPRAutoMerge } from './client/merge/pr-auto-merge' +import { updatePRTitle } from './client/update/pr-details' +import { _getMergeQueueCacheSizeForTests } from './client/detect/repository-merge-metadata-cache' import { resetGraphQLRateLimitGuardMocks } from './client-test-harness' const { diff --git a/src/main/github/client-merged-pr-visibility.test.ts b/src/main/github/client-merged-pr-visibility.test.ts index 22501ec97350..e24c903b8914 100644 --- a/src/main/github/client-merged-pr-visibility.test.ts +++ b/src/main/github/client-merged-pr-visibility.test.ts @@ -26,7 +26,8 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { getPRForBranchOutcome, getPRForBranch } from './client' +import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' +import { getPRForBranch } from './client/lookup/get-pr-for-branch' import { resetPRForBranchMocks } from './client-test-harness' const { diff --git a/src/main/github/client-pr-branch-discovery.test.ts b/src/main/github/client-pr-branch-discovery.test.ts index 2e89ecfa45e0..82167d8bdda0 100644 --- a/src/main/github/client-pr-branch-discovery.test.ts +++ b/src/main/github/client-pr-branch-discovery.test.ts @@ -26,7 +26,7 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { getPRForBranch } from './client' +import { getPRForBranch } from './client/lookup/get-pr-for-branch' import { resetPRForBranchMocks } from './client-test-harness' const { diff --git a/src/main/github/client-pr-check-details.test.ts b/src/main/github/client-pr-check-details.test.ts index 4bb2fcbae762..e411e960e949 100644 --- a/src/main/github/client-pr-check-details.test.ts +++ b/src/main/github/client-pr-check-details.test.ts @@ -50,7 +50,8 @@ vi.mock('./rate-limit', () => ({ spendsSharedGitHubComQuota: vi.fn(() => true) })) -import { getPRCheckDetails, _resetOwnerRepoCache } from './client' +import { getPRCheckDetails } from './client/check/get-pr-check-details' +import { _resetOwnerRepoCache } from './gh-utils' describe('getPRCheckDetails', () => { beforeEach(() => { diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts index a9732cacf214..aa8b31a476cd 100644 --- a/src/main/github/client-pr-checks.test.ts +++ b/src/main/github/client-pr-checks.test.ts @@ -84,7 +84,9 @@ vi.mock('./rate-limit', () => ({ spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock })) -import { getPRChecks, rerunPRChecks, _resetOwnerRepoCache } from './client' +import { getPRChecks } from './client/check/get-pr-checks' +import { rerunPRChecks } from './client/check/rerun-pr-checks' +import { _resetOwnerRepoCache } from './gh-utils' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' diff --git a/src/main/github/client-pr-comment-reactions.test.ts b/src/main/github/client-pr-comment-reactions.test.ts index f2792c7922a6..4123dbc94a86 100644 --- a/src/main/github/client-pr-comment-reactions.test.ts +++ b/src/main/github/client-pr-comment-reactions.test.ts @@ -26,7 +26,8 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { getPRComments, setPRCommentReaction } from './client' +import { getPRComments } from './client/fetch/get-pr-comments' +import { setPRCommentReaction } from './client/update/pr-comment-reaction' import { resetGraphQLRateLimitGuardMocks } from './client-test-harness' import type { RateLimitGuardResult } from './client-test-mocks' diff --git a/src/main/github/client-pr-conflict-summary.test.ts b/src/main/github/client-pr-conflict-summary.test.ts index a71939112743..2df6b81b6099 100644 --- a/src/main/github/client-pr-conflict-summary.test.ts +++ b/src/main/github/client-pr-conflict-summary.test.ts @@ -26,7 +26,7 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { getPRForBranch } from './client' +import { getPRForBranch } from './client/lookup/get-pr-for-branch' import { resetPRForBranchMocks } from './client-test-harness' const { diff --git a/src/main/github/client-pr-fallback-number.test.ts b/src/main/github/client-pr-fallback-number.test.ts index 59245e377562..2c63f4ddc016 100644 --- a/src/main/github/client-pr-fallback-number.test.ts +++ b/src/main/github/client-pr-fallback-number.test.ts @@ -26,7 +26,8 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { getPRForBranchOutcome, getPRForBranch } from './client' +import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' +import { getPRForBranch } from './client/lookup/get-pr-for-branch' import { resetPRForBranchMocks } from './client-test-harness' const { diff --git a/src/main/github/client-pr-linked-lookup.test.ts b/src/main/github/client-pr-linked-lookup.test.ts index e4965ee0cb98..36b2d0df0ca1 100644 --- a/src/main/github/client-pr-linked-lookup.test.ts +++ b/src/main/github/client-pr-linked-lookup.test.ts @@ -26,7 +26,7 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { getPRForBranch } from './client' +import { getPRForBranch } from './client/lookup/get-pr-for-branch' import { resetPRForBranchMocks } from './client-test-harness' const { diff --git a/src/main/github/client-pr-push-target.test.ts b/src/main/github/client-pr-push-target.test.ts index 36fd97823f3b..210cfbf6d518 100644 --- a/src/main/github/client-pr-push-target.test.ts +++ b/src/main/github/client-pr-push-target.test.ts @@ -26,7 +26,9 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { getRepoSlug, getRepoUpstream, getWorkItem, getPullRequestPushTarget } from './client' +import { getRepoSlug, getRepoUpstream } from './client/fetch/repo-slug-upstream' +import { getWorkItem } from './client/fetch/get-work-item' +import { getPullRequestPushTarget } from './client/lookup/pull-request-push-target' import { resetPRForBranchMocks } from './client-test-harness' const { diff --git a/src/main/github/client-pr-state.test.ts b/src/main/github/client-pr-state.test.ts index 8ca2cd85b754..3522d3022067 100644 --- a/src/main/github/client-pr-state.test.ts +++ b/src/main/github/client-pr-state.test.ts @@ -26,7 +26,9 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { markPRReadyForReview, updatePRState, _resetOwnerRepoCache } from './client' +import { markPRReadyForReview } from './client/update/pr-ready' +import { updatePRState } from './client/update/pr-state' +import { _resetOwnerRepoCache } from './gh-utils' import { resetOriginRepositoryCache } from './client-test-harness' const { diff --git a/src/main/github/client-rate-limit-block.test.ts b/src/main/github/client-rate-limit-block.test.ts index 969cc16af952..a5070fbb4ba5 100644 --- a/src/main/github/client-rate-limit-block.test.ts +++ b/src/main/github/client-rate-limit-block.test.ts @@ -26,7 +26,8 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { getGitHubPRLookupRateLimitBlock, _resetOwnerRepoCache } from './client' +import { getGitHubPRLookupRateLimitBlock } from './client/lookup/pr-lookup-rate-limit' +import { _resetOwnerRepoCache } from './gh-utils' import { resetOriginRepositoryCache } from './client-test-harness' import type { RateLimitGuardResult } from './client-test-mocks' 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 1f9bb1b17046..c42e3dc3f5cb 100644 --- a/src/main/github/client-ssh-provider-execution-boundary.test.ts +++ b/src/main/github/client-ssh-provider-execution-boundary.test.ts @@ -26,7 +26,8 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { getPRForBranch, getPRForBranchOutcome } from './client' +import { getPRForBranch } from './client/lookup/get-pr-for-branch' +import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' import { resetPRForBranchMocks } from './client-test-harness' const { diff --git a/src/main/github/client-stack-merge-guard.test.ts b/src/main/github/client-stack-merge-guard.test.ts index c47f786d2edb..ce2cdc540f37 100644 --- a/src/main/github/client-stack-merge-guard.test.ts +++ b/src/main/github/client-stack-merge-guard.test.ts @@ -26,7 +26,8 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { getPRForBranch, mergePR } from './client' +import { getPRForBranch } from './client/lookup/get-pr-for-branch' +import { mergePR } from './client/merge/merge-pr' import { resetGraphQLRateLimitGuardMocks } from './client-test-harness' const { ghExecFileAsyncMock, getOwnerRepoMock, acquireMock, releaseMock } = clientMocks diff --git a/src/main/github/client-starred.test.ts b/src/main/github/client-starred.test.ts index 2997aef883a2..48a7f2d284ef 100644 --- a/src/main/github/client-starred.test.ts +++ b/src/main/github/client-starred.test.ts @@ -26,7 +26,7 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { __resetOrcaStarCheckForTests, checkOrcaStarred, starOrca } from './client' +import { __resetOrcaStarCheckForTests, checkOrcaStarred, starOrca } from './client/fetch/orca-star' import { resetOriginRepositoryCache } from './client-test-harness' const { execFileAsyncMock, ghExecFileAsyncMock, acquireMock, releaseMock } = clientMocks diff --git a/src/main/github/client-test-harness.ts b/src/main/github/client-test-harness.ts index 1f16338a5ab5..645c4f8a0c55 100644 --- a/src/main/github/client-test-harness.ts +++ b/src/main/github/client-test-harness.ts @@ -1,10 +1,8 @@ import type { GitHubClientMocks } from './client-test-mocks' -import { - _resetOwnerRepoCache, - _resetMergeQueueCacheForTests, - _resetPRStackSummaryCacheForTests, - __resetTrackedUpstreamBranchCacheForTests -} from './client' +import { _resetOwnerRepoCache } from './gh-utils' +import { _resetMergeQueueCacheForTests } from './client/detect/repository-merge-metadata-cache' +import { _resetPRStackSummaryCacheForTests } from './client/lookup/pr-stack-summary-cache' +import { __resetTrackedUpstreamBranchCacheForTests } from './client/lookup/tracked-upstream-cache' import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership' import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' @@ -88,5 +86,6 @@ export function resetGraphQLRateLimitGuardMocks(mocks: GitHubClientMocks): void mocks.acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + _resetPRStackSummaryCacheForTests() __resetPRConflictSummaryCachesForTests() } 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 eb1237394358..ee967202c7da 100644 --- a/src/main/github/client-tracked-upstream-fork-owner.test.ts +++ b/src/main/github/client-tracked-upstream-fork-owner.test.ts @@ -26,7 +26,7 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { getPRForBranch } from './client' +import { getPRForBranch } from './client/lookup/get-pr-for-branch' import { resetPRForBranchMocks } from './client-test-harness' const { diff --git a/src/main/github/client-tracked-upstream-snapshot.test.ts b/src/main/github/client-tracked-upstream-snapshot.test.ts index 6bcf5dd93830..1eb2200415d2 100644 --- a/src/main/github/client-tracked-upstream-snapshot.test.ts +++ b/src/main/github/client-tracked-upstream-snapshot.test.ts @@ -26,11 +26,11 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) +import { getPRForBranch } from './client/lookup/get-pr-for-branch' import { - getPRForBranch, _getTrackedUpstreamBranchCacheSizesForTests, __resetTrackedUpstreamBranchCacheForTests -} from './client' +} from './client/lookup/tracked-upstream-cache' import { resetPRForBranchMocks } from './client-test-harness' const { diff --git a/src/main/github/client-work-item-check-summary.test.ts b/src/main/github/client-work-item-check-summary.test.ts index 2543fa3a2881..bdcb1cd5866d 100644 --- a/src/main/github/client-work-item-check-summary.test.ts +++ b/src/main/github/client-work-item-check-summary.test.ts @@ -47,7 +47,9 @@ vi.mock('./rate-limit', () => ({ spendsSharedGitHubComQuota: vi.fn(() => true) })) -import { getWorkItem, _resetMergeQueueCacheForTests, _resetOwnerRepoCache } from './client' +import { getWorkItem } from './client/fetch/get-work-item' +import { _resetMergeQueueCacheForTests } from './client/detect/repository-merge-metadata-cache' +import { _resetOwnerRepoCache } from './gh-utils' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' // Why: only `pr view` is fixtured; the merge-metadata fan-out is best-effort and must not mask the summary. diff --git a/src/main/github/client-work-items-query-paging.test.ts b/src/main/github/client-work-items-query-paging.test.ts index 6ff7ace0cc96..eb8237586efb 100644 --- a/src/main/github/client-work-items-query-paging.test.ts +++ b/src/main/github/client-work-items-query-paging.test.ts @@ -115,12 +115,10 @@ vi.mock('./github-api-repository', async (importOriginal) => { } }) -import { - countWorkItems, - listWorkItems, - _resetMergeQueueCacheForTests, - _resetOwnerRepoCache -} from './client' +import { countWorkItems } from './client/list/count-work-items' +import { listWorkItems } from './client/list/list-work-items' +import { _resetMergeQueueCacheForTests } from './client/detect/repository-merge-metadata-cache' +import { _resetOwnerRepoCache } from './gh-utils' import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github/work-items-query-bounds' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index 318784097684..f49738d6a562 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -109,12 +109,10 @@ vi.mock('./github-api-repository', async (importOriginal) => { } }) -import { - countWorkItems, - listWorkItems, - _resetMergeQueueCacheForTests, - _resetOwnerRepoCache -} from './client' +import { countWorkItems } from './client/list/count-work-items' +import { listWorkItems } from './client/list/list-work-items' +import { _resetMergeQueueCacheForTests } from './client/detect/repository-merge-metadata-cache' +import { _resetOwnerRepoCache } from './gh-utils' import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github/work-items-query-bounds' import { _resetRemoteNameListingCache } from '../git/remote-name-listing' diff --git a/src/main/github/default-branch-stale-pr.test.ts b/src/main/github/default-branch-stale-pr.test.ts index 8facc4867f03..c4faa88a5289 100644 --- a/src/main/github/default-branch-stale-pr.test.ts +++ b/src/main/github/default-branch-stale-pr.test.ts @@ -122,13 +122,11 @@ vi.mock('./rate-limit', () => ({ noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock })) -import { - getPRForBranch, - getPRForBranchOutcome, - _resetOwnerRepoCache, - _resetMergeQueueCacheForTests, - __resetTrackedUpstreamBranchCacheForTests -} from './client' +import { getPRForBranch } from './client/lookup/get-pr-for-branch' +import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' +import { _resetOwnerRepoCache } from './gh-utils' +import { _resetMergeQueueCacheForTests } from './client/detect/repository-merge-metadata-cache' +import { __resetTrackedUpstreamBranchCacheForTests } from './client/lookup/tracked-upstream-cache' import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership' import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' 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 9c6f2a21159a..4eef43a27af1 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 @@ -31,7 +31,7 @@ vi.mock('./local-git-config-signature', () => ({ import { _resetRemoteNameListingCache } from '../git/remote-name-listing' import { getOwnerRepoForRemote, _resetOwnerRepoCache } from './github-repository-identity' import { getOwnerRepo } from './github-owner-repo-selection' -import { getRepoUpstream } from './client' +import { getRepoUpstream } from './client/fetch/repo-slug-upstream' const FORK_PATH = '/tmp/fork-checkout' const NON_FORK_PATH = '/tmp/plain-checkout' diff --git a/src/main/ipc/agent-session-attachment-upload-ipc.ts b/src/main/ipc/agent-session-attachment-upload-ipc.ts new file mode 100644 index 000000000000..9fcf1ba357d5 --- /dev/null +++ b/src/main/ipc/agent-session-attachment-upload-ipc.ts @@ -0,0 +1,36 @@ +import { app, ipcMain } from 'electron' +import type { + AgentSessionAttachmentPathUploadResult, + AgentSessionAttachmentUploadTarget +} from '../../shared/agent-session-attachments' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { uploadExternalPathsToAgentSessionAttachments } from './agent-session-attachment-upload' +import { abortWhenRendererGone } from './renderer-lifetime-abort' + +export function registerAgentSessionAttachmentUploadHandlers(): void { + ipcMain.handle( + 'fs:uploadPathsToAgentSessionAttachments', + async ( + event, + args: AgentSessionAttachmentUploadTarget & { paths: string[] } + ): Promise => { + const userDataPath = app.getPath('userData') + // Why: a reload or close must stop the transfer; the server sweeps the part file it leaves. + const lifetime = abortWhenRendererGone(event.sender) + try { + return await uploadExternalPathsToAgentSessionAttachments( + { + ...args, + // The manual-disconnect check keys on the resolved id, not whatever selector came in. + environmentId: resolveEnvironment(userDataPath, args.environmentId).id, + userDataPath, + signal: lifetime.signal + }, + args.paths + ) + } finally { + lifetime.dispose() + } + } + ) +} diff --git a/src/main/ipc/agent-session-attachment-upload.test.ts b/src/main/ipc/agent-session-attachment-upload.test.ts new file mode 100644 index 000000000000..80300d92674e --- /dev/null +++ b/src/main/ipc/agent-session-attachment-upload.test.ts @@ -0,0 +1,166 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store' + +type CallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal } +type Call = { + method: string + params: Record + revision?: number + options?: CallOptions +} + +const calls: Call[] = [] +let serverStore: AgentSessionAttachmentStore +let failMethod: string | null = null + +// The paired server, played by a real store: every call answers as the RPC handler would. +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: async ( + _userDataPath: string, + _environmentId: string, + method: string, + params: Record, + _timeoutMs?: number, + revision?: number, + _envelope?: unknown, + options?: CallOptions + ) => { + calls.push({ method, params, revision, options }) + if (method === failMethod) { + return { ok: false, error: { code: 'internal', message: `${method} failed` } } + } + const callerKey = 'client-a' + const uploadId = String(params.uploadId) + try { + const result = + method === 'agentSessionAttachment.uploadStart' + ? await serverStore.startUpload({ + callerKey, + sessionId: String(params.sessionId), + name: String(params.name), + byteLength: Number(params.byteLength) + }) + : method === 'agentSessionAttachment.uploadAppend' + ? await serverStore.appendChunk({ + callerKey, + uploadId, + offset: Number(params.offset), + contentBase64: String(params.contentBase64) + }) + : method === 'agentSessionAttachment.uploadCommit' + ? await serverStore.commitUpload({ callerKey, uploadId }) + : await serverStore.abortUpload({ callerKey, uploadId }) + return { ok: true, result } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { ok: false, error: { code: 'internal', message } } + } + } +})) + +const { uploadBufferToAgentSessionAttachments, uploadExternalPathsToAgentSessionAttachments } = + await import('./agent-session-attachment-upload') + +let workDir: string +const context = () => ({ + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a', + userDataPath: join(workDir, 'client-user-data') +}) + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'orca-attachment-upload-')) + serverStore = new AgentSessionAttachmentStore( + join(workDir, 'server', 'agent-session-attachments'), + { hasSession: () => true } + ) + calls.length = 0 + failMethod = null +}) + +afterEach(async () => { + serverStore.clearInFlightForTests() + await rm(workDir, { recursive: true, force: true }) +}) + +describe('uploadExternalPathsToAgentSessionAttachments', () => { + it('uploads dropped files in order and returns only server paths', async () => { + const big = Buffer.alloc(1024 * 1024, 7) + await writeFile(join(workDir, 'big.bin'), big) + await writeFile(join(workDir, 'notes.md'), '# hi') + + const result = await uploadExternalPathsToAgentSessionAttachments(context(), [ + join(workDir, 'big.bin'), + join(workDir, 'notes.md') + ]) + + expect(result.skipped).toEqual([]) + expect(result.failed).toEqual([]) + expect(result.uploaded.map(({ sourcePath }) => sourcePath)).toEqual([ + join(workDir, 'big.bin'), + join(workDir, 'notes.md') + ]) + for (const { path } of result.uploaded) { + expect(path.startsWith(serverStore.rootDir)).toBe(true) + } + expect(await readFile(result.uploaded[0].path)).toEqual(big) + expect(await readFile(result.uploaded[1].path, 'utf8')).toBe('# hi') + }) + + it('pins every call to the pairing and every call to the server process', async () => { + await writeFile(join(workDir, 'a.txt'), 'a') + await uploadExternalPathsToAgentSessionAttachments(context(), [join(workDir, 'a.txt')]) + expect(calls.map((call) => call.method)).toEqual([ + 'agentSessionAttachment.uploadStart', + 'agentSessionAttachment.uploadAppend', + 'agentSessionAttachment.uploadCommit' + ]) + for (const call of calls) { + expect(call.revision).toBe(7) + expect(call.options?.expectedEnvironmentRuntimeId).toBe('runtime-a') + } + }) + + it('skips a folder as unsupported without walking into it', async () => { + await mkdir(join(workDir, 'folder', 'nested'), { recursive: true }) + await writeFile(join(workDir, 'folder', 'inside.txt'), 'x') + // A link inside would make the stager skip the folder as a symlink: never reached. + await symlink(join(workDir, 'folder', 'inside.txt'), join(workDir, 'folder', 'nested', 'link')) + const result = await uploadExternalPathsToAgentSessionAttachments(context(), [ + join(workDir, 'folder') + ]) + expect(result.skipped).toEqual([{ sourcePath: join(workDir, 'folder'), reason: 'unsupported' }]) + expect(calls).toEqual([]) + }) + + it('aborts a failed upload on the server and reports the file as failed', async () => { + await writeFile(join(workDir, 'a.txt'), 'a') + failMethod = 'agentSessionAttachment.uploadCommit' + const result = await uploadExternalPathsToAgentSessionAttachments(context(), [ + join(workDir, 'a.txt') + ]) + expect(result.uploaded).toEqual([]) + expect(result.failed).toEqual([ + { sourcePath: join(workDir, 'a.txt'), reason: 'agentSessionAttachment.uploadCommit failed' } + ]) + expect(calls.at(-1)?.method).toBe('agentSessionAttachment.uploadAbort') + }) +}) + +describe('uploadBufferToAgentSessionAttachments', () => { + it('stores a pasted image under the chat', async () => { + const png = Buffer.alloc(900 * 1024, 3) + const stored = await uploadBufferToAgentSessionAttachments(context(), 'orca-paste-1.png', png) + expect(stored.name).toBe('orca-paste-1.png') + expect(await readFile(stored.path)).toEqual(png) + // 900 KiB in 384 KiB slices. + expect( + calls.filter((call) => call.method === 'agentSessionAttachment.uploadAppend') + ).toHaveLength(3) + }) +}) diff --git a/src/main/ipc/agent-session-attachment-upload.ts b/src/main/ipc/agent-session-attachment-upload.ts new file mode 100644 index 000000000000..258c5d355d9a --- /dev/null +++ b/src/main/ipc/agent-session-attachment-upload.ts @@ -0,0 +1,216 @@ +// Client side of chat attachments on a paired server: main owns the file handles, the clipboard and +// the runtime socket, so it pumps the bytes into the server's attachment store and hands the +// renderer only the server path. + +import { randomUUID } from 'node:crypto' +import { lstat } from 'node:fs/promises' +import { basename } from 'node:path' +import { + AGENT_SESSION_ATTACHMENT_CHUNK_BYTES, + AGENT_SESSION_ATTACHMENT_MAX_BYTES, + agentSessionPastedImageName, + type AgentSessionAttachmentClipboardTarget, + type AgentSessionAttachmentPathUploadResult, + type AgentSessionAttachmentUploadCommitResult, + type AgentSessionAttachmentUploadStartResult, + type AgentSessionAttachmentUploadTarget +} from '../../shared/agent-session-attachments' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { callRuntimeEnvironment } from './runtime-environment-transport-routing' +import { + isRuntimeEnvironmentManuallyDisconnected, + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE +} from './runtime-environment-manual-disconnect' +import { stageOneSourceForRuntimeUpload } from './filesystem-runtime-upload-staging' +import { streamExternalFileSlices } from './runtime-upload-file-stream' +import { formatByteCeiling } from './runtime-import-limits' + +const ATTACHMENT_CALL_TIMEOUT_MS = 30_000 + +async function isDirectory(path: string): Promise { + try { + return (await lstat(path)).isDirectory() + } catch { + // The stager reports a path it cannot read with its own reason. + return false + } +} + +type UploadContext = AgentSessionAttachmentUploadTarget & { + userDataPath: string + signal?: AbortSignal +} + +async function callAttachmentMethod( + context: UploadContext, + method: string, + params: unknown +): Promise { + if (isRuntimeEnvironmentManuallyDisconnected(context.environmentId)) { + throw new Error(RUNTIME_MANUALLY_DISCONNECTED_MESSAGE) + } + const response = await callRuntimeEnvironment( + context.userDataPath, + context.environmentId, + method, + params, + ATTACHMENT_CALL_TIMEOUT_MS, + context.expectedEnvironmentPairingRevision, + undefined, + { + expectedEnvironmentRuntimeId: context.expectedEnvironmentRuntimeId, + signal: context.signal + } + ) + if (response.ok !== true) { + throw new Error(response.error.message || response.error.code) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each caller names the result type of the agentSessionAttachment.* method it calls, whose server handler returns exactly that shape. + return response.result as TResult +} + +/** start → body → commit; any failure aborts, so the server frees the slot and its part file. */ +async function uploadAttachment( + context: UploadContext, + file: { name: string; byteLength: number }, + writeBody: ( + appendSlice: (contentBase64: string, offset: number) => Promise + ) => Promise +): Promise { + const { uploadId } = await callAttachmentMethod( + context, + 'agentSessionAttachment.uploadStart', + { sessionId: context.sessionId, name: file.name, byteLength: file.byteLength } + ) + try { + await writeBody((contentBase64, offset) => + callAttachmentMethod(context, 'agentSessionAttachment.uploadAppend', { + uploadId, + offset, + contentBase64 + }) + ) + return await callAttachmentMethod( + context, + 'agentSessionAttachment.uploadCommit', + { uploadId } + ) + } catch (error) { + await callAttachmentMethod( + { ...context, signal: undefined }, + 'agentSessionAttachment.uploadAbort', + { uploadId } + ).catch(() => {}) + throw error + } +} + +export function uploadBufferToAgentSessionAttachments( + context: UploadContext, + name: string, + buffer: Buffer +): Promise { + return uploadAttachment(context, { name, byteLength: buffer.byteLength }, async (appendSlice) => { + if (buffer.byteLength === 0) { + await appendSlice('', 0) + return + } + for ( + let offset = 0; + offset < buffer.byteLength; + offset += AGENT_SESSION_ATTACHMENT_CHUNK_BYTES + ) { + context.signal?.throwIfAborted() + const slice = buffer.subarray(offset, offset + AGENT_SESSION_ATTACHMENT_CHUNK_BYTES) + await appendSlice(slice.toString('base64'), offset) + } + }) +} + +/** A pasted image for a structured chat on a paired server, stored in that server's store. */ +export async function uploadPastedImageToAgentSessionAttachments( + target: AgentSessionAttachmentClipboardTarget, + runtimeEnvironmentId: string, + userDataPath: string, + buffer: Buffer +): Promise { + const stored = await uploadBufferToAgentSessionAttachments( + { + ...target, + environmentId: resolveEnvironment(userDataPath, runtimeEnvironmentId).id, + userDataPath + }, + agentSessionPastedImageName(Date.now(), randomUUID()), + buffer + ) + return stored.path +} + +/** + * Upload dropped or picked client-local files, in input order. A folder is skipped as + * unsupported: a chat attachment is a file. + */ +export async function uploadExternalPathsToAgentSessionAttachments( + context: UploadContext, + paths: readonly string[] +): Promise { + const result: AgentSessionAttachmentPathUploadResult = { uploaded: [], skipped: [], failed: [] } + for (const sourcePath of paths) { + context.signal?.throwIfAborted() + // Before staging, which would walk and open everything inside a folder only to skip it. + if (await isDirectory(sourcePath)) { + result.skipped.push({ sourcePath, reason: 'unsupported' }) + continue + } + const staged = await stageOneSourceForRuntimeUpload(sourcePath) + if (staged.status === 'skipped') { + result.skipped.push({ sourcePath, reason: staged.reason }) + continue + } + if (staged.status === 'failed') { + result.failed.push({ sourcePath, reason: staged.reason }) + continue + } + const entry = staged.entries[0] + if (staged.kind !== 'file' || !entry || entry.kind !== 'file') { + result.skipped.push({ sourcePath, reason: 'unsupported' }) + continue + } + if (entry.byteLength > AGENT_SESSION_ATTACHMENT_MAX_BYTES) { + result.failed.push({ + sourcePath, + reason: + `'${basename(sourcePath)}' is ${formatByteCeiling(entry.byteLength)}, over the ` + + `${formatByteCeiling(AGENT_SESSION_ATTACHMENT_MAX_BYTES)} chat attachment limit` + }) + continue + } + try { + const stored = await uploadAttachment( + context, + { name: basename(sourcePath), byteLength: entry.byteLength }, + async (appendSlice) => { + await streamExternalFileSlices({ + sourceRootPath: sourcePath, + entryRelativePath: '', + expected: entry, + signal: context.signal, + maxFileBytes: AGENT_SESSION_ATTACHMENT_MAX_BYTES, + limitLabel: 'chat attachment limit', + writeSlice: appendSlice + }) + } + ) + result.uploaded.push({ sourcePath, path: stored.path }) + } catch (error) { + if (context.signal?.aborted) { + throw error + } + result.failed.push({ + sourcePath, + reason: error instanceof Error ? error.message : String(error) + }) + } + } + return result +} diff --git a/src/main/ipc/claude-accounts.ts b/src/main/ipc/claude-accounts.ts index f88f094e46b3..181164a46468 100644 --- a/src/main/ipc/claude-accounts.ts +++ b/src/main/ipc/claude-accounts.ts @@ -1,10 +1,10 @@ import { ipcMain } from 'electron' -import type { ClaudeAccountAddTarget, ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeAccountService } from '../claude-accounts/service' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountService): void { ipcMain.handle('claudeAccounts:list', () => claudeAccounts.listAccounts()) - ipcMain.handle('claudeAccounts:add', (_event, args?: ClaudeAccountAddTarget) => + ipcMain.handle('claudeAccounts:add', (_event, args?: ClaudeAccountSelectionTarget) => claudeAccounts.addAccount(args) ) ipcMain.handle('claudeAccounts:cancelPendingLogin', () => claudeAccounts.cancelPendingLogin()) @@ -16,11 +16,9 @@ export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountServi ) ipcMain.handle( 'claudeAccounts:select', - (_event, args: { accountId: string | null } & ClaudeAccountSelectionTarget) => { - if (!args.runtime) { - return claudeAccounts.selectAccount(args.accountId) - } - return claudeAccounts.selectAccountForTarget(args.accountId, args) - } + (_event, args: { accountId: string | null } & ClaudeAccountSelectionTarget) => + args.runtime + ? claudeAccounts.selectAccountForTarget(args.accountId, args) + : claudeAccounts.selectAccount(args.accountId) ) } diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 4fbbc7516ce9..1a628c301cf8 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -1,3 +1,4 @@ +import { SKILL_INSTALL_RESULT_V2_CAPABILITY } from '../../shared/skill-install-capability' /** * The desktop renderer talks to two hosts — its own main process and a paired remote — and used to * advertise a different capability set to each, hand-maintained on both sides. `agent.launch` is @@ -15,7 +16,6 @@ import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, - SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index ab205b559758..61566cd084bf 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -5,6 +5,7 @@ import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, @@ -42,6 +43,7 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, // The renderer reads `agentSession.agents` and renders a chat tab of any agent its host lists. STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, // Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer diff --git a/src/main/ipc/feedback-request-ownership.test.ts b/src/main/ipc/feedback-request-ownership.test.ts new file mode 100644 index 000000000000..728bebdfa67a --- /dev/null +++ b/src/main/ipc/feedback-request-ownership.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() })) + +vi.mock('electron', () => ({ + app: { getVersion: () => '1.2.3-test' }, + net: { fetch: fetchMock } +})) + +import { submitFeedback } from './feedback' +import { FEEDBACK_API_URL, postFeedback, type FeedbackSubmitBody } from './feedback-request' + +const plainSubmission: Parameters[0] = { + feedback: 'request ownership regression', + submitAnonymously: true, + githubLogin: null, + githubEmail: null +} + +const imageSubmission: Parameters[0] = { + ...plainSubmission, + images: [{ contentType: 'image/png', data: new Uint8Array([1, 2, 3]) }] +} + +function requestSignal(index = 0): AbortSignal { + const signal = fetchMock.mock.calls[index]?.[1]?.signal + if (!signal) { + throw new Error('Expected an owned feedback request signal') + } + return signal +} + +describe('feedback request ownership', () => { + beforeEach(() => fetchMock.mockReset()) + + it('retires an unread response after successful text submission', async () => { + fetchMock.mockResolvedValue(new Response('unused response')) + + await expect(submitFeedback(plainSubmission)).resolves.toEqual({ ok: true }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(requestSignal().aborted).toBe(true) + }) + + it('retires both failed text attempts while preserving the retry result', async () => { + fetchMock.mockResolvedValue(new Response('unused error', { status: 503 })) + + await expect(submitFeedback(plainSubmission)).resolves.toEqual({ + ok: false, + status: 503, + error: 'status 503; retry: status 503' + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(requestSignal(0).aborted).toBe(true) + expect(requestSignal(1).aborted).toBe(true) + }) + + it('retires a rejected image response without replaying the attachments', async () => { + fetchMock.mockResolvedValue(new Response('unused error', { status: 503 })) + + await expect(submitFeedback(imageSubmission)).resolves.toEqual({ + ok: false, + status: 503, + error: 'status 503' + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(requestSignal().aborted).toBe(true) + }) + + it('keeps the request live until the image response reader finishes', async () => { + const reading = Promise.withResolvers() + let finishResponse = (): void => { + throw new Error('Response stream has not started') + } + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"imagesDelivered":')) + finishResponse = () => { + controller.enqueue(new TextEncoder().encode('true}')) + controller.close() + } + }, + pull() { + reading.resolve() + } + }) + ) + fetchMock.mockResolvedValue(response) + + const submission = submitFeedback(imageSubmission) + await reading.promise + expect(requestSignal().aborted).toBe(false) + finishResponse() + + await expect(submission).resolves.toEqual({ ok: true, imagesDelivered: true }) + expect(requestSignal().aborted).toBe(true) + }) + + it('retires diagnostic and report-only requests after an attachment fallback', async () => { + fetchMock + .mockResolvedValueOnce(new Response('unused error', { status: 413 })) + .mockResolvedValueOnce(new Response('unused response')) + + await expect( + submitFeedback({ + ...plainSubmission, + submissionType: 'crash', + feedbackWithoutDiagnosticBundle: 'report without diagnostics', + diagnosticBundle: { + bundleSubmissionId: 'ownership-regression', + content: '{"type":"bundle-header"}\n', + bytes: 25, + spanCount: 1 + } + }) + ).resolves.toEqual({ + ok: true, + diagnosticBundleFailure: { status: 413, error: 'status 413' } + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(requestSignal(0).aborted).toBe(true) + expect(requestSignal(1).aborted).toBe(true) + }) + + it('retires failed network attempts without turning them into deadline errors', async () => { + fetchMock + .mockRejectedValueOnce(new Error('first network failure')) + .mockRejectedValueOnce(new Error('retry network failure')) + + await expect(submitFeedback(plainSubmission)).resolves.toEqual({ + ok: false, + status: null, + error: 'first network failure; retry: retry network failure' + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(requestSignal(0).aborted).toBe(true) + expect(requestSignal(1).aborted).toBe(true) + }) + + it('preserves the response reader error when retiring its request', async () => { + fetchMock.mockResolvedValue(new Response('unused response')) + const body: FeedbackSubmitBody = { + feedback: plainSubmission.feedback, + submissionType: 'feedback', + githubLogin: null, + githubEmail: null, + appVersion: 'test', + platform: process.platform, + osRelease: 'test', + arch: process.arch + } + const failure = new Error('response reader failure') + + await expect( + postFeedback(FEEDBACK_API_URL, body, undefined, async () => { + throw failure + }) + ).rejects.toBe(failure) + + expect(requestSignal().aborted).toBe(true) + }) +}) diff --git a/src/main/ipc/feedback-request.ts b/src/main/ipc/feedback-request.ts index 4d92a77f6b80..9d95b2360f07 100644 --- a/src/main/ipc/feedback-request.ts +++ b/src/main/ipc/feedback-request.ts @@ -71,6 +71,7 @@ export async function postFeedback( throw error } finally { clearTimeout(timeout) + controller.abort() } } diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index de02a93e7ac2..6aa3502adb78 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -11,6 +11,7 @@ import { } from '../local-file-access-resolution' import { isENOENT } from '../filesystem-path-containment' import { registerFilesystemMutationHandlers } from '../filesystem-mutations' +import { registerAgentSessionAttachmentUploadHandlers } from '../agent-session-attachment-upload-ipc' import type { FilesystemHandlerContext } from './filesystem-handler-context' import { assertLocalWriteTargetIsRegularFile } from './local-regular-file-read' @@ -99,4 +100,5 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex ) registerFilesystemMutationHandlers(store) + registerAgentSessionAttachmentUploadHandlers() } diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 02e2928f3c69..3b8e95d85697 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -14,6 +14,8 @@ import { type SubscribeNativeChatTranscriptArgs } from '../native-chat/transcript-watch' import { abortWhenRendererGone } from './renderer-lifetime-abort' +import { agentHookServer } from '../agent-hooks/server' +import { nativeChatTranscriptPathOnExecutionHost } from '../native-chat/ssh-transcript-path' // Re-export so existing test imports of `clearNativeChatTranscriptCache` from // this module keep working after the cache moved to transcript-read-cache.ts. @@ -40,7 +42,11 @@ async function readSession(args: NativeChatReadSessionArgs): Promise { if (!canPublish()) { diff --git a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts index 7e4178f35d3e..470582f35615 100644 --- a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts +++ b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts @@ -8,7 +8,6 @@ import { } from './pty-ipc-mock-registry' import { posixOnlyIt, makeDisposable } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' -import * as livePtyGate from '../claude-accounts/live-pty-gate' import { registerPtyHandlers, setLocalPtyProvider, getLocalPtyProvider } from './pty' import { join } from 'node:path' // Why resolved rather than hardcoded: the wrapper tree is content-addressed. @@ -367,7 +366,6 @@ describe('registerPtyHandlers', () => { } spawnMock.mockReturnValue(proc) const isRecoveryReloadInFlight = vi.fn(() => true) - const markClaudePtyExitedSpy = vi.spyOn(livePtyGate, 'markClaudePtyExited') registerPtyHandlers( mainWindow as never, @@ -396,11 +394,8 @@ describe('registerPtyHandlers', () => { expect(killSpy).not.toHaveBeenCalled() expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(markClaudePtyExitedSpy).not.toHaveBeenCalled() const listed = await getLocalPtyProvider().listProcesses() expect(listed.some((info) => info.id === spawnResult.id)).toBe(true) - - markClaudePtyExitedSpy.mockRestore() }) // Why: guard against over-suppression — with no recovery reload in flight the sweep MUST still reclaim genuinely orphaned local PTYs. it('still sweeps orphaned local PTYs when no recovery reload is in flight', async () => { 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 d530a9cd1947..36c02a0f2346 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -1,14 +1,13 @@ import { withFreshOmpLaunch } from '../../shared/omp-fresh-launch' import { describe, expect, it, vi } from 'vitest' import { piBuildPtyEnvMock, spawnMock } from './pty-ipc-mock-registry' -import { TEST_CODEX_HOME, makeDisposable } from './pty-ipc-test-constants' +import { TEST_CODEX_HOME } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { LocalPtyProvider } from '../providers/local-pty-provider' import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' -import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' -import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty' +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' @@ -320,49 +319,6 @@ describe('registerPtyHandlers', () => { id: expect.any(String) }) }) - it('marks local Claude launches live until the PTY is killed', async () => { - let exitCb: ((info: { exitCode: number }) => void) | undefined - spawnMock.mockReturnValue({ - onData: vi.fn(() => makeDisposable()), - onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { - exitCb = cb - return makeDisposable() - }), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(() => exitCb?.({ exitCode: -1 })), - process: 'zsh', - pid: 12345 - }) - const prepareClaudeAuth = vi.fn(async () => ({ - configDir: '/tmp/claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'managed:account-1' - })) - registerPtyHandlers(mainWindow as never, undefined, undefined, undefined, prepareClaudeAuth) - - const spawnResult = (await handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - command: 'claude' - })) as { id: string } - - expect(prepareClaudeAuth).toHaveBeenCalledTimes(1) - expect(hasLiveClaudePtys()).toBe(true) - - await handlers.get('pty:kill')!(null, { id: spawnResult.id }) - - expect(hasLiveClaudePtys()).toBe(false) - }) - it('clears Claude live-PTY tracking from shared provider teardown', () => { - markClaudePtySpawned('ssh-claude-pty') - expect(hasLiveClaudePtys()).toBe(true) - - clearProviderPtyState('ssh-claude-pty') - - expect(hasLiveClaudePtys()).toBe(false) - }) it('defaults LANG to en_US.UTF-8 when not inherited from process.env', async () => { const env = await spawnAndGetEnv(undefined, { LANG: undefined }) expect(env.LANG).toBe('en_US.UTF-8') diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.test.ts b/src/main/ipc/pty/ipc/claude-old-terminal.test.ts new file mode 100644 index 000000000000..6d8a3e16e00a --- /dev/null +++ b/src/main/ipc/pty/ipc/claude-old-terminal.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +type Handler = (event: unknown, args: { id: unknown }) => Promise + +const mocks = vi.hoisted(() => ({ + handlers: new Map(), + olderPtyIds: new Set(), + startup: vi.fn<() => Promise | undefined>() +})) +vi.mock('../../pty-host-bindings', () => ({ + getPtyIpc: () => ({ + handle: (channel: string, handler: Handler) => mocks.handlers.set(channel, handler) + }) +})) +vi.mock('../../../daemon/daemon-provider-state', () => ({ + isTerminalFromBeforeDaemonProtocol: (id: string, protocolVersion: number) => + protocolVersion === 42 && mocks.olderPtyIds.has(id) +})) + +import { installPtyClaudeOldTerminalIpcHandler } from './claude-old-terminal' + +beforeEach(() => { + mocks.handlers.clear() + mocks.olderPtyIds = new Set(['old-1']) + installPtyClaudeOldTerminalIpcHandler({ getLocalPtyProviderStartupPromise: mocks.startup }) +}) + +it('answers true only for a pane owned by a daemon from before per-account Claude folders', async () => { + const ask = mocks.handlers.get('pty:openedBeforeClaudeAccounts')! + expect(await ask({}, { id: 'old-1' })).toBe(true) + // A pane on this build's daemon, or an SSH pane, which no local daemon owns. + expect(await ask({}, { id: 'new-1' })).toBe(false) + expect(await ask({}, { id: 'ssh:conn-1:pty-1' })).toBe(false) + expect(await ask({}, { id: 42 })).toBe(false) + expect(mocks.startup).toHaveBeenCalled() +}) diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.ts b/src/main/ipc/pty/ipc/claude-old-terminal.ts new file mode 100644 index 000000000000..385800241cbd --- /dev/null +++ b/src/main/ipc/pty/ipc/claude-old-terminal.ts @@ -0,0 +1,23 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import { CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION } from '../../../daemon/daemon-protocol-version' +import { isTerminalFromBeforeDaemonProtocol } from '../../../daemon/daemon-provider-state' + +type Deps = { getLocalPtyProviderStartupPromise: () => Promise | undefined } + +/** Whether a pane's daemon predates the claude function that follows the selected account. */ +export function installPtyClaudeOldTerminalIpcHandler(deps: Deps): void { + getPtyIpc().handle( + 'pty:openedBeforeClaudeAccounts', + async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string') { + return false + } + // Why: the pre-swap provider does not own restored daemon ids. + await deps.getLocalPtyProviderStartupPromise() + return isTerminalFromBeforeDaemonProtocol( + args.id, + CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION + ) + } + ) +} diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 24df59c1201c..ffcc5d158bb3 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -1,6 +1,5 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { agentHookServer } from '../../../agent-hooks/server' -import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' import { registerPty } from '../../../memory/pty-registry' import type { PtySpawnResult } from '../../../providers/types' import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state' @@ -125,9 +124,6 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) - } if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) } diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index b6a50c43bbbf..4b8d71c04417 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -4,8 +4,6 @@ import { resolveLocalWindowsTerminalRuntimeOptions } from '../../../../shared/local-windows-terminal-runtime' import { isWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths' -import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' -import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../../../claude-accounts/environment' import { mintPtySessionId } from '../../../daemon/pty-session-id' import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' import { LocalPtyProvider } from '../../../providers/local-pty-provider' @@ -194,9 +192,6 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) - } ctx.terminalRuntimeOptions = process.platform === 'win32' && !args.connectionId ? resolveLocalWindowsTerminalRuntimeOptions({ diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts index 31bacec82f7f..ec13ad0d36e2 100644 --- a/src/main/ipc/pty/provider/bind-listeners.ts +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -1,4 +1,3 @@ -import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { isCurrentPtyExit, ptyOwnership } from './ownership-state' import { @@ -92,7 +91,6 @@ export function bindProviderListeners(session: PtyIpcSession): void { if (!isLocalProvider) { clearProviderPtyState(payload.id) ptyOwnership.delete(payload.id) - markClaudePtyExited(payload.id) if (syntheticExit) { session.runtime?.markPtyStopRequested(payload.id) } diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts index 8a362b74aaf2..5c4265bdc6ea 100644 --- a/src/main/ipc/pty/provider/liveness.ts +++ b/src/main/ipc/pty/provider/liveness.ts @@ -10,7 +10,6 @@ import { isSshPtyProvenExitedOnRelayError } from '../../../providers/ssh-pty-errors' import type { IPtyProvider } from '../../../providers/types' -import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { ptyIncarnationById, ptyOwnership } from './ownership-state' import { getRelayPtyId } from './registry' import { @@ -179,6 +178,5 @@ export function finishPtyShutdown( store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') } ptyOwnership.delete(id) - markClaudePtyExited(id) return incarnationId } diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index 7a494522ec01..eeb5fef028c5 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -10,7 +10,6 @@ import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' -import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { buildPtyHostEnv } from '../host-env/assembly' import { getCompatibleSelectedCodexHomePath, @@ -126,7 +125,6 @@ export function configureLocalPtyProvider(args: { } clearProviderPtyState(id) ptyOwnership.delete(id) - markClaudePtyExited(id) runtime?.onPtyExit(id, code, incarnationId, { providerExitObserved: true, ...(cause ? { cause } : {}) diff --git a/src/main/ipc/pty/provider/state-cleanup.ts b/src/main/ipc/pty/provider/state-cleanup.ts index f70b07dbff85..aa99d191abc0 100644 --- a/src/main/ipc/pty/provider/state-cleanup.ts +++ b/src/main/ipc/pty/provider/state-cleanup.ts @@ -1,6 +1,5 @@ import { advertisedUrlWatcher } from '../../../ports/advertised-url-watcher' import { unregisterPty } from '../../../memory/pty-registry' -import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { forgetCodexPaneAccount } from '../../../codex/codex-pane-account-registry' import { openCodeHookService } from '../../../opencode/hook-service' import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service' @@ -49,8 +48,6 @@ export function clearProviderPtyState( // new teardown path forgets to remove one provider's overlay/hook state. openCodeHookService.clearPty(id) piTitlebarExtensionService.clearPty(id) - // Why: SSH exit/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards. - markClaudePtyExited(id) ptySizes.delete(id) ptyIncarnationById.delete(id) lastInputAtByPty.delete(id) diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 7f29e895e0bc..5e823ac535ca 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -13,6 +13,7 @@ import { finishPtyShutdown } from './provider/liveness' import type { GetSelectedCodexHomePath, PrepareClaudeAuth } from './host-env/types' import { installPtyInspectIpcHandlers } from './ipc/inspect' import { installPtyCodexSharedServerIpcHandler } from './ipc/codex-shared-server' +import { installPtyClaudeOldTerminalIpcHandler } from './ipc/claude-old-terminal' import { installPtyKillIpcHandler, stopReplacedPanePty, @@ -124,6 +125,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:isCodexOnSharedServer') ipcMain.removeHandler('pty:disableCodexSharedServerAutoStart') ipcMain.removeHandler('pty:stopCodexSharedServer') + ipcMain.removeHandler('pty:openedBeforeClaudeAccounts') ipcMain.removeHandler('pty:getCwd') ipcMain.removeHandler('pty:getSize') ipcMain.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') @@ -286,5 +288,6 @@ export function registerPtyHandlers( installPtyResizeVisibilityIpc(session) installPtyInspectIpcHandlers({ getLocalPtyProviderStartupPromise }) installPtyCodexSharedServerIpcHandler({ getLocalPtyProviderStartupPromise }) + installPtyClaudeOldTerminalIpcHandler({ getLocalPtyProviderStartupPromise }) installPtyKillIpcHandler(killDeps) } diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index c38637371c36..e6e301aa8afa 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -7,7 +7,6 @@ import { recordCodexPaneAccountForSpawn, codexReattachedHomeRouteField } from '../host-env/codex-home' -import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' import { registerPty } from '../../../memory/pty-registry' import { rememberPaneKeyForPty } from '../pane/key-state' import { @@ -240,9 +239,6 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (!ctx.stablePaneOwner) { ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null) } - if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { - markClaudePtySpawned(ctx.result.id) - } if (args.telemetry && !ctx.stablePaneOwner) { recordPtySpawnTelemetry(args.telemetry) } diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index cfbace932ab8..88db8879630e 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -96,6 +96,7 @@ describe('runtime pty spawn preflight: Claude account routing in a WSL pane', () new ClaudeProfileRouter({ getSettings: () => getDefaultSettings('/tmp'), dataRoot: '/data/orca', + env: {}, runSetup }) ) diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index c82b0656a377..729cf74af786 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -23,10 +23,8 @@ import { } from '../host-env/fresh-spawn-routing' import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' import { @@ -70,9 +68,6 @@ export async function prepareRuntimePtySpawn( } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) - } // Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime. // `args.shellOverride` is the per-request pick (`terminal create --shell`), read here the way // the renderer twin (ipc/spawn-preflight.ts) reads a tab's override. Without it a runtime create @@ -151,9 +146,6 @@ export async function prepareRuntimePtySpawn( ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) : null - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) - } if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) } diff --git a/src/main/ipc/remote-workspace-target-session-export.test.ts b/src/main/ipc/remote-workspace-target-session-export.test.ts new file mode 100644 index 000000000000..aa60c2d2929e --- /dev/null +++ b/src/main/ipc/remote-workspace-target-session-export.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import type { Store } from '../persistence' +import type { Repo } from '../../shared/repo-types' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { TerminalTab } from '../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { createRepoRowExecutionHostLookup } from '../../shared/worktree-execution-host-resolution' +import { + createWorktreeOwnerResolver, + persistedSessionForTarget +} from './remote-workspace-target-session-export' + +const TARGET_ID = 'target-1' +const WORKTREE_ID = 'repo-1::/remote/repo' + +function tab(id: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId: WORKTREE_ID, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function session(tabs: TerminalTab[]): WorkspaceSessionState { + return { ...getDefaultWorkspaceSession(), tabsByWorktree: { [WORKTREE_ID]: tabs } } +} + +describe('persistedSessionForTarget', () => { + it("publishes the ssh partition's tabs, not a stray local copy of an SSH-owned workspace", () => { + const partitions: Record = { + local: session([tab('tab-stale')]), + [`ssh:${TARGET_ID}`]: session([tab('tab-live')]) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the publish fallback reads only getWorkspaceSession. + const store = { + getWorkspaceSession: (hostId?: string) => partitions[hostId ?? 'local'] + } as unknown as Store + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership resolution reads only id, connectionId and executionHostId. + const repos = [{ id: 'repo-1', connectionId: TARGET_ID, executionHostId: null }] as Repo[] + + const published = persistedSessionForTarget( + store, + TARGET_ID, + createWorktreeOwnerResolver(createRepoRowExecutionHostLookup(repos)) + ) + + expect(published.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-live']) + }) +}) diff --git a/src/main/ipc/remote-workspace-target-session-export.ts b/src/main/ipc/remote-workspace-target-session-export.ts index c88f8cb620bd..9adef9312e93 100644 --- a/src/main/ipc/remote-workspace-target-session-export.ts +++ b/src/main/ipc/remote-workspace-target-session-export.ts @@ -113,9 +113,14 @@ function catalogAttributionForPartition( resolveWorktreeOwner: WorktreeOwnerResolver, host: WorkspaceSessionState, hostId: ExecutionHostId -): { contestedSessionKeys: Set; foreignSessionKeys: Set } { +): { + contestedSessionKeys: Set + foreignSessionKeys: Set + ownedSessionKeys: Set +} { const contestedSessionKeys = new Set() const foreignSessionKeys = new Set() + const ownedSessionKeys = new Set() for (const workspaceId of workspaceIdsNamedByPartition(host)) { // A folder key names no repo, and `getRepoIdFromWorktreeId` hands back the whole key rather // than nothing, so the catalog would be asked about `folder:` and answer `unknown`. @@ -130,7 +135,9 @@ function catalogAttributionForPartition( } } else if (resolution.hostId !== hostId) { foreignSessionKeys.add(workspaceId) + } else { + ownedSessionKeys.add(workspaceId) } } - return { contestedSessionKeys, foreignSessionKeys } + return { contestedSessionKeys, foreignSessionKeys, ownedSessionKeys } } diff --git a/src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts b/src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts new file mode 100644 index 000000000000..06c2461b9c2d --- /dev/null +++ b/src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { BrowserWindow } from 'electron' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { closeTestStores, createStore, testState } from '../../persistence-test-harness' +import type { WorkspaceAttachment } from '../../../shared/worktree/types' +import { registerFolderWorkspaceHandlers } from './folder-workspace-handlers' + +const { handlers } = vi.hoisted(() => ({ + handlers: new Map unknown>() +})) +vi.mock('electron', () => ({ + BrowserWindow: class {}, + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false }, + ipcMain: { + handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => { + handlers.set(channel, handler) + } + } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) +vi.mock('./repos-changed-notification', () => ({ notifyReposChanged: vi.fn() })) + +const linkedItems: WorkspaceAttachment[] = [ + { provider: 'github', type: 'pr', number: 7 }, + { provider: 'gitlab', type: 'mr', number: 8 }, + { provider: 'linear', type: 'issue', number: 0, identifier: 'APP-42' } +] + +async function invoke(channel: string, args: unknown): Promise { + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`Handler not registered: ${channel}`) + } + return handler(undefined, args) +} + +describe('folder workspace attachment desktop IPC', () => { + beforeEach(() => { + handlers.clear() + testState.dir = mkdtempSync(join(tmpdir(), 'orca-folder-ipc-')) + }) + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('preserves all attachments through the registered update handler, durable reload, and list handler', async () => { + const store = createStore() + const group = store.createProjectGroup({ + name: 'Docs', + parentPath: testState.dir, + createdFrom: 'folder-scan' + }) + const workspace = store.createFolderWorkspace({ projectGroupId: group.id }) + registerFolderWorkspaceHandlers(new BrowserWindow(), store, { + deleteFolderWorkspace: vi.fn(async () => ({ deleted: true })) + }) + expect( + await invoke('folderWorkspaces:update', { + folderWorkspaceId: workspace.id, + updates: { linkedItems } + }) + ).toMatchObject({ linkedItems }) + store.flush() + const restored = createStore() + expect(restored.getFolderWorkspace(workspace.id)?.linkedItems).toEqual(linkedItems) + expect(await invoke('folderWorkspaces:list', undefined)).toContainEqual( + expect.objectContaining({ linkedItems }) + ) + expect( + await invoke('folderWorkspaces:update', { + folderWorkspaceId: workspace.id, + updates: { linkedItems: [] } + }) + ).toMatchObject({ linkedItems: [], linkedTask: null }) + }) + + it('preserves collections on creation and rejects malformed attachment writes', async () => { + const store = createStore() + const group = store.createProjectGroup({ + name: 'Docs', + parentPath: testState.dir, + createdFrom: 'folder-scan' + }) + registerFolderWorkspaceHandlers(new BrowserWindow(), store, { + deleteFolderWorkspace: vi.fn(async () => ({ deleted: true })) + }) + expect( + await invoke('folderWorkspaces:create', { projectGroupId: group.id, linkedItems }) + ).toMatchObject({ linkedItems }) + await expect( + invoke('folderWorkspaces:create', { projectGroupId: group.id, linkedItems: [{}] }) + ).rejects.toThrow('invalid_folder_workspace_create_args') + }) + it('preserves concurrent collection additions through desktop IPC', async () => { + const store = createStore() + const group = store.createProjectGroup({ + name: 'Docs', + parentPath: testState.dir, + createdFrom: 'folder-scan' + }) + const base = [linkedItems[0]] + const workspace = store.createFolderWorkspace({ projectGroupId: group.id, linkedItems: base }) + registerFolderWorkspaceHandlers(new BrowserWindow(), store, { + deleteFolderWorkspace: vi.fn(async () => ({ deleted: true })) + }) + await invoke('folderWorkspaces:update', { + folderWorkspaceId: workspace.id, + updates: { linkedItemsBase: base, linkedItems: [linkedItems[0], linkedItems[1]] } + }) + await invoke('folderWorkspaces:update', { + folderWorkspaceId: workspace.id, + updates: { linkedItemsBase: base, linkedItems: [linkedItems[2]] } + }) + expect(store.getFolderWorkspace(workspace.id)?.linkedItems).toEqual([ + linkedItems[1], + linkedItems[2] + ]) + }) +}) diff --git a/src/main/ipc/repos/folder-workspace-handlers.ts b/src/main/ipc/repos/folder-workspace-handlers.ts index 634c6aae0108..744d919d2823 100644 --- a/src/main/ipc/repos/folder-workspace-handlers.ts +++ b/src/main/ipc/repos/folder-workspace-handlers.ts @@ -23,7 +23,7 @@ import { visibleFolderWorkspaces } from '../../ssh/orcad-retained-source' export function registerFolderWorkspaceHandlers( mainWindow: BrowserWindow, store: Store, - runtime: OrcaRuntimeService + runtime: Pick ): void { ipcMain.handle('folderWorkspaces:list', (): FolderWorkspace[] => visibleFolderWorkspaces(store)) diff --git a/src/main/ipc/repos/repo-ipc-arg-schemas.ts b/src/main/ipc/repos/repo-ipc-arg-schemas.ts index 967601ecb4da..3093ed0024bc 100644 --- a/src/main/ipc/repos/repo-ipc-arg-schemas.ts +++ b/src/main/ipc/repos/repo-ipc-arg-schemas.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { isTuiAgent } from '../../../shared/tui-agent-config' import { TaskSourceContextSchema } from '../../../shared/task-source-context-schema' import { WorkspaceLinkedItemSchema } from '../../../shared/workspace-linked-item-schema' +import { WorkspaceAttachmentsSchema } from '../../../shared/workspace-attachment-schema' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' import { DiffCommentSchema } from '../../../shared/diff-comment-schema' import { normalizeExecutionHostId } from '../../../shared/execution-host' @@ -134,6 +135,8 @@ export const FolderWorkspaceCreateArgs = z folderPath: z.string().nullable().optional(), connectionId: z.string().nullable().optional(), linkedTask: FolderWorkspaceLinkedTaskArgs.optional(), + linkedItems: WorkspaceAttachmentsSchema.optional(), + linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), createdWithAgent: z.string().refine(isTuiAgent).optional(), pendingFirstAgentMessageRename: z.boolean().optional() @@ -147,6 +150,9 @@ export const FolderWorkspaceUpdateArgs = z.object({ name: z.string().optional(), folderPath: z.string().optional(), linkedTask: FolderWorkspaceLinkedTaskArgs.optional(), + linkedItems: WorkspaceAttachmentsSchema.optional(), + linkedItemsBase: WorkspaceAttachmentsSchema.optional(), + linkedItemsSelectionChanged: z.boolean().optional(), linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), comment: z.string().optional(), isArchived: z.boolean().optional(), diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts index 079549e47111..00a5788537e5 100644 --- a/src/main/ipc/runtime-upload-file-stream.ts +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -35,6 +35,38 @@ export type RuntimeUploadFileStreamArgs = RuntimeUploadFileStreamRequest & { */ export async function streamExternalFileToRuntime( args: RuntimeUploadFileStreamArgs +): Promise<{ byteLength: number }> { + return streamExternalFileSlices({ + sourceRootPath: args.sourceRootPath, + entryRelativePath: args.entryRelativePath, + expected: args.expected, + signal: args.signal, + maxFileBytes: REMOTE_IMPORT_MAX_FILE_BYTES, + limitLabel: 'per-file remote import limit', + writeSlice: (contentBase64, offset) => sendChunk(args, contentBase64, offset > 0) + }) +} + +export type ExternalFileSliceStreamArgs = { + /** Client-local path of the dropped source (file, or root of a dropped directory). */ + sourceRootPath: string + /** Path of this file within the dropped directory; empty when the source is a file. */ + entryRelativePath: string + expected: StagedRuntimeUploadFileIdentity + signal?: AbortSignal + maxFileBytes: number + /** Names the ceiling in the refusal, e.g. "per-file remote import limit". */ + limitLabel: string + /** Sends one base64 slice; `offset` is in source bytes. An empty file sends one empty slice. */ + writeSlice: (contentBase64: string, offset: number) => Promise +} + +/** + * Read a staged client-local file in slices and hand each to `writeSlice`, refusing a source that + * was swapped or changed since staging. The destination decides what a slice becomes. + */ +export async function streamExternalFileSlices( + args: ExternalFileSliceStreamArgs ): Promise<{ byteLength: number }> { const sourcePath = resolveEntrySourcePath(args.sourceRootPath, args.entryRelativePath) @@ -72,16 +104,16 @@ export async function streamExternalFileToRuntime( const totalBytes = openedStat.size // Why: enforced again where the bytes actually move. Staging is a separate // call, so the ceiling only holds here if this boundary checks it too. - if (totalBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { + if (totalBytes > args.maxFileBytes) { throw new Error( `'${displayPath}' is ${formatByteCeiling(totalBytes)}, over the ` + - `${formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)} per-file remote import limit` + `${formatByteCeiling(args.maxFileBytes)} ${args.limitLabel}` ) } if (totalBytes === 0) { // Why: a zero-byte source produces no slices, but the destination still // has to exist before commitUpload renames it into place. - await sendChunk(args, '', false) + await args.writeSlice('', 0) } else { const buffer = Buffer.allocUnsafe(Math.min(RUNTIME_UPLOAD_SLICE_BYTES, totalBytes)) let offset = 0 @@ -93,7 +125,7 @@ export async function streamExternalFileToRuntime( if (bytesRead === 0) { throw new Error(`File truncated during upload: '${displayPath}'`) } - await sendChunk(args, buffer.subarray(0, bytesRead).toString('base64'), offset > 0) + await args.writeSlice(buffer.subarray(0, bytesRead).toString('base64'), offset) offset += bytesRead } } diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 3089af28e74a..b1b4a8cdea47 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -99,6 +99,7 @@ type SettingsChangedListener = ( const store = { getSettings: vi.fn(), updateSettings: vi.fn(), + updateSettingsAndFlush: vi.fn(), getGitHubCache: vi.fn(), setGitHubCache: vi.fn(), onSettingsChanged: vi.fn(() => () => {}) @@ -107,6 +108,7 @@ const store = { describe('registerSettingsHandlers', () => { beforeEach(() => { handleMock.mockClear() + store.updateSettingsAndFlush.mockReset() onMock.mockClear() applyAppIconMock.mockClear() applyAgentStatusHooksEnabledMock.mockReset().mockResolvedValue([]) @@ -239,6 +241,23 @@ describe('registerSettingsHandlers', () => { ) }) + it('propagates a failed durable force-delete preference write through settings:set', async () => { + store.getSettings.mockReturnValue({ alwaysForceDeleteWorktrees: false }) + store.updateSettingsAndFlush.mockRejectedValue(new Error('Disk full')) + registerSettingsHandlers(store as never) + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] + if (typeof handler !== 'function') { + throw new Error('Missing settings:set handler') + } + await expect( + handler(settingsInvokeEvent, { alwaysForceDeleteWorktrees: true, editorWordWrap: false }) + ).rejects.toThrow('Disk full') + expect(store.updateSettingsAndFlush).toHaveBeenCalledWith( + { alwaysForceDeleteWorktrees: true, editorWordWrap: false }, + { notifyListeners: true, originWebContentsId: 1 } + ) + }) + it('persists Active Server only through the dedicated preference channel', () => { store.updateSettings.mockReturnValue({ activeRuntimeEnvironmentId: 'windows-2' }) registerSettingsHandlers(store as never) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index e2a173e21b16..8442bfd86829 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -199,10 +199,14 @@ export function registerSettingsHandlers( // (e.g. blur after a no-op edit), and a `settings_changed` event for a // no-op flip would inflate the experimental-feature-adoption signal. const before = store.getSettings() - const result = store.updateSettings(sanitizedArgs, { + const updateOptions = { notifyListeners: true, originWebContentsId: event.sender.id - }) + } + const result = + 'alwaysForceDeleteWorktrees' in sanitizedArgs + ? await store.updateSettingsAndFlush(sanitizedArgs, updateOptions) + : store.updateSettings(sanitizedArgs, updateOptions) const proxySettingsChanged = ('httpProxyUrl' in sanitizedArgs && before.httpProxyUrl !== result.httpProxyUrl) || ('httpProxyBypassRules' in sanitizedArgs && diff --git a/src/main/ipc/sparse-preset-attribution.test.ts b/src/main/ipc/sparse-preset-attribution.test.ts new file mode 100644 index 000000000000..34861c818d9f --- /dev/null +++ b/src/main/ipc/sparse-preset-attribution.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SparsePreset } from '../../shared/worktree/create-types' +import { attributedSparsePresetId } from './sparse-preset-attribution' + +const PRESET: SparsePreset = { + id: 'preset-1', + repoId: 'repo-1', + name: 'web', + directories: ['apps/web', 'packages/ui'], + createdAt: 0, + updatedAt: 0 +} + +describe('the sparse preset a create is recorded under', () => { + it('keeps a preset whose directories are exactly the ones checked out, in any order', () => { + expect( + attributedSparsePresetId(() => [PRESET], 'repo-1', 'preset-1', ['packages/ui', 'apps/web']) + ).toBe('preset-1') + }) + + it('keeps a preset saved with trailing slashes', () => { + const preset = { ...PRESET, directories: ['apps/web/', 'packages/ui/'] } + expect( + attributedSparsePresetId(() => [preset], 'repo-1', 'preset-1', ['apps/web', 'packages/ui']) + ).toBe('preset-1') + }) + + it.each([ + ['an edited selection', 'repo-1', 'preset-1', ['apps/web']], + [ + 'a selection that repeats one of its directories', + 'repo-1', + 'preset-1', + ['apps/web', 'apps/web'] + ], + ['another repo’s preset', 'repo-2', 'preset-1', ['apps/web', 'packages/ui']], + ['a preset that no longer exists', 'repo-1', 'preset-gone', ['apps/web', 'packages/ui']] + ])('records none for %s', (_case, repoId, presetId, directories) => { + expect(attributedSparsePresetId(() => [PRESET], repoId, presetId, directories)).toBeUndefined() + }) + + it.each([undefined, ''])('does not read presets when no preset was chosen (%j)', (presetId) => { + const readPresets = vi.fn(() => [PRESET]) + expect( + attributedSparsePresetId(readPresets, 'repo-1', presetId, ['apps/web', 'packages/ui']) + ).toBeUndefined() + expect(readPresets).not.toHaveBeenCalled() + }) + + it('records none, without throwing, when presets cannot be read', () => { + const readPresets = (): SparsePreset[] => { + throw new Error('corrupt presets') + } + expect( + attributedSparsePresetId(readPresets, 'repo-1', 'preset-1', ['apps/web', 'packages/ui']) + ).toBeUndefined() + }) + + it('records none for a preset whose saved directories are not repo-relative', () => { + const preset = { ...PRESET, directories: ['/abs/apps/web', 'packages/ui'] } + expect( + attributedSparsePresetId(() => [preset], 'repo-1', 'preset-1', [ + 'abs/apps/web', + 'packages/ui' + ]) + ).toBeUndefined() + }) +}) diff --git a/src/main/ipc/sparse-preset-attribution.ts b/src/main/ipc/sparse-preset-attribution.ts new file mode 100644 index 000000000000..4c186c401acd --- /dev/null +++ b/src/main/ipc/sparse-preset-attribution.ts @@ -0,0 +1,33 @@ +import type { SparsePreset } from '../../shared/worktree/create-types' +import { normalizeSparseDirectories } from './sparse-checkout-directories' + +/** + * The preset a sparse create is recorded under: only one of this repo's presets whose directories + * are exactly the ones checked out, so an edited selection is never shown as the preset it began as. + */ +export function attributedSparsePresetId( + readPresets: () => readonly SparsePreset[], + repoId: string, + presetId: string | undefined, + sparseDirectories: readonly string[] +): string | undefined { + if (!presetId) { + return undefined + } + try { + const preset = readPresets().find((entry) => entry.id === presetId) + if (preset?.repoId !== repoId) { + return undefined + } + // Set-based so directory order doesn't matter, as the renderer's `sparseDirectoriesMatch` does. + const presetSet = new Set(normalizeSparseDirectories(preset.directories)) + const checkedOutSet = new Set(sparseDirectories) + const directoriesMatch = + presetSet.size === checkedOutSet.size && + [...checkedOutSet].every((entry) => presetSet.has(entry)) + return directoriesMatch ? preset.id : undefined + } catch { + // Unreadable or corrupt preset data must not block the create or falsely label the new worktree. + return undefined + } +} diff --git a/src/main/ipc/worktree-linked-work-item-metadata.ts b/src/main/ipc/worktree-linked-work-item-metadata.ts index e07e78ddfcbb..143cc35ca7a0 100644 --- a/src/main/ipc/worktree-linked-work-item-metadata.ts +++ b/src/main/ipc/worktree-linked-work-item-metadata.ts @@ -9,6 +9,7 @@ type LinkedWorkItemMetadata = Pick< | 'linkedAzureDevOpsPR' | 'linkedGiteaPR' | 'linkedWorkItem' + | 'linkedItems' | 'linkedTaskSourceContext' > @@ -20,6 +21,7 @@ export function getLinkedWorkItemMetadata(meta: WorktreeMeta | undefined): Linke linkedAzureDevOpsPR: meta?.linkedAzureDevOpsPR ?? null, linkedGiteaPR: meta?.linkedGiteaPR ?? null, linkedWorkItem: meta?.linkedWorkItem ?? null, + ...(meta?.linkedItems !== undefined ? { linkedItems: meta.linkedItems } : {}), linkedTaskSourceContext: meta?.linkedTaskSourceContext ?? null } } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 4e4f1d51f4aa..7b7285d1a084 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -140,6 +140,7 @@ import { formatWorktreeIncludeCopyWarning } from './worktree-include-copy-budget import { resolveWorktreeIncludePaths } from '../git/worktree-include-file' import { resolveWorktreeSharedDirectories } from '../git/worktree-shared-directories' import { normalizeSparseDirectories } from './sparse-checkout-directories' +import { attributedSparsePresetId } from './sparse-preset-attribution' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import type { IFilesystemProvider } from '../providers/types' import { @@ -1890,24 +1891,12 @@ export async function createRemoteWorktree( if (args.sparseCheckout && sparseDirectories.length === 0) { throw new Error('Sparse checkout requires at least one repo-relative directory.') } - let sparsePresetId: string | undefined - if (args.sparseCheckout?.presetId) { - const preset = store - .getSparsePresets(repo.id) - .find((entry) => entry.id === args.sparseCheckout?.presetId) - if (preset?.repoId === repo.id) { - try { - const presetDirectories = normalizeSparseDirectories(preset.directories) - const presetSet = new Set(presetDirectories) - const directoriesMatch = - presetDirectories.length === sparseDirectories.length && - sparseDirectories.every((entry) => presetSet.has(entry)) - sparsePresetId = directoriesMatch ? preset.id : undefined - } catch { - // Why: corrupt preset data should not block creation or falsely label the new worktree. - } - } - } + const sparsePresetId = attributedSparsePresetId( + () => store.getSparsePresets(repo.id), + repo.id, + args.sparseCheckout?.presetId, + sparseDirectories + ) // Why: addWorktree/setup probes run inside the new path; older relays need that root registered before accepting git/fs ops there. await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [remotePath]) @@ -2119,6 +2108,7 @@ export async function createRemoteWorktree( : {}), ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), + ...(args.linkedItems !== undefined ? { linkedItems: args.linkedItems } : {}), ...(args.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: args.linkedTaskSourceContext } : {}), @@ -2379,25 +2369,12 @@ async function performLocalWorktreeCreate( if (args.sparseCheckout && sparseDirectories.length === 0) { throw new Error('Sparse checkout requires at least one repo-relative directory.') } - let sparsePresetId: string | undefined - if (args.sparseCheckout?.presetId) { - const preset = store - .getSparsePresets(repo.id) - .find((entry) => entry.id === args.sparseCheckout?.presetId) - if (preset?.repoId === repo.id) { - try { - const presetDirectories = normalizeSparseDirectories(preset.directories) - // Why: Set-based compare so directory order doesn't affect attribution — matches renderer's sparseDirectoriesMatch. - const presetSet = new Set(presetDirectories) - const directoriesMatch = - presetDirectories.length === sparseDirectories.length && - sparseDirectories.every((entry) => presetSet.has(entry)) - sparsePresetId = directoriesMatch ? preset.id : undefined - } catch { - // Why: corrupt preset data should not block creation or falsely label the new worktree. - } - } - } + const sparsePresetId = attributedSparsePresetId( + () => store.getSparsePresets(repo.id), + repo.id, + args.sparseCheckout?.presetId, + sparseDirectories + ) let effectiveRequestedName = requestedName let effectiveSanitizedName = sanitizedName @@ -2825,6 +2802,7 @@ async function performLocalWorktreeCreate( : {}), ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), + ...(args.linkedItems !== undefined ? { linkedItems: args.linkedItems } : {}), ...(args.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: args.linkedTaskSourceContext } : {}), diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index 5827eb653913..c58cdc42d391 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -1,10 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as filesystemAuth from './filesystem-auth' +import * as authorizedRootsCache from './registered-worktree-roots-cache' import * as localWorktreeFilesystem from '../local-worktree-filesystem' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { GitWorktreeInfo } from '../../shared/worktree/types' import type { RedactableSpan } from '../observability/redactor' +import type { Store } from '../persistence' import { _resetTracerForTests, setActiveSink } from '../observability/tracer' import { agentHookServer } from '../agent-hooks/server' import { makePaneKey } from '../../shared/stable-pane-id' @@ -26,7 +29,13 @@ import { getLocalPtyProviderMock, getSshPtyProviderMock } from './worktrees-test-module-mocks' -import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { + handlers, + harnessRepo, + mainWindow, + setupWorktreeHandlers, + store +} from './worktrees-test-harness' import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' @@ -120,15 +129,47 @@ describe('registerWorktreeHandlers', () => { beforeEach(() => { runtimeStub = setupWorktreeHandlers() + vi.spyOn(filesystemAuth, 'invalidateAuthorizedRootsCacheForRepo').mockClear() + vi.spyOn(authorizedRootsCache, 'invalidateAuthorizedRootsCache').mockClear() }) it('prunes the persisted cleanup and space snapshots on removal', async () => { mockKnownFeatureWorktree() getEffectiveHooksMock.mockReturnValue(null) removeWorktreeMock.mockResolvedValue({}) + const unrelatedRepo = { ...harnessRepo, id: 'repo-2', path: '/unrelated/repo' } + store.getRepos.mockReturnValue([harnessRepo, unrelatedRepo]) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this existing IPC store fixture supplies the repository, project, folder, and settings methods the root registry reads. + const cacheStore = store as unknown as Store + authorizedRootsCache.registerWorktreeRootsForRepo(cacheStore, 'repo-1', [ + '/workspace/feature-wt' + ]) + authorizedRootsCache.registerWorktreeRootsForRepo(cacheStore, 'repo-2', ['/unrelated/worktree']) + const removedRevision = authorizedRootsCache.getRegisteredWorktreeRootsRevision('repo-1') + const unrelatedRevision = authorizedRootsCache.getRegisteredWorktreeRootsRevision('repo-2') await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' }) + expect(authorizedRootsCache.getRegisteredWorktreeRootsRevision('repo-1')).toBeGreaterThan( + removedRevision + ) + expect(authorizedRootsCache.getRegisteredWorktreeRootsRevision('repo-2')).toBe( + unrelatedRevision + ) + expect(authorizedRootsCache.isRegisteredWorktreePath('/workspace/feature-wt', cacheStore)).toBe( + false + ) + expect(authorizedRootsCache.isRegisteredWorktreePath('/unrelated/worktree', cacheStore)).toBe( + true + ) + + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledOnce() + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledWith( + store, + 'repo-1' + ) + expect(authorizedRootsCache.invalidateAuthorizedRootsCache).not.toHaveBeenCalled() + // A removed workspace must never resurrect from the cached scan snapshots. expect(pruneCleanupScanSnapshotMock).toHaveBeenCalledWith( '/profile-a', @@ -142,6 +183,22 @@ describe('registerWorktreeHandlers', () => { ) }) + it('globally invalidates when the removed repo has no registered authorization owner', async () => { + mockKnownFeatureWorktree() + store.getRepos.mockReturnValue([]) + getEffectiveHooksMock.mockReturnValue(null) + removeWorktreeMock.mockResolvedValue({}) + + await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' }) + + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledOnce() + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledWith( + store, + 'repo-1' + ) + expect(authorizedRootsCache.invalidateAuthorizedRootsCache).toHaveBeenCalledOnce() + }) + it('purges only the selected host when a normal worktree id is owned locally and over SSH', async () => { const worktreeId = 'repo-1::/workspace/feature-wt' const localRepo = { @@ -290,6 +347,13 @@ describe('registerWorktreeHandlers', () => { worktreeId: 'repo-1::/workspace/feature-wt' }) + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledOnce() + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledWith( + store, + 'repo-1' + ) + expect(authorizedRootsCache.invalidateAuthorizedRootsCache).not.toHaveBeenCalled() + // Should have called git worktree prune to clean up stale tracking expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { cwd: '/workspace/repo' diff --git a/src/main/ipc/worktrees-remove-preflight.test.ts b/src/main/ipc/worktrees-remove-preflight.test.ts index 335eeda6e909..39d6758291f2 100644 --- a/src/main/ipc/worktrees-remove-preflight.test.ts +++ b/src/main/ipc/worktrees-remove-preflight.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as filesystemAuth from './filesystem-auth' +import * as authorizedRootsCache from './registered-worktree-roots-cache' import { removeWorktreeLinkedPathsMock, findExistingWorktreeSymlinkPathsMock, @@ -104,6 +106,8 @@ describe('registerWorktreeHandlers', () => { beforeEach(() => { runtimeStub = setupWorktreeHandlers() + vi.spyOn(filesystemAuth, 'invalidateAuthorizedRootsCacheForRepo').mockClear() + vi.spyOn(authorizedRootsCache, 'invalidateAuthorizedRootsCache').mockClear() }) it('fails dirty non-force deletes before PTY teardown', async () => { @@ -139,6 +143,8 @@ describe('registerWorktreeHandlers', () => { expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled() expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).not.toHaveBeenCalled() + expect(authorizedRootsCache.invalidateAuthorizedRootsCache).not.toHaveBeenCalled() }) it('propagates a timed-out removal preflight before watcher teardown', async () => { @@ -200,6 +206,8 @@ describe('registerWorktreeHandlers', () => { expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled() expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(filesystemAuth.invalidateAuthorizedRootsCacheForRepo).not.toHaveBeenCalled() + expect(authorizedRootsCache.invalidateAuthorizedRootsCache).not.toHaveBeenCalled() }) it('rechecks a local Git lock after the archive hook before teardown', async () => { diff --git a/src/main/ipc/worktrees/create/folder-workspace-creation.ts b/src/main/ipc/worktrees/create/folder-workspace-creation.ts index c870ef734ebe..6581a9ffd5ff 100644 --- a/src/main/ipc/worktrees/create/folder-workspace-creation.ts +++ b/src/main/ipc/worktrees/create/folder-workspace-creation.ts @@ -58,6 +58,7 @@ export function createFolderWorkspace( : {}), ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), + ...(args.linkedItems !== undefined ? { linkedItems: args.linkedItems } : {}), ...(args.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: args.linkedTaskSourceContext } : {}) diff --git a/src/main/ipc/worktrees/folder-workspace-model.ts b/src/main/ipc/worktrees/folder-workspace-model.ts index 8f42175fce47..83738697bba7 100644 --- a/src/main/ipc/worktrees/folder-workspace-model.ts +++ b/src/main/ipc/worktrees/folder-workspace-model.ts @@ -54,6 +54,7 @@ export function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: Workt linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null, linkedGiteaPR: meta.linkedGiteaPR ?? null, linkedWorkItem: meta.linkedWorkItem ?? null, + ...(meta.linkedItems !== undefined ? { linkedItems: meta.linkedItems } : {}), linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, isArchived: meta.isArchived ?? false, isUnread: meta.isUnread ?? false, diff --git a/src/main/ipc/worktrees/ipc-context-schemas.ts b/src/main/ipc/worktrees/ipc-context-schemas.ts index e25a2e96d4be..f4f957201249 100644 --- a/src/main/ipc/worktrees/ipc-context-schemas.ts +++ b/src/main/ipc/worktrees/ipc-context-schemas.ts @@ -7,6 +7,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import type { NestedWorktreeRemovalApproval } from '../../../shared/worktree/nested-removal' import type { ListDetectedWorktreesArgs } from '../../../shared/detected-worktree-provider-contract' import { WorkspaceLinkedItemSchema } from '../../../shared/workspace-linked-item-schema' +import { WorkspaceAttachmentsSchema } from '../../../shared/workspace-attachment-schema' import { TaskSourceContextSchema } from '../../../shared/task-source-context-schema' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' @@ -38,9 +39,18 @@ export const NullableTaskSourceContextSchema = TaskSourceContextSchema.nullable( export function normalizeLinkedWorkItemFields< T extends { linkedWorkItem?: unknown + linkedItems?: unknown + linkedItemsBase?: unknown + linkedItemsSelectionChanged?: boolean linkedTaskSourceContext?: unknown } >(input: T): T { + if ( + input.linkedItemsSelectionChanged !== undefined && + typeof input.linkedItemsSelectionChanged !== 'boolean' + ) { + throw new Error('Invalid attachment selection mutation') + } const linkedWorkItem = input.linkedWorkItem === undefined ? undefined @@ -58,6 +68,12 @@ export function normalizeLinkedWorkItemFields< } return { ...input, + ...(input.linkedItems !== undefined + ? { linkedItems: WorkspaceAttachmentsSchema.parse(input.linkedItems) } + : {}), + ...(input.linkedItemsBase !== undefined + ? { linkedItemsBase: WorkspaceAttachmentsSchema.parse(input.linkedItemsBase) } + : {}), ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}) } diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts index 5a27d987a788..52d8f26ee24d 100644 --- a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -22,7 +22,7 @@ import { findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from '../../worktree-symlinks' -import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import { invalidateAuthorizedRootsCacheForRepo } from '../../filesystem-auth' import { runWorktreeChangeInvalidators } from '../../worktree-change-invalidators' import { formatWorktreeRemovalError, @@ -276,7 +276,7 @@ async function finishLocalWorktreeRemoval({ hostId: removalHostId }) ) - invalidateAuthorizedRootsCache() + invalidateAuthorizedRootsCacheForRepo(store, repoId) removalCompleted = true return {} } else { @@ -315,7 +315,7 @@ async function finishLocalWorktreeRemoval({ ) }) await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => { - invalidateAuthorizedRootsCache() + invalidateAuthorizedRootsCacheForRepo(store, repoId) }) return removalResult ?? {} } diff --git a/src/main/jsonl-rpc/agent-connection.test.ts b/src/main/jsonl-rpc/agent-connection.test.ts new file mode 100644 index 000000000000..d8cd3e009ea4 --- /dev/null +++ b/src/main/jsonl-rpc/agent-connection.test.ts @@ -0,0 +1,224 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import { ROOT_ONLY_GRACEFUL_EXIT_MS } from '../provider-process/provider-process-close' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' +import type { terminateProviderProcessTree } from '../provider-process/provider-process-teardown' +import { JsonlRpcAgentConnection, type JsonlRpcAgentConnectionOptions } from './agent-connection' + +const teardown = vi.hoisted(() => + vi.fn(async () => 'unverifiable') +) +vi.mock('../provider-process/provider-process-teardown', () => ({ + terminateProviderProcessTree: teardown +})) +const opened: { connection: JsonlRpcAgentConnection; exit: () => void }[] = [] +const grace = + process.platform === 'win32' ? ROOT_ONLY_GRACEFUL_EXIT_MS : PROVIDER_SUPERVISOR_MAX_STOP_MS + +function fixture( + options: JsonlRpcAgentConnectionOptions = {}, + behavior: { exitOnEnd?: boolean; processless?: boolean } = {} +) { + const child = Object.assign(new EventEmitter(), { + pid: behavior.processless ? undefined : 9_999_999, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true) + }) + const spawn = vi.fn(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Fixture supplies piped stdio, pid, kill and process lifecycle events read by the shared supervisor. + return child as unknown as ReturnType + }) + if (behavior.exitOnEnd !== false) { + child.stdin.once('finish', () => child.emit('exit', 0, null)) + } + const connection = new JsonlRpcAgentConnection( + { + command: 'fixture-rpc-agent', + args: ['--mode', 'rpc'], + cwd: '/execution-host/folder', + env: { PI_CODING_AGENT_DIR: '/host/account', STRIPPED: 'overlay' }, + envToDelete: ['STRIPPED'] + }, + options, + spawn + ) + opened.push({ connection, exit: () => child.emit('exit', 0, null) }) + return { child, spawn, connection } +} +afterEach(async () => { + for (const { connection, exit } of opened.splice(0)) { + exit() + await connection.close() + } + teardown.mockReset().mockResolvedValue('unverifiable') + vi.useRealTimers() +}) + +describe('JSON-lines RPC process ownership', () => { + it('uses host-resolved launch data and exposes ordinary RPC replies', async () => { + const { connection, child, spawn } = fixture() + expect(spawn).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + cwd: '/execution-host/folder', + env: expect.objectContaining({ PI_CODING_AGENT_DIR: '/host/account' }), + stdio: ['pipe', 'pipe', 'pipe'] + }) + ) + expect(spawn.mock.calls[0][0].env).not.toHaveProperty('STRIPPED') + const pending = connection.request('get_state') + child.stdout.write( + '{"type":"response","id":"orca-1","command":"get_state","success":true,"data":{"sessionFile":"/host/session.jsonl"}}\n' + ) + expect(await pending).toEqual({ sessionFile: '/host/session.jsonl' }) + expect(await connection.close()).toEqual({ root: 'exited', tree: null }) + }) + + it('settles requests on proven exit with stdout open, and excludes stderr from public errors', async () => { + vi.useFakeTimers() + const onExit = vi.fn() + const { connection, child } = fixture({ onExit }) + const pending = expect(connection.request('prompt')).rejects.toThrow('code 7') + child.stderr.write('sensitive stderr text') + child.emit('exit', 7, null) + child.emit('close', 7, null) + await vi.advanceTimersByTimeAsync(1_000) + await pending + expect(child.stdout.readableEnded).toBe(false) + expect(connection.rootVerdict).toBe('exited') + expect(onExit).toHaveBeenCalledExactlyOnceWith(expect.any(Error), { + expected: false, + exit: { code: 7, signal: null, processless: false } + }) + expect(onExit.mock.calls[0][0].message).not.toContain('sensitive') + }) + + it('drains final response and event bytes written before the root exit', async () => { + const onRecord = vi.fn() + const onExit = vi.fn() + const { connection, child } = fixture({ onRecord, onExit }) + const request = connection.request('get_state') + child.emit('exit', 0, null) + expect(connection.rootVerdict).toBe('exited') + expect(onExit).not.toHaveBeenCalled() + child.stdout.end( + '{"type":"response","command":"get_state","id":"orca-1","success":true,"data":{}}\n{"type":"agent_settled"}\n' + ) + await expect(request).resolves.toEqual({}) + await new Promise((resolve) => setImmediate(resolve)) + expect(onRecord).toHaveBeenCalledExactlyOnceWith({ type: 'agent_settled' }) + expect(onExit).toHaveBeenCalledOnce() + }) + + it('closes a broken stdout transport and cleans up, while keeping host exit evidence separate', async () => { + vi.useFakeTimers() + const onExit = vi.fn() + const onClose = vi.fn() + const { connection, child } = fixture({ onExit, onClose }, { exitOnEnd: false }) + const pending = expect(connection.request('prompt')).rejects.toThrow('stream closed') + child.stdout.emit('end') + await pending + expect(connection.closed).toBe(true) + expect(connection.rootVerdict).toBe('live') + expect(onClose).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(250) + expect(onClose).toHaveBeenCalledOnce() + expect(onExit).not.toHaveBeenCalled() + child.emit('exit', 0, null) + expect(connection.rootVerdict).toBe('exited') + await connection.close() + }) + + it('preserves the numeric exit when stdout closes first', async () => { + const onClose = vi.fn(), + onExit = vi.fn() + const { connection, child } = fixture({ onClose, onExit }, { exitOnEnd: false }) + child.stdout.emit('end') + child.emit('exit', 7, null) + expect(onClose).not.toHaveBeenCalled() + expect(onExit).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('code 7') }), + expect.anything() + ) + await connection.close() + }) + + it('joins close attempts and retries unproven exit without starting another process', async () => { + vi.useFakeTimers() + const { connection, child, spawn } = fixture({}, { exitOnEnd: false }) + const first = connection.close() + const joined = connection.close() + await vi.advanceTimersByTimeAsync(grace + 1_000) + expect(await first).toEqual({ root: 'live', tree: 'unverifiable' }) + expect(await joined).toEqual({ root: 'live', tree: 'unverifiable' }) + expect(teardown).toHaveBeenCalledOnce() + teardown.mockImplementationOnce(async () => { + child.emit('exit', null, 'SIGKILL') + return 'unverifiable' + }) + const retry = connection.close() + await vi.advanceTimersByTimeAsync(grace + 1_000) + expect(await retry).toEqual({ root: 'exited', tree: 'unverifiable' }) + expect(spawn).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps retained descendant uncertainty when the root exits between attempts', async () => { + vi.useFakeTimers() + const { connection, child } = fixture({}, { exitOnEnd: false }) + const close = connection.close() + await vi.advanceTimersByTimeAsync(grace + 1_000) + await close + child.emit('exit', null, 'SIGKILL') + await connection.close() + expect(connection.lastCloseResult).toEqual({ root: 'live', tree: 'unverifiable' }) + expect(connection.rootVerdict).toBe('exited') + }) + + it('requires processless close evidence after a failed spawn', async () => { + const onExit = vi.fn() + const { connection, child } = fixture({ onExit }, { processless: true, exitOnEnd: false }) + const pending = expect(connection.request('prompt')).rejects.toThrow('ENOENT') + child.emit('error', new Error('ENOENT')) + await pending + expect(connection.rootVerdict).toBe('unverifiable') + expect(onExit).not.toHaveBeenCalled() + child.emit('close', null, null) + expect(connection.rootVerdict).toBe('exited') + expect(onExit.mock.calls[0][1].exit.processless).toBe(true) + }) + + it('handles stderr failure, and isolates failing close and exit observers', async () => { + const { connection, child } = fixture({ + onClose: () => { + throw new Error('close observer') + }, + onExit: () => { + throw new Error('exit observer') + }, + onDiagnostic: () => { + throw new Error('diagnostic observer') + } + }) + const pending = expect(connection.request('prompt')).rejects.toThrow('stderr failed') + child.stderr.emit('error', new Error('stderr failed')) + await pending + expect(() => child.emit('exit', 1, null)).not.toThrow() + }) + + it('validates peer limits before spawning', () => { + const spawn = vi.fn() + expect( + () => + new JsonlRpcAgentConnection( + { command: 'fixture', args: [] }, + { peer: { requestTimeoutMs: 0 } }, + spawn + ) + ).toThrow('timer duration') + expect(spawn).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/jsonl-rpc/agent-connection.ts b/src/main/jsonl-rpc/agent-connection.ts new file mode 100644 index 000000000000..65bd6cfc9ad3 --- /dev/null +++ b/src/main/jsonl-rpc/agent-connection.ts @@ -0,0 +1,182 @@ +import { spawnProcess } from '../../shared/child-process/run-process' +import { + spawnManagedProviderProcess, + type ManagedProviderProcess, + type ProviderProcessExit +} from '../provider-process/managed-provider-process' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { ProviderProcessCloseResult } from '../provider-process/provider-process-close' +import { + JsonlRpcPeer, + JsonlRpcStreamClosedError, + type JsonlRpcPeerHandlers, + type JsonlRpcRecord +} from './peer' +import { resolveJsonlRpcPeerOptions, type JsonlRpcPeerOptions } from './peer-limits' + +export type JsonlRpcAgentConnectionOptions = JsonlRpcPeerHandlers & { + peer?: JsonlRpcPeerOptions + onExit?: (error: Error, context: { expected: boolean; exit: ProviderProcessExit }) => void +} + +/** The owning runtime keeps process exit evidence separate from transport closure. */ +export class JsonlRpcAgentConnection { + private readonly managed: ManagedProviderProcess + private readonly peer: JsonlRpcPeer + private closing = false + private finishObservedExit?: () => void + private streamExitTimer?: ReturnType + + constructor( + launch: ProviderProcessLaunch, + private readonly options: JsonlRpcAgentConnectionOptions = {}, + spawnImpl: typeof spawnProcess = spawnProcess + ) { + const peerOptions = resolveJsonlRpcPeerOptions(options.peer) + this.managed = spawnManagedProviderProcess(launch, { + spawnImpl, + site: 'jsonl-rpc-agent-teardown' + }) + const managed = this.managed + this.peer = new JsonlRpcPeer( + managed.child.stdout, + managed.child.stdin, + { + ...options, + onClose: (error) => { + const fail = (): void => { + if (this.closing || managed.rootVerdict === 'exited') { + return + } + void managed.close().then( + (result) => { + if (result.root !== 'exited') { + this.diagnose('Agent exit was not proven after RPC failure') + } + }, + () => this.diagnose('Agent cleanup failed after RPC failure') + ) + options.onClose?.(error) + } + if ( + error instanceof JsonlRpcStreamClosedError && + !this.closing && + managed.rootVerdict !== 'exited' + ) { + // EOF often precedes the exit carrying an import/startup failure's code. + this.streamExitTimer = setTimeout(fail, 250) + this.streamExitTimer.unref() + } else { + fail() + } + } + }, + peerOptions + ) + managed.child.on('error', this.onError) + managed.child.stderr.on('error', this.onError) + managed.onExit((exit) => { + clearTimeout(this.streamExitTimer) + const error = new Error( + exit.processless + ? 'Agent process could not start' + : `Agent process exited (code ${exit.code ?? 'none'}, signal ${exit.signal ?? 'none'})` + ) + this.peer.finishOnInputEnd(error) + const stdout = managed.child.stdout + let finished = false + let timer: ReturnType | undefined + const finish = (): void => { + if (finished) { + return + } + finished = true + clearTimeout(timer) + stdout.removeListener('end', finish) + stdout.removeListener('close', finish) + this.finishObservedExit = undefined + this.peer.close(error) + managed.child.removeListener('error', this.onError) + try { + options.onExit?.(error, { expected: this.closing, exit }) + } catch { + this.diagnose('Agent exit observer failed') + } + } + this.finishObservedExit = finish + if ( + this.closing || + exit.processless || + this.peer.closed || + stdout.destroyed || + stdout.readableEnded + ) { + finish() + } else { + stdout.once('end', finish) + stdout.once('close', finish) + // A descendant can inherit stdout after the provider root exits. + timer = setTimeout(finish, 1_000) + timer.unref() + } + }) + } + + get pid(): number | undefined { + return this.managed.child.pid + } + get closed(): boolean { + return this.peer.closed + } + get rootVerdict(): ManagedProviderProcess['rootVerdict'] { + return this.managed.rootVerdict + } + get processless(): boolean { + return this.managed.processless + } + get lastCloseResult(): ManagedProviderProcess['lastCloseResult'] { + return this.managed.lastCloseResult + } + + onExit(listener: () => void): void { + this.managed.onExit(listener) + } + + request( + command: string, + params: Record = {}, + options: { timeoutMs?: number | null } = {} + ): Promise { + return this.peer.request(command, params, options) + } + + send(record: JsonlRpcRecord): Promise { + return this.peer.send(record) + } + + pauseReading(): void { + this.peer.pauseReading() + } + + resumeReading(): void { + this.peer.resumeReading() + } + + /** Stop sends the dialect's abort first, then closes this connection through the supervisor. */ + close(error?: Error): Promise { + this.closing = true + clearTimeout(this.streamExitTimer) + this.finishObservedExit?.() + this.peer.close(error) + return this.managed.close() + } + + private readonly onError = (error: Error): void => this.peer.close(error) + private diagnose(message: string): void { + try { + this.options.onDiagnostic?.(message) + } catch { + /* Diagnostics cannot break cleanup. */ + } + } +} diff --git a/src/main/jsonl-rpc/peer-limits.ts b/src/main/jsonl-rpc/peer-limits.ts new file mode 100644 index 000000000000..851adec01f4c --- /dev/null +++ b/src/main/jsonl-rpc/peer-limits.ts @@ -0,0 +1,36 @@ +import { isSafeTimerDelayMs } from '../../shared/timer-delay' + +export type JsonlRpcPeerOptions = { + maxLineBytes?: number + maxQueuedWriteBytes?: number + maxPendingRequests?: number + requestTimeoutMs?: number +} + +export function resolveJsonlRpcPeerOptions( + options: JsonlRpcPeerOptions = {} +): Required { + return { + maxLineBytes: positiveInteger(options.maxLineBytes, 8 * 1024 * 1024), + maxQueuedWriteBytes: positiveInteger(options.maxQueuedWriteBytes, 32 * 1024 * 1024), + maxPendingRequests: positiveInteger(options.maxPendingRequests, 128), + requestTimeoutMs: jsonlRpcRequestTimeout(options.requestTimeoutMs ?? 30_000) + } +} + +function positiveInteger(value: number | undefined, fallback: number): number { + if (value === undefined) { + return fallback + } + if (!Number.isSafeInteger(value) || value <= 0) { + throw new RangeError('JSON-lines RPC limits must be positive finite integers') + } + return value +} + +export function jsonlRpcRequestTimeout(value: number): number { + if (!isSafeTimerDelayMs(value) || value <= 0) { + throw new RangeError('JSON-lines RPC timeout must be a positive finite timer duration') + } + return value +} diff --git a/src/main/jsonl-rpc/peer.test.ts b/src/main/jsonl-rpc/peer.test.ts new file mode 100644 index 000000000000..dc4b8e816b5a --- /dev/null +++ b/src/main/jsonl-rpc/peer.test.ts @@ -0,0 +1,224 @@ +import { PassThrough, Writable } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { JsonlRpcPeer, type JsonlRpcPeerHandlers } from './peer' +import type { JsonlRpcPeerOptions } from './peer-limits' + +const peers: JsonlRpcPeer[] = [] +function fixture( + handlers: JsonlRpcPeerHandlers = {}, + options: JsonlRpcPeerOptions = {}, + output = new PassThrough() +) { + const input = new PassThrough() + const peer = new JsonlRpcPeer(input, output, handlers, options) + peers.push(peer) + const reply = (record: unknown): void => { + input.write(`${JSON.stringify(record)}\n`) + } + return { input, output, peer, reply } +} +afterEach(() => { + for (const peer of peers.splice(0)) { + peer.close() + } + vi.useRealTimers() +}) + +describe('JSON-lines RPC peer', () => { + it('correlates control replies and preserves unknown event fields', async () => { + const onRecord = vi.fn() + const { peer, output, reply } = fixture({ onRecord }) + const pending = peer.request('get_state') + expect(JSON.parse(output.read().toString())).toEqual({ type: 'get_state', id: 'orca-1' }) + reply({ type: 'agent_start', future: { enabled: true } }) + reply({ + type: 'response', + id: 'orca-1', + command: 'get_state', + success: true, + data: { isStreaming: false } + }) + expect(await pending).toEqual({ isStreaming: false }) + expect(onRecord).toHaveBeenCalledExactlyOnceWith({ + type: 'agent_start', + future: { enabled: true } + }) + }) + + it('frames only on LF, preserving Unicode separators and split UTF-8 code points', () => { + const onRecord = vi.fn() + const { input } = fixture({ onRecord }) + const bytes = Buffer.from('{"type":"message_update","text":"a\u2028b\u2029c😀"}\r\n') + const split = bytes.indexOf(Buffer.from('😀')) + 2 + input.write(bytes.subarray(0, split)) + expect(onRecord).not.toHaveBeenCalled() + input.write(bytes.subarray(split)) + expect(onRecord).toHaveBeenCalledExactlyOnceWith({ + type: 'message_update', + text: 'a\u2028b\u2029c😀' + }) + }) + + it('pauses within a burst and preserves queued records and a partial suffix across repeated pauses', async () => { + const records: unknown[] = [] + const { peer, input } = fixture({ + onRecord: (record) => { + records.push(record) + peer.pauseReading() + } + }) + input.write('{"type":"first"}\n{"type":"second"}\n{"type":"thi') + expect(records).toEqual([{ type: 'first' }]) + input.write('rd"}\n{"type":"fourth"}\n') + peer.resumeReading() + expect(records).toEqual([{ type: 'first' }, { type: 'second' }]) + peer.resumeReading() + await new Promise((resolve) => setImmediate(resolve)) + expect(records).toEqual([{ type: 'first' }, { type: 'second' }, { type: 'third' }]) + peer.resumeReading() + expect(records).toEqual([ + { type: 'first' }, + { type: 'second' }, + { type: 'third' }, + { type: 'fourth' } + ]) + }) + + it('ignores non-JSON output and routes id-less acknowledgements and dialogs', async () => { + const onRecord = vi.fn() + const onDiagnostic = vi.fn() + const { peer, input, output, reply } = fixture({ onRecord, onDiagnostic }) + const writes: string[] = [] + output.on('data', (chunk: Buffer) => writes.push(chunk.toString())) + input.write('extension banner\n\n') + await peer.send({ type: 'prompt', message: '/ask' }) + reply({ type: 'extension_ui_request', id: 'dialog-1', method: 'input', title: 'Answer' }) + await peer.send({ type: 'extension_ui_response', id: 'dialog-1', value: '' }) + reply({ type: 'response', command: 'prompt', success: true }) + expect(onRecord.mock.calls.map(([record]) => record.type)).toEqual([ + 'extension_ui_request', + 'response' + ]) + expect(writes.join('')).toContain('"value":""') + expect(onDiagnostic).toHaveBeenCalledExactlyOnceWith('Ignored non-JSON agent output') + }) + + it('does not time out prompts waiting for slash expansion, but bounds control requests', async () => { + vi.useFakeTimers() + const { peer, reply } = fixture({}, { requestTimeoutMs: 100 }) + const prompt = peer.request('prompt', { message: '/ask' }, { timeoutMs: null }) + const control = expect(peer.request('get_state')).rejects.toThrow('get_state in time') + await vi.advanceTimersByTimeAsync(1_000) + await control + reply({ type: 'response', id: 'orca-1', command: 'prompt', success: true }) + await expect(prompt).resolves.toBeUndefined() + const bounded = expect(peer.request('prompt', {}, { timeoutMs: 10 })).rejects.toThrow('in time') + await vi.advanceTimersByTimeAsync(10) + await bounded + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([ + { type: 'response', command: 'wrong', success: true }, + { type: 'response', command: 'prompt', success: 'yes' }, + { type: 'reponse', command: 'prompt', success: true }, + { type: 4 } + ])('rejects malformed identified replies without stranding a prompt: %j', async (record) => { + const { peer, reply } = fixture() + const pending = expect(peer.request('prompt')).rejects.toThrow('Invalid agent RPC response') + reply({ ...record, id: 'orca-1' }) + await pending + }) + + it('ignores unsolicited replies and preserves structured command errors', async () => { + const onRecord = vi.fn() + const { peer, reply } = fixture({ onRecord }) + const pending = expect(peer.request('get_state')).rejects.toThrow( + 'No API key found for provider' + ) + reply({ type: 'response', id: 'other', command: 'get_state', success: true }) + reply({ + type: 'response', + id: 'orca-1', + command: 'get_state', + success: false, + error: 'No API key found for provider' + }) + await pending + expect(onRecord).not.toHaveBeenCalled() + }) + + it('closes on oversized records, with bounded diagnostics and no later dispatch', async () => { + const onRecord = vi.fn() + const { peer, input, reply } = fixture({ onRecord }, { maxLineBytes: 64 }) + const pending = expect(peer.request('get_state')).rejects.toThrow('size limit') + input.write(`{"type":"secret","text":"${'x'.repeat(100)}`) + reply({ type: 'agent_settled' }) + await pending + expect(peer.closed).toBe(true) + expect(onRecord).not.toHaveBeenCalled() + }) + + it('enforces pending capacity, and releases it after timeout and invalid response', async () => { + vi.useFakeTimers() + const { peer, reply } = fixture({}, { maxPendingRequests: 1, requestTimeoutMs: 10 }) + const first = expect(peer.request('get_state')).rejects.toThrow('in time') + await expect(peer.request('get_state')).rejects.toThrow('capacity') + await vi.advanceTimersByTimeAsync(10) + await first + const second = peer.request('get_state') + reply({ type: 'response', id: 'orca-2', command: 'get_state', success: true }) + await second + expect(vi.getTimerCount()).toBe(0) + }) + + it('settles all requests and detaches listeners on EOF, and isolates close observers', async () => { + const { peer, input, output } = fixture({ + onClose: () => { + throw new Error('observer') + } + }) + const pending = expect(peer.request('prompt')).rejects.toThrow('stream closed') + input.emit('end') + input.emit('close') + await pending + expect(input.listenerCount('data')).toBe(0) + expect(output.listenerCount('drain')).toBe(0) + await expect(peer.send({ type: 'abort' })).rejects.toThrow('stream closed') + }) + + it('rejects future writes after a broken pipe and consumes delayed stream errors', async () => { + const output = new Writable({ + autoDestroy: false, + write(_data, _encoding, callback) { + callback(new Error('broken pipe')) + } + }) + const input = new PassThrough() + const peer = new JsonlRpcPeer(input, output) + peers.push(peer) + await expect(peer.send({ type: 'abort' })).rejects.toThrow('broken pipe') + await expect(peer.request('prompt')).rejects.toThrow('broken pipe') + expect(() => output.emit('error', new Error('late error'))).not.toThrow() + }) + + it('closes a stalled active control write on timeout so later aborts cannot wait on it', async () => { + vi.useFakeTimers() + const output = new Writable({ write() {} }) + const peer = new JsonlRpcPeer(new PassThrough(), output, {}, { requestTimeoutMs: 10 }) + peers.push(peer) + const pending = expect(peer.request('get_state')).rejects.toThrow('in time') + await vi.advanceTimersByTimeAsync(10) + await pending + expect(peer.closed).toBe(true) + await expect(peer.send({ type: 'abort' })).rejects.toThrow('in time') + }) + + it('rejects invalid limits before installing stream listeners', () => { + const input = new PassThrough() + expect(() => new JsonlRpcPeer(input, new PassThrough(), {}, { maxLineBytes: 0 })).toThrow( + 'positive finite integers' + ) + expect(input.listenerCount('data')).toBe(0) + }) +}) diff --git a/src/main/jsonl-rpc/peer.ts b/src/main/jsonl-rpc/peer.ts new file mode 100644 index 000000000000..992d7f987e2c --- /dev/null +++ b/src/main/jsonl-rpc/peer.ts @@ -0,0 +1,295 @@ +import type { Readable, Writable } from 'node:stream' +import { z } from 'zod' +import { + createIncrementalNdjsonFramer, + encodeNdjson +} from '../../shared/main-process-ndjson-framer' +import { detachProviderStreamErrorHandler } from '../provider-process/provider-stdio-error-boundary' +import { ProviderStdioWriteQueue } from '../provider-process/provider-stdio-write-queue' +import { + jsonlRpcRequestTimeout, + resolveJsonlRpcPeerOptions, + type JsonlRpcPeerOptions +} from './peer-limits' + +const recordSchema = z.looseObject({ type: z.string() }) +const responseSchema = z.looseObject({ + type: z.literal('response'), + id: z.string().optional(), + command: z.string(), + success: z.boolean(), + data: z.unknown().optional(), + error: z.string().optional() +}) + +export type JsonlRpcRecord = z.infer +export class JsonlRpcStreamClosedError extends Error { + constructor() { + super('Agent JSON-lines RPC stream closed') + this.name = 'JsonlRpcStreamClosedError' + } +} +export class JsonlRpcResponseError extends Error { + constructor( + readonly command: string, + message: string + ) { + super(message) + this.name = 'JsonlRpcResponseError' + } +} +export type JsonlRpcPeerHandlers = { + /** Includes deferred prompt acknowledgements without an id. */ + /** A dialect failure is fatal: continuing would discard session or tool lifecycle evidence. */ + onRecord?: (record: JsonlRpcRecord) => void + onClose?: (error: Error) => void + onDiagnostic?: (message: string) => void +} +type PendingRequest = { + command: string + resolve: (value: unknown) => void + reject: (error: Error) => void + timer?: ReturnType +} + +/** Transport only: a dialect owns turns, dialogs, and session handles. */ +export class JsonlRpcPeer { + private readonly limits: Required + private readonly pending = new Map() + private readonly writer: ProviderStdioWriteQueue + private readonly framer: ReturnType + private nextId = 0 + private terminalError?: Error + private inputEndError?: Error + private readingPaused = false + + constructor( + private readonly input: Readable, + private readonly output: Writable, + private readonly handlers: JsonlRpcPeerHandlers = {}, + options: JsonlRpcPeerOptions = {} + ) { + this.limits = resolveJsonlRpcPeerOptions(options) + this.writer = new ProviderStdioWriteQueue( + output, + this.limits.maxQueuedWriteBytes, + (error) => this.close(error), + { + capacity: () => new Error('JSON-lines RPC write queue capacity exceeded'), + closed: () => new Error('JSON-lines RPC output is not writable') + } + ) + this.framer = createIncrementalNdjsonFramer( + (record) => this.dispatch(record), + (rejected) => { + if (rejected.kind === 'line-too-long') { + this.close(new Error('JSON-lines RPC record exceeded the size limit')) + } else { + this.diagnose('Ignored non-JSON agent output') + } + }, + { + maxLineBytes: this.limits.maxLineBytes, + shouldPause: () => this.readingPaused || this.closed + } + ) + input.setEncoding('utf8') + input.on('data', this.onData) + input.on('end', this.onEnd) + input.on('close', this.onEnd) + input.on('error', this.onError) + output.on('finish', this.onEnd) + output.on('close', this.onEnd) + output.on('error', this.onError) + if (input.destroyed || input.readableEnded || output.destroyed || !output.writable) { + this.onEnd() + } + } + + get closed(): boolean { + return this.terminalError !== undefined + } + + /** A process exit can precede the last bytes already written to stdout. */ + finishOnInputEnd(error: Error): void { + this.inputEndError = error + } + + pauseReading(): void { + this.readingPaused = true + this.input.pause() + } + + resumeReading(): void { + if (this.closed) { + return + } + this.readingPaused = false + try { + this.framer.resume() + if (!this.readingPaused && !this.closed) { + this.input.resume() + } + } catch (error) { + this.close(error instanceof Error ? error : new Error(String(error))) + } + } + + request( + command: string, + params: Record = {}, + options: { timeoutMs?: number | null } = {} + ): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + if (this.pending.size >= this.limits.maxPendingRequests) { + return Promise.reject(new Error('JSON-lines RPC pending request capacity exceeded')) + } + let timeoutMs: number | null + try { + timeoutMs = + options.timeoutMs === null + ? null + : jsonlRpcRequestTimeout(options.timeoutMs ?? this.limits.requestTimeoutMs) + } catch (error) { + return Promise.reject(error) + } + const id = `orca-${++this.nextId}` + const controller = new AbortController() + return new Promise((resolve, reject) => { + const timer = + timeoutMs === null + ? undefined + : setTimeout(() => { + this.pending.delete(id) + const error = new Error(`Agent did not answer ${command} in time`) + controller.abort(error) + reject(error) + }, timeoutMs) + timer?.unref() + this.pending.set(id, { command, resolve, reject, timer }) + void this.send({ ...params, type: command, id }, controller.signal).catch( + (error: unknown) => { + this.rejectPending(id, error instanceof Error ? error : new Error(String(error))) + } + ) + }) + } + + /** The dialect may track acknowledgements independently of transport correlation. */ + send(record: JsonlRpcRecord, signal?: AbortSignal): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + try { + return this.writer.write(encodeNdjson(record, this.limits.maxLineBytes), signal) + } catch (error) { + return Promise.reject(error) + } + } + + close(error: Error = new Error('Agent JSON-lines RPC stream closed')): void { + if (this.terminalError) { + return + } + this.terminalError = error + this.input.removeListener('data', this.onData) + this.input.removeListener('end', this.onEnd) + this.input.removeListener('close', this.onEnd) + detachProviderStreamErrorHandler(this.input, this.onError) + this.output.removeListener('finish', this.onEnd) + this.output.removeListener('close', this.onEnd) + detachProviderStreamErrorHandler(this.output, this.onError) + this.framer.reset() + this.writer.close(error) + for (const id of this.pending.keys()) { + this.rejectPending(id, error) + } + try { + this.handlers.onClose?.(error) + } catch { + this.diagnose('Agent RPC close observer failed') + } + } + + private readonly onData = (chunk: string): void => { + try { + this.framer.feed(chunk) + } catch (error) { + this.close(error instanceof Error ? error : new Error(String(error))) + } + } + private readonly onEnd = (): void => + this.close(this.inputEndError ?? new JsonlRpcStreamClosedError()) + private readonly onError = (error: Error): void => this.close(error) + + private diagnose(message: string): void { + try { + this.handlers.onDiagnostic?.(message) + } catch { + /* Diagnostics cannot break cleanup. */ + } + } + + private rejectPending(id: string, error: Error): void { + const pending = this.pending.get(id) + if (!pending) { + return + } + this.pending.delete(id) + clearTimeout(pending.timer) + pending.reject(error) + } + + private dispatch(value: unknown): void { + if (this.closed) { + return + } + const parsed = recordSchema.safeParse(value) + if (!parsed.success) { + const identified = z.object({ id: z.string() }).safeParse(value) + if (identified.success) { + this.rejectPending(identified.data.id, new Error('Invalid agent RPC response')) + } + this.diagnose('Ignored invalid agent RPC record') + return + } + const record = parsed.data + if ( + record.type !== 'response' && + record.type !== 'extension_ui_request' && + typeof record.id === 'string' && + this.pending.has(record.id) && + (Object.hasOwn(record, 'command') || Object.hasOwn(record, 'success')) + ) { + this.rejectPending(record.id, new Error('Invalid agent RPC response')) + return + } + if (record.type === 'response' && typeof record.id === 'string') { + const pending = this.pending.get(record.id) + if (!pending) { + return + } + const response = responseSchema.safeParse(record) + if (!response.success || response.data.command !== pending.command) { + this.rejectPending(record.id, new Error('Invalid agent RPC response')) + return + } + this.pending.delete(record.id) + clearTimeout(pending.timer) + if (response.data.success) { + pending.resolve(response.data.data) + } else { + pending.reject( + new JsonlRpcResponseError( + pending.command, + response.data.error ?? `Agent rejected ${pending.command}` + ) + ) + } + return + } + this.handlers.onRecord?.(record) + } +} diff --git a/src/main/jsonl-rpc/timeline-final-tail.test.ts b/src/main/jsonl-rpc/timeline-final-tail.test.ts new file mode 100644 index 000000000000..f39f9e789918 --- /dev/null +++ b/src/main/jsonl-rpc/timeline-final-tail.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + pendingApproval, + SESSION, + GENERATION, + NAMESPACE +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { evictStructuredAgentSession } from '../native-chat/agent-session-wire/structured-agent-session-eviction' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { JsonlRpcTimelineLane } from './timeline-lane' + +const cleanups: (() => void)[] = [] +afterEach(async () => { + cleanups.splice(0).forEach((cleanup) => cleanup()) + vi.useRealTimers() + await closeProviderTimelineRigs() +}) + +describe('Pi final tail before host eviction', () => { + it('admits text and settlements before the exit barrier while ordinary and lifecycle queues are full', async () => { + const { journal } = await openProviderTimelineRig({ agent: 'pi' }) + const logging = testEventSinkLogging(SESSION) + const deferred = createDeferredStructuredAgentSessionEventSink({ + ...logging, + watermarks: { maxQueuedOperations: 1, maxLifecycleQueuedOperations: 1 } + }) + deferred.bind({ journal, fence: 1, publish: () => {} }) + const sink = providerTimelineSink(deferred.sink) + if (!sink) { + throw new Error('the sink must provide transitions') + } + const onFailed = vi.fn() + const lane = new JsonlRpcTimelineLane({ + sink, + sessionId: SESSION, + agent: 'pi', + generation: GENERATION, + namespace: NAMESPACE, + pauseReading: vi.fn(), + resumeReading: vi.fn(), + onInputAccepted: vi.fn(), + onFailed + }) + cleanups.push( + () => lane.dispose(), + () => deferred.close() + ) + for (const event of [ + { type: 'turn.open', turn: 'run:1', at: 1 }, + { type: 'request.open', request: 'confirm:1', body: pendingApproval }, + { + type: 'item.open', + item: 'tool:1', + body: { kind: 'tool-call', name: 'bash', input: {}, state: 'running' } + } + ] as const) { + lane.apply([event]) + await deferred.drained() + lane.retry() + } + const gate = Promise.withResolvers() + cleanups.push(() => gate.resolve()) + const append = (recordId: string, lifecycle = false) => + deferred.sink.tryAppendItem?.( + { provider: 'legacy', agent: 'pi', sessionId: SESSION, recordId }, + { kind: 'status', tone: 'info', text: recordId }, + { lifecycle, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + // The nested write joins behind this held read; later writes stay in the real journal queue. + const blocked = journal.readInOrder(() => { + expect(append('ordinary-full')).toEqual({ accepted: true }) + return gate.promise + }) + expect(append('lifecycle-full', true)).toEqual({ accepted: true }) + expect(append('ordinary-refused')).toEqual({ accepted: false, reason: 'backpressure' }) + expect(append('lifecycle-refused', true)).toEqual({ accepted: false, reason: 'backpressure' }) + const before = journal.snapshot().items.length + vi.useFakeTimers() + lane.apply([ + { type: 'text.delta', item: { id: 'reply' }, channel: 'assistant', text: 'Final reply' }, + { + type: 'item.update', + item: 'tail', + body: { kind: 'status', tone: 'info', text: 'Final status' } + } + ]) + lane.finalize() + lane.apply([{ type: 'session.ended', verdict: { state: 'interrupted', completedAt: 2 } }]) + let landed = false + const barrier = deferred.drained().then((result) => { + landed = true + return result + }) + await vi.advanceTimersByTimeAsync(2_501) + expect(journal.snapshot().items).toHaveLength(before) + expect(landed).toBe(false) + expect(onFailed).not.toHaveBeenCalled() + gate.resolve() + await blocked + await expect(barrier).resolves.toEqual({ ok: true }) + const acknowledgeRelease = vi.fn(() => lane.dispose()) + await evictStructuredAgentSession({ + sessionId: SESSION, + eventSink: deferred, + logger: logging.logger, + acknowledgeRelease, + discardSink: vi.fn() + }) + expect(acknowledgeRelease).toHaveBeenCalledOnce() + expect(journal.snapshot().items.map((item) => item.body)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'Final reply' }] + }), + expect.objectContaining({ kind: 'status', text: 'Final status' }), + expect.objectContaining({ + kind: 'approval', + resolution: expect.objectContaining({ state: 'cancelled' }) + }), + expect.objectContaining({ kind: 'tool-call', state: 'failed' }) + ]) + ) + }) + + it('fails excessive final output explicitly instead of leaving a refused tail waiting for eviction', async () => { + const failures: unknown[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(SESSION), + onFailed: (error) => failures.push(error) + }) + cleanups.push(() => deferred.close()) + expect( + deferred.sink.tryAppendTransition?.({ + finalTail: true, + lifecycle: true, + publish: false, + steps: [ + { + kind: 'settlement', + settlementId: 'oversize', + reservedBytes: 16 * 1024 * 1024 + 1, + resolve: () => [] + } + ] + }) + ).toEqual({ accepted: false, reason: 'failed' }) + expect(failures).toEqual([ + expect.objectContaining({ + message: 'structured agent-session final tail exceeded its reserved capacity' + }) + ]) + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + }) +}) diff --git a/src/main/jsonl-rpc/timeline-lane.test.ts b/src/main/jsonl-rpc/timeline-lane.test.ts new file mode 100644 index 000000000000..8ef584d1ac35 --- /dev/null +++ b/src/main/jsonl-rpc/timeline-lane.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + refusingSink, + SESSION, + GENERATION, + NAMESPACE +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { JsonlRpcTimelineLane } from './timeline-lane' + +const lanes: JsonlRpcTimelineLane[] = [] +afterEach(async () => { + for (const lane of lanes.splice(0)) { + lane.dispose() + } + vi.useRealTimers() + await closeProviderTimelineRigs() +}) + +async function rig() { + const journal = await openProviderTimelineRig({ agent: 'pi' }) + let blocked = true + const pauseReading = vi.fn(), + resumeReading = vi.fn(), + onInputAccepted = vi.fn(), + onFailed = vi.fn() + const lane = new JsonlRpcTimelineLane({ + sink: refusingSink(journal.sink, () => blocked), + sessionId: SESSION, + agent: 'pi', + generation: GENERATION, + namespace: NAMESPACE, + pauseReading, + resumeReading, + onInputAccepted, + onFailed + }) + lanes.push(lane) + return { + journal, + lane, + pauseReading, + resumeReading, + onInputAccepted, + onFailed, + unblock: () => { + blocked = false + } + } +} + +describe('JSON-lines timeline admission', () => { + it('admits the final tail with the host settlement budget when ordinary writes are full', async () => { + const journal = await openProviderTimelineRig({ agent: 'pi' }) + const lane = new JsonlRpcTimelineLane({ + sink: { + ...journal.sink, + tryAppendTransition: (transition) => + transition.lifecycle + ? journal.sink.tryAppendTransition(transition) + : { accepted: false, reason: 'backpressure' } + }, + sessionId: SESSION, + agent: 'pi', + generation: GENERATION, + namespace: NAMESPACE, + pauseReading: vi.fn(), + resumeReading: vi.fn(), + onInputAccepted: vi.fn(), + onFailed: vi.fn() + }) + lanes.push(lane) + lane.apply([ + { + type: 'item.update', + item: 'final', + body: { kind: 'status', tone: 'info', text: 'Final tail' } + } + ]) + expect(await journal.rows()).toEqual([]) + lane.finalize() + await lane.drained() + expect(await journal.rows()).toEqual([ + expect.objectContaining({ body: { kind: 'status', tone: 'info', text: 'Final tail' } }) + ]) + }) + + it('holds an entire parsed batch and accepts input only after journal admission', async () => { + const test = await rig() + test.lane.apply([ + { type: 'turn.open', turn: 'run:1', at: 1 }, + { type: 'input.accepted', clientMessageId: 'message:1', requestedAt: 1 }, + { type: 'turn.end', at: 2, state: 'completed', outcome: 'success' } + ]) + expect(test.pauseReading).toHaveBeenCalled() + expect(test.onInputAccepted).not.toHaveBeenCalled() + expect(await test.journal.turns()).toEqual([]) + test.unblock() + test.lane.retry() + await test.lane.drained() + expect(test.onInputAccepted).toHaveBeenCalledExactlyOnceWith('message:1') + expect(test.resumeReading).toHaveBeenCalled() + expect(await test.journal.turns()).toHaveLength(1) + expect((await test.journal.turns())[0]).toMatchObject({ + state: 'completed', + outcome: 'success' + }) + }) + + it('retries paused input and clears its timer after draining', async () => { + const test = await rig() + vi.useFakeTimers() + test.lane.apply([{ type: 'turn.open', turn: 'run:1', at: 1 }]) + test.unblock() + await vi.advanceTimersByTimeAsync(250) + await test.lane.drained() + expect(test.lane.openTurnId).not.toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('bounds data already parsed in a batch after the reader pauses', async () => { + const test = await rig() + test.lane.apply( + Array.from({ length: 257 }, (_, index) => ({ + type: 'item.close', + item: `item:${index}`, + body: { kind: 'status', tone: 'info', text: 'held' } + })) + ) + expect(test.onFailed).toHaveBeenCalledExactlyOnceWith('Agent timeline queue capacity exceeded') + await expect(test.lane.drained()).resolves.toBeUndefined() + expect(test.onInputAccepted).not.toHaveBeenCalled() + }) + + it('releases drain and turn waiters when a failed lane is disposed', async () => { + const test = await rig() + test.unblock() + test.lane.apply([{ type: 'turn.open', turn: 'run:1', at: 1 }]) + const turn = test.lane.openTurnId + expect(turn).not.toBeNull() + const waiting = test.lane.whenTurnLeaves(turn!) + test.lane.dispose() + await expect(waiting).resolves.toBeUndefined() + }) +}) diff --git a/src/main/jsonl-rpc/timeline-lane.ts b/src/main/jsonl-rpc/timeline-lane.ts new file mode 100644 index 000000000000..36c759e42ded --- /dev/null +++ b/src/main/jsonl-rpc/timeline-lane.ts @@ -0,0 +1,190 @@ +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + createProviderTimelineAssembler, + type ProviderTimelineAssembler +} from '../native-chat/agent-session-timeline/provider-timeline-assembler' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { createLegacyProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity' +import type { ProviderTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +const MAX_HELD_EVENTS = 256 +const MAX_HELD_BYTES = 4 * 1024 * 1024 + +export type JsonlRpcTimelineLaneDeps = { + sink: ProviderTimelineSink + sessionId: string + agent: string + generation: string + namespace: string + pauseReading: () => void + resumeReading: () => void + onInputAccepted: (clientMessageId: string) => void + onFailed: (reason: string) => void +} + +/** A dialect supplies semantic events; the shared assembler owns every journal write. */ +export class JsonlRpcTimelineLane { + private readonly assembler: ProviderTimelineAssembler + private readonly identity: ReturnType + private readonly held: { event: ProviderTimelineEvent; bytes: number }[] = [] + private heldBytes = 0 + private draining = false + private ended = false + private finalizing = false + private retryTimer?: ReturnType + private readonly watchers = new Set<() => void>() + + constructor(private readonly deps: JsonlRpcTimelineLaneDeps) { + this.assembler = createProviderTimelineAssembler({ + ...deps, + sink: { + ...deps.sink, + tryAppendTransition: (transition) => + deps.sink.tryAppendTransition( + this.finalizing ? { ...transition, lifecycle: true, finalTail: true } : transition + ) + } + }) + this.identity = createLegacyProviderTimelineIdentityScheme(deps) + } + + get openTurnId(): string | null { + return this.assembler.openTurnId + } + + beginCommand(command: StructuredAgentSessionCommandRun): void { + this.retry() + if (this.ended || this.held.length > 0) { + throw new Error('Provider timeline has not drained for this command') + } + this.assembler.beginCommand(command) + } + + forgetCommand(turnId: string): void { + this.assembler.forgetCommand(turnId) + } + + apply(events: readonly ProviderTimelineEvent[]): void { + if (this.ended) { + return + } + for (const event of events) { + const bytes = Buffer.byteLength(JSON.stringify(event)) + if (this.held.length >= MAX_HELD_EVENTS || this.heldBytes + bytes > MAX_HELD_BYTES) { + this.fail('Agent timeline queue capacity exceeded') + return + } + this.held.push({ event, bytes }) + this.heldBytes += bytes + this.retry() + } + } + + retry(): void { + if (this.draining || this.ended) { + return + } + clearTimeout(this.retryTimer) + this.retryTimer = undefined + this.draining = true + try { + while (this.held.length > 0 && !this.ended) { + const entry = this.held[0] + const result = this.assembler.apply(entry.event) + if (!result.admission.accepted) { + if (result.admission.reason !== 'backpressure') { + this.fail(result.admission.reason) + } else { + this.deps.pauseReading() + this.retryTimer = setTimeout(() => this.retry(), 250) + this.retryTimer.unref() + } + return + } + this.held.shift() + this.heldBytes -= entry.bytes + if (entry.event.type === 'input.accepted' && !result.dropped) { + this.deps.onInputAccepted(entry.event.clientMessageId) + } + } + } finally { + this.draining = false + this.notify() + } + this.deps.resumeReading() + } + + whenTurnLeaves(turnId: string): Promise { + return new Promise((resolve) => { + const check = (): void => { + if (this.ended || this.openTurnId !== turnId) { + this.watchers.delete(check) + resolve() + } + } + this.watchers.add(check) + check() + }) + } + + drained(): Promise { + return new Promise((resolve) => { + const check = (): void => { + if (this.ended || this.held.length === 0) { + this.watchers.delete(check) + resolve() + } + } + this.watchers.add(check) + check() + }) + } + + isRequestRow(itemId: string, key: string): boolean { + const row = parseAgentJournalItemKey(itemId) + const expected = this.identity.request({ + generation: this.deps.generation, + key, + incarnation: 1 + }) + return ( + row?.provider === 'legacy' && + expected.provider === 'legacy' && + row.agent === this.deps.agent && + row.sessionId === this.deps.sessionId && + (row.recordId === expected.recordId || row.recordId.startsWith(`${expected.recordId}#`)) + ) + } + + flush(): void { + this.assembler.flush() + } + + finalize(): void { + // Admission must precede the host's exit barrier even when lifecycle writes are full. + this.finalizing = true + this.retry() + this.flush() + } + + dispose(): void { + this.ended = true + clearTimeout(this.retryTimer) + this.held.length = 0 + this.heldBytes = 0 + this.assembler.dispose() + this.notify() + } + + private fail(reason: string): void { + this.dispose() + this.deps.onFailed(reason) + } + + private notify(): void { + for (const check of this.watchers) { + check() + } + } +} diff --git a/src/main/macos-keychain/generic-password.test.ts b/src/main/macos-keychain/generic-password.test.ts index 38ec1b05a723..604fd8b00bd5 100644 --- a/src/main/macos-keychain/generic-password.test.ts +++ b/src/main/macos-keychain/generic-password.test.ts @@ -4,12 +4,7 @@ const execFileMock = vi.hoisted(() => vi.fn()) vi.mock('node:child_process', () => ({ execFile: execFileMock })) -import { - deleteKeychainPassword, - isKeychainNotFoundError, - readKeychainPassword, - writeKeychainPassword -} from './generic-password' +import { isKeychainNotFoundError, readKeychainPassword } from './generic-password' const originalPlatform = process.platform @@ -71,27 +66,10 @@ describe('readKeychainPassword', () => { it('never shells out off macOS', async () => { setPlatform('win32') await expect(readKeychainPassword('svc', 'acct')).resolves.toBeNull() - await writeKeychainPassword('svc', 'acct', 'value') - await deleteKeychainPassword('svc', 'acct') expect(execFileMock).not.toHaveBeenCalled() }) }) -describe('deleteKeychainPassword', () => { - it('swallows a missing item', async () => { - fail(Object.assign(new Error('could not be found'), { code: 44 })) - await expect(deleteKeychainPassword('svc', 'acct')).resolves.toBeUndefined() - }) - - it('surfaces an access failure only when the caller asks for it', async () => { - fail(new Error('User interaction is not allowed')) - await expect(deleteKeychainPassword('svc', 'acct')).resolves.toBeUndefined() - await expect( - deleteKeychainPassword('svc', 'acct', { failOnAccessError: true }) - ).rejects.toThrow('User interaction is not allowed') - }) -}) - describe('isKeychainNotFoundError', () => { it('recognizes the security(1) not-found signals', () => { expect(isKeychainNotFoundError({ code: 44 })).toBe(true) diff --git a/src/main/macos-keychain/generic-password.ts b/src/main/macos-keychain/generic-password.ts index 8a805edd809b..1616008f303b 100644 --- a/src/main/macos-keychain/generic-password.ts +++ b/src/main/macos-keychain/generic-password.ts @@ -79,20 +79,6 @@ export function execSecurityCommand(args: string[]): Promise { - return execSecurityCommand(args).then(undefined, (error: unknown) => { - if (options?.ignoreNotFound && isKeychainNotFoundError(error)) { - return - } - if (!options?.ignoreFailure) { - throw error - } - }) -} - export async function readKeychainPassword( service: string, account: string @@ -120,28 +106,3 @@ export async function readKeychainPassword( throw error } } - -export async function writeKeychainPassword( - service: string, - account: string, - contents: string -): Promise { - if (process.platform !== 'darwin') { - return - } - await execSecurity(['add-generic-password', '-U', '-s', service, '-a', account, '-w', contents]) -} - -export async function deleteKeychainPassword( - service: string, - account: string, - options?: { failOnAccessError?: boolean } -): Promise { - if (process.platform !== 'darwin') { - return - } - await execSecurity(['delete-generic-password', '-s', service, '-a', account], { - ignoreNotFound: true, - ignoreFailure: !options?.failOnAccessError - }) -} diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts index bc96800a5aa6..dc8394e4e014 100644 --- a/src/main/managed-data-accounts/service.test.ts +++ b/src/main/managed-data-accounts/service.test.ts @@ -52,6 +52,27 @@ function openCodeSource(sessionTable = 'session'): void { } describe('managed data accounts', () => { + it('resolves a pinned OpenCode profile by ID after selection changes', async () => { + openCodeSource() + const first = (await service.add('opencode', source, 'First')).activeAccountId + if (!first) { + throw new Error('Expected selected first profile') + } + const original = service.environmentForAccount('opencode', first) + const second = (await service.add('opencode', source, 'Second')).activeAccountId + if (!second) { + throw new Error('Expected selected second profile') + } + expect(service.launchEnvironment('opencode')).toEqual( + service.environmentForAccount('opencode', second) + ) + expect(service.environmentForAccount('opencode', first)).toEqual(original) + await service.remove('opencode', first) + expect(() => service.environmentForAccount('opencode', first)).toThrow( + 'Managed account not found.' + ) + }) + it.each(['before write', 'after write', 'unrestricted'])( 'preserves credentials and original metadata when removal persistence fails %s', async (failure) => { diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts index 749fb023876e..7335a72f2696 100644 --- a/src/main/managed-data-accounts/service.ts +++ b/src/main/managed-data-accounts/service.ts @@ -152,14 +152,16 @@ export class ManagedDataAccountService { if (!state.activeAccountId) { return {} } - return this.profileEnvironment(provider, state.activeAccountId) + return this.environmentForAccount(provider, state.activeAccountId) } transcriptEnvironments(provider: ManagedDataAccountProvider): Record[] { const state = this.list(provider) const selected = state.accounts.filter((account) => account.id === state.activeAccountId) const others = state.accounts.filter((account) => account.id !== state.activeAccountId) - return [...selected, ...others].map((account) => this.profileEnvironment(provider, account.id)) + return [...selected, ...others].map((account) => + this.environmentForAccount(provider, account.id) + ) } captureOriginalEnvironment( @@ -217,7 +219,7 @@ export class ManagedDataAccountService { this.inlineAuthBaselines.clear() } - private profileEnvironment( + environmentForAccount( provider: ManagedDataAccountProvider, accountId: string ): Record { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts index d59f0d124c33..fcd20b489312 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts @@ -4,7 +4,10 @@ import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore } from './agent-model-catalog-store' -import type { AgentSessionStoredAgent } from '../../../shared/agent-session-stored-agent' +import { + isLegacyAgentSessionAccountHome, + type AgentSessionAccountHome +} from '../../../shared/agent-session-account-home' /** * Everything that changes which models a listing can answer with: the agent, @@ -14,22 +17,22 @@ import type { AgentSessionStoredAgent } from '../../../shared/agent-session-stor */ export type AgentModelCatalogIdentity = { agent: string - accountHomeVariable: string - accountHomePath: string /** Null on the native host; WSL distros each carry their own CLI. */ wslDistro: string | null -} +} & ( + | { accountHomeVariable: string; accountHomePath: string; accountHome?: never } + | { accountHome: AgentSessionAccountHome; accountHomeVariable?: never; accountHomePath?: never } +) export function agentModelCatalogFingerprint(identity: AgentModelCatalogIdentity): string { + const account = identity.accountHome + const parts = account + ? isLegacyAgentSessionAccountHome(account) + ? [account.variable, account.path] + : [account.kind, account.locator] + : [identity.accountHomeVariable, identity.accountHomePath] return createHash('sha256') - .update( - JSON.stringify([ - identity.agent, - identity.accountHomeVariable, - identity.accountHomePath, - identity.wslDistro ?? '' - ]) - ) + .update(JSON.stringify([identity.agent, ...parts, identity.wslDistro ?? ''])) .digest('hex') } @@ -40,8 +43,7 @@ export function agentModelCatalogIdentityForRecord( ): AgentModelCatalogIdentity { return { agent: record.provider, - accountHomeVariable: record.accountHome.variable, - accountHomePath: record.accountHome.path, + accountHome: record.accountHome, wslDistro: record.location.wslDistro } } @@ -56,7 +58,7 @@ export function agentModelCatalogFingerprintForRecord( * Native only: both structured adapters refuse non-native locations at launch. */ export function agentModelCatalogSessionAccess( store: AgentModelCatalogStore | undefined, - agent: Pick, + agent: { agent: string; accountHomeVariable: string }, accountHomePath: string | null ): AgentModelCatalogSessionAccess | undefined { if (!store || !accountHomePath) { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 3cba2c9e6a64..b56dc291d442 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -3,6 +3,7 @@ import type { AgentSessionAccountHome, AgentSessionRecord } from '../../../shared/agent-session-record' +import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home' import { agentModelCatalogFingerprint, agentModelCatalogFingerprintForRecord @@ -102,7 +103,10 @@ export function createAgentModelCatalogService( if (scoped) { fingerprint = agentModelCatalogFingerprintForRecord(scoped) // Probes spawn natively; a WSL-pinned record has no host-side lister. - accountHomePath = scoped.location.wslDistro === null ? scoped.accountHome.path : null + accountHomePath = + scoped.location.wslDistro === null && isLegacyAgentSessionAccountHome(scoped.accountHome) + ? scoped.accountHome.path + : null } else { let resolved: AgentSessionAccountHome try { @@ -112,11 +116,10 @@ export function createAgentModelCatalogService( } fingerprint = agentModelCatalogFingerprint({ agent: params.agent, - accountHomeVariable: resolved.variable, - accountHomePath: resolved.path, + accountHome: resolved, wslDistro: null }) - accountHomePath = resolved.path + accountHomePath = isLegacyAgentSessionAccountHome(resolved) ? resolved.path : null } let entry = deps.store.get(fingerprint) const probe = deps.probes?.[params.agent] diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts new file mode 100644 index 000000000000..9401bf7e9a4a --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts @@ -0,0 +1,280 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { formatNativeChatFileReference } from '../../../shared/agent-image-paste' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + isAgentSessionAttachmentExpiredError, + markUnclaimedUploadForSweep +} from './agent-session-attachment-claims' +import { + agentSessionAttachmentReferences, + agentSessionAttachmentStoreRoot +} from './agent-session-attachment-references' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +const UPLOAD_A = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01' +const UPLOAD_B = '7c2d9e10-3f4a-4b5c-8d6e-2f1a0b9c8d02' + +function message(...blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks } +} + +describe('agentSessionAttachmentReferences', () => { + const root = '/srv/orca/agent-session-attachments' + + it('finds image paths and file references in text, however the reference is quoted', () => { + const quoted = formatNativeChatFileReference(`${root}/${UPLOAD_B}/my "notes" and 'more'.txt`) + const references = agentSessionAttachmentReferences( + root, + message( + { type: 'image-ref', path: `${root}/${UPLOAD_A}/shot.png` }, + { type: 'text', text: `read ${quoted} please` } + ), + 'linux' + ) + expect([...references].sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) + }) + + it("leaves a mention of any other store path as text: another server's, a ~ form, a bare name", () => { + const mentions = [ + `@/home/other/agent-session-attachments/${UPLOAD_A}/a.txt`, + `~/Library/Application Support/Orca/agent-session-attachments/${UPLOAD_A}/x.png`, + `see agent-session-attachments/${UPLOAD_A}`, + // This host's root as the tail of a longer path is still another path. + `/mnt${root}/${UPLOAD_A}/a.txt` + ] + for (const text of mentions) { + expect( + agentSessionAttachmentReferences(root, message({ type: 'text', text }), 'linux') + ).toEqual(new Set()) + } + }) + + it('matches a Windows store with either separator and in any case', () => { + const windowsRoot = 'C:\\Users\\me\\AppData\\Roaming\\orca\\agent-session-attachments' + const references = agentSessionAttachmentReferences( + windowsRoot, + message( + { + type: 'image-ref', + path: `c:/users/ME/appdata/roaming/orca/agent-session-attachments/${UPLOAD_A}/s.png` + }, + { type: 'text', text: `@"${windowsRoot}\\${UPLOAD_B.toUpperCase()}\\a b.txt"` } + ), + 'win32' + ) + expect([...references].sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) + }) +}) + +describe('claims written with the message', () => { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-c', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) + } + let stateDirectory: string + let storeRoot: string + let clock = 1_000 + const journals = createTrackedJournalOpener() + + function open(sessionId = IDENTITY.sessionId): Promise { + return journals.open({ + identity: { ...IDENTITY, sessionId }, + stateDirectory, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) + } + + async function storeUpload(uploadId: string, name = 'shot.png'): Promise { + await mkdir(join(storeRoot, uploadId), { recursive: true }) + await writeFile(join(storeRoot, uploadId, name), 'bytes') + return join(storeRoot, uploadId, name) + } + + function claims(): { upload_id: string; session_id: string }[] { + return openTestJournalHostDatabase(stateDirectory) + .db.prepare( + 'SELECT upload_id, session_id FROM agent_session_attachment_claims ORDER BY session_id' + ) + .all() + .flatMap((row) => + typeof row === 'object' && + row !== null && + 'upload_id' in row && + 'session_id' in row && + typeof row.upload_id === 'string' && + typeof row.session_id === 'string' + ? [{ upload_id: row.upload_id, session_id: row.session_id }] + : [] + ) + } + + function clientSend(journal: AgentSessionJournal, id: string, body: AgentJournalMessageItem) { + return journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: `fp-${id}`, + body, + fence: 0, + origin: 'client' + }) + } + + beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-claims-')) + storeRoot = agentSessionAttachmentStoreRoot(stateDirectory) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(stateDirectory, { recursive: true, force: true }) + }) + + it("claims a client message's stored uploads in the same write", async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A) + await clientSend(journal, 'send-1', message({ type: 'image-ref', path })) + expect(journal.submission('send-1')).toBeTruthy() + expect(claims()).toEqual([{ upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }]) + }) + + it('refuses the whole client message, writing nothing, when an upload is gone', async () => { + const journal = await open() + const error = await clientSend( + journal, + 'send-1', + message( + { type: 'text', text: 'see the image' }, + { type: 'image-ref', path: join(storeRoot, UPLOAD_A, 'shot.png') } + ) + ).catch((caught: unknown) => caught) + expect(isAgentSessionAttachmentExpiredError(error)).toBe(true) + expect(journal.submission('send-1')).toBeUndefined() + expect(claims()).toEqual([]) + }) + + it('refuses an upload the sweep already marked, even while its file is still on disk', async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A) + expect( + markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1) + ).toBe(true) + const error = await clientSend(journal, 'send-1', message({ type: 'image-ref', path })).catch( + (caught: unknown) => caught + ) + expect(isAgentSessionAttachmentExpiredError(error)).toBe(true) + }) + + it('a claim landing first keeps the sweep from marking the upload', async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A) + await clientSend(journal, 'send-1', message({ type: 'image-ref', path })) + expect( + markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1) + ).toBe(false) + }) + + it("never refuses the host's own message, and claims what it names", async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A) + await journal.appendSubmission({ + clientMessageId: 'host-1', + payloadFingerprint: 'fp', + body: message( + { type: 'image-ref', path }, + { type: 'image-ref', path: join(storeRoot, UPLOAD_B, 'gone.png') } + ), + fence: 0, + origin: 'host' + }) + expect(journal.submission('host-1')).toBeTruthy() + expect( + claims() + .map((claim) => claim.upload_id) + .sort() + ).toEqual([UPLOAD_A, UPLOAD_B].sort()) + }) + + it("refuses a client's draft naming a gone upload, and the /clear carry claims for its new chat", async () => { + const journal = await open() + const missing = message({ type: 'text', text: `@${join(storeRoot, UPLOAD_B, 'a.txt')}` }) + const refused = await journal.queuedMessages + .insert({ + messageId: 'draft-0', + body: missing, + fingerprint: 'fp', + hostInstance: 'p', + requireAttachments: true + }) + .catch((caught: unknown) => caught) + expect(isAgentSessionAttachmentExpiredError(refused)).toBe(true) + expect(journal.queuedMessages.list()).toHaveLength(0) + + const path = await storeUpload(UPLOAD_A, 'notes.txt') + const body = message({ type: 'text', text: `read @${path}` }) + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body, + fingerprint: 'fp', + hostInstance: 'p', + requireAttachments: true + }) + const replacement = await open('session-d') + await replacement.queuedMessages.insert({ + messageId: 'draft-1', + body, + fingerprint: 'fp-d', + hostInstance: 'p', + carriedFrom: IDENTITY.sessionId + }) + expect(claims()).toEqual([ + { upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }, + { upload_id: UPLOAD_A, session_id: 'session-d' } + ]) + }) + + it("does not refuse a draft's conversion: the draft claimed its uploads when it was written", async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A, 'notes.txt') + const body = message({ type: 'text', text: `read @${path}` }) + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body, + fingerprint: 'fp', + hostInstance: 'p', + requireAttachments: true + }) + await rm(join(storeRoot, UPLOAD_A), { recursive: true }) + await journal.appendSubmission( + { clientMessageId: 'handoff-1', payloadFingerprint: 'fp', body, fence: 0, origin: 'client' }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + expect(journal.submission('handoff-1')).toBeTruthy() + }) + + it('keeps its claims across a host restart', async () => { + let journal = await open() + const path = await storeUpload(UPLOAD_A) + await clientSend(journal, 'send-1', message({ type: 'image-ref', path })) + await journals.closeAll() + journal = await open() + expect(claims()).toEqual([{ upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }]) + expect( + markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1) + ).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts new file mode 100644 index 000000000000..ac41b427e58b --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts @@ -0,0 +1,201 @@ +// Which chats hold each stored attachment. A message claims the uploads it references in the same +// host transaction that makes it durable, and the sweep may delete only an upload nobody claims: +// both are synchronous statements on the host's one journal connection, so exactly one wins. + +import { existsSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import type Database from '../../sqlite/sync-database' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + AGENT_SESSION_ATTACHMENT_PART_FILE, + agentSessionAttachmentReferences, + agentSessionAttachmentStoreRoot +} from './agent-session-attachment-references' + +const CLAIM_SAVEPOINT = 'agent_session_attachment_claims' + +/** + * Created at every writable open with no `user_version` bump, like the draft table + * (`ensureQueuedMessagesTable`): an older build ignores them and stays writable. + * Claims are keyed by upload and chat, so a /clear carry adds the replacement as a second holder + * and a chat's deletion frees an upload only when its last holder goes. + */ +export function ensureAgentSessionAttachmentClaimTables(db: Database.Database): void { + db.exec(` +CREATE TABLE IF NOT EXISTS agent_session_attachment_claims ( + upload_id TEXT NOT NULL, + session_id TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + PRIMARY KEY (upload_id, session_id) +); +CREATE TABLE IF NOT EXISTS agent_session_attachment_sweeps ( + upload_id TEXT NOT NULL PRIMARY KEY, + started_at INTEGER NOT NULL +); +`) +} + +export class AgentSessionAttachmentExpiredError extends Error { + constructor() { + super('A referenced chat attachment is no longer stored on this host.') + this.name = 'AgentSessionAttachmentExpiredError' + } +} + +export function isAgentSessionAttachmentExpiredError( + error: unknown +): error is AgentSessionAttachmentExpiredError { + return error instanceof AgentSessionAttachmentExpiredError +} + +function uploadIsStored(root: string, uploadId: string): boolean { + try { + return readdirSync(join(root, uploadId)).some( + (entry) => entry !== AGENT_SESSION_ATTACHMENT_PART_FILE + ) + } catch { + return false + } +} + +function isBeingSwept(db: Database.Database, uploadId: string): boolean { + return ( + db + .prepare('SELECT 1 FROM agent_session_attachment_sweeps WHERE upload_id = ?') + .get(uploadId) !== undefined + ) +} + +function insertClaims( + db: Database.Database, + uploadIds: Iterable, + sessionId: string, + now: number +): void { + const insert = db.prepare( + 'INSERT OR IGNORE INTO agent_session_attachment_claims (upload_id, session_id, claimed_at) VALUES (?, ?, ?)' + ) + for (const uploadId of uploadIds) { + insert.run(uploadId, sessionId, now) + } +} + +export type AgentSessionAttachmentClaim = { + stateDirectory: string + sessionId: string + body: AgentJournalMessageItem + /** A client's own new message: every upload of this host's store it names must still be stored, + * or the whole message is refused. The host's own writes (a /clear carry, a draft it sends) never refuse. */ + required: boolean + now: number +} + +/** + * Runs inside the transaction that writes the message. Required: throws + * `AgentSessionAttachmentExpiredError`, rolling the write back, when a reference names an upload + * this host does not hold or is deleting. Best effort: claims what it can and reports, never + * failing the write it rides on. + */ +export function claimAgentSessionAttachmentsInTransaction( + db: Database.Database, + claim: AgentSessionAttachmentClaim +): void { + const root = agentSessionAttachmentStoreRoot(claim.stateDirectory) + const uploadIds = agentSessionAttachmentReferences(root, claim.body) + if (uploadIds.size === 0) { + return + } + if (claim.required) { + if ( + [...uploadIds].some( + (uploadId) => isBeingSwept(db, uploadId) || !uploadIsStored(root, uploadId) + ) + ) { + throw new AgentSessionAttachmentExpiredError() + } + insertClaims(db, uploadIds, claim.sessionId, claim.now) + return + } + db.exec(`SAVEPOINT ${CLAIM_SAVEPOINT}`) + try { + insertClaims(db, uploadIds, claim.sessionId, claim.now) + db.exec(`RELEASE ${CLAIM_SAVEPOINT}`) + } catch (error) { + db.exec(`ROLLBACK TO ${CLAIM_SAVEPOINT}`) + db.exec(`RELEASE ${CLAIM_SAVEPOINT}`) + console.warn('[agent-session-attachments] claim skipped:', { + sessionId: claim.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } +} + +/** The sweep's side of the race: marks an upload as being deleted only while nobody claims it. + * True means this sweep won and may delete it; every later claim refuses it. */ +export function markUnclaimedUploadForSweep( + db: Database.Database, + uploadId: string, + now: number +): boolean { + const result = db + .prepare( + `INSERT OR IGNORE INTO agent_session_attachment_sweeps (upload_id, started_at) + SELECT ?, ? WHERE NOT EXISTS + (SELECT 1 FROM agent_session_attachment_claims WHERE upload_id = ?)` + ) + .run(uploadId, now, uploadId) + return Number(result.changes) > 0 +} + +export function listUploadsBeingSwept(db: Database.Database): string[] { + return db + .prepare('SELECT upload_id FROM agent_session_attachment_sweeps') + .all() + .flatMap((row) => + typeof row === 'object' && + row !== null && + 'upload_id' in row && + typeof row.upload_id === 'string' + ? [row.upload_id] + : [] + ) +} + +export function finishUploadSweep(db: Database.Database, uploadId: string): void { + db.prepare('DELETE FROM agent_session_attachment_sweeps WHERE upload_id = ?').run(uploadId) +} + +/** + * Claims nothing can use any more: their chat has no record, or their upload is gone from disk + * (removed outside Orca). Re-derived on every sweep, so a claim cannot outlive what it protects. + * Synchronous with the facts it reads, so a claim written meanwhile is judged on today's facts. + */ +export function pruneAgentSessionAttachmentClaims( + db: Database.Database, + facts: { root: string; recordedSessionIds: Pick, 'has'> | null } +): number { + const rows = db + .prepare('SELECT upload_id, session_id FROM agent_session_attachment_claims') + .all() + .flatMap((row) => + typeof row === 'object' && + row !== null && + 'upload_id' in row && + 'session_id' in row && + typeof row.upload_id === 'string' && + typeof row.session_id === 'string' + ? [{ uploadId: row.upload_id, sessionId: row.session_id }] + : [] + ) + const remove = db.prepare( + 'DELETE FROM agent_session_attachment_claims WHERE upload_id = ? AND session_id = ?' + ) + let removed = 0 + for (const { uploadId, sessionId } of rows) { + const chatGone = facts.recordedSessionIds !== null && !facts.recordedSessionIds.has(sessionId) + if (chatGone || !existsSync(join(facts.root, uploadId))) { + removed += Number(remove.run(uploadId, sessionId).changes) + } + } + return removed +} diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts new file mode 100644 index 000000000000..f08896ad4a5d --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts @@ -0,0 +1,94 @@ +// How the host recognizes its own attachment store in what a client sends: a stored file is +// `//`, and only the upload id is needed to know which upload it is. + +import { isAbsolute, join, relative, sep } from 'node:path' +import { AGENT_SESSION_ATTACHMENTS_DIR_NAME } from '../../../shared/agent-session-attachments' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' + +export const AGENT_SESSION_ATTACHMENT_PART_FILE = '.upload.part' + +const UPLOAD_ID_SOURCE = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' +const UPLOAD_ID = new RegExp(`^${UPLOAD_ID_SOURCE}$`) + +export function agentSessionAttachmentStoreRoot(stateDirectory: string): string { + return join(stateDirectory, AGENT_SESSION_ATTACHMENTS_DIR_NAME) +} + +export function isAgentSessionAttachmentUploadId(value: string): boolean { + return UPLOAD_ID.test(value) +} + +/** The upload a stored file belongs to, or null for anything that is not exactly a committed file + * directly in an upload directory of this store. */ +export function parseAgentSessionAttachmentStorePath( + root: string, + filePath: string +): { uploadId: string; name: string } | null { + if (!isAbsolute(filePath)) { + return null + } + const inside = relative(root, filePath) + const segments = inside.split(sep) + const [uploadId, name] = segments + if ( + inside === '' || + isAbsolute(inside) || + segments.length !== 2 || + !uploadId || + !name || + !isAgentSessionAttachmentUploadId(uploadId) || + name === AGENT_SESSION_ATTACHMENT_PART_FILE + ) { + return null + } + return { uploadId, name } +} + +// A root preceded by one of these is the tail of some other, longer path. +const PATH_CHARACTER = /[\w.~\\/-]/ + +function normalizedRoot(root: string, platform: NodeJS.Platform): string { + const forward = root.replace(/\\/g, '/') + return platform === 'win32' ? forward.toLowerCase() : forward +} + +/** + * The uploads of this host's store a message body names, in image paths and file references in its + * text alike. Only a path that starts with this host's exact store root counts: matched on the + * decoded body by `/`, so quoting or a file name with spaces cannot hide one, + * and Windows paths match with either separator and in any case. Any other mention of a store + * path (another server's, a `~` form, a bare name) is only text. + */ +export function agentSessionAttachmentReferences( + root: string, + body: AgentJournalMessageItem, + platform: NodeJS.Platform = process.platform +): Set { + const uploadIds = new Set() + const ownRoot = normalizedRoot(root, platform) + const pattern = new RegExp( + `${AGENT_SESSION_ATTACHMENTS_DIR_NAME}[\\\\/](${UPLOAD_ID_SOURCE})(?![0-9a-z-])`, + platform === 'win32' ? 'gi' : 'g' + ) + for (const block of body.blocks) { + const text = + block.type === 'text' ? block.text : block.type === 'image-ref' ? block.path : undefined + if (!text) { + continue + } + for (const match of text.matchAll(pattern)) { + const rootEnd = match.index + AGENT_SESSION_ATTACHMENTS_DIR_NAME.length + const rootStart = rootEnd - root.length + const uploadId = match[1]?.toLowerCase() + if ( + uploadId && + rootStart >= 0 && + !PATH_CHARACTER.test(text.charAt(rootStart - 1)) && + normalizedRoot(text.slice(rootStart, rootEnd), platform) === ownRoot + ) { + uploadIds.add(uploadId) + } + } + } + return uploadIds +} diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store-registry.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store-registry.ts new file mode 100644 index 000000000000..e0f4201618a3 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store-registry.ts @@ -0,0 +1,15 @@ +// The installed attachment store, apart from the store itself: the RPC methods are imported +// wherever the method table is, and must not load the store's file-reading dependencies with it. + +import type { AgentSessionAttachmentStore } from './agent-session-attachment-store' + +let installedStore: AgentSessionAttachmentStore | null = null + +/** Installed with the structured host, which owns the state directory the store lives in. */ +export function setAgentSessionAttachmentStore(store: AgentSessionAttachmentStore | null): void { + installedStore = store +} + +export function getAgentSessionAttachmentStore(): AgentSessionAttachmentStore | null { + return installedStore +} diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts new file mode 100644 index 000000000000..fc0c57d30b24 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts @@ -0,0 +1,231 @@ +import { mkdtemp, readdir, readFile, rm, stat, symlink, writeFile, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionAttachmentStore } from './agent-session-attachment-store' +import { REMOTE_RPC_MAX_CONTENT_BYTES } from '../../../shared/remote-rpc-content-budget' +import { isMobileE2EETextPayloadWithinLimit } from '../../runtime/rpc/mobile-e2ee-outbound-admission' + +let root: string +let store: AgentSessionAttachmentStore +const caller = 'client-a' + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-attachments-')) + store = new AgentSessionAttachmentStore(join(root, 'agent-session-attachments'), { + hasSession: (sessionId) => sessionId.startsWith('session-') + }) +}) + +afterEach(async () => { + store.clearInFlightForTests() + vi.useRealTimers() + await rm(root, { recursive: true, force: true }) +}) + +async function upload(name: string, bytes: Buffer, sessionId = 'session-1') { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId, + name, + byteLength: bytes.byteLength + }) + await store.appendChunk({ + callerKey: caller, + uploadId, + offset: 0, + contentBase64: bytes.toString('base64') + }) + return store.commitUpload({ callerKey: caller, uploadId }) +} + +describe('AgentSessionAttachmentStore', () => { + it('stores the bytes in an upload directory of its own, keeping the file name', async () => { + const stored = await upload('screen shot.png', Buffer.from('png-bytes')) + expect(stored.name).toBe('screen shot.png') + expect(stored.byteLength).toBe(9) + expect(await readdir(store.rootDir)).toHaveLength(1) + expect(stored.path.startsWith(store.rootDir)).toBe(true) + expect(stored.path.endsWith('screen shot.png')).toBe(true) + expect(await readFile(stored.path, 'utf8')).toBe('png-bytes') + // The part file is gone once the name holds the whole file. + expect(await readdir(join(stored.path, '..'))).toEqual(['screen shot.png']) + }) + + it('keeps only the last path segment of a name, never a path out of the store', async () => { + const stored = await upload('../../../etc/passwd', Buffer.from('x')) + expect(stored.name).toBe('passwd') + expect(stored.path.startsWith(store.rootDir)).toBe(true) + }) + + it('refuses an upload for a chat this host does not hold', async () => { + await expect( + store.startUpload({ callerKey: caller, sessionId: 'unknown', name: 'a.txt', byteLength: 1 }) + ).rejects.toThrow('not on this host') + await expect(readdir(store.rootDir)).rejects.toThrow() + }) + + it('appends chunks in order and refuses one out of order or past the declared size', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 4 + }) + const chunk = (text: string) => Buffer.from(text).toString('base64') + await store.appendChunk({ callerKey: caller, uploadId, offset: 0, contentBase64: chunk('ab') }) + await expect( + store.appendChunk({ callerKey: caller, uploadId, offset: 0, contentBase64: chunk('cd') }) + ).rejects.toThrow('out of order') + await expect( + store.appendChunk({ callerKey: caller, uploadId, offset: 2, contentBase64: chunk('cde') }) + ).rejects.toThrow('exceeded its declared size') + await store.appendChunk({ callerKey: caller, uploadId, offset: 2, contentBase64: chunk('cd') }) + const stored = await store.commitUpload({ callerKey: caller, uploadId }) + expect(await readFile(stored.path, 'utf8')).toBe('abcd') + }) + + it('refuses an incomplete commit and removes what it had', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 10 + }) + await expect(store.commitUpload({ callerKey: caller, uploadId })).rejects.toThrow('incomplete') + expect(await readdir(store.rootDir)).toEqual([]) + }) + + it('keeps an upload in flight until its file is renamed into place, so no sweep takes it', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 1 + }) + await store.appendChunk({ callerKey: caller, uploadId, offset: 0, contentBase64: 'eA==' }) + const committing = store.commitUpload({ callerKey: caller, uploadId }) + expect(store.isUploadInFlight(uploadId)).toBe(true) + await committing + expect(store.isUploadInFlight(uploadId)).toBe(false) + }) + + it('stores an empty file', async () => { + const stored = await upload('empty.txt', Buffer.alloc(0)) + expect((await stat(stored.path)).size).toBe(0) + }) + + it('does not let another client touch an upload it did not start', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 1 + }) + await expect( + store.appendChunk({ callerKey: 'client-b', uploadId, offset: 0, contentBase64: 'eA==' }) + ).rejects.toThrow('not found') + await expect(store.commitUpload({ callerKey: 'client-b', uploadId })).rejects.toThrow( + 'not found' + ) + await expect(store.abortUpload({ callerKey: 'client-b', uploadId })).rejects.toThrow( + 'not found' + ) + expect(store.isUploadInFlight(uploadId)).toBe(true) + }) + + it('removes an aborted upload', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 1 + }) + await store.abortUpload({ callerKey: caller, uploadId }) + expect(store.isUploadInFlight(uploadId)).toBe(false) + expect(await readdir(store.rootDir)).toEqual([]) + }) + + it('forgets and removes an upload its client abandoned', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 1 + }) + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + vi.useRealTimers() + await vi.waitFor(async () => expect(await readdir(store.rootDir)).toEqual([])) + expect(store.isUploadInFlight(uploadId)).toBe(false) + await expect(store.commitUpload({ callerKey: caller, uploadId })).rejects.toThrow('not found') + }) + + it('reads back a stored image and nothing outside the store', async () => { + const stored = await upload('shot.png', Buffer.from('png-bytes')) + await expect(store.readPreview(stored.path)).resolves.toEqual({ + content: Buffer.from('png-bytes').toString('base64'), + isBinary: true, + isImage: true, + mimeType: 'image/png' + }) + const outside = join(root, 'outside.png') + await writeFile(outside, 'secret') + await expect(store.readPreview(outside)).rejects.toThrow('Not an attachment image') + const notImage = await upload('notes.txt', Buffer.from('text')) + await expect(store.readPreview(notImage.path)).rejects.toThrow('Not an attachment image') + }) + + it('reads only a file exactly at /', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'shot.png', + byteLength: 3 + }) + const uploadDir = join(store.rootDir, uploadId) + // Deeper than a stored file: nothing the store wrote lives there. + await mkdir(join(uploadDir, 'nested'), { recursive: true }) + await writeFile(join(uploadDir, 'nested', 'x.png'), 'x') + await expect(store.readPreview(join(uploadDir, 'nested', 'x.png'))).rejects.toThrow( + 'Not an attachment image' + ) + }) + + it('never follows a link out of the store', async () => { + const stored = await upload('shot.png', Buffer.from('png-bytes')) + const outside = join(root, 'secret.png') + await writeFile(outside, 'secret') + await rm(stored.path) + await symlink(outside, stored.path) + await expect(store.readPreview(stored.path)).rejects.toThrow('Not an attachment image') + }) + + it('refuses an image over the reply budget instead of overflowing the connection', async () => { + const big = Buffer.alloc(3.5 * 1024 * 1024, 1) + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'big.png', + byteLength: big.byteLength + }) + await store.appendChunk({ + callerKey: caller, + uploadId, + offset: 0, + contentBase64: big.toString('base64') + }) + const stored = await store.commitUpload({ callerKey: caller, uploadId }) + // The budget a remote reply gets: what the server's outbound limit leaves after the envelope. + await expect(store.readPreview(stored.path, REMOTE_RPC_MAX_CONTENT_BYTES)).rejects.toThrow( + 'file_too_large' + ) + // A local read keeps the larger preview limit. + await expect(store.readPreview(stored.path)).resolves.toMatchObject({ mimeType: 'image/png' }) + // An image that fits the budget also fits the server's outbound limit once enveloped. + const fits = await upload('fits.png', Buffer.alloc(2.5 * 1024 * 1024, 1)) + const preview = await store.readPreview(fits.path, REMOTE_RPC_MAX_CONTENT_BYTES) + const reply = JSON.stringify({ id: 'request-1', ok: true, result: preview }) + expect(isMobileE2EETextPayloadWithinLimit(reply)).toBe(true) + }) +}) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts new file mode 100644 index 000000000000..a9626e86afaf --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts @@ -0,0 +1,225 @@ +// The server-owned store for files a client attaches to a structured chat. +// +// `//`: one directory per upload keeps the user's file name +// intact (agents read meaning into it) without two uploads colliding. Bytes land in a hidden part +// file and are renamed into place on commit, so a stored name is always complete. Which chats hold +// an upload is recorded by the message that names it (`agent-session-attachment-claims.ts`). + +import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises' +import { extname, join } from 'node:path' +import { + sanitizeAgentSessionAttachmentName, + type AgentSessionAttachmentUploadCommitResult +} from '../../../shared/agent-session-attachments' +import type { RuntimeFilePreviewResult } from '../../../shared/runtime-types' +import { readAuthorizedDocPreviewFile } from '../../../shared/doc-preview-file-access' +import { renameFileWithWindowsRetryAsync } from '../../../shared/windows-retry-file-operations' +import { IMAGE_FILE_MIME_TYPES } from '../../../shared/image-file-extensions' +import { + LOCAL_PREVIEWABLE_BINARY_MAX_BYTES, + assertPreviewWithinTransportBudget, + previewableBinaryByteLimit +} from '../../runtime/runtime-file-commands-mobile-file-list-limit' +import { ChunkedUploadRegistry, nextChunkedUploadLength } from './chunked-upload-registry' +import { + AGENT_SESSION_ATTACHMENT_PART_FILE, + parseAgentSessionAttachmentStorePath +} from './agent-session-attachment-references' + +const UPLOAD_MAX_CONCURRENT = 8 +const UPLOAD_IDLE_TTL_MS = 5 * 60 * 1000 +const NOT_FOUND = 'Attachment upload was not found' +const NOT_AN_IMAGE = 'Not an attachment image' +const PREVIEW_TOO_LARGE = 'file_too_large' + +function isTooLarge(error: unknown): boolean { + return error instanceof Error && error.message === PREVIEW_TOO_LARGE +} + +type InFlightUpload = { + callerKey: string + uploadDir: string + name: string + expectedLength: number + receivedLength: number + writing: boolean +} + +export type AgentSessionAttachmentStoreOptions = { + /** Whether this host holds the chat: an upload for any other chat is refused at its start. */ + hasSession: (sessionId: string) => boolean +} + +export class AgentSessionAttachmentStore { + private readonly uploads = new ChunkedUploadRegistry({ + maxConcurrent: UPLOAD_MAX_CONCURRENT, + ttlMs: UPLOAD_IDLE_TTL_MS, + tooManyMessage: 'Too many attachment uploads are in progress', + notFoundMessage: NOT_FOUND, + onExpire: (upload) => void removeQuietly(upload.uploadDir) + }) + + constructor( + readonly rootDir: string, + private readonly options: AgentSessionAttachmentStoreOptions + ) {} + + /** Upload ids whose bytes are still arriving; the sweep leaves their directories alone. */ + isUploadInFlight(uploadId: string): boolean { + return this.uploads.has(uploadId) + } + + async startUpload(args: { + callerKey: string + sessionId: string + name: string + byteLength: number + }): Promise<{ uploadId: string }> { + if (!this.options.hasSession(args.sessionId)) { + throw new Error('This chat is not on this host') + } + const name = sanitizeAgentSessionAttachmentName(args.name) + const uploadId = this.uploads.create((id) => ({ + callerKey: args.callerKey, + uploadDir: join(this.rootDir, id), + name, + expectedLength: args.byteLength, + receivedLength: 0, + writing: false + })) + const uploadDir = join(this.rootDir, uploadId) + try { + await mkdir(uploadDir, { recursive: true }) + await writeFile(join(uploadDir, AGENT_SESSION_ATTACHMENT_PART_FILE), '', { flag: 'wx' }) + } catch (error) { + this.uploads.delete(uploadId) + await removeQuietly(uploadDir) + throw error + } + return { uploadId } + } + + async appendChunk(args: { + callerKey: string + uploadId: string + offset: number + contentBase64: string + }): Promise<{ receivedBytes: number }> { + const upload = this.requireOwned(args.uploadId, args.callerKey) + if (upload.writing) { + throw new Error('Attachment chunk is already being written') + } + const bytes = Buffer.from(args.contentBase64, 'base64') + const nextLength = nextChunkedUploadLength(upload, args.offset, bytes.byteLength, { + outOfOrder: 'Attachment chunk offset is out of order', + exceeded: 'Attachment upload exceeded its declared size' + }) + upload.writing = true + try { + await appendFile(join(upload.uploadDir, AGENT_SESSION_ATTACHMENT_PART_FILE), bytes) + upload.receivedLength = nextLength + } finally { + upload.writing = false + } + this.uploads.touch(args.uploadId) + return { receivedBytes: upload.receivedLength } + } + + async commitUpload(args: { + callerKey: string + uploadId: string + }): Promise { + const upload = this.requireOwned(args.uploadId, args.callerKey) + if (upload.writing) { + throw new Error('Attachment chunk is still being written') + } + // In flight until the rename lands, so the sweep never takes a slow upload's part file. + upload.writing = true + try { + if (upload.receivedLength !== upload.expectedLength) { + throw new Error('Attachment upload is incomplete') + } + const finalPath = join(upload.uploadDir, upload.name) + // An antivirus or indexer briefly holding the part file on Windows must not lose the upload. + await renameFileWithWindowsRetryAsync( + join(upload.uploadDir, AGENT_SESSION_ATTACHMENT_PART_FILE), + finalPath + ) + return { path: finalPath, name: upload.name, byteLength: upload.receivedLength } + } catch (error) { + await removeQuietly(upload.uploadDir) + throw error + } finally { + this.uploads.delete(args.uploadId) + } + } + + async abortUpload(args: { callerKey: string; uploadId: string }): Promise<{ aborted: true }> { + const upload = this.uploads.peek(args.uploadId) + if (upload) { + if (upload.callerKey !== args.callerKey) { + throw new Error(NOT_FOUND) + } + this.uploads.delete(args.uploadId) + await removeQuietly(upload.uploadDir) + } + return { aborted: true } + } + + /** + * A stored image's bytes, for a client that shows it. Anything outside the store is refused, and + * the read is protected and bounded: `maxContentBytes` is the reply's budget on a remote + * connection, and an image over it is refused as too large rather than sent. + */ + async readPreview(filePath: string, maxContentBytes?: number): Promise { + if ( + !IMAGE_FILE_MIME_TYPES[extname(filePath).toLowerCase()] || + !parseAgentSessionAttachmentStorePath(this.rootDir, filePath) + ) { + throw new Error(NOT_AN_IMAGE) + } + let read: Awaited> + try { + read = await readAuthorizedDocPreviewFile({ + boundaryPath: this.rootDir, + entryPath: filePath, + implicitRootPath: null, + authorizedRootPaths: [], + targetPath: filePath, + maxTextBytes: 0, + maxBinaryBytes: + maxContentBytes === undefined + ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES + : previewableBinaryByteLimit(maxContentBytes) + }) + } catch (error) { + // One answer for everything else, so a read says nothing about the server's other files. + throw new Error(isTooLarge(error) ? PREVIEW_TOO_LARGE : NOT_AN_IMAGE) + } + if (!read.isBinary || !read.mimeType?.startsWith('image/')) { + throw new Error(NOT_AN_IMAGE) + } + return assertPreviewWithinTransportBudget( + { content: read.content, isBinary: true, isImage: true, mimeType: read.mimeType }, + maxContentBytes + ) + } + + clearInFlightForTests(): void { + this.uploads.clear() + } + + private requireOwned(uploadId: string, callerKey: string): InFlightUpload { + const upload = this.uploads.require(uploadId) + // Another client's upload reads as absent, not as forbidden. + if (upload.callerKey !== callerKey) { + throw new Error(NOT_FOUND) + } + return upload + } +} + +export async function removeQuietly(path: string): Promise { + // Cleanup is best effort: a file that will not go is retried by the next sweep. + await rm(path, { recursive: true, force: true }).catch(() => {}) +} diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts new file mode 100644 index 000000000000..e414633a66bc --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts @@ -0,0 +1,199 @@ +import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type Database from '../../sqlite/sync-database' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import { + claimAgentSessionAttachmentsInTransaction, + isAgentSessionAttachmentExpiredError, + markUnclaimedUploadForSweep +} from './agent-session-attachment-claims' +import { + AGENT_SESSION_ATTACHMENT_PART_FILE, + agentSessionAttachmentStoreRoot +} from './agent-session-attachment-references' +import { AgentSessionAttachmentStore } from './agent-session-attachment-store' +import { + sweepAgentSessionAttachments, + type AgentSessionAttachmentSweepFacts +} from './agent-session-attachment-sweep' + +const HOUR = 60 * 60 * 1000 +const NOW = Date.now() +const OLD = 24 * HOUR + 1 +const SENT = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01' +const UNSENT = '7c2d9e10-3f4a-4b5c-8d6e-2f1a0b9c8d02' +const CARRIED = '3e1f0a9b-8c7d-4e6f-a5b4-c3d2e1f0a903' + +let stateDirectory: string +let store: AgentSessionAttachmentStore +let db: Database.Database +let recorded: Set | null + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-sweep-')) + store = new AgentSessionAttachmentStore(agentSessionAttachmentStoreRoot(stateDirectory), { + hasSession: () => true + }) + db = openTestJournalHostDatabase(stateDirectory).db + recorded = new Set(['session-1', 'session-2']) +}) + +afterEach(async () => { + store.clearInFlightForTests() + closeTestJournalHostDatabases() + await rm(stateDirectory, { recursive: true, force: true }) +}) + +function facts(overrides: Partial = {}) { + return { database: () => db, recordedSessionIds: () => recorded, ...overrides } +} + +/** A stored upload (or a bare part file) last touched `ageMs` ago. */ +async function seedUpload( + uploadId: string, + ageMs: number, + file: string = 'shot.png' +): Promise { + const uploadDir = join(store.rootDir, uploadId) + await mkdir(uploadDir, { recursive: true }) + await writeFile(join(uploadDir, file), 'bytes') + const at = new Date(NOW - ageMs) + await utimes(uploadDir, at, at) + return join(uploadDir, file) +} + +function claim(sessionId: string, path: string, required = true): void { + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'image-ref', path }] + } + db.exec('BEGIN') + try { + claimAgentSessionAttachmentsInTransaction(db, { + stateDirectory, + sessionId, + body, + required, + now: NOW + }) + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} + +function claimedSessions(): string[] { + return db + .prepare('SELECT session_id FROM agent_session_attachment_claims ORDER BY session_id') + .all() + .flatMap((row) => + typeof row === 'object' && row !== null && 'session_id' in row ? [String(row.session_id)] : [] + ) +} + +async function remaining(): Promise { + return (await readdir(store.rootDir).catch(() => [])).sort() +} + +describe('sweepAgentSessionAttachments', () => { + it('removes a part file nobody finished after an hour, and keeps a fresh one', async () => { + await seedUpload(SENT, HOUR + 1, AGENT_SESSION_ATTACHMENT_PART_FILE) + await seedUpload(UNSENT, HOUR - 1000, AGENT_SESSION_ATTACHMENT_PART_FILE) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([UNSENT]) + }) + + it('removes an upload nobody claimed after a day, and keeps a claimed one and a fresh one', async () => { + claim('session-1', await seedUpload(SENT, OLD)) + await seedUpload(UNSENT, OLD) + await seedUpload(CARRIED, HOUR) + const { removed } = await sweepAgentSessionAttachments(store, facts(), NOW) + expect(removed).toEqual([join(store.rootDir, UNSENT)]) + expect(await remaining()).toEqual([SENT, CARRIED].sort()) + }) + + it('keeps an upload any chat still claims: a /clear carry is a second holder', async () => { + const path = await seedUpload(CARRIED, OLD) + claim('session-1', path) + claim('session-2', path, false) + // The source chat goes; the replacement still holds the upload. + recorded = new Set(['session-2']) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([CARRIED]) + expect(claimedSessions()).toEqual(['session-2']) + // Once no chat holds it, it is unclaimed and goes. + recorded = new Set() + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(claimedSessions()).toEqual([]) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([]) + }) + + it('a send after the sweep marked an upload is refused, never sent without its file', async () => { + const path = await seedUpload(UNSENT, OLD) + // The sweep wins the race: its mark lands before the claim. + expect(markUnclaimedUploadForSweep(db, UNSENT, NOW)).toBe(true) + let refused: unknown + try { + claim('session-1', path) + } catch (error) { + refused = error + } + expect(isAgentSessionAttachmentExpiredError(refused)).toBe(true) + expect(claimedSessions()).toEqual([]) + }) + + it('finishes a delete a crashed sweep left marked, then forgets the mark', async () => { + await seedUpload(UNSENT, HOUR) + // Marked, then the process died before the files went. + expect(markUnclaimedUploadForSweep(db, UNSENT, NOW)).toBe(true) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([]) + expect(db.prepare('SELECT COUNT(*) AS n FROM agent_session_attachment_sweeps').get()).toEqual({ + n: 0 + }) + }) + + it('judges no claim orphaned while the record list is incomplete', async () => { + claim('session-1', await seedUpload(SENT, OLD)) + recorded = null + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(claimedSessions()).toEqual(['session-1']) + expect(await remaining()).toEqual([SENT]) + }) + + it('drops a claim whose upload was removed outside Orca', async () => { + claim('session-1', await seedUpload(SENT, OLD)) + await rm(join(store.rootDir, SENT), { recursive: true }) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(claimedSessions()).toEqual([]) + }) + + it('removes only part files when it cannot write claims (a newer Orca wrote the journal)', async () => { + await seedUpload(UNSENT, OLD) + await seedUpload(SENT, HOUR + 1, AGENT_SESSION_ATTACHMENT_PART_FILE) + await sweepAgentSessionAttachments(store, facts({ database: () => null }), NOW) + expect(await remaining()).toEqual([UNSENT]) + }) + + it('leaves an upload in flight alone however old its directory is', async () => { + const { uploadId } = await store.startUpload({ + callerKey: 'client-a', + sessionId: 'session-1', + name: 'a.png', + byteLength: 1 + }) + const at = new Date(NOW - OLD) + await utimes(join(store.rootDir, uploadId), at, at) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([uploadId]) + }) +}) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts new file mode 100644 index 000000000000..cb685a018939 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts @@ -0,0 +1,157 @@ +// When a stored attachment may go, re-derived on every sweep from the disk and the claim table. +// +// - A part file nobody is writing (the uploader crashed or vanished) goes after an hour. +// - An upload no message ever claimed goes after a day: its chip was removed or its draft +// abandoned. The sweep first marks it in the same database the claims live in, and only if no +// claim exists; a send that names it afterwards is refused instead of losing its file. +// - A claimed upload stays while any chat holding it has a record. The host never deletes a chat +// today, so that is as long as its transcript. +// - A mark left by a sweep that died mid-delete is finished by the next one. + +import { readdir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import type Database from '../../sqlite/sync-database' +import { + finishUploadSweep, + listUploadsBeingSwept, + markUnclaimedUploadForSweep, + pruneAgentSessionAttachmentClaims +} from './agent-session-attachment-claims' +import { AGENT_SESSION_ATTACHMENT_PART_FILE } from './agent-session-attachment-references' +import { removeQuietly, type AgentSessionAttachmentStore } from './agent-session-attachment-store' + +export const ABANDONED_PART_MAX_AGE_MS = 60 * 60 * 1000 +export const UNCLAIMED_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000 + +export type AgentSessionAttachmentSweepFacts = { + /** The host's journal connection, or null when it cannot take writes (closed, or written by a + * newer Orca): without it nothing claimed can be told apart, so only part files go. */ + database: () => Database.Database | null + /** Every chat this host holds a record for; null while that list is incomplete (records still + * owed their import), when no chat's claims can be judged orphaned. */ + recordedSessionIds: () => Pick, 'has'> | null +} + +export type AgentSessionAttachmentSweepResult = { removed: string[] } + +export async function sweepAgentSessionAttachments( + store: AgentSessionAttachmentStore, + facts: AgentSessionAttachmentSweepFacts, + now = Date.now() +): Promise { + const removed: string[] = [] + // Read again at every statement: the host may close the connection while the sweep awaits disk. + const { database } = facts + for (const uploadId of withDatabase(database, listUploadsBeingSwept) ?? []) { + await removeQuietly(join(store.rootDir, uploadId)) + withDatabase(database, (db) => finishUploadSweep(db, uploadId)) + } + for (const uploadId of await listDirectories(store.rootDir)) { + if (store.isUploadInFlight(uploadId)) { + continue + } + const uploadDir = join(store.rootDir, uploadId) + const stored = (await listEntries(uploadDir)).some( + (entry) => entry !== AGENT_SESSION_ATTACHMENT_PART_FILE + ) + const ageMs = now - (await modifiedAt(uploadDir)) + if (!stored) { + if (ageMs > ABANDONED_PART_MAX_AGE_MS) { + await removeQuietly(uploadDir) + removed.push(uploadDir) + } + continue + } + // A claim landing first keeps the upload; this mark landing first refuses that claim. + if ( + ageMs <= UNCLAIMED_ATTACHMENT_MAX_AGE_MS || + withDatabase(database, (db) => markUnclaimedUploadForSweep(db, uploadId, now)) !== true + ) { + continue + } + await removeQuietly(uploadDir) + withDatabase(database, (db) => finishUploadSweep(db, uploadId)) + removed.push(uploadDir) + } + withDatabase(database, (db) => + pruneAgentSessionAttachmentClaims(db, { + root: store.rootDir, + recordedSessionIds: facts.recordedSessionIds() + }) + ) + return { removed } +} + +function withDatabase( + database: AgentSessionAttachmentSweepFacts['database'], + run: (db: Database.Database) => T +): T | undefined { + const db = database() + return db ? run(db) : undefined +} + +async function listEntries(dir: string): Promise { + try { + return await readdir(dir) + } catch { + return [] + } +} + +async function listDirectories(dir: string): Promise { + try { + const entries = await readdir(dir, { withFileTypes: true }) + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) + } catch { + return [] + } +} + +async function modifiedAt(path: string): Promise { + try { + return (await stat(path)).mtimeMs + } catch { + return Number.POSITIVE_INFINITY + } +} + +export type AgentSessionAttachmentSweeper = { stop: () => void } + +/** + * Sweeps shortly after the host comes up and then periodically. Best effort: a failed sweep is + * logged and the next one tries again; nothing waits on it. + */ +export function startAgentSessionAttachmentSweeps( + store: AgentSessionAttachmentStore, + facts: AgentSessionAttachmentSweepFacts, + options: { + initialDelayMs: number + intervalMs: number + onError: (error: unknown) => void + } +): AgentSessionAttachmentSweeper { + let running = false + let stopped = false + const run = (): void => { + if (running || stopped) { + return + } + running = true + void sweepAgentSessionAttachments(store, facts) + .catch(options.onError) + .finally(() => { + running = false + }) + } + const initial = setTimeout(run, options.initialDelayMs) + const interval = setInterval(run, options.intervalMs) + initial.unref?.() + interval.unref?.() + return { + stop: () => { + stopped = true + clearTimeout(initial) + clearInterval(interval) + } + } +} diff --git a/src/main/native-chat/agent-session-attachments/chunked-upload-registry.ts b/src/main/native-chat/agent-session-attachments/chunked-upload-registry.ts new file mode 100644 index 000000000000..729fc089c639 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/chunked-upload-registry.ts @@ -0,0 +1,130 @@ +import { randomUUID } from 'node:crypto' + +type RegistryEntry = { + value: T + expiresAt: number + ttlTimer: ReturnType +} + +export type ChunkedUploadRegistryOptions = { + maxConcurrent: number + /** Idle lifetime: every touch restarts it. */ + ttlMs: number + tooManyMessage: string + notFoundMessage: string + /** Runs when an upload expires unfinished; never for an explicit delete. */ + onExpire?: (value: T) => void +} + +/** + * In-flight chunked uploads held between start and commit: bounded in number, each forgotten after + * an idle TTL so a client that disappears mid-upload cannot pin the slot. + */ +export class ChunkedUploadRegistry { + private readonly entries = new Map>() + + constructor(private readonly options: ChunkedUploadRegistryOptions) {} + + get size(): number { + return this.entries.size + } + + /** `build` receives the new id, for state that is named after it. */ + create(build: (uploadId: string) => T): string { + this.pruneExpired() + if (this.entries.size >= this.options.maxConcurrent) { + throw new Error(this.options.tooManyMessage) + } + const uploadId = randomUUID() + this.entries.set(uploadId, { + value: build(uploadId), + expiresAt: Date.now() + this.options.ttlMs, + ttlTimer: this.scheduleExpiry(uploadId) + }) + return uploadId + } + + require(uploadId: string): T { + this.pruneExpired() + const entry = this.entries.get(uploadId) + if (!entry) { + throw new Error(this.options.notFoundMessage) + } + return entry.value + } + + peek(uploadId: string): T | undefined { + this.pruneExpired() + return this.entries.get(uploadId)?.value + } + + has(uploadId: string): boolean { + return this.entries.has(uploadId) + } + + touch(uploadId: string): void { + const entry = this.entries.get(uploadId) + if (!entry) { + return + } + clearTimeout(entry.ttlTimer) + entry.expiresAt = Date.now() + this.options.ttlMs + entry.ttlTimer = this.scheduleExpiry(uploadId) + } + + delete(uploadId: string): T | undefined { + const entry = this.entries.get(uploadId) + if (!entry) { + return undefined + } + clearTimeout(entry.ttlTimer) + this.entries.delete(uploadId) + return entry.value + } + + clear(): void { + for (const uploadId of this.entries.keys()) { + this.delete(uploadId) + } + } + + private expire(uploadId: string): void { + const value = this.delete(uploadId) + if (value !== undefined) { + this.options.onExpire?.(value) + } + } + + private pruneExpired(now = Date.now()): void { + for (const [uploadId, entry] of this.entries) { + if (entry.expiresAt <= now) { + this.expire(uploadId) + } + } + } + + private scheduleExpiry(uploadId: string): ReturnType { + const timer = setTimeout(() => this.expire(uploadId), this.options.ttlMs) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } + return timer + } +} + +/** The next chunk must start where the last one ended and stay within the declared size. */ +export function nextChunkedUploadLength( + upload: { expectedLength: number; receivedLength: number }, + offset: number, + chunkLength: number, + messages: { outOfOrder: string; exceeded: string } +): number { + if (offset !== upload.receivedLength) { + throw new Error(messages.outOfOrder) + } + const nextLength = upload.receivedLength + chunkLength + if (nextLength > upload.expectedLength) { + throw new Error(messages.exceeded) + } + return nextLength +} diff --git a/src/main/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index 04483d099583..ba961541a7e9 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -14,6 +14,7 @@ import { } from './journal-database-schema' import { JournalUnreleasedSchemaError } from './journal-open-failure' import { ensureQueuedMessagesTable } from './queued-message-schema' +import { ensureAgentSessionAttachmentClaimTables } from '../agent-session-attachments/agent-session-attachment-claims' export const JOURNAL_BUSY_TIMEOUT_MS = 5000 /** Bounds the WAL a checkpoint leaves behind; SQLite truncates it back to this after a reset. */ @@ -80,6 +81,7 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase { // already at the current version, and this table must exist at EVERY // writable open with no `user_version` bump (see `ensureQueuedMessagesTable`). ensureQueuedMessagesTable(probe) + ensureAgentSessionAttachmentClaimTables(probe) hardenSqliteDatabaseFiles(dbPath) transferred = true return { db: probe, readOnly: false } diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts index 1aaa0b76df95..15bbcf53aa0f 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts @@ -11,6 +11,8 @@ // exists to remove, and a user who wants the message sent anyway rotates the id // — one re-typed message, and a first delivery by construction. +import { STRUCTURED_AGENT_SESSION_SUBMISSION_MISSING } from '../../../shared/structured-agent-session-send-evidence' + /** A previous process wrote the message and died before learning its outcome. */ export const DISPATCH_DOUBT_HOST_RESTARTED = 'host_restarted_before_acknowledgement' @@ -21,7 +23,7 @@ export const DISPATCH_DOUBT_PROVIDER_EXITED = 'provider_exited_before_acknowledg export const DISPATCH_DOUBT_PERSISTENCE_FAILED = 'dispatch_result_persistence_failed' /** The operation tombstone survived recovery but its journal submission did not. */ -export const DISPATCH_DOUBT_SUBMISSION_MISSING = 'durable_send_submission_missing' +export const DISPATCH_DOUBT_SUBMISSION_MISSING = STRUCTURED_AGENT_SESSION_SUBMISSION_MISSING /** The provider reported its thread not running with no turn open, so nothing is * left that could still acknowledge the message. */ diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts index 7c6deabef407..b952f7672c5a 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts @@ -10,34 +10,29 @@ import { } from '../../../shared/structured-agent-session-dispatch-rejection' import { rejectedDraftSettlement } from './journal-dispatch-settlement' -function settle(kind: SubmissionRejectionKind, origin?: 'client' | 'host') { - return rejectedDraftSettlement({ - ...agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }), - origin - }) +function settle(kind: SubmissionRejectionKind) { + return rejectedDraftSettlement( + agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) + ) } describe('what a rejection does to the draft it was consumed from', () => { it("a Stop's withdrawal sends it back to waiting, under the queue's pause rather than a hold of its own", () => { - expect(settle('cancelled')).toEqual({ state: 'waiting', kept: false }) + expect(settle('cancelled')).toEqual({ state: 'waiting' }) expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_CANCELLED })).toEqual({ - state: 'waiting', - kept: false + state: 'waiting' }) - expect(settle('notDelivered')).toEqual({ state: 'waiting', kept: false }) + expect(settle('notDelivered')).toEqual({ state: 'waiting' }) }) - // A Send the person asked for and the host never handed over waits for them; the queue's own - // hand-off, or one from a build that recorded no origin, waits under the queue's pause. - it('a restart or close before hand-over sends it back to waiting, kept only when the person sent it', () => { + // Whoever sent it, it waits under the reopen's pause like every card the chat closed with. + it('a restart or close before hand-over sends it back to waiting, with no hold of its own', () => { for (const kind of ['hostRestarted', 'chatClosed'] as const) { - expect(settle(kind, 'client')).toEqual({ state: 'waiting', kept: true }) - expect(settle(kind, 'host')).toEqual({ state: 'waiting', kept: false }) - expect(settle(kind)).toEqual({ state: 'waiting', kept: false }) + expect(settle(kind)).toEqual({ state: 'waiting' }) } - expect( - rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED, origin: 'client' }) - ).toEqual({ state: 'waiting', kept: true }) + expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED })).toEqual({ + state: 'waiting' + }) }) it('a failure returns the card for the user to act on', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts index 6b3e53f36b37..c56e4f47d45d 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts @@ -30,27 +30,18 @@ export function consumedSubmissionWasRejected( } /** What a consumed draft becomes when its submission is rejected. */ -export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting'; kept: boolean } +export type RejectedDraftSettlement = { state: 'returned' | 'waiting' } /** - * Where no one failed the user, the draft goes back to waiting at its own position, under whatever - * pauses the queue: a Stop's, or the restart's, derived from the host instance. A Send the person - * asked for (`origin` client) that a restart or a close cut short is kept (`kept`) until they send - * it again, as the host keeps every message a person sent and it never handed over; the queue's - * own hand-off is not theirs, so it waits as any queued card does: under the restart's pause after - * a restart, and plainly queued after a close in the same process. A returned card would block the drafts - * behind it on a failure that never happened. A failure returns the card with its refusal for the - * user to act on. + * Where no one failed the user, the draft goes back to waiting, under whatever pauses the queue: a + * Stop's, or, when a restart or a close cut it short, the reopen's, so it follows the chat's next + * turn like every card the chat closed with. A returned card would block the drafts behind it on a + * failure that never happened. A failure returns the card with its refusal for the user to act on. */ export function rejectedDraftSettlement( - rejected: Pick & { rejection?: unknown } + rejected: Pick & { rejection?: unknown } ): RejectedDraftSettlement { - const { verdict, kind } = classifyDispatchRejection(rejected) - if (verdict !== null) { - return { state: 'returned' } - } - const cutShort = kind === 'hostRestarted' || kind === 'chatClosed' - return { state: 'waiting', kept: cutShort && rejected.origin === 'client' } + return { state: classifyDispatchRejection(rejected).verdict !== null ? 'returned' : 'waiting' } } /** True when committing this row NEWLY settles the submission to `rejected` — diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 6337b00417bf..36247e2571ff 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -68,7 +68,7 @@ function replace(input: { reason: 'legacy_import', fence: 1, items: input.items, - queuePause: { lifted: false, liveStop: null }, + queuePause: { lifted: false, liveStop: null, reopened: false }, now, mintEpoch: () => `epoch-${clock}`, onPublished: input.onPublished ?? (() => undefined) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index da53b2e9a11d..dd56f2ac0f75 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -16,6 +16,7 @@ import type { JournalLoad } from './journal-open' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' import { + buildJournalQueueReopenRow, buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' @@ -77,7 +78,7 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } - const { lifted, liveStop } = input.queuePause + const { lifted, liveStop, reopened } = input.queuePause const place = () => ({ state, seq: state.lastSequence + 1, fence: input.fence, ts: input.now() }) if (lifted) { const row = buildJournalQueueResumeRow(place()) @@ -89,6 +90,11 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } + if (reopened) { + const row = buildJournalQueueReopenRow(place()) + applyJournalRow(state, row) + rows.push(row) + } const { sessionId } = input.identity input.database.transaction((db) => { diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index e87de366fff2..aaea0228b6e9 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -28,14 +28,21 @@ export class JournalHostDatabase { /** A failed transaction's ROLLBACK failed too, so the transaction may still be open. */ private stranded = false - private constructor(opened: OpenJournalDatabase) { + private constructor( + opened: OpenJournalDatabase, + /** Where the database lives; per-runtime end records sit beside it. */ + readonly stateDirectory: string + ) { this.connection = opened.db this.readOnly = opened.readOnly } static open(stateDirectory: string): JournalHostDatabase { mkdirSync(stateDirectory, { recursive: true }) - return new JournalHostDatabase(openJournalDatabase(journalDatabasePath(stateDirectory))) + return new JournalHostDatabase( + openJournalDatabase(journalDatabasePath(stateDirectory)), + stateDirectory + ) } get isClosed(): boolean { diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.test.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.test.ts new file mode 100644 index 000000000000..b26fabac98d0 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + journalRowSchemaVersion +} from '../../../shared/agent-session-journal-types' +import { partitionJournalLifecycleMutations } from './journal-lifecycle-batch-partition' +import { createJournalReducerState } from './journal-reducer' +import { + journalLifecycleBatchRowBuilder, + journalLifecycleMutationRow, + type JournalLifecycleMutationInput +} from './journal-row-builders' +import { + MAX_JOURNAL_LIFECYCLE_BATCH_BYTES, + MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS, + parseJournalRow, + serializeJournalRow, + type JournalLifecycleBatchRow +} from './journal-row-schema' + +const EPOCH = '00000000-0000-4000-8000-000000000000' + +function item( + index: number, + text: string +): Extract { + return { + kind: 'item', + identity: { provider: 'orca', clientMessageId: `item:${index}` }, + body: { kind: 'tool-call', name: 'shell', input: { command: text }, state: 'failed' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } +} + +// Frozen size probe from the previous partitioner, including Stop's larger turn outcome. +function previousProbe(settlementId: string, mutations: readonly JournalLifecycleMutationInput[]) { + const row: JournalLifecycleBatchRow = { + v: journalRowSchemaVersion( + mutations.flatMap((mutation) => (mutation.kind === 'item' ? [mutation.body] : [])) + ), + kind: 'lifecycle-batch', + epoch: EPOCH, + seq: Number.MAX_SAFE_INTEGER, + fence: Number.MAX_SAFE_INTEGER, + ts: Number.MAX_SAFE_INTEGER, + settlementId, + mutations: mutations.map((mutation) => + journalLifecycleMutationRow( + mutation.kind === 'item' && + mutation.body.kind === 'turn' && + mutation.body.state === 'interrupted' && + mutation.body.outcome === undefined + ? { ...mutation, body: { ...mutation.body, outcome: 'cancellation' } } + : mutation, + agentJournalItemKey(mutation.identity), + Number.MAX_SAFE_INTEGER + ) + ) + } + return Buffer.byteLength(JSON.stringify(row), 'utf8') + 1 +} + +function previousPartition( + settlementId: string, + mutations: readonly JournalLifecycleMutationInput[] +) { + const chunks: JournalLifecycleMutationInput[][] = [] + const probeId = `${settlementId}:${mutations.length}/${mutations.length}` + let pending: JournalLifecycleMutationInput[] = [] + for (const mutation of mutations) { + const candidate = [...pending, mutation] + if ( + pending.length > 0 && + previousProbe(probeId, candidate) > MAX_JOURNAL_LIFECYCLE_BATCH_BYTES + ) { + chunks.push(pending) + pending = [mutation] + } else { + pending = candidate + } + if (pending.length === MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS) { + chunks.push(pending) + pending = [] + } + } + if (pending.length > 0) { + chunks.push(pending) + } + return chunks.map((mutations, index) => ({ + settlementId: + chunks.length === 1 ? settlementId : `${settlementId}:${index + 1}/${chunks.length}`, + mutations + })) +} + +afterEach(() => vi.restoreAllMocks()) + +describe('journal lifecycle batch partitioning', () => { + it('leaves empty and singleton batches uninspected, including an oversized singleton', () => { + const oversized = item(0, 'x'.repeat(MAX_JOURNAL_LIFECYCLE_BATCH_BYTES)) + const stringify = vi.spyOn(JSON, 'stringify') + expect(partitionJournalLifecycleMutations('one', [])).toEqual([]) + expect(partitionJournalLifecycleMutations('one', [oversized])).toEqual([ + { settlementId: 'one', mutations: [oversized] } + ]) + expect(stringify).not.toHaveBeenCalled() + }) + + it('preserves the mutation cap and the oversized leading mutation rule', () => { + const small = Array.from({ length: 401 }, (_, index) => item(index, 'small')) + expect( + partitionJournalLifecycleMutations('count', small).map((chunk) => chunk.mutations.length) + ).toEqual([200, 200, 1]) + const oversized = [item(0, 'x'.repeat(MAX_JOURNAL_LIFECYCLE_BATCH_BYTES)), item(1, 'small')] + expect(partitionJournalLifecycleMutations('large', oversized)).toEqual( + previousPartition('large', oversized) + ) + }) + + it('keeps exact UTF-8 byte-boundary admission through the production row builder', () => { + const settlementId = 'boundary:"\\😀' + const mutations = [item(0, ''), item(1, '')] + const available = + MAX_JOURNAL_LIFECYCLE_BATCH_BYTES - previousProbe(`${settlementId}:2/2`, mutations) + const atLimit = [item(0, 'x'.repeat(available)), item(1, '')] + const overLimit = [item(0, 'x'.repeat(available + 1)), item(1, '')] + expect(partitionJournalLifecycleMutations(settlementId, atLimit)).toHaveLength(1) + expect(partitionJournalLifecycleMutations(settlementId, overLimit)).toHaveLength(2) + const state = createJournalReducerState('session', EPOCH) + for (const mutations of [atLimit, overLimit]) { + for (const chunk of partitionJournalLifecycleMutations(settlementId, mutations)) { + const row = journalLifecycleBatchRowBuilder( + () => state, + chunk.settlementId, + chunk.mutations, + { + fence: 1, + recovered: true + } + )(1, 1) + const serialized = serializeJournalRow(row) + expect(Buffer.byteLength(serialized)).toBeLessThanOrEqual(MAX_JOURNAL_LIFECYCLE_BATCH_BYTES) + expect(parseJournalRow(serialized).ok).toBe(true) + } + } + }) + + it('matches the previous chunks for seeded mixed mutations, linkage and JSON escaping', () => { + let seed = 82413 + const random = (limit: number): number => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed % limit + } + const symbols = ['x', '"\\\n\t', '界😀', '\uD800', '\0\r\u001f'] + for (let fixture = 0; fixture < 48; fixture++) { + const text = symbols[random(symbols.length)].repeat(random(8_000)) + const mutations = Array.from( + { length: random(230) }, + (_, index): JournalLifecycleMutationInput => { + const identity = { provider: 'orca' as const, clientMessageId: `item:"\\界😀:${index}` } + if (index % 23 === 0) { + return { kind: 'tombstone', identity } + } + if (index % 19 === 0) { + return { + kind: 'item', + identity, + body: { kind: 'turn', turnId: `turn:${index}`, state: 'interrupted', startedAt: 1 }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + } + return { + ...item(index, text), + identity, + linkage: { + agentId: 'child', + parentAgentId: 'parent', + providerParentRef: 'call:"\\界😀', + producerKind: 'background', + attempt: 2 + } + } + } + ) + const settlementId = `settle:"\\\n😀:${'q'.repeat(random(2_000))}` + const actual = partitionJournalLifecycleMutations(settlementId, mutations) + expect(actual).toEqual(previousPartition(settlementId, mutations)) + const flattened = actual.flatMap((chunk) => chunk.mutations) + expect(flattened).toHaveLength(mutations.length) + for (let index = 0; index < flattened.length; index++) { + expect(flattened[index]).toBe(mutations[index]) + } + } + }) + + it('keeps serialization work proportional to the settled payload', () => { + const mutations = Array.from({ length: 128 }, (_, index) => item(index, 'x'.repeat(16 * 1024))) + const payloadBytes = Buffer.byteLength(JSON.stringify(mutations)) + const stringify = JSON.stringify + let serializedBytes = 0 + vi.spyOn(JSON, 'stringify').mockImplementation((value) => { + const encoded = stringify(value) + if (encoded !== undefined) { + serializedBytes += Buffer.byteLength(encoded) + } + return encoded + }) + const chunks = partitionJournalLifecycleMutations('linear', mutations) + vi.restoreAllMocks() + expect(chunks.flatMap((chunk) => chunk.mutations)).toEqual(mutations) + expect(serializedBytes).toBeLessThan(payloadBytes * 3) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts index 2074e4d77c13..f4403e9ed27f 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts @@ -22,20 +22,45 @@ export function partitionJournalLifecycleMutations( if (mutations.length === 0) { return [] } + if (mutations.length === 1) { + return [{ settlementId, mutations: [mutations[0]] }] + } const chunks: JournalLifecycleMutationInput[][] = [] const probeId = chunkSettlementId(settlementId, mutations.length - 1, mutations.length) let pending: JournalLifecycleMutationInput[] = [] + let pendingBytes = 0 + let pendingVersion = journalRowSchemaVersion([]) + const overheadByVersion = new Map() + const overhead = (version: number): number => { + let bytes = overheadByVersion.get(version) + if (bytes === undefined) { + bytes = serializedLifecycleBatchOverhead(probeId, version) + overheadByVersion.set(version, bytes) + } + return bytes + } for (const mutation of mutations) { - const candidate = [...pending, mutation] - if (pending.length > 0 && !serializedLifecycleBatchFits(probeId, candidate)) { + const bytes = Buffer.byteLength(JSON.stringify(toLifecycleMutationRow(mutation)), 'utf8') + const version = journalRowSchemaVersion(mutation.kind === 'item' ? [mutation.body] : []) + const candidateVersion = Math.max(pendingVersion, version) + const candidateBytes = pendingBytes + bytes + (pending.length > 0 ? 1 : 0) + if ( + pending.length > 0 && + candidateBytes + overhead(candidateVersion) > MAX_JOURNAL_LIFECYCLE_BATCH_BYTES + ) { chunks.push(pending) - pending = [mutation] - } else { - pending = candidate + pending = [] + pendingBytes = 0 + pendingVersion = journalRowSchemaVersion([]) } + pendingBytes += bytes + (pending.length > 0 ? 1 : 0) + pendingVersion = Math.max(pendingVersion, version) + pending.push(mutation) if (pending.length === MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS) { chunks.push(pending) pending = [] + pendingBytes = 0 + pendingVersion = journalRowSchemaVersion([]) } } if (pending.length > 0) { @@ -52,23 +77,18 @@ function chunkSettlementId(settlementId: string, index: number, total: number): return `${settlementId}:${index + 1}/${total}` } -function serializedLifecycleBatchFits( - settlementId: string, - mutations: readonly JournalLifecycleMutationInput[] -): boolean { +function serializedLifecycleBatchOverhead(settlementId: string, version: number): number { const row: JournalLifecycleBatchRow = { - v: journalRowSchemaVersion( - mutations.flatMap((mutation) => (mutation.kind === 'item' ? [mutation.body] : [])) - ), + v: version, kind: 'lifecycle-batch', epoch: '00000000-0000-4000-8000-000000000000', seq: Number.MAX_SAFE_INTEGER, fence: Number.MAX_SAFE_INTEGER, ts: Number.MAX_SAFE_INTEGER, settlementId, - mutations: mutations.map(toLifecycleMutationRow) + mutations: [] } - return Buffer.byteLength(JSON.stringify(row), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES + return Buffer.byteLength(JSON.stringify(row), 'utf8') + 1 } /** Sized as a Stop may write it (`turnEndAfterStop`), so the chunk built from it still fits. */ diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts index d8c5c1737b74..6e226a4c6cff 100644 --- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts @@ -3,7 +3,8 @@ import type { AgentJournalItemBody, AgentJournalPromptOption, AgentJournalQuestion, - AgentJournalQuestionItem + AgentJournalQuestionItem, + AgentJournalFreeTextInput } from '../../../shared/agent-session-journal-types' import { isPlanApprovalSubject } from '../../../shared/agent-session-approval-subject' import { @@ -97,7 +98,8 @@ export function boundJournalPromptBody( : {}), ...(body.freeTextQuestionId ? { freeTextQuestionId: boundPromptIdentifier(body.freeTextQuestionId) } - : {}) + : {}), + ...(body.freeTextInput ? { freeTextInput: boundFreeTextInput(body.freeTextInput) } : {}) } } @@ -110,7 +112,19 @@ function boundPromptQuestion(question: AgentJournalQuestion): AgentJournalQuesti options: boundPromptOptions(question.options), ...(question.freeTextQuestionId ? { freeTextQuestionId: boundPromptIdentifier(question.freeTextQuestionId) } - : {}) + : {}), + ...(question.freeTextInput ? { freeTextInput: boundFreeTextInput(question.freeTextInput) } : {}) + } +} + +function boundFreeTextInput(input: AgentJournalFreeTextInput): AgentJournalFreeTextInput { + return { + ...(input.allowEmpty === undefined ? {} : { allowEmpty: input.allowEmpty }), + ...(input.multiline === undefined ? {} : { multiline: input.multiline }), + ...(input.initialValue === undefined + ? {} + : { initialValue: boundPromptText(input.initialValue) }), + ...(input.placeholder === undefined ? {} : { placeholder: boundPromptText(input.placeholder) }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index bb6eb2ef4d78..5b0e2580c45e 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -5,7 +5,10 @@ // working status, teardown, or the idle sweep. import type Database from '../../sqlite/sync-database' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalCursor, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, AGENT_SESSION_OPERATION_FUTURE_SKEW_MS @@ -13,9 +16,9 @@ import { import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' -import type { JournalOperationReceipt, JournalRowTransactionHook } from './journal-row-writer' +import type { JournalOperationReceipt } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' -import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' +import { holdQueuedMessages } from './queued-message-holds' import { deriveQueuePauses, journalUserStopInForce, @@ -42,6 +45,7 @@ import { settleQueuedMessagesForRow } from './queued-message-settlement' import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards' +import type { JournalAttachmentClaim } from './journal-submission-hook' import type { JournalWriteBody, JournalWriteResult } from './journal-write-queue' /** Tombstones must outlive the window in which their operation id could still be admitted as new. */ @@ -55,14 +59,15 @@ export type JournalQueuedMessagesDeps = { database: () => JournalHostDatabase readOnly: () => boolean state: () => JournalReducerState - /** Whether this handle found the row at `sequence` on disk when it opened. */ - wroteBeforeOpen: (sequence: number) => boolean + /** Where the reopen's pause begins when this handle could not mark it (`reopenFloor`). */ + reopenFloor: () => AgentJournalCursor | null /** The journal's own commit notification. Every standalone draft-table * transaction that changed rows fires it after COMMIT, so a draft or hold * change publishes and wakes the drain through the same path a journal row * does — no call site can forget. In-transaction consume and the returned * transition already ride their row's own commit. */ committed: () => void + claimAttachments: JournalAttachmentClaim } export class JournalQueuedMessages { @@ -107,8 +112,10 @@ export class JournalQueuedMessages { } /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused; - * `holdReason` carries a hold of its own over with it. `receipt`: the send's ledger answer, - * committed with the draft only when this inserts it. */ + * `holdReason` carries a hold of its own over with it. + * `requireAttachments`: a client's own draft, refused whole when an attachment it names is no + * longer stored; the host's own writes (the carry) claim best effort. + * `receipt`: the send's ledger answer, committed with the draft only when this inserts it. */ insert( input: { messageId: string @@ -116,11 +123,13 @@ export class JournalQueuedMessages { fingerprint: string hostInstance: string carriedFrom?: string + requireAttachments?: true holdReason?: QueuedMessageHoldReason }, receipt?: JournalOperationReceipt ): Promise { const { sessionId } = this.deps + const { requireAttachments, ...draft } = input let inserted = false return this.transact( (db) => { @@ -130,10 +139,11 @@ export class JournalQueuedMessages { // gets here, so an existing row is the same accept landing twice. return existing } + this.deps.claimAttachments(db, input.body, requireAttachments === true) inserted = true const { epoch, lastSequence } = this.deps.state() const row = insertQueuedMessage(db, { - ...input, + ...draft, sessionId, queuedAt: { epoch, sequence: lastSequence }, now: this.deps.now() @@ -157,8 +167,21 @@ export class JournalQueuedMessages { } /** The queue's pauses in force, derived from the fold and the cards (`queued-message-pause.ts`). */ - pauses(hostInstance: string): DerivedQueuePause[] { - return this.derivePauses(this.list(), hostInstance) + pauses(): DerivedQueuePause[] { + return this.derivePauses(this.list()) + } + + /** A card waits, or is mid-hand-off and may come back to waiting: a chat that stops running + * marks it (`AgentSessionJournal.markQueueReopen`). */ + awaitReopenMark(): boolean { + const { submissions } = this.deps.state() + return this.list().some((row) => { + if (row.state === 'waiting') { + return true + } + const handOff = row.consumedAs ? submissions.get(row.consumedAs)?.dispatchState : undefined + return row.state === 'dispatched' && (handOff === 'pending' || handOff === 'unknown') + }) } /** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ @@ -167,37 +190,17 @@ export class JournalQueuedMessages { return journalUserStopInForce(state.queuePauseMarks, state.latestAcceptedTurnSequence) } - private derivePauses( - cards: readonly QueuedMessageRow[], - hostInstance: string - ): DerivedQueuePause[] { + private derivePauses(cards: readonly QueuedMessageRow[]): DerivedQueuePause[] { const state = this.deps.state() return deriveQueuePauses({ epoch: state.epoch, marks: state.queuePauseMarks, latestAcceptedTurnSequence: state.latestAcceptedTurnSequence, cards, - hostInstance, - restartEnded: this.restartEnded() + reopenFloor: this.deps.reopenFloor() }) } - /** A turn started since this handle opened, which ends a restart's pause. */ - restartEnded(): boolean { - const latest = this.deps.state().latestAcceptedTurnSequence - return latest > 0 && !this.deps.wroteBeforeOpen(latest) - } - - /** Adopts waiting rows another host instance wrote into this one, ending a restart's pause. - * Returns whether anything changed. */ - adopt(hostInstance: string): Promise { - const { sessionId } = this.deps - return this.transact( - (db) => adoptQueuedMessages(db, { sessionId, hostInstance }), - (changed) => changed > 0 - ).then((changed) => changed > 0) - } - /** Inside the caller's journal-row transaction (`journal-unsent-send-hold.ts`): one kept send * becomes a card, and the cards ahead of the queue take the positions given. False when a card * by that id already exists, which then stands. */ @@ -286,7 +289,7 @@ export class JournalQueuedMessages { // pick and this claim (both run on the session's serialized lane, held across the send), so // this guards any pause-relevant row written off that lane from overtaking a held card. const cards = listQueuedMessages(db, this.deps.sessionId) - const pauses = this.derivePauses(cards, input.yieldsToPause.hostInstance) + const pauses = this.derivePauses(cards) if (nextSendableQueuedCard(pauses, cards)?.messageId !== input.messageId) { throw new QueuedMessageNotConsumableError(input.messageId, input.expect) } @@ -367,15 +370,6 @@ export class JournalQueuedMessages { } } -/** The per-append hook converting one draft inside the append's own transaction. */ -export function queuedMessageConsumeHook( - queuedMessages: JournalQueuedMessages, - consumedAs: string, - consume: JournalSubmissionConsume -): JournalRowTransactionHook { - return (db) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }) -} - export class QueuedMessageNotConsumableError extends Error { constructor( readonly messageId: string, diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index f1484bf5da8a..d4f7e543df53 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -77,7 +77,11 @@ function unseenHistory( } return { ...history, - items: history.items.filter((item) => !committed.has(agentJournalItemKey(item.identity))) + // An item with no identity of its own cannot be one the journal committed under it; the + // adapter bounds its window to after the journal's last accepted send instead. + items: history.items.filter( + (item) => item.identity === undefined || !committed.has(agentJournalItemKey(item.identity)) + ) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index d2a95a6db724..1d226761ae25 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -95,6 +95,12 @@ export type JournalTombstoneRow = JournalRowBase & { stopEvent?: JournalStopEvent /** Present: not a removal but a person's Resume of the queue, on an id no item ever takes. */ queueResume?: true + /** Present: not a removal but a reopen that found waiting cards (`queued-message-pause.ts`), on + * an id no item ever takes. */ + queueReopen?: true + /** On a reopen mark written after the chat stopped: where it stopped, so a send accepted since + * lifts it. Absent: the mark's own row. */ + queueReopenSince?: number } /** One Stop that took effect. Temporary carrier: a tombstone's extra key, which every host ignores, @@ -112,16 +118,21 @@ export type JournalStopEvent = { caller?: string } -/** A tombstone that carries a Stop event or a Resume mark instead of removing an item. */ +/** A tombstone that carries a Stop event, a Resume or a reopen mark instead of removing an item. */ export type JournalStopOrResumeRow = JournalTombstoneRow & ( | { stopEvent: NonNullable } | { queueResume: NonNullable } + | { queueReopen: NonNullable } ) -/** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ +/** A Stop's event, a Resume or a reopen mark. Any value counts, so a newer build's mark never + * removes an item. */ export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrResumeRow { - return row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) + return ( + row.kind === 'tombstone' && + (row.stopEvent !== undefined || row.queueResume !== undefined || row.queueReopen !== undefined) + ) } /** The write-ahead row. Durable BEFORE the adapter dispatches anything; it diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts index e763af8b2e6c..c4ddb98bc398 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts @@ -1,12 +1,13 @@ -// The rows a Stop's event and a person's Resume append: tombstones of ids no item ever takes, -// each carrying its record as an extra key (`journal-row-schema.ts` says why not a row kind). +// The rows a Stop's event, a person's Resume and a reopen that found waiting cards append: +// tombstones of ids no item ever takes, each carrying its record as an extra key +// (`journal-row-schema.ts` says why not a row kind). import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { JournalReducerState } from './journal-reducer' import { journalRowBase } from './journal-row-builders' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' -/** One id per mark kind; no item ever takes either. */ +/** One id per mark kind; no item ever takes any of them. */ const JOURNAL_STOP_EVENT_ITEM_ID = agentJournalItemKey({ provider: 'orca', clientMessageId: 'stop-event' @@ -15,6 +16,10 @@ const JOURNAL_QUEUE_RESUME_ITEM_ID = agentJournalItemKey({ provider: 'orca', clientMessageId: 'queue-resume' }) +const JOURNAL_QUEUE_REOPEN_ITEM_ID = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'queue-reopen' +}) type RowPlace = { state: JournalReducerState; seq: number; fence: number; ts: number } @@ -40,6 +45,19 @@ export function buildJournalQueueResumeRow(input: RowPlace): JournalTombstoneRow } } +export function buildJournalQueueReopenRow( + input: RowPlace & { since?: number } +): JournalTombstoneRow { + return { + kind: 'tombstone', + itemId: JOURNAL_QUEUE_REOPEN_ITEM_ID, + revision: 1, + queueReopen: true, + ...(input.since !== undefined ? { queueReopenSince: input.since } : {}), + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + /** A Stop taking effect now: its event's time is its row's. */ export function journalStopEventRowBuilder( state: () => JournalReducerState, @@ -56,3 +74,12 @@ export function journalQueueResumeRowBuilder( ): (seq: number, ts: number) => JournalTombstoneRow { return (seq, ts) => buildJournalQueueResumeRow({ state: state(), seq, fence, ts }) } + +export function journalQueueReopenRowBuilder( + state: () => JournalReducerState, + fence: number, + since?: number +): (seq: number, ts: number) => JournalTombstoneRow { + return (seq, ts) => + buildJournalQueueReopenRow({ state: state(), seq, fence, ts, ...(since ? { since } : {}) }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 600d673b6d81..5acba78506a4 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -23,6 +23,8 @@ import { JournalSubmissionWriter } from './journal-submission-writer' import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' import type { JournalWriteBody } from './journal-write-queue' +import type { JournalAttachmentClaim } from './journal-submission-hook' +import { claimAgentSessionAttachmentsInTransaction } from '../agent-session-attachments/agent-session-attachment-claims' export type JournalStoreHost = { /** Fires the journal's commit listener for a durable change that appended no @@ -69,11 +71,21 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal queuePauseRestatement: () => journalQueuePauseRestatement( host.state().queuePauseMarks, - host.state().latestAcceptedTurnSequence + host.state().latestAcceptedTurnSequence, + host.journal().queuedMessages.pauses() ), cursor: host.cursor, adopt: host.adopt }) + const claimAttachments: JournalAttachmentClaim = (db, body, required) => + claimAgentSessionAttachmentsInTransaction(db, { + // Uploaded attachments are stored beside the journal database. + stateDirectory: host.database().stateDirectory, + sessionId: host.identity.sessionId, + body, + required, + now: host.now() + }) const queuedMessages = new JournalQueuedMessages({ sessionId: host.identity.sessionId, now: host.now, @@ -81,8 +93,9 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal database: host.database, readOnly: host.readOnly, state: host.state, - wroteBeforeOpen: (sequence) => host.journal().wroteBeforeOpen(sequence), - committed: host.notifyCommitted + reopenFloor: () => host.journal().reopenFloor(), + committed: host.notifyCommitted, + claimAttachments }) const rowWriter = new JournalRowWriter({ sessionId: host.identity.sessionId, @@ -119,7 +132,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal state: host.state, identity: host.identity, rowWriter, - queuedMessages + queuedMessages, + claimAttachments }), itemAppender: new JournalItemAppender({ state: host.state, diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 7492ea7e461c..41b892adf348 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -112,9 +112,9 @@ export type JournalSubmissionConsume = { /** The host process handing it off, stamped on the draft so a hand-off withdrawn back to * waiting belongs to the process that sent it, not the one that first wrote the card. */ hostInstance?: string - /** The queue's own send: refused in the consume's transaction while the queue's pause, as - * this host instance derives it, holds the card. Send-now omits it. */ - yieldsToPause?: { hostInstance: string } + /** The queue's own send: refused in the consume's transaction while the queue's pause holds + * the card. Send-now omits it. */ + yieldsToPause?: true } export type JournalItemAppendInput = { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 7548f6e5502e..06bdfba25fea 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -57,6 +57,7 @@ import type { import type { JournalQueuedMessages } from './journal-queued-messages' import { journalQueueResumeRowBuilder, + journalQueueReopenRowBuilder, journalStopEventRowBuilder } from './journal-stop-and-resume-rows' import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' @@ -84,6 +85,7 @@ export class AgentSessionJournal { private state: JournalReducerState private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 } + private reopenUnmarked: AgentJournalCursor | null = null private onCommitted: (() => void) | null = null private readonly queue: JournalWriteQueue private readonly rowWriter: JournalRowWriter @@ -156,6 +158,12 @@ export class AgentSessionJournal { ) } + /** Where the reopen's pause begins when this handle could not write its mark: where the mark + * would have gone. Null once a mark is written. Per handle, so the next open marks again. */ + reopenFloor(): AgentJournalCursor | null { + return this.reopenUnmarked + } + async open(): Promise { await this.restore() this.openedThrough = this.cursor() @@ -292,6 +300,23 @@ export class AgentSessionJournal { return this.rowWriter.append(journalQueueResumeRowBuilder(() => this.state, fence)) } + /** This open found waiting cards an earlier handle wrote (`queued-message-pause.ts`). */ + appendQueueReopen(fence: number, since?: number): Promise { + return this.rowWriter.append(journalQueueReopenRowBuilder(() => this.state, fence, since)) + } + + /** Marks the reopen when a card waits or is mid-hand-off (it may come back to waiting), from + * `since` when the chat stopped before now; a failed write leaves where the mark would have gone + * as the pause's start (`reopenFloor`), and throws. */ + async markQueueReopen(fence: number, since?: number): Promise { + if (this.queuedMessages.awaitReopenMark()) { + const sequence = since ?? this.state.lastSequence + 1 + this.reopenUnmarked = { epoch: this.state.epoch, sequence } + await this.appendQueueReopen(fence, since) + this.reopenUnmarked = null + } + } + appendLifecycleBatch(input: JournalLifecycleBatchInput): Promise { return this.lifecycleBatchAppender.append(input) } diff --git a/src/main/native-chat/agent-session-journal/journal-submission-hook.ts b/src/main/native-chat/agent-session-journal/journal-submission-hook.ts new file mode 100644 index 000000000000..84be3ab91c98 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-hook.ts @@ -0,0 +1,30 @@ +import type Database from '../../sqlite/sync-database' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { JournalQueuedMessages } from './journal-queued-messages' +import type { JournalRowTransactionHook } from './journal-row-writer' +import type { JournalSubmissionConsume, JournalSubmissionInput } from './journal-store-contracts' + +/** Claims the stored chat attachments a message written to this journal names, in the write's own + * transaction; `required` refuses the whole write when one is no longer stored. */ +export type JournalAttachmentClaim = ( + db: Database.Database, + body: AgentJournalMessageItem, + required: boolean +) => void + +/** The submission append's hook, inside its transaction: converts the draft it hands off, if any, + * and claims the stored attachments it names. A client's new message must name only attachments + * still stored; a draft's conversion was claimed when the draft was written. */ +export function journalSubmissionHook( + queuedMessages: JournalQueuedMessages, + claimAttachments: JournalAttachmentClaim, + input: Pick, + consume: JournalSubmissionConsume | undefined +): JournalRowTransactionHook { + return (db) => { + if (consume) { + queuedMessages.consumeInTransaction(db, { ...consume, consumedAs: input.clientMessageId }) + } + claimAttachments(db, input.body, input.origin === 'client' && !consume) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts index 4647ba1e0e37..91522f76efef 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -30,7 +30,9 @@ export type ProviderHistoryItem = { /** Fingerprint of the submitted payload, when the caller can compute one from * provider content. Used only to break an otherwise unique tie. */ payloadFingerprint: string | null - identity: AgentJournalItemIdentity + /** Absent when the provider keeps no identity of its own for the item: a submission it matches + * keeps Orca's (`orca:`), which is the identity its row already has. */ + identity?: AgentJournalItemIdentity } export type ProviderHistoryWindow = { @@ -90,6 +92,7 @@ export function reconcileSubmissions(input: { // A submission that already adopted a key re-matches on that key, not on the // provider's raw id — the raw id renumbers, the identity-derived key does not. Boolean(submission.providerItemId) && + item.identity !== undefined && submission.providerItemId === agentJournalItemKey(item.identity) ) claimBy( @@ -169,7 +172,7 @@ function resolveOne( clientMessageId: submission.clientMessageId, outcome: 'accepted', providerItemId: item.providerItemId, - identity: item.identity + identity: item.identity ?? { provider: 'orca', clientMessageId: submission.clientMessageId } } } if (ambiguous.has(submission.clientMessageId)) { diff --git a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts index c79fa6030315..1fc0ac21d0da 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts @@ -5,7 +5,8 @@ import type { AgentJournalCursor, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' +import type { JournalQueuedMessages } from './journal-queued-messages' +import { journalSubmissionHook, type JournalAttachmentClaim } from './journal-submission-hook' import type { JournalReducerState } from './journal-reducer' import { journalDispatchRowBuilder, journalSubmissionRowBuilder } from './journal-row-builders' import type { @@ -24,6 +25,7 @@ export type JournalSubmissionWriterDeps = { identity: AgentSessionJournalIdentity rowWriter: JournalRowWriter queuedMessages: JournalQueuedMessages + claimAttachments: JournalAttachmentClaim } export class JournalSubmissionWriter { @@ -42,10 +44,10 @@ export class JournalSubmissionWriter { /** The send's ledger answer, committed with this row. */ receipt?: JournalOperationReceipt ): Promise { - const { identity, queuedMessages, rowWriter, state } = this.deps + const { claimAttachments, identity, queuedMessages, rowWriter, state } = this.deps return rowWriter.append( journalSubmissionRowBuilder(state, identity, input, consume), - consume && queuedMessageConsumeHook(queuedMessages, input.clientMessageId, consume), + journalSubmissionHook(queuedMessages, claimAttachments, input, consume), receipt ) } diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts index 293519d3680f..29fb7eb35fa2 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts @@ -17,7 +17,6 @@ import { } from '../../../shared/agent-session-message-source' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { structuredAgentSessionCompactBody } from '../agent-session-wire/structured-agent-session-command-turn' @@ -180,7 +179,7 @@ describe('which sends an earlier host process left unsent are kept', () => { }) expect(reopened.queuedMessages.get(id)).toMatchObject({ state: 'waiting', - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + holdReason: null, hostInstance: HOST, body: message(`text of ${id}`), fingerprint: fingerprint(message(`text of ${id}`)), @@ -229,14 +228,13 @@ describe('which sends an earlier host process left unsent are kept', () => { } }) - // Only a Send the person asked for comes back kept; the queue's own hand-off waits under the - // restart's pause, where it stood. + // Whoever sent it, the card comes back with no hold of its own, to wait under the reopen's pause. it.each([ - { by: 'Send now', origin: 'client' as const, holdReason: QUEUED_MESSAGE_PAUSED_KEPT }, - { by: 'the queue', origin: 'host' as const, holdReason: null } + { by: 'Send now', origin: 'client' as const }, + { by: 'the queue', origin: 'host' as const } ])( 'a card’s own hand-off by $by returns its card, and makes no second one', - async ({ origin, holdReason }) => { + async ({ origin }) => { const journal = await afterRestart(async (earlier) => { await earlier.queuedMessages.insert({ messageId: 'card', @@ -262,7 +260,7 @@ describe('which sends an earlier host process left unsent are kept', () => { expect(journal.queuedMessages.get('card')).toMatchObject({ state: 'waiting', consumedAs: null, - holdReason + holdReason: null }) expect(journal.queuedMessages.get('handoff')).toBeNull() } @@ -383,7 +381,14 @@ describe('where kept sends go in the queue', () => { }) // That run kept A, at a stale place behind C, then died before B. await interrupted.resolveDispatch( - { clientMessageId: 'A', state: 'rejected', ...HOST_RESTARTED, fence: 0, recovered: true }, + { + clientMessageId: 'A', + state: 'rejected', + ...HOST_RESTARTED, + fence: 0, + recovered: true, + keptAsQueuedMessageId: 'A' + }, (db) => { interrupted.queuedMessages.holdInTransaction(db, { card: { @@ -391,7 +396,6 @@ describe('where kept sends go in the queue', () => { body: message('text of A'), fingerprint: fingerprint(message('text of A')), hostInstance: HOST, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, queuedAt: { epoch: 'epoch-1', sequence: interrupted.submission('A')!.acceptedSequence! @@ -419,7 +423,7 @@ describe('a close of the chat', () => { expect(cardOrder(journal)).toEqual(['person']) expect(journal.queuedMessages.get('person')).toMatchObject({ - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + holdReason: null, hostInstance: HOST }) for (const id of ['person', 'mail']) { diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index 9341fbce6e11..fd5a774c7bf2 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -1,9 +1,10 @@ // What becomes of a send the host accepted and can no longer hand over: an earlier host process // quit or crashed first (settled at the next open), or the chat is closing. Either way the agent // provably never got it. A person's message (typed, or a launch's first prompt) is kept as a card -// at the head of the queue, held until the person sends, edits or deletes it (`kept`). Everything -// else is rejected as before, because something else re-derives it or the person re-runs it. The -// submission itself is always rejected, so it is never handed over twice. +// at the head of the queue, an ordinary one: like every card the chat closed with, it waits for the +// chat's next turn (`queued-message-pause.ts`). Everything else is rejected as before, because +// something else re-derives it or the person re-runs it. The submission itself is always rejected, +// so it is never handed over twice. import type { AgentJournalItemBody, @@ -12,7 +13,6 @@ import type { } from '../../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' @@ -75,7 +75,8 @@ export function isUnansweredHandedOverSubmission( * both. This batch's cards, and the card of a Send the person asked for, go to the head of the * queue in the order they were accepted, behind the cards an earlier settlement kept; the queue's * own hand-off returns its card where it stood. Throws once every row was - * tried when one of them could not be written at all: that row stays queued. + * tried when one of them could not be written at all: that row stays queued. Resolves where the + * newest send it settled was accepted, or null when it settled none. */ export async function holdUnsentSends( journal: AgentSessionJournal, @@ -87,7 +88,7 @@ export async function holdUnsentSends( * handed and never echoed. It ran none, so each is unsent as surely as a queued one. */ unrun?: true } -): Promise { +): Promise { const { hold } = input const unsent = journal .submissions() @@ -101,7 +102,7 @@ export async function holdUnsentSends( ) .sort((a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0)) if (unsent.length === 0) { - return + return null } const { epoch } = journal.cursor() const kept = unsent.map((submission) => ({ @@ -144,7 +145,6 @@ export async function holdUnsentSends( card.body ), hostInstance: input.hostInstance, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, queuedAt: { epoch, sequence: submission.acceptedSequence ?? 0 }, position: card.position } @@ -185,6 +185,7 @@ export async function holdUnsentSends( if (failures.length > 0) { throw new AggregateError(failures, 'settling unsent sends failed') } + return unsent.at(-1)?.acceptedSequence ?? 0 } /** Where each card of the batch goes: right before every other card, the cards an earlier @@ -196,8 +197,18 @@ function headOfQueuePositions( batch: readonly { submission: AgentJournalSubmission; body: AgentJournalMessageItem | null }[] ): { placed: QueuedMessagePositionMove[]; earlier: QueuedMessagePositionMove[] } { const cards = journal.queuedMessages.list() + const keptIds = new Set( + journal.submissions().flatMap((entry) => entry.keptAsQueuedMessageId ?? []) + ) + const { epoch } = journal.cursor() + // Kept by an earlier settlement: its rejection names it, or, past an epoch that dropped that + // row, it was queued in an earlier epoch, before anything this one accepted. const earlier = cards - .filter((card) => card.state === 'waiting' && card.holdReason === QUEUED_MESSAGE_PAUSED_KEPT) + .filter( + (card) => + card.state === 'waiting' && + (keptIds.has(card.messageId) || (card.queuedAt !== null && card.queuedAt.epoch !== epoch)) + ) .map((card) => card.messageId) const placed: QueuedMessagePositionMove[] = [] for (const { submission, body } of batch) { diff --git a/src/main/native-chat/agent-session-journal/queued-message-holds.ts b/src/main/native-chat/agent-session-journal/queued-message-holds.ts index 05867436e12e..c3f90590c117 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-holds.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-holds.ts @@ -1,7 +1,7 @@ // Per-draft holds: what keeps one card from auto-sending, stored on its row. A -// Stop or a restart pauses the queue instead (`queued-message-pause.ts`, derived); -// a per-draft hold is a conversion that failed, or a send the host kept -// (`QueuedMessageHoldReason`), which an explicit Send releases. +// Stop, a /clear or a reopen pauses the queue instead (`queued-message-pause.ts`, +// derived); a per-draft hold is a conversion that failed (`QueuedMessageHoldReason`), +// which an explicit Send releases. import type Database from '../../sqlite/sync-database' import type { QueuedMessageHoldReason } from './queued-message-table' @@ -27,23 +27,3 @@ export function holdQueuedMessages( } return held } - -/** Ends a restart's pause: waiting rows another host instance wrote are adopted - * into this one, the same fact the pause is derived from, so no second copy - * exists. Also clears a per-row 'stopped' hold an earlier build of the queue - * wrote, which this build only ever lifts. Returns how many rows it changed. */ -export function adoptQueuedMessages( - db: Database.Database, - input: { sessionId: string; hostInstance: string } -): number { - return Number( - db - .prepare( - `UPDATE queued_messages - SET host_instance = ?, hold_reason = CASE WHEN hold_reason = 'stopped' THEN NULL ELSE hold_reason END - WHERE session_id = ? AND state = 'waiting' - AND (host_instance <> ? OR hold_reason = 'stopped')` - ) - .run(input.hostInstance, input.sessionId, input.hostInstance).changes ?? 0 - ) -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 83570726945d..dabc1d64335d 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalMessageItem, AgentSessionJournalIdentity @@ -29,7 +29,6 @@ import { } from './queued-message-pause' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { @@ -97,12 +96,12 @@ function userStop(journal: AgentSessionJournal) { } function reason(journal: AgentSessionJournal): string | null { - return journal.queuedMessages.pauses(HOST)[0]?.reason ?? null + return journal.queuedMessages.pauses()[0]?.reason ?? null } /** Each card, and whether a pause in force holds it. */ function held(journal: AgentSessionJournal): [string, boolean][] { - const pauses = journal.queuedMessages.pauses(HOST) + const pauses = journal.queuedMessages.pauses() return journal.queuedMessages .list() .filter((card) => card.state === 'waiting') @@ -308,7 +307,7 @@ describe("the queue's pause, derived from the journal", () => { expect: 'waiting', settledByOp: null, hostInstance: HOST, - ...(automatic ? { yieldsToPause: { hostInstance: HOST } } : {}) + ...(automatic ? { yieldsToPause: true as const } : {}) } ) await expect(consume('drain-1', true)).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) @@ -390,7 +389,7 @@ describe("the queue's pause, derived from the journal", () => { await turn(journal, 'typed') await userStop(journal) await journal.replaceEpochItems('handle_forked', 0, []) - expect(journal.queuedMessages.pauses(HOST).map((pause) => pause.reason)).toEqual(['stopped']) + expect(journal.queuedMessages.pauses().map((pause) => pause.reason)).toEqual(['stopped']) await journal.appendQueueResume(0) expect(reason(journal)).toBeNull() }) @@ -460,7 +459,7 @@ describe('which cards a pause holds', () => { expect: 'waiting', settledByOp: null, hostInstance: HOST, - yieldsToPause: { hostInstance: HOST } + yieldsToPause: true } ) ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) @@ -480,89 +479,191 @@ describe('which cards a pause holds', () => { }) }) -describe("a restart's pause", () => { - it("adopting a restart's rows moves them into this instance and clears an older build's stored 'stopped' hold; send_failed stays", async () => { - const journal = await open() - await journal.queuedMessages.insert({ - messageId: 'draft-restart', - body: message('written before the restart'), - fingerprint: 'fp-draft-restart', - hostInstance: 'proc-0' - }) - expect(reason(journal)).toBe('restarted') - await queueDraft(journal, 'draft-legacy') - await queueDraft(journal, 'draft-failed') - await journal.queuedMessages.hold({ messageIds: ['draft-failed'], reason: 'send_failed' }) - const db = new Database(journalDatabasePath(root)) - db.prepare("UPDATE queued_messages SET hold_reason = 'stopped' WHERE message_id = ?").run( - 'draft-legacy' - ) - db.close() - journal.queuedMessages.invalidate() - expect(await journal.queuedMessages.adopt(HOST)).toBe(true) +describe("a reopen's pause", () => { + /** This handle closes and the next one opens and marks it, as a quit, a crash or a chat close + * leaves it; `failMark` makes the mark's write fail. */ + async function reopen( + journal: AgentSessionJournal, + failMark = false + ): Promise { + await journal.close() + const reopened = await open() + if (failMark) { + vi.spyOn(reopened, 'appendQueueReopen').mockRejectedValueOnce(new Error('disk full')) + await expect(reopened.markQueueReopen(0)).rejects.toThrow('disk full') + } else { + await reopened.markQueueReopen(0) + } + return reopened + } + + it('holds the cards queued before the reopen, never one queued after its mark', async () => { + let journal = await open() + await queueDraft(journal, 'before') expect(reason(journal)).toBeNull() - expect( - journal.queuedMessages.list().map((row) => [row.messageId, row.hostInstance, row.holdReason]) - ).toEqual([ - ['draft-restart', HOST, null], - ['draft-legacy', HOST, null], - ['draft-failed', HOST, 'send_failed'] + journal = await reopen(journal) + await queueDraft(journal, 'after') + expect(held(journal)).toEqual([ + ['before', true], + ['after', false] ]) + expect(reason(journal)).toBe('restarted') }) - it('adoption and Resume keep a kept card held: only its own Send, Edit or Delete releases it', async () => { - const journal = await open() - const kept = await journal.queuedMessages.insert({ - messageId: 'kept', - body: message('kept across a restart'), - fingerprint: 'fp-kept', - hostInstance: 'proc-0', - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }) - expect(kept.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) - expect(await journal.queuedMessages.adopt(HOST)).toBe(true) - expect(journal.queuedMessages.get('kept')).toMatchObject({ - hostInstance: HOST, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT + it.each([ + ['an accepted turn', (journal: AgentSessionJournal) => turn(journal, 'carry-on')], + ['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)] + ])('%s after the reopen lifts it', async (_name, lift) => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + await lift(journal) + expect(reason(journal)).toBeNull() + }) + + it('a turn the agent has not accepted yet lifts nothing', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + await turn(journal, 'carry-on', false) + expect(reason(journal)).toBe('restarted') + }) + + // A hand-off in flight may come back to waiting after the open: the open marks for it too. + it('marks a reopen whose only card is mid-hand-off, and holds it once it comes back', async () => { + let journal = await open() + await queueDraft(journal, 'in-flight') + await journal.appendSubmission( + { + clientMessageId: 'drain-1', + payloadFingerprint: 'fp-in-flight', + body: message('in-flight'), + fence: 0, + handoverRecorded: true + }, + { messageId: 'in-flight', expect: 'waiting', settledByOp: null, hostInstance: HOST } + ) + journal = await reopen(journal) + await journal.resolveDispatch({ + clientMessageId: 'drain-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), + fence: 0 }) + expect(held(journal)).toEqual([['in-flight', true]]) }) - it('an adoption with nothing to adopt changes nothing and fires no commit notification', async () => { + // Written after a later send was accepted, the mark starts where the chat stopped, so that + // send still lifts it; it never narrows a wider mark before it. + it('a mark written late starts where the chat stopped, and narrows no earlier mark', async () => { const journal = await open() - await queueDraft(journal, 'draft-1') - const revision = journal.queuedMessages.revision() - expect(await journal.queuedMessages.adopt(HOST)).toBe(false) - expect(journal.queuedMessages.revision()).toBe(revision) + await queueDraft(journal, 'kept') + const stopped = journal.cursor().sequence + 1 + await turn(journal, 'later') + await journal.markQueueReopen(0, stopped) + expect(reason(journal)).toBeNull() + + const reopened = await reopen(journal) + expect(reason(reopened)).toBe('restarted') + await reopened.markQueueReopen(0, stopped) + expect(reason(reopened)).toBe('restarted') + }) + + it('a reopen with no waiting card writes nothing', async () => { + let journal = await open() + await turn(journal, 'earlier') + const before = journal.cursor() + journal = await reopen(journal) + expect(journal.cursor()).toEqual(before) + await queueDraft(journal, 'after') + expect(held(journal)).toEqual([['after', false]]) + }) + + it('a second reopen before any turn still holds them; one after a turn holds them again', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + journal = await reopen(journal) + expect(reason(journal)).toBe('restarted') + await turn(journal, 'carry-on') + expect(reason(journal)).toBeNull() + // Still waiting (here, nothing drained it): the chat closed with it again, so it waits again. + journal = await reopen(journal) + expect(reason(journal)).toBe('restarted') + }) + + // The open could not write its mark: the open itself is the boundary, so nothing sends by + // itself; the next turn or Resume lifts it, and the next open marks again. + it('without a mark the open itself is where the pause begins', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal, true) + expect(held(journal)).toEqual([['before', true]]) + await turn(journal, 'carry-on') + expect(reason(journal)).toBeNull() + }) + + it("reads an earlier build's stored 'kept' and 'stopped' holds as none; send_failed stays", async () => { + let journal = await open() + for (const id of ['was-kept', 'was-stopped', 'failed']) { + await queueDraft(journal, id) + } + await journal.queuedMessages.hold({ messageIds: ['failed'], reason: 'send_failed' }) + const db = new Database(journalDatabasePath(root)) + for (const [id, stored] of [ + ['was-kept', 'kept'], + ['was-stopped', 'stopped'] + ]) { + db.prepare('UPDATE queued_messages SET hold_reason = ? WHERE message_id = ?').run(stored, id) + } + db.close() + journal = await reopen(journal) + expect(journal.queuedMessages.list().map((row) => [row.messageId, row.holdReason])).toEqual([ + ['was-kept', null], + ['was-stopped', null], + ['failed', 'send_failed'] + ]) + expect(held(journal)).toEqual([ + ['was-kept', true], + ['was-stopped', true], + ['failed', false] + ]) }) }) describe('which cards the pauses in force hold', () => { type Card = Parameters[1] & { messageId: string } - const DEAD = 'proc-0' function card(messageId: string, queuedAfter: number, fields: Partial = {}): Card { const queuedAt = { epoch: 'epoch-1', sequence: queuedAfter + 1 } - const base = { state: 'waiting', holdReason: null, hostInstance: HOST, carriedFrom: null } + const base = { state: 'waiting', holdReason: null, carriedFrom: null } return { messageId, ...base, queuedAt, ...fields } } - /** A Stop at sequence 5 unless `stopped` is 0; no turn or Resume since. */ - function pausesOver(cards: readonly Card[], stopped = 5) { + /** A Stop at sequence 5 unless `stopped` is 0; this handle opened at `opened` and could not mark + * it (0: nothing to mark); no turn, Resume or reopen mark since. */ + function pausesOver(cards: readonly Card[], stopped = 5, opened = 0) { return deriveQueuePauses({ epoch: 'epoch-1', marks: { latestStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, - resumedSequence: 0 + resumedSequence: 0, + reopenedSequence: 0 }, latestAcceptedTurnSequence: 0, cards, - hostInstance: HOST, - restartEnded: false + reopenFloor: opened ? { epoch: 'epoch-1', sequence: opened + 1 } : null }) } - function holding(cards: readonly Card[], stopped?: number): [string, string | null][] { - const pauses = pausesOver(cards, stopped) + function holding( + cards: readonly Card[], + stopped?: number, + opened?: number + ): [string, string | null][] { + const pauses = pausesOver(cards, stopped, opened) return cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null]) } @@ -580,64 +681,51 @@ describe('which cards the pauses in force hold', () => { ]) }) - it("a Stop that holds nothing never hides a restart's: a dead process's card queued after it waits", () => { - const after = card('after', 5, { hostInstance: DEAD }) - expect(pausesOver([after]).map((pause) => pause.reason)).toEqual(['stopped', 'restarted']) - expect(holding([after])).toEqual([['after', 'restarted']]) - expect(nextSendableQueuedCard(pausesOver([after]), [after])).toBeNull() - expect(resumableQueuePause(pausesOver([after]), [after])?.reason).toBe('restarted') - // Written by this process, nothing holds it: a card queued after a Stop sends normally. - const live = card('after', 5) - expect(nextSendableQueuedCard(pausesOver([live]), [live])).toBe(live) + it("a Stop that holds nothing never hides a reopen's: a card from before the open waits", () => { + const after = card('after', 5) + expect(pausesOver([after], 5, 6).map((pause) => pause.reason)).toEqual(['stopped', 'restarted']) + expect(holding([after], 5, 6)).toEqual([['after', 'restarted']]) + expect(nextSendableQueuedCard(pausesOver([after], 5, 6), [after])).toBeNull() + // Queued since the open, nothing holds it: a card queued after a Stop sends normally. + expect(nextSendableQueuedCard(pausesOver([after]), [after])).toBe(after) }) it("a card names the first pause holding it, and the header names the first held card's", () => { - const cards = [ - card('before', 3, { hostInstance: DEAD }), - card('after', 5, { hostInstance: DEAD }) - ] - expect(holding(cards)).toEqual([ + const cards = [card('before', 3), card('after', 5)] + expect(holding(cards, 5, 6)).toEqual([ ['before', 'stopped'], ['after', 'restarted'] ]) - expect(resumableQueuePause(pausesOver(cards), cards)?.reason).toBe('stopped') + expect(resumableQueuePause(pausesOver(cards, 5, 6), cards)?.reason).toBe('stopped') }) - // Held on its own, as a card whose send failed: the queue goes past it, and Resume is offered - // over the cards a pause holds behind it. - it('a kept card is skipped like a send_failed one; the cards behind it still send', () => { - const behind = card('behind', 2) - const kept = [card('kept', 1, { holdReason: QUEUED_MESSAGE_PAUSED_KEPT }), behind] - expect(holding(kept, 0)).toEqual([ - ['kept', null], - ['behind', null] + // R13-1: a message Orca kept after a quit is an ordinary card, in order, under the reopen's pause. + it('a card from before the open waits in order, and so do the cards behind it', () => { + const cards = [card('kept', 1), card('behind', 2)] + expect(holding(cards, 0, 4)).toEqual([ + ['kept', 'restarted'], + ['behind', 'restarted'] ]) - expect(nextSendableQueuedCard(pausesOver(kept, 0), kept)).toBe(behind) - expect(resumableQueuePause(pausesOver(kept, 0), kept)).toBeNull() - const restarted = [kept[0]!, card('dead', 2, { hostInstance: DEAD })] - expect(nextSendableQueuedCard(pausesOver(restarted, 0), restarted)).toBeNull() - expect(resumableQueuePause(pausesOver(restarted, 0), restarted)?.reason).toBe('restarted') - }) - - // A dead process's card held on its own waits for its own Send, so it pauses no other card. - it('a card held on its own from a dead process starts no restart pause', () => { - for (const holdReason of [QUEUED_MESSAGE_PAUSED_KEPT, 'send_failed']) { - const cards = [card('held', 1, { hostInstance: DEAD, holdReason }), card('live', 2)] - expect(pausesOver(cards, 0)).toEqual([]) - expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)?.messageId).toBe('live') - } + expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)).toBeNull() + }) + + // A card held on its own waits for its own Send, so it starts no reopen pause for the others. + it('a card held on its own from before the open starts no reopen pause', () => { + const cards = [card('held', 1, { holdReason: 'send_failed' }), card('live', 5)] + expect(pausesOver(cards, 0, 4)).toEqual([]) + expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)?.messageId).toBe('live') }) - it("a /clear's pause that holds nothing never hides a restart's", () => { + it("a /clear's pause that holds nothing never hides a reopen's", () => { const cards = [ card('carried', 1, { carriedFrom: 'source-session', holdReason: 'send_failed' }), - card('typed', 2, { hostInstance: DEAD }) + card('typed', 2) ] - expect(pausesOver(cards, 0).map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) - expect(holding(cards, 0)).toEqual([ + expect(pausesOver(cards, 0, 4).map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) + expect(holding(cards, 0, 4)).toEqual([ ['carried', null], ['typed', 'restarted'] ]) - expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)).toBeNull() + expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)).toBeNull() }) }) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 5b13856b8f0a..a7a652d81b7c 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -5,12 +5,14 @@ // person's pauses. // - 'cleared': a card /clear carried into this conversation waits, and no turn or Resume has // happened here since. -// - 'restarted': a waiting card with no hold of its own was written by another host process, -// and no turn has started since this conversation opened. Never published: after a restart -// nothing sends by itself, and the next turn (the carry-on or the person's own message) runs -// first. +// - 'restarted': a card queued before this conversation last opened waits — Orca quit or +// crashed, or the chat closed, while it waited — and no turn or Resume has happened since the +// open. The open marks itself with a row when it finds waiting cards (`queueReopen`), so a card +// queued after it is never mistaken for one from before. Never published: nothing sends by +// itself, and the next turn (the carry-on or the person's own message) runs first. // Any accepted turn lifts them, whoever sent it: a person, Orca's own messages, or the queue. -// A card held on its own (`hold_reason`) is outside every pause: only an action on it releases it. +// A card held on its own (`hold_reason`, a failed conversion) is outside every pause: only an +// action on it releases it. import type { AgentJournalCursor, @@ -33,29 +35,32 @@ export type JournalStopSettle = { export type JournalStopFailedOn = { turnId: string } | { openedAfter: number } -/** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ +/** The latest Stop event, whatever its reason, the latest Resume row and the latest reopen mark, + * folded by the reducer. */ export type JournalQueuePauseMarks = { latestStop: { sequence: number; event: JournalStopEvent; settle?: JournalStopSettle } | null /** 0 when none. */ resumedSequence: number + /** 0 when none. */ + reopenedSequence: number } export type DerivedQueuePause = { reason: QueuePauseReason - /** Where a Stop's pause began: a card queued at or after it is newer. Null for the others. */ + /** Where a Stop's or a reopen's pause began: a card queued at or after it is newer. Null for + * /clear's, which holds the cards it carried. */ since: AgentJournalCursor | null } type QueueCard = { state: string holdReason: string | null - hostInstance: string carriedFrom: string | null queuedAt: AgentJournalCursor | null } export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { - return { latestStop: null, resumedSequence: 0 } + return { latestStop: null, resumedSequence: 0, reopenedSequence: 0 } } /** The keys are read from disk unchecked: a value no build writes (a corrupt row) is ignored. */ @@ -79,6 +84,11 @@ export function foldJournalQueuePauseMark( marks.latestStop = { sequence: row.seq, event: row.stopEvent } } else if (row.queueResume === true) { marks.resumedSequence = row.seq + } else if (row.queueReopen === true) { + // Never earlier than a mark before it: a late mark of one send narrows no wider one. + const since = row.queueReopenSince + const start = typeof since === 'number' && since > 0 && since <= row.seq ? since : row.seq + marks.reopenedSequence = Math.max(marks.reopenedSequence, start) } } @@ -103,20 +113,23 @@ export function journalUserStopInForce( } /** What a rewind's new epoch restates so its pauses read as they did: a lift of /clear's pause (a - * turn or a Resume happened), then the Stop still in force, in that order so the lift - * never ends the Stop. */ + * turn or a Resume happened), then the Stop still in force, then the reopen's pause still holding + * a card, in that order so the lift never ends either. */ export type JournalQueuePauseRestatement = { lifted: boolean liveStop: JournalStopEvent | null + reopened: boolean } export function journalQueuePauseRestatement( marks: JournalQueuePauseMarks, - latestAcceptedTurnSequence: number + latestAcceptedTurnSequence: number, + pauses: readonly DerivedQueuePause[] ): JournalQueuePauseRestatement { return { lifted: latestAcceptedTurnSequence > 0 || marks.resumedSequence > 0, - liveStop: journalUserStopInForce(marks, latestAcceptedTurnSequence)?.event ?? null + liveStop: journalUserStopInForce(marks, latestAcceptedTurnSequence)?.event ?? null, + reopened: pauses.some((pause) => pause.reason === 'restarted') } } @@ -127,9 +140,8 @@ export function deriveQueuePauses(input: { marks: JournalQueuePauseMarks latestAcceptedTurnSequence: number cards: readonly QueueCard[] - hostInstance: string - /** A turn started since this conversation opened. */ - restartEnded: boolean + /** Where the reopen's pause begins when this handle could not mark it; null otherwise. */ + reopenFloor: AgentJournalCursor | null }): DerivedQueuePause[] { const { epoch, marks, latestAcceptedTurnSequence } = input const pauses: DerivedQueuePause[] = [] @@ -142,21 +154,42 @@ export function deriveQueuePauses(input: { if (carried.length > 0 && latestAcceptedTurnSequence === 0 && marks.resumedSequence === 0) { pauses.push({ reason: 'cleared', since: null }) } - // A card held on its own waits for its own Send whoever wrote it, so it pauses nothing else. - const foreign = waiting.some( - (card) => card.holdReason === null && card.hostInstance !== input.hostInstance - ) - if (!input.restartEnded && foreign) { - // The process that wrote a card is gone: every card waits, whenever it was written. - pauses.push({ reason: 'restarted', since: null }) + const reopened = reopenPause(input) + if ( + reopened && + waiting.some((card) => card.holdReason === null && queuedBefore(reopened, card)) + ) { + pauses.push(reopened) } return pauses } -/** Queued before the pause began: for /clear, a card it carried; for a restart, every card. For a - * Stop, a card queued before its row; one from another epoch (before a rewind) or from a build - * that recorded no position counts as before. A withdrawn steer keeps its position, so is held. */ -function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { +/** Where the latest reopen began: its mark, or, when this handle could not write one, the open + * itself, so a failed write holds a little more and never sends anything by itself. A floor from + * another epoch is not this one's (a rewind restates the mark). Null once a turn or a Resume came + * after it. */ +function reopenPause(input: { + epoch: string + marks: JournalQueuePauseMarks + latestAcceptedTurnSequence: number + reopenFloor: AgentJournalCursor | null +}): DerivedQueuePause | null { + const { epoch, marks, reopenFloor } = input + const floor = reopenFloor?.epoch === epoch ? reopenFloor.sequence : 0 + const sequence = Math.max(marks.reopenedSequence, floor) + if (sequence === 0) { + return null + } + if (Math.max(input.latestAcceptedTurnSequence, marks.resumedSequence) >= sequence) { + return null + } + return { reason: 'restarted', since: { epoch, sequence } } +} + +/** Queued before the pause began: for /clear, a card it carried. For a Stop or a reopen, a card + * queued before its row; one from another epoch (before a rewind) or from a build that recorded + * no position counts as before. A withdrawn steer keeps its position, so is held. */ +function queuedBefore(pause: DerivedQueuePause, card: QueueCard): boolean { if (pause.reason === 'cleared') { return card.carriedFrom !== null } @@ -180,7 +213,7 @@ export function queuePauseHolding( return undefined } // A card queued AFTER a Stop is a new instruction and is not held; it still waits behind a held one. - return pauses.find((pause) => queuedBeforePause(pause, card)) + return pauses.find((pause) => queuedBefore(pause, card)) } /** The card the queue sends next: the oldest waiting one with no hold of its own, unless a diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts index 029bdb74e6f3..9c30bf4311dd 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -58,7 +58,6 @@ export function settleOwedQueuedMessages( consumedRef, reason: submission?.reason ?? null, rejection: submission?.rejection, - origin: submission?.origin, now: input.now }) settled += changed ? 1 : 0 @@ -124,7 +123,6 @@ export function settleQueuedMessagesForRow( consumedRef: row.clientMessageId, reason: row.reason, rejection: row.rejection, - origin: submission?.origin, now: input.now }) return changed + (settled ? 1 : 0) diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index 7160718124bc..02e07bc53ee6 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -13,7 +13,6 @@ import type { } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import Database from '../../sqlite/sync-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' import { journalDatabasePath } from './journal-host-database' @@ -420,11 +419,11 @@ describe('returned transition (D1/N4)', () => { }) it.each([ - { by: 'the queue', origin: 'host' as const, holdReason: null }, - { by: 'the person', origin: 'client' as const, holdReason: QUEUED_MESSAGE_PAUSED_KEPT } + { by: 'the queue', origin: 'host' as const }, + { by: 'the person', origin: 'client' as const } ])( 'a restart between $by’s consume and handover sends the draft back to waiting', - async ({ origin, holdReason }) => { + async ({ origin }) => { let journal = await open() await queueDraft(journal, 'draft-1') await consumeDraft(journal, 'draft-1', { origin }) @@ -434,11 +433,10 @@ describe('returned transition (D1/N4)', () => { await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => journal.wroteBeforeOpen(submission.acceptedSequence) ) - // The queue's own hand-off waits under the restart's pause, derived from the row's host - // instance; a Send the person asked for waits for them, kept. + // Whoever sent it, it waits with no hold of its own, under the reopen's pause. expect(journal.queuedMessages.get('draft-1')).toMatchObject({ state: 'waiting', - holdReason, + holdReason: null, hostInstance: 'proc-1', consumedAs: null, returnedReason: null @@ -595,23 +593,15 @@ describe('open-time repair and retention', () => { } }) - // The repair reaches the live hook's answer from the stored rejection and who asked for it. + // The repair reaches the live hook's answer from the stored rejection. it.each([ - { - origin: 'client' as const, - cause: 'hostRestarted' as const, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }, - { origin: 'host' as const, cause: 'hostRestarted' as const, holdReason: null }, - { - origin: 'client' as const, - cause: 'chatClosed' as const, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }, - { origin: 'host' as const, cause: 'chatClosed' as const, holdReason: null } + { origin: 'client' as const, cause: 'hostRestarted' as const }, + { origin: 'host' as const, cause: 'hostRestarted' as const }, + { origin: 'client' as const, cause: 'chatClosed' as const }, + { origin: 'host' as const, cause: 'chatClosed' as const } ])( - 'a skipped hook for a $origin hand-off cut short ($cause) is repaired at open, holdReason $holdReason', - async ({ origin, cause, holdReason }) => { + 'a skipped hook for a $origin hand-off cut short ($cause) is repaired at open, back to waiting', + async ({ origin, cause }) => { let journal = await open() await queueDraft(journal, 'draft-1') await consumeDraft(journal, 'draft-1', { origin }) @@ -629,7 +619,7 @@ describe('open-time repair and retention', () => { journal = await open() expect(journal.queuedMessages.get('draft-1')).toMatchObject({ state: 'waiting', - holdReason, + holdReason: null, consumedAs: null }) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts index 1e5fdb2ac03b..51233b66abb0 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts @@ -55,7 +55,7 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul createdAt: record.created_at, hostInstance: record.host_instance, state, - holdReason: record.hold_reason, + holdReason: storedHoldReason(record.hold_reason), returnedReason: record.returned_reason, returnedRejection: storedRejection(record.returned_rejection), settledAt: record.settled_at, @@ -69,6 +69,14 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul } } +/** Holds an earlier build stored that this one derives instead: a kept send (`kept`) waits under + * the reopen's pause, a Stop's (`stopped`) under the Stop's. */ +const QUEUED_MESSAGE_RETIRED_HOLD_REASONS: ReadonlySet = new Set(['kept', 'stopped']) + +function storedHoldReason(stored: string | null): string | null { + return stored !== null && QUEUED_MESSAGE_RETIRED_HOLD_REASONS.has(stored) ? null : stored +} + function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { if (json === null) { return null diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index f3e82613b1f3..c9b380a25352 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -13,23 +13,18 @@ import type { AgentJournalCursor, AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - type QUEUED_MESSAGE_PAUSED_SEND_FAILED -} from '../../../shared/agent-session-queued-message-wire' +import type { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../shared/agent-session-queued-message-wire' import { rejectedDraftSettlement } from './journal-dispatch-settlement' import { readStoredQueuedMessageRow } from './queued-message-stored-row' export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' -/** Why ONE waiting draft is held from auto-sending: its conversion failed (`send_failed`), or it - * is a send the host accepted and kept across a restart or a close (`kept`). Stored on the row, so - * it survives handle eviction and restart; a wire marker (it publishes as `pausedReason`). A Stop - * or a restart pauses the whole queue instead. A reader treats an unknown stored value as a plain - * hold. */ -export type QueuedMessageHoldReason = - | typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED - | typeof QUEUED_MESSAGE_PAUSED_KEPT +/** Why ONE waiting draft is held from auto-sending: its conversion failed (`send_failed`). Stored + * on the row, so it survives handle eviction and restart; a wire marker (it publishes as + * `pausedReason`). A Stop, a /clear or a reopen pauses the queue instead. A reader treats an + * unknown stored value as a plain hold, and the holds earlier builds wrote that this one derives + * (`QUEUED_MESSAGE_RETIRED_HOLD_REASONS`) as none. */ +export type QueuedMessageHoldReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED /** Definitively unsettled: what Stop, /clear, Edit and the budget count, and * what the published list shows. Pending/unknown/accepted deliveries and @@ -231,8 +226,8 @@ export function withdrawQueuedMessages( * dispatched → returned, or back to waiting (`rejectedDraftSettlement`), * matched on the draft's CURRENT hand-off (`consumed_as`), so a re-send refused * again still settles while a late duplicate of an earlier refusal matches - * nothing. A draft back to waiting keeps its position and carries no refusal, held as `kept` - * when the settlement says so; its spent submissions stay findable by their `queuedMessageId` link. + * nothing. A draft back to waiting keeps its position and carries no refusal; its spent + * submissions stay findable by their `queuedMessageId` link. */ export function settleRejectedQueuedMessage( db: Database.Database, @@ -241,8 +236,6 @@ export function settleRejectedQueuedMessage( consumedRef: string reason: string | null rejection: UnreadAgentSessionFailureFact | undefined - /** Who asked for the rejected hand-off (`AgentJournalSubmission.origin`). */ - origin: 'client' | 'host' | undefined now: number } ): boolean { @@ -252,15 +245,11 @@ export function settleRejectedQueuedMessage( ? db .prepare( `UPDATE queued_messages - SET state = 'waiting', hold_reason = ?, consumed_as = NULL, + SET state = 'waiting', hold_reason = NULL, consumed_as = NULL, returned_reason = NULL, returned_rejection = NULL, settled_at = NULL, settled_by_op = NULL WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?` ) - .run( - settlement.kept ? QUEUED_MESSAGE_PAUSED_KEPT : null, - input.sessionId, - input.consumedRef - ) + .run(input.sessionId, input.consumedRef) : db .prepare( `UPDATE queued_messages diff --git a/src/main/native-chat/agent-session-record-test-fixture.ts b/src/main/native-chat/agent-session-record-test-fixture.ts new file mode 100644 index 000000000000..21e7f1d5191e --- /dev/null +++ b/src/main/native-chat/agent-session-record-test-fixture.ts @@ -0,0 +1,50 @@ +// A complete, typed agent-session record for tests that care about a few of its fields. + +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + type AgentSessionExecutionLocation, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' + +export function agentSessionRecordFixture( + overrides: Partial> & { + sessionId: string + location?: Partial + } +): AgentSessionRecord { + const { location, ...rest } = overrides + return { + schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, + provider: 'claude', + providerHandleChain: [], + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' }, + createdAt: 0, + updatedAt: 0, + lease: { + sessionId: overrides.sessionId, + runtimeKind: 'native', + runtimeFence: 1, + handoffStage: null, + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: null, + leaseDeadlineAt: 0, + lastRenewedAt: 0, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: 'test-key', + claimStatus: 'released', + unreconciled: false, + deathEvidence: null + }, + ...rest, + location: { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder', + ...location + } + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index fdd10ab61041..cee9087f240a 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -14,6 +14,9 @@ // recovered: a new child is a new assembler, in a new acquisition generation. import type { AgentType } from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionCommandRun } from '../agent-session-wire/structured-agent-session-adapter' import type { AgentSessionDeltaCoalescerDeps } from '../agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' import { @@ -47,6 +50,9 @@ export type { ProviderTimelineDropRule } from './provider-timeline-decision' export type { ProviderTimelineApplyResult } from './provider-timeline-text-events' export type ProviderTimelineAssembler = { + /** The host already wrote this command's running turn; the provider ends that same row. */ + beginCommand(command: StructuredAgentSessionCommandRun): void + forgetCommand(turnId: string): void apply(event: ProviderTimelineEvent): ProviderTimelineApplyResult /** The turn id of the open turn, as its row and a client's Stop name it. */ readonly openTurnId: string | null @@ -191,6 +197,27 @@ export function createProviderTimelineAssembler( } return { + forgetCommand: (turnId) => { + if (state.open?.turnId === turnId) { + state.endTurn(state.open) + } + }, + beginCommand: (command) => { + if (state.ended || state.open || state.stopped) { + throw new Error('Provider timeline cannot adopt a command while work is open') + } + const running = readAgentJournalTurn(command.running) + if (!running || running.state !== 'running' || running.turnId !== command.turnId) { + throw new Error('Provider timeline command is not a running host turn') + } + state.open = { + identity: command.identity, + itemId: agentJournalItemKey(command.identity), + turnId: command.turnId, + key: { source: 'provider', value: command.turnId }, + running + } + }, apply, get openTurnId() { return state.open?.turnId ?? null diff --git a/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.test.ts b/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.test.ts index 35737dd84ccf..9dd2b5c48f23 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.test.ts @@ -77,6 +77,28 @@ describe('conversation outline read', () => { expect(replyBytes(trimmed.entries)).toBeLessThanOrEqual(budget) }) + it('carries the reply to each prompt, and drops replies before it shortens a preview', () => { + const items = Array.from({ length: 10 }, (_, index): AgentJournalRenderItem[] => [ + prompt(index * 2 + 1, 'x'.repeat(100)), + { + itemId: `reply-${index}`, + revision: 1, + sequence: index * 2 + 2, + observedAt: index * 2 + 2, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Answer' }] } + } + ]).flat() + const full = readAgentSessionConversationOutline(snapshot(items), Number.MAX_SAFE_INTEGER) + expect(full.entries.map((entry) => entry.reply)).toEqual(Array(10).fill('Answer')) + + // One byte short: the budget counts a separator per entry, the array two brackets. + const tight = readAgentSessionConversationOutline(snapshot(items), replyBytes(full.entries) - 2) + expect(tight.entries.every((entry) => entry.reply === undefined)).toBe(true) + expect(tight.entries.map((entry) => entry.preview)).toEqual( + full.entries.map((entry) => entry.preview) + ) + }) + it('stays inside the history page budget for a journal far past it', () => { // ~10k prompts of 1 KB each: an order of magnitude past the budget at full preview. const items = Array.from({ length: 10_000 }, (_, index) => diff --git a/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.ts b/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.ts index 198a813b2171..ecdcb30062d4 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.ts @@ -2,8 +2,9 @@ // request rather than stored, so it can never disagree with the journal it lists. // // One reply must fit the same budget a history page does. Past it the reply -// degrades in order of what the rail can best spare: shorter previews, then no -// previews (a tick still marks the message), and only then the oldest entries. +// degrades in order of what the rail can best spare: the agent's replies, then +// shorter previews, then no previews (a tick still marks the message), and only +// then the oldest entries. import { AGENT_SESSION_OUTLINE_PREVIEW_MAX_CHARS, @@ -32,6 +33,12 @@ function withPreviewCap( }) } +function withoutReplies( + entries: readonly AgentSessionConversationOutlineEntry[] +): AgentSessionConversationOutlineEntry[] { + return entries.map(({ reply: _reply, ...entry }) => entry) +} + function totalBytes(entries: readonly AgentSessionConversationOutlineEntry[]): number { return entries.reduce((total, entry) => total + entryBytes(entry), 0) } @@ -42,6 +49,9 @@ export function readAgentSessionConversationOutline( ): AgentSessionConversationOutline { const projected = projectAgentSessionConversationOutline(snapshot.items, snapshot.submissions) let entries = withPreviewCap(projected, AGENT_SESSION_OUTLINE_PREVIEW_MAX_CHARS) + if (totalBytes(entries) > budgetBytes) { + entries = withoutReplies(entries) + } for (const maxChars of [DEGRADED_PREVIEW_MAX_CHARS, 0]) { if (totalBytes(entries) <= budgetBytes) { break diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts index fcfafac9ae96..c8790eb06b2a 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { AGENT_SESSION_DELTA_COALESCE_MS, createAgentSessionDeltaCoalescer @@ -244,3 +244,40 @@ describe('agent-session delta coalescer', () => { expect(emitted.get('item-3')).toEqual({ text: '', observedBytes: 80, truncated: true }) }) }) + +describe('snapshot admission before assembly', () => { + it('reports retained UTF-16 length after Unicode appends and byte truncation', () => { + const clock = manualClock() + const shouldEmit = vi.fn(() => true) + const emitted: [string, string][] = [] + const instance = createAgentSessionDeltaCoalescer({ + schedule: clock.schedule, + maxRetainedBytes: 40, + shouldEmit, + emit: (key, text) => emitted.push([key, text]) + }) + instance.append('unicode', 'éé') + instance.append('surrogate', '\ud83d') + instance.append('surrogate', '\ude00') + clock.fire() + expect(shouldEmit.mock.calls).toEqual([ + ['unicode', 2], + ['surrogate', 2] + ]) + expect(emitted).toEqual([ + ['unicode', 'éé'], + ['surrogate', '😀'] + ]) + + instance.append('unicode', `${'é'.repeat(20)}more`) + clock.fire() + const truncated = 'ééé\n[Orca: streamed output truncated]' + expect(shouldEmit).toHaveBeenLastCalledWith('unicode', truncated.length) + expect(emitted.at(-1)).toEqual(['unicode', truncated]) + const calls = shouldEmit.mock.calls.length + instance.append('unicode', 'ignored') + clock.fire() + expect(shouldEmit).toHaveBeenCalledTimes(calls) + instance.dispose() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts index eff850ae8e05..7c04312f5842 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts @@ -32,6 +32,8 @@ export type AgentSessionDeltaSnapshot = { export type AgentSessionDeltaCoalescerDeps = { /** Called with the FULL text accumulated for the key, not the increment. */ emit: (key: string, text: string, snapshot: AgentSessionDeltaSnapshot) => unknown + /** False accepts a skipped snapshot; sink refusal is reported by emit. */ + shouldEmit?: (key: string, textLength: number) => boolean windowMs?: number maxRetainedBytes?: number maxTotalRetainedBytes?: number @@ -82,6 +84,7 @@ export function createAgentSessionDeltaCoalescer( { chunks: string[] retainedBytes: number + textLength: number observedBytes: number truncated: boolean dirty: boolean @@ -96,6 +99,10 @@ export function createAgentSessionDeltaCoalescer( if (!stream?.dirty) { return true } + if (deps.shouldEmit?.(key, stream.textLength) === false) { + stream.dirty = false + return true + } const text = stream.chunks.join('') const emitted = deps.emit(key, text, { text, @@ -159,6 +166,7 @@ export function createAgentSessionDeltaCoalescer( stream = { chunks: [], retainedBytes: 0, + textLength: 0, observedBytes: 0, truncated: false, dirty: false @@ -182,6 +190,9 @@ export function createAgentSessionDeltaCoalescer( streamLimit ) totalRetainedBytes += next.retainedBytes - stream.retainedBytes + stream.textLength = next.truncated + ? (next.chunks[0]?.length ?? 0) + : stream.textLength + delta.length stream.chunks = next.chunks stream.retainedBytes = next.retainedBytes stream.truncated = next.truncated diff --git a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts index da783ac0749e..5a1e43ce2223 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts @@ -21,3 +21,8 @@ export type StructuredAgentDefinition = AgentSessionStoredAgent & { effortDefaultsToModel: boolean } } + +/** An agent whose account is one config directory, as Claude's and Codex's are. */ +export type DirectoryAccountAgentDefinition = StructuredAgentDefinition & { + accountHomeVariable: string +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts new file mode 100644 index 000000000000..90c94306db1d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts @@ -0,0 +1,127 @@ +// A chat starts whatever the store stamped on its owner: the Orca runtime holding the process is +// recorded beside its identity, and the start compares the process, not the stamp. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { agentSessionRuntimeIncarnation } from '../../runtime/agent-session-runtime-attribution' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + attachFingerprintFields, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import { performAttach } from './structured-agent-session-attach-flow' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' + +const NOW = 1_800_000_000_000 +const SESSION = 'stamped-owner-session' +const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +function attachParams(): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + } + } +} + +it('starts a chat whose recorded owner carries the runtime stamp', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-stamped-owner-')) + const store = await openTestAgentSessionRecordStore(root) + // The record as the store published it, stamp included, rather than the transition's own copy. + const commit = store.commitProcessIdentity.bind(store) + vi.spyOn(store, 'commitProcessIdentity').mockImplementation(async (input) => { + await commit(input) + const published = store.getRecord(input.sessionId) + if (!published) { + throw new Error('the committed record is missing') + } + return published + }) + const adapter: StructuredAgentSessionAdapter = { + acquire: vi.fn( + async ({ fence, spawnToken, onSpawned }) => { + const process = { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken } + await onSpawned?.(process) + return { + process, + link: { + linkId: 'created-link', + handle: codexProviderHandle('thread-1'), + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + } + ), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } + + const attached = await performAttach({ + agents: NO_STRUCTURED_AGENTS, + logger: createStructuredAgentSessionLogger(), + store, + adapter, + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(), + now: () => NOW, + onAttached: () => {} + }) + + expect(attached).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.lease.ownerProcess).toMatchObject({ + pid: 4242, + runtime: agentSessionRuntimeIncarnation() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 3389bc615e58..70019d6da3e5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -1,5 +1,8 @@ import { isDeepStrictEqual } from 'node:util' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../../shared/agent-session-record' import { AgentSessionPreSpawnError, isAgentSessionPreSpawnError, @@ -11,6 +14,17 @@ import type { AttachFlowInput } from './structured-agent-session-attach-flow' import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import { withAgentSessionCreatePhase } from '../../observability/agent-session-instrumentation' +/** The same process, whatever Orca runtime the store stamped on its record (`runtime`): that stamp + * is about who holds the process, not which process it is. */ +function sameOwnerProcess( + stored: AgentSessionProcessIdentity, + acquired: AgentSessionProcessIdentity +): boolean { + const { runtime: _storedRuntime, ...storedProcess } = stored + const { runtime: _acquiredRuntime, ...acquiredProcess } = acquired + return isDeepStrictEqual(storedProcess, acquiredProcess) +} + /** A reservation with no process behind it is only a promise to spawn; the * adapter makes it real and the store then grants the writer. */ export async function acquireOwner( @@ -79,7 +93,7 @@ export async function acquireOwner( process: acquired.process, now: input.now() }) - } else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) { + } else if (!sameOwnerProcess(record.lease.ownerProcess, acquired.process)) { throw new Error('agent_session_ownership_unknown') } const proved = await input.store.proveOwner({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index cb2dc19465a5..fb26aeeb7562 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -134,6 +134,8 @@ export type AgentSessionPreSpawnReason = Extract< | 'managedAccountUnsupported' | 'launchFolderMissing' | 'historyInOtherAccount' + | 'claudeAccountFolderMissing' + | 'claudeAccountSetupFailed' | 'agentCommandNotRunnable' > diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts new file mode 100644 index 000000000000..9228f6f0743f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts @@ -0,0 +1,145 @@ +// A message naming a chat attachment the host no longer stores is refused whole at admission, +// with a reason the client's existing rejected-send row puts into words, on the direct path and +// the queued one alike; a stored one is claimed, and a /clear carries the claim to its new chat. + +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { agentSessionAttachmentStoreRoot } from '../agent-session-attachments/agent-session-attachment-references' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + createQueuedMessageTestRig, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +const UPLOAD = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01' +const EXPIRED = { + ok: false, + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'attachmentExpired' } } +} + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +function storedPath(name: string): string { + return join(agentSessionAttachmentStoreRoot(rig.root), UPLOAD, name) +} + +async function storeUpload(name: string): Promise { + await mkdir(join(agentSessionAttachmentStoreRoot(rig.root), UPLOAD), { recursive: true }) + await writeFile(storedPath(name), 'bytes') + return storedPath(name) +} + +function clientSend(body: AgentJournalMessageItem, delivery?: 'queue-if-active') { + const clientOperationId = hostTestOperationId() + const fields = { body, ...(delivery ? { delivery } : {}) } + return rig.host.send(CALLER, { + envelope: rig.envelope(fields, 'agentSession.send', clientOperationId), + body, + ...(delivery ? { delivery } : {}), + userSend: true + }) +} + +function claimedSessions(): string[] { + return openTestJournalHostDatabase(rig.root) + .db.prepare('SELECT session_id FROM agent_session_attachment_claims ORDER BY session_id') + .all() + .flatMap((row) => + typeof row === 'object' && row !== null && 'session_id' in row ? [String(row.session_id)] : [] + ) +} + +function textWith(path: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `read @${path}` }] } +} + +describe('attachments at send admission', () => { + it('refuses a send naming an expired image before anything is recorded', async () => { + const result = await clientSend({ + kind: 'message', + role: 'user', + blocks: [{ type: 'image-ref', path: storedPath('shot.png') }] + }) + expect(result).toMatchObject(EXPIRED) + expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(0) + expect(rig.dispatch).not.toHaveBeenCalled() + }) + + it('refuses a queued send naming an expired file, and queues nothing', async () => { + await rig.workingSend() + expect(await clientSend(textWith(storedPath('notes.txt')), 'queue-if-active')).toMatchObject( + EXPIRED + ) + expect(await rig.drafts()).toHaveLength(0) + }) + + it('claims a stored upload, and the /clear carry claims it for the replacement chat', async () => { + const working = await rig.workingSend() + const path = await storeUpload('notes.txt') + expect(await clientSend(textWith(path), 'queue-if-active')).toMatchObject({ + ok: true, + value: { queued: { state: 'waiting' } } + }) + expect(claimedSessions()).toEqual([SESSION]) + await rig.stop() + await rig.settleAccepted(working, 'a') + const fields = { command: 'clear' as const } + const cleared = await rig.host.conversationCommand(CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + expect(replacementId).toBeTruthy() + expect(claimedSessions()).toEqual([SESSION, replacementId].sort()) + }) + + it('sends text that only mentions another store path, claiming nothing', async () => { + const result = await clientSend( + textWith(`/home/someone/.config/Orca/agent-session-attachments/${UPLOAD}/notes.txt`) + ) + expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + expect(claimedSessions()).toEqual([]) + }) + + it('replays an admitted send under the same id without judging its attachments again', async () => { + const path = await storeUpload('shot.png') + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'image-ref', path }] + } + const clientOperationId = hostTestOperationId() + const params = { + envelope: rig.envelope({ body }, 'agentSession.send', clientOperationId), + body, + userSend: true as const + } + expect(await rig.host.send(CALLER, params)).toMatchObject({ ok: true, replayed: false }) + // The upload goes; the outbox's resend of the same operation still answers as sent. + await rm(join(agentSessionAttachmentStoreRoot(rig.root), UPLOAD), { recursive: true }) + expect(await rig.host.send(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + }) + + it("never refuses a host-side send (orchestration mail, a restart's continuation)", async () => { + const body = textWith(storedPath('notes.txt')) + const clientOperationId = hostTestOperationId() + const result = await rig.host.send(CALLER, { + envelope: rig.envelope({ body }, 'agentSession.send', clientOperationId), + body + }) + expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-continuation-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-continuation-verdict.ts new file mode 100644 index 000000000000..9a75c6b0ed5a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-continuation-verdict.ts @@ -0,0 +1,47 @@ +// What a continuation's send came to, read from its submission: the four dispatch states, never +// collapsed into transport success (`StructuredAgentSessionContinuationOutcome`). + +import { + readAgentSessionFailureFact, + type UnreadAgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' + +export type ContinuationSubmission = { + dispatchState?: string + reason?: string | null + rejection?: UnreadAgentSessionFailureFact +} + +export function refusedBy( + sessionId: string, + submission: ContinuationSubmission +): StructuredAgentSessionContinuationOutcome { + // A start the agent was refused files that refusal's code, which the failure guidance keys on. + const refusal = readAgentSessionFailureFact(submission.rejection)?.refusal + return { + sessionId, + outcome: 'refused', + reason: refusal?.code ?? submission.reason ?? 'agent_session_dispatch_rejected', + ...(refusal ? { refusal } : {}) + } +} + +export function verdictOf( + sessionId: string, + submission: ContinuationSubmission | undefined +): StructuredAgentSessionContinuationOutcome { + const dispatch = submission?.dispatchState + if (submission && dispatch === 'rejected') { + return refusedBy(sessionId, submission) + } + if (dispatch === 'pending') { + // Still pending after settlement gave up: handed off, never confirmed. + return { sessionId, outcome: 'pending' } + } + // `unknown`, or a peer that reported no state at all: delivery is unverifiable, so this claims + // neither success nor failure — and writes no note saying the agent was asked to continue. + return dispatch === 'accepted' + ? { sessionId, outcome: 'continued' } + : { sessionId, outcome: 'unknown' } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index f60b2f295834..1100c0abf81b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -52,7 +52,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { const stopAgent = (sessionId: string, ending: StructuredAgentSessionStopEnding) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, ending) - const closeConversation = async (sessionId: string): Promise => { + const closeConversation = async (sessionId: string, atRest = false): Promise => { // The handle carries the proof that releases a lease its child's wind-down could not. if (await releaseLeaseOfEndedStructuredAgentSessionChild(host.context(), sessionId)) { return false @@ -66,7 +66,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { host.closeStatus(id, { listed: !tabs.present || tabs.sessionIds.includes(id) }) } }, - sessionId + sessionId, + atRest ) } @@ -91,7 +92,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause: 'host-stop' }), - closeConversation, + closeConversation: (sessionId) => closeConversation(sessionId, true), logger: deps().logger, ...deps().idleSweep }) @@ -140,7 +141,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { }) }, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is - * still queued will not be sent; a person's message stays as a held card. */ + * still queued waits for the chat's next turn; a person's unsent message stays as a card. */ close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => { // Outside the queue: a start the provider never answers must not hold the close behind it. host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') @@ -148,8 +149,11 @@ export function createStructuredAgentSessionConversationLifetime(host: { readRefusals.forget(sessionId) const session = sessions.get(sessionId) if (session) { - // Settled before the stop, so no start delivers it. - await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal) + // Settled and marked before the stop, so no start delivers it, and nothing it closed with + // sends by itself, even if the handle stays open. + await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { + mark: 'always' + }) } await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) await closeConversation(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index aa1a5c3d6191..ec7f478501f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -6,8 +6,9 @@ // crash boundary. That // needs no lease: provider history decides such a row later, under a won lease, in the attach. A // row an earlier process accepted and never handed over (it quit or crashed first) is settled here -// too, before any reader, command or child sees it: a person's message is kept as a held card, the -// rest rejected (`journal-unsent-send-hold.ts`). Nothing here starts a provider child. +// too, before any reader, command or child sees it: a person's message is kept as a card, the rest +// rejected (`journal-unsent-send-hold.ts`). The cards then wait for the chat's next turn +// (`queued-message-pause.ts`). Nothing here starts a provider child. import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -23,7 +24,10 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + markStructuredQueueReopen, + structuredAgentSessionHostInstance +} from './structured-agent-session-queued-pause' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -115,6 +119,9 @@ export async function openStructuredAgentSessionConversationJournal( error }) } + // After the leftovers became cards, so the mark follows every card this open found. Every open + // comes after the chat stopped running: the idle sweep never closes one with cards waiting. + await markStructuredQueueReopen(sessionId, journal, fence, deps.logger) // No child in this process writes to a journal nobody had open, so whatever it shows running // belongs to a generation that is gone, whatever the lease still claims. Settled before any // reader or child sees it. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index ac3e264ee0cb..a36518c5e614 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -39,6 +39,8 @@ import { import { resettleOpenStructuredAgentSessionConversation } from './structured-agent-session-conversation-open' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' +import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut' +import { beginAgentSessionRuntimeRecord } from '../../runtime/agent-session-runtime-end-record' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { @@ -360,6 +362,41 @@ describe('a turn a read reached before the reconcile proved its owner dead', () ).toEqual({ key: 'workedFor', duration: '27s' }) }) + it('explains the turn its proof revised, so the cut reads once and offers Continue', async () => { + // The Orca that crashed recorded that it started, and never that it ended. + const crashed = crashedClaudeRecord() + const ownerProcess = crashed.lease.ownerProcess + if (!ownerProcess) { + throw new Error('the crashed record has no owner') + } + await seedTestAgentSessionRecordStore(root, { + records: [ + { + ...crashed, + lease: { ...crashed.lease, ownerProcess: { ...ownerProcess, runtime: 'runtime-crashed' } } + } + ] + }) + beginAgentSessionRuntimeRecord(root, 'runtime-crashed', TOOL_STARTED_AT - 60_000) + store = await openTestAgentSessionRecordStore(root) + openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) + expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) + + await host.reconcileRestartLeases() + await drainSession() + + const { items } = await host.journalSnapshot(SESSION) + const turnItemId = items.find((item) => item.body.kind === 'turn')?.itemId + expect(items.flatMap((item) => (item.body.kind === 'status' ? [item.turnScope] : []))).toEqual([ + { kind: 'turn', turnItemId } + ]) + expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: 'crash' }) + const readerRows = withNativeChatCutTurnNotices(items, { agentName: 'Claude' }).filter( + (item) => item.body.kind === 'status' + ) + expect(readerRows).toHaveLength(1) + }) + it('revises nothing twice, whoever re-runs the settle', async () => { openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) await host.history({ sessionId: SESSION, direction: 'tail' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index ce11c2dc34c9..f3c8c5df9fea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -45,8 +45,9 @@ import { } from './structured-agent-session-stale-turn-verdict' import { exitedRootTurnScope, - runningRootTurnScope + settledRootTurnScope } from './structured-agent-session-exit-turn-scope' +import { orcaStopRowBody } from './structured-agent-session-orca-stop-row' import { hasUnfinishedStructuredAgentSessionWork, isInProgressStructuredAgentSessionItem, @@ -242,16 +243,9 @@ export async function settleStaleStructuredAgentSessionState(input: { clientMessageId: `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` }, // The death evidence is Orca's log text, never a sentence for a person: the row says only - // that the provider stopped. - body: { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { - ...input.failureTextContext, - surface: 'row' - }), - tone: 'error' - }, - turnScope: runningRootTurnScope(items) + // that the provider stopped, and how Orca ended when the provider died with it. + body: orcaStopRowBody(input.failureTextContext, evidence.runtimeEnd), + turnScope: settledRootTurnScope(items, turnEnds) }) } for (const chunk of partitionJournalLifecycleMutations(settlementId, mutations)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index a7989f940ef2..380742f0e235 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -17,8 +17,6 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' import { @@ -41,6 +39,7 @@ import { handOverSubmission } from './structured-agent-session-turns' import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { holdRestartedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -167,11 +166,12 @@ export class StructuredAgentSessionDeliveryLoop { // The open already did, unless its write failed: a row an earlier handle wrote is never handed // over, whether it outlived a quit or a crash. A failure here throws, so none is: this run then // fails, which rejects every queued send, this process's own too. - await holdUnsentSends(session.journal, { - fence: this.deps.conversationFence(sessionId), - hostInstance: structuredAgentSessionHostInstance(), - hold: { cause: 'hostRestarted' } - }) + await holdRestartedStructuredAgentSessionSends( + this.deps.logger, + sessionId, + session.journal, + this.deps.conversationFence(sessionId) + ) if (!(await this.closeWhatTheUserClosed(sessionId, session))) { // Never start an agent for a message the user closed; the next wake re-derives and retries. return this.stop(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 9a0637c99bc7..3d5d9847400b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -9,6 +9,7 @@ import type { StructuredAgentSessionSinkWatermarks } from './structured-agent-session-event-sink' import type { StructuredAgentSessionTransitionJournal } from './structured-agent-session-transition' +import { StructuredAgentSessionFinalTailReservation } from './structured-agent-session-final-tail-reservation' export type StructuredAgentSessionSinkOperation = { sequence: number @@ -16,6 +17,7 @@ export type StructuredAgentSessionSinkOperation = { /** Lifecycle rows use their own bounded reservation budget. */ lifecycleBytes?: number lifecycle?: boolean + finalTail?: true /** Marks a publication, which writes no row: it runs at handover, or at its place in the * journal's queue while writes wait there, and one still waiting with the same key is replaced * by the next. Journal writes never coalesce: @@ -26,7 +28,10 @@ export type StructuredAgentSessionSinkOperation = { run: (target: StructuredAgentSessionEventTarget) => Promise | void } -type Admitted = StructuredAgentSessionSinkOperation & { superseded?: boolean } +type Admitted = StructuredAgentSessionSinkOperation & { + superseded?: boolean + releaseFinalTail?: () => void +} export type StructuredAgentSessionDrainWaiter = { through: number @@ -59,6 +64,7 @@ export class StructuredAgentSessionSinkQueue { private readonly buffered: Admitted[] = [] private readonly waitingPublications = new Map() private readonly waiters: StructuredAgentSessionDrainWaiter[] = [] + private readonly finalTail = new StructuredAgentSessionFinalTailReservation() constructor( private readonly deps: { @@ -121,6 +127,7 @@ export class StructuredAgentSessionSinkQueue { close(): void { this.closed = true this.dropBuffered() + this.finalTail.close() this.updateBackpressure() } @@ -160,7 +167,8 @@ export class StructuredAgentSessionSinkQueue { : key === undefined ? undefined : this.waitingPublications.get(key) - const lifecycle = operation.lifecycle ?? options.lifecycle === true + const finalTail = operation.finalTail === true + const lifecycle = !finalTail && (operation.lifecycle ?? options.lifecycle === true) const lifecycleBytes = lifecycle ? (operation.lifecycleBytes ?? operation.bytes) : 0 const nextBytes = this.queuedBytes - (replaced?.bytes ?? 0) + operation.bytes const nextOperations = this.queuedOperations + (replaced ? 0 : 1) @@ -169,6 +177,7 @@ export class StructuredAgentSessionSinkQueue { const nextLifecycleOperations = this.lifecycleQueuedOperations - (replaced?.lifecycle ? 1 : 0) + (lifecycle ? 1 : 0) const exceedsOrdinary = + !finalTail && !lifecycle && (nextBytes > this.deps.watermarks.maxQueuedBytes || nextOperations > this.deps.watermarks.maxQueuedOperations) @@ -180,11 +189,18 @@ export class StructuredAgentSessionSinkQueue { this.setBackpressure(true) return { accepted: false, reason: 'backpressure' } } + const releaseFinalTail = + finalTail && key === undefined ? this.finalTail.reserve(operation.bytes) : undefined + if (finalTail && !releaseFinalTail) { + this.fail(new Error('structured agent-session final tail exceeded its reserved capacity')) + return { accepted: false, reason: 'failed' } + } const accepted: Admitted = { ...operation, sequence: ++this.acceptedSequence, lifecycle, - lifecycleBytes + lifecycleBytes, + releaseFinalTail: releaseFinalTail ?? undefined } if (replaceAt >= 0) { this.buffered.splice(replaceAt, 1) @@ -243,6 +259,7 @@ export class StructuredAgentSessionSinkQueue { } private release(operation: Admitted): void { + operation.releaseFinalTail?.() this.queuedBytes = Math.max(0, this.queuedBytes - operation.bytes) this.queuedOperations = Math.max(0, this.queuedOperations - 1) if (operation.lifecycle) { @@ -313,6 +330,7 @@ export class StructuredAgentSessionSinkQueue { this.deps.onFailed?.(error) } this.dropBuffered() + this.finalTail.close() this.updateBackpressure() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts index c336a1d3eca2..ff53b6492a1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts @@ -7,6 +7,8 @@ import { type AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' /** The turn the exit ended: still running, or already ended at the exit's instant by the child's @@ -26,11 +28,13 @@ export function exitedRootTurnScope( return ended ? { kind: 'turn', turnItemId: ended.itemId } : AGENT_JOURNAL_THREAD_SCOPE } -export function runningRootTurnScope( - items: readonly AgentJournalRenderItem[] +/** The newest root turn a settle ends: one still running, or one an earlier settle left + * `unverifiable` that a proof now revises, whose row no longer reads running. */ +export function settledRootTurnScope( + items: readonly AgentJournalRenderItem[], + turnEnds: readonly JournalLifecycleMutationInput[] ): AgentJournalTurnScope { - const running = items.findLast( - (item) => isRootAgentJournalItem(item) && readAgentJournalTurn(item.body)?.state === 'running' - ) - return running ? { kind: 'turn', turnItemId: running.itemId } : AGENT_JOURNAL_THREAD_SCOPE + const ended = new Set(turnEnds.map((mutation) => agentJournalItemKey(mutation.identity))) + const settled = items.findLast((item) => isRootAgentJournalItem(item) && ended.has(item.itemId)) + return settled ? { kind: 'turn', turnItemId: settled.itemId } : AGENT_JOURNAL_THREAD_SCOPE } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 979044625181..79bf84fb6d43 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -31,6 +31,8 @@ const TYPED_START_REFUSALS = [ 'managedAccountUnsupported', 'launchFolderMissing', 'historyInOtherAccount', + 'claudeAccountFolderMissing', + 'claudeAccountSetupFailed', 'agentCommandNotRunnable' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.test.ts new file mode 100644 index 000000000000..1c33de1ce122 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { StructuredAgentSessionFinalTailReservation } from './structured-agent-session-final-tail-reservation' + +describe('finalized provider tail reservations', () => { + it('bounds outstanding bytes and releases each admission once', () => { + const reservation = new StructuredAgentSessionFinalTailReservation() + const release = reservation.reserve(16 * 1024 * 1024) + expect(release).not.toBeNull() + expect(reservation.reserve(1)).toBeNull() + release?.() + release?.() + expect(reservation.reserve(16 * 1024 * 1024)).not.toBeNull() + expect(reservation.reserve(1)).toBeNull() + }) + + it('bounds operations even when they write no bytes', () => { + const reservation = new StructuredAgentSessionFinalTailReservation() + const releases = Array.from({ length: 512 }, () => reservation.reserve(0)) + expect(releases.every(Boolean)).toBe(true) + expect(reservation.reserve(0)).toBeNull() + releases[0]?.() + expect(reservation.reserve(0)).not.toBeNull() + expect(reservation.reserve(0)).toBeNull() + }) + + it('abandons outstanding reservations on close and refuses later admission', () => { + const reservation = new StructuredAgentSessionFinalTailReservation() + const release = reservation.reserve(100) + reservation.close() + release?.() + reservation.close() + expect(reservation.reserve(0)).toBeNull() + }) + + it('refuses invalid sizes', () => { + const reservation = new StructuredAgentSessionFinalTailReservation() + for (const bytes of [-1, 0.5, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(reservation.reserve(bytes)).toBeNull() + } + expect(reservation.reserve(0)).not.toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.ts new file mode 100644 index 000000000000..92b8e0f897f4 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.ts @@ -0,0 +1,38 @@ +const MAX_FINAL_TAIL_BYTES = 16 * 1024 * 1024 +const MAX_FINAL_TAIL_OPERATIONS = 512 + +/** Finalized providers can admit their bounded tail before the host takes its exit barrier. */ +export class StructuredAgentSessionFinalTailReservation { + private bytes = 0 + private operations = 0 + private closed = false + + reserve(bytes: number): (() => void) | null { + if ( + this.closed || + !Number.isSafeInteger(bytes) || + bytes < 0 || + this.bytes + bytes > MAX_FINAL_TAIL_BYTES || + this.operations >= MAX_FINAL_TAIL_OPERATIONS + ) { + return null + } + this.bytes += bytes + this.operations += 1 + let released = false + return () => { + if (released || this.closed) { + return + } + released = true + this.bytes -= bytes + this.operations -= 1 + } + } + + close(): void { + this.closed = true + this.bytes = 0 + this.operations = 0 + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 4e4247c316fa..9abd58aa0218 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -74,7 +74,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { holdClosed: async (sessionId, which) => { const session = sessions.get(sessionId) return session - ? holdClosedStructuredAgentSessionSends(deps, sessionId, session.journal, which) + ? holdClosedStructuredAgentSessionSends(deps, sessionId, session.journal, { + mark: 'settled', + which + }) : true }, failureTextContext: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 84dec78d0717..8e072e714b6d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -24,8 +24,17 @@ import type { } from './structured-agent-session-host-types' import type { StructuredAgentSessionChildExit } from './structured-agent-session-child-exit' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + markStructuredQueueReopen, + structuredAgentSessionHostInstance +} from './structured-agent-session-queued-pause' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' +import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' +import { + recordStructuredAgentSessionShutdownCut, + runningRootTurnItemId +} from './structured-agent-session-orca-stop-row' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' export type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' import { recordStopEvent, @@ -61,31 +70,62 @@ type ConversationCloseDeps = Pick & { } /** What is still queued when the chat closes will not be handed over: a person's message is kept - * as a held card, the rest rejected (`journal-unsent-send-hold.ts`). A quit is not a close: the - * next open settles what it left. `which` narrows it to the messages a close that did not complete + * as a card that waits for the chat's next turn, the rest rejected (`journal-unsent-send-hold.ts`). + * A quit is not a close: the next open settles what it left. The chat stops running, so the + * reopen mark follows (`markStructuredQueueReopen`): on every `close`, or only once it settled a + * send, for a later re-check of the same close, which must never mark past a new send. `which` narrows it to the messages a close that did not complete * closed. Best effort, so a close never waits on it: resolves false when it failed, reported and * never thrown. */ export async function holdClosedStructuredAgentSessionSends( deps: ConversationCloseDeps, sessionId: string, journal: StructuredAgentSessionHostSession['journal'], - which?: (submission: AgentJournalSubmission) => boolean + close: { mark: 'always' | 'settled'; which?: (submission: AgentJournalSubmission) => boolean } ): Promise { - return holdUnsentSends(journal, { - fence: structuredAgentSessionConversationFence(deps.store, sessionId), + const fence = structuredAgentSessionConversationFence(deps.store, sessionId) + const { which } = close + const settled = await holdUnsentSends(journal, { + fence, hostInstance: structuredAgentSessionHostInstance(), hold: { cause: 'chatClosed', ...(which ? { which } : {}) } }).then( - () => true, + (newest) => ({ ok: true, newest }), (error: unknown) => { deps.logger.warn('settling queued messages of a closed chat failed', { scope: 'queued-abandon', sessionId, error }) - return false + return { ok: false, newest: null } } ) + if (close.mark === 'always') { + await markStructuredQueueReopen(sessionId, journal, fence, deps.logger) + } else if (settled.newest !== null) { + // A send that woke this re-check came after the ones it settled: the mark starts at them. + await markStructuredQueueReopen(sessionId, journal, fence, deps.logger, settled.newest + 1) + } + return settled.ok +} + +/** What an earlier host process left queued and the open could not settle (its write failed): + * kept or rejected now, never handed over. A failure throws. A send that woke this came after the + * ones it settled, so the reopen mark starts where they did. */ +export async function holdRestartedStructuredAgentSessionSends( + logger: StructuredAgentSessionHostDeps['logger'], + sessionId: string, + journal: StructuredAgentSessionHostSession['journal'], + fence: number +): Promise { + const hostInstance = structuredAgentSessionHostInstance() + const settled = await holdUnsentSends(journal, { + fence, + hostInstance, + hold: { cause: 'hostRestarted' } + }) + if (settled !== null) { + await markStructuredQueueReopen(sessionId, journal, fence, logger, settled + 1) + } } /** @@ -128,6 +168,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( ) } const { close } = child + // Read before the kill: the turn a quit's stop may cut. + const quitCuts = 'quit' in ending && ending.quit ? runningRootTurnItemId(session.journal) : null try { if (context.restartWitness) { await snapshotBeforeStructuredAgentSessionStop( @@ -146,6 +188,22 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( new Error('provider child exit was not proven') ) } + // The exit handler has settled the turn by now, so the row reads its verdict. + if ('quit' in ending && ending.quit) { + const record = context.deps.store.getRecord(sessionId) + await recordStructuredAgentSessionShutdownCut({ + journal: session.journal, + sessionId, + fence: child.fence, + generation: child.generation ?? 'unknown', + turnItemId: quitCuts, + trigger: ending.quit, + ...(record + ? { failureTextContext: structuredAgentSessionFailureWordsContext(record) } + : {}), + logger: context.deps.logger + }) + } } finally { // A person's close binds what its child's end cut; done, proven or not, it binds no more. void close?.recorded.then((settle) => session.journal.stopMarks.settled(settle)) @@ -159,14 +217,18 @@ export type StructuredAgentSessionCloseCause = Extract< > /** Whether the conversation's handle is only a cache now: no child, and nothing queued or waiting - * on the provider. */ + * on the provider. `atRest`: the idle sweep's own close, which also keeps a chat with a card + * waiting, since a reopen marks it to wait for a turn (`markStructuredQueueReopen`), and an + * eviction the person never saw must not; a person's close has marked them already. */ export function structuredAgentSessionConversationClosable( - session: StructuredAgentSessionHostSession + session: StructuredAgentSessionHostSession, + atRest = false ): boolean { return ( session.child === null && !session.journal.submissions().some(isQueuedAgentJournalSubmission) && - session.journal.pendingSubmissions().length === 0 + session.journal.pendingSubmissions().length === 0 && + !(atRest && session.journal.queuedMessages.awaitReopenMark()) ) } @@ -181,10 +243,11 @@ export async function closeStructuredAgentSessionConversationUnderSerialize( /** The status row outlives the handle; see `StructuredAgentSessionClientDelivery`. */ closeStatus: (sessionId: string) => void }, - sessionId: string + sessionId: string, + atRest = false ): Promise { const session = context.sessions.get(sessionId) - if (!session || !structuredAgentSessionConversationClosable(session)) { + if (!session || !structuredAgentSessionConversationClosable(session, atRest)) { return false } context.sessions.delete(sessionId) @@ -198,7 +261,8 @@ export async function evictOwnedStructuredAgentSessions( context: StructuredAgentSessionLifetimeContext & { serialize: (sessionId: string, task: () => Promise) => Promise }, - retainOnFailure: Set + retainOnFailure: Set, + trigger: AgentSessionResumeTrigger = 'quit' ): Promise { const ownedSessionIds = [...context.sessions] .filter(([, session]) => session.child !== null) @@ -216,7 +280,7 @@ export async function evictOwnedStructuredAgentSessions( await context.serialize(sessionId, () => stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { cause: 'evict', - quit: true + quit: trigger }) ) retainOnFailure.delete(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts index 1c9e2720aca9..85629500a0b4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts @@ -9,16 +9,18 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import type { JournalStopSettle } from '../agent-session-journal/queued-message-pause' import { sentSinceStop } from './structured-agent-session-queued-stop' +import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' /** How a stop ends the child, and why (`lastEndedChild`). A person's Stop wrote its event in its * own step (`recorded` names its reason); any other stop names the reason its event records, with - * the host's text for it. Quit writes none: its resume marker's trigger records why. */ + * the host's text for it. Quit writes none: its row about a turn it cut says why. */ export type StructuredAgentSessionStopEnding = | { recorded: 'user-stop' } | { cause: Exclude reason?: string - quit?: true + /** The app is quitting, and why: a turn this stop cuts gets a row naming it. */ + quit?: AgentSessionResumeTrigger /** The idle sweep judged the agent resting (`owesWork`): a send it retires unanswered is * no work its event records. */ resting?: true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 0c9e59fba766..759075639593 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -174,7 +174,8 @@ export async function flushStructuredAgentSessionHost( stopped: context.restartResume.confirmStopped } }, - retainSessionIds + retainSessionIds, + context.trigger ), beginResumeMarkers: () => context.restartResume.beginTeardown(context.trigger), recordResumeMarkers: context.restartResume.recordMarkers, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts index a96388ac4ca3..c7e2e0390783 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -6,7 +6,7 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-ses import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import { projectNativeChatTranscriptMessages } from '../../../shared/native-chat-transcript-projection' import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' -import { createStructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionSendBody } from '../../../shared/structured-agent-session-send-mutation' import { attachFingerprintFields } from './structured-agent-session-attach' import type { AgentSessionAttachParams } from './structured-agent-session-attach' @@ -74,17 +74,13 @@ export function hostTestDrawnRowIds( snapshot: AgentJournalSnapshot, sent: readonly { clientMessageId: string; text: string }[] ): string[] { - const outbox = sent.map((message) => ({ - ...createStructuredAgentSessionOutboxEntry({ - ...message, - sessionId: HOST_TEST_SESSION, - attachments: [], - queuedAt: HOST_TEST_NOW - }), - state: 'dispatching' as const + const optimistic = sent.map((message) => ({ + clientMessageId: message.clientMessageId, + body: structuredAgentSessionSendBody(message.text, []), + queuedAt: HOST_TEST_NOW })) return projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages(snapshot.items, outbox, snapshot.submissions, { + projectStructuredAgentSessionMessages(snapshot.items, optimistic, snapshot.submissions, { rejectedInPlace: true }) ).map(({ id }) => id) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts new file mode 100644 index 000000000000..ee3f3901ea2c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts @@ -0,0 +1,227 @@ +// Continue on a reply an Orca stop cut off: the restart continuation, bound to the cut turn and +// re-checked under the session lock, so it works with or without a restart offer and never sends +// twice. + +import { expect, it, vi } from 'vitest' +import { + AGENT_SESSION_RESTART_CONTINUATION_MESSAGE, + AGENT_SESSION_RESTART_CONTINUATION_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + restartContinuationMessage +} from '../../../shared/agent-session-restart-continuation' +import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut' +import { CALLER, envelope } from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' +import { + interruptedRestart, + statusNotes +} from './structured-agent-session-restart-interruption-test-harness' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { previousExitUnverifiableRefusal } from './structured-agent-session-child-close' + +type Restarted = Awaited> + +async function cutTurn(host: StructuredAgentSessionHost): Promise { + const { items } = await host.journalSnapshot(SESSION) + const cut = latestNativeChatOrcaStopCut(items, []) + if (!cut) { + throw new Error('the restart left no Orca-stop cut') + } + return cut.turnItemId +} + +function sentTexts(dispatch: Restarted['dispatch']): string[] { + return dispatch.mock.calls.map(([input]) => + input.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('') + ) +} + +it('continues with no restart offer, as Orca itself, and the chat says so', async () => { + const { host, dispatch } = await interruptedRestart() + // What a crash, a paired server's restart or a dismissed offer leaves: no offer at all. + await host.restartResume.dismiss() + expect(await host.restartResume.list()).toEqual([]) + const send = vi.spyOn(host, 'send') + + const answer = await host.restartResume.continueInterrupted(SESSION, await cutTurn(host)) + + expect(answer).toMatchObject({ + sessionId: SESSION, + outcome: expect.stringMatching(/pending|continued/) + }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(sentTexts(dispatch)).toEqual([AGENT_SESSION_RESTART_CONTINUATION_MESSAGE]) + // Sent under Orca's own caller, so it reads as Orca's and leaves a queue pause alone. + expect(send.mock.calls[0]?.[0]).toEqual({ + callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER + }) + await vi.waitFor(async () => + expect(JSON.stringify(await statusNotes(host))).toContain( + AGENT_SESSION_RESTART_CONTINUATION_NOTE + ) + ) +}) + +it('continues a chat that still has its restart offer, in its words, and the offer is spent', async () => { + const { host, dispatch, marker } = await interruptedRestart() + expect(await host.restartResume.list()).toHaveLength(1) + + await host.restartResume.continueInterrupted(SESSION, await cutTurn(host)) + + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(sentTexts(dispatch)).toEqual([restartContinuationMessage(marker!)]) + expect(await host.restartResume.list()).toEqual([]) +}) + +it('sends once for two clicks at the same moment', async () => { + const { host, dispatch } = await interruptedRestart() + const turnItemId = await cutTurn(host) + + const answers = await Promise.all([ + host.restartResume.continueInterrupted(SESSION, turnItemId), + host.restartResume.continueInterrupted(SESSION, turnItemId) + ]) + + expect(answers.filter((answer) => answer.outcome === 'superseded')).toHaveLength(1) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) +}) + +it('sends nothing for a retry after an answer that was lost', async () => { + const { host, dispatch } = await interruptedRestart() + const turnItemId = await cutTurn(host) + await host.restartResume.continueInterrupted(SESSION, turnItemId) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + + // The client never heard back, so it asks again for the same cut. + const retry = await host.restartResume.continueInterrupted(SESSION, turnItemId) + + expect(retry).toEqual({ sessionId: SESSION, outcome: 'superseded' }) + expect(dispatch).toHaveBeenCalledOnce() +}) + +it("sends nothing once the user's own message came first", async () => { + const { host, dispatch } = await interruptedRestart() + const turnItemId = await cutTurn(host) + const body = hostTestMessage('A new request') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + + const answer = await host.restartResume.continueInterrupted(SESSION, turnItemId) + + expect(answer).toEqual({ sessionId: SESSION, outcome: 'superseded' }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(sentTexts(dispatch)).toEqual(['A new request']) +}) + +it('sends nothing for a turn that is not the cut the chat sits on', async () => { + const { host, dispatch } = await interruptedRestart() + + const answer = await host.restartResume.continueInterrupted(SESSION, 'some-other-turn') + + expect(answer).toEqual({ sessionId: SESSION, outcome: 'superseded' }) + expect(dispatch).not.toHaveBeenCalled() +}) + +it('continues when reading the restart offer hangs', async () => { + const { host, dispatch } = await interruptedRestart() + const turnItemId = await cutTurn(host) + vi.spyOn(host.deps.recoveryCapsule!, 'list').mockReturnValue(new Promise(() => {})) + + await host.restartResume.continueInterrupted(SESSION, turnItemId) + + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) +}, 10_000) + +it('continues when the restart offer cannot be read', async () => { + const { host, dispatch } = await interruptedRestart() + const turnItemId = await cutTurn(host) + vi.spyOn(host.deps.recoveryCapsule!, 'list').mockRejectedValue(new Error('unreadable')) + + await host.restartResume.continueInterrupted(SESSION, turnItemId) + + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) +}) + +it('answers once Orca accepted it, while the agent is still starting', async () => { + const { host, acquire, dispatch } = await interruptedRestart() + const turnItemId = await cutTurn(host) + const start = acquire.getMockImplementation() + if (!start) { + throw new Error('the harness acquire has no implementation') + } + let finishStarting!: () => void + const starting = new Promise((resolve) => { + finishStarting = resolve + }) + // An agent start slower than a paired client's wait for the answer. + acquire.mockImplementationOnce(async (input) => { + await starting + return start(input) + }) + + const answer = await host.restartResume.continueInterrupted(SESSION, turnItemId) + + expect(answer).toEqual({ sessionId: SESSION, outcome: 'pending' }) + expect(dispatch).not.toHaveBeenCalled() + finishStarting() + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) +}) + +it('says in the chat when the agent cannot start after the click was answered', async () => { + const { host, acquire } = await interruptedRestart() + const turnItemId = await cutTurn(host) + acquire.mockRejectedValueOnce(new Error('the agent could not start')) + + const answer = await host.restartResume.continueInterrupted(SESSION, turnItemId) + + expect(answer).toEqual({ sessionId: SESSION, outcome: 'pending' }) + await vi.waitFor(async () => + expect(await statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + tone: 'error' + }) + ) +}) + +it('continues a reply the user had steered before Orca stopped', async () => { + const { host, dispatch, store, marker } = await interruptedRestart() + const turnItemId = await cutTurn(host) + const fence = store.getRecord(SESSION)?.lease.runtimeFence + const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (fence === undefined || fence === null || !journal) { + throw new Error('the restarted chat has no open journal') + } + // A steer the cut turn took: the client still offers Continue, so the host must agree. + await journal.appendItem( + { provider: 'orca', clientMessageId: 'steer' }, + hostTestMessage('also update the tests'), + { fence, turnScope: { kind: 'turn', turnItemId } } + ) + + const answer = await host.restartResume.continueInterrupted(SESSION, turnItemId) + + expect(answer).toEqual({ sessionId: SESSION, outcome: 'pending' }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(sentTexts(dispatch)).toEqual([restartContinuationMessage(marker!)]) +}) + +it('answers a refusal before anything was accepted, and a retry adds no note to the chat', async () => { + const { host, dispatch } = await interruptedRestart() + const turnItemId = await cutTurn(host) + const notesBefore = await statusNotes(host) + vi.spyOn(host, 'send').mockResolvedValue({ + ok: false, + refusal: previousExitUnverifiableRefusal() + }) + + const first = await host.restartResume.continueInterrupted(SESSION, turnItemId) + const retry = await host.restartResume.continueInterrupted(SESSION, turnItemId) + + expect([first.outcome, retry.outcome]).toEqual(['refused', 'refused']) + // The click's answer is the one report; the chat stays as it was. + expect(await statusNotes(host)).toEqual(notesBefore) + expect(dispatch).not.toHaveBeenCalled() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.ts new file mode 100644 index 000000000000..49420832226e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.ts @@ -0,0 +1,119 @@ +// Continue, pressed in a chat whose reply an Orca stop cut off: the restart continuation, bound to +// that cut turn rather than to a restart offer. An offer, when one survives, only picks the words; +// none is needed, so a crash or a paired server's restart continues the same way. + +import { createHash, randomUUID } from 'node:crypto' +import { withTimeout } from '../../../shared/promise-timeout-fallback' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut' +import { + continuationDeps, + RestartContinuationSupersededError, + startStructuredAgentSessionContinuation, + type StructuredAgentSessionContinuationHost, + type StructuredAgentSessionContinuationOutcome +} from './structured-agent-session-restart-continuation' + +export type StructuredAgentSessionInterruptedContinuationOutcome = { + sessionId: string + /** `superseded`: the chat is no longer sitting on that cut (a message, or another Continue, came + * first), so nothing was sent. */ + outcome: StructuredAgentSessionContinuationOutcome['outcome'] | 'superseded' + reason?: string +} + +const hex16 = (parts: readonly unknown[]): string => + createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16) + +/** Dated by the click, as the ledger requires of a new id; tagged with the cut it continues. */ +function interruptedContinuationId( + sessionId: string, + turnItemId: string, + actionAt: number, + nonce: string +): string { + return `${Math.trunc(actionAt).toString().padStart(13, '0')}-${hex16([sessionId, turnItemId])}${hex16([sessionId, turnItemId, nonce])}` +} + +/** + * Sends the continuation when, at acceptance inside the session lock, the chat's latest turn is + * still `turnItemId`, cut by an Orca stop with nothing sent since. A second Continue (another + * client, a retry after a lost answer) finds the first one's message there and sends nothing. + */ +export async function continueInterruptedStructuredAgentSessionTurn( + host: Omit, + input: { + sessionId: string + turnItemId: string + /** The chat's restart offer, when one could be read: its words cover lost background work. */ + offer: Pick | null + nonce: string + } +): Promise { + const { sessionId, turnItemId } = input + const stillCut = (): boolean => { + const journal = host.sessions.get(sessionId)?.journal + return ( + journal !== undefined && + latestNativeChatOrcaStopCut(journal.snapshot().items, journal.submissions())?.turnItemId === + turnItemId + ) + } + const continuationId = interruptedContinuationId(sessionId, turnItemId, host.now(), input.nonce) + try { + const started = await startStructuredAgentSessionContinuation( + // As a send answers: a paired client's wait is shorter than an agent's start can be. + { ...continuationDeps(host, stillCut), answerAtAcceptance: true }, + sessionId, + input.offer ?? {}, + continuationId + ) + if ('verdict' in started) { + // Accepted: the agent's start and answer can take a whole turn, and the chat's own note + // reports a failure, so the click is answered now. + void started.verdict().catch(() => { + host.logger.warn('settling an interrupted-turn continuation failed', { + scope: 'interrupted-continuation', + sessionId + }) + }) + return { sessionId, outcome: 'pending' } + } + return { + sessionId, + outcome: started.done.outcome, + ...(started.done.reason ? { reason: started.done.reason } : {}) + } + } catch (error) { + if (error instanceof RestartContinuationSupersededError) { + return { sessionId, outcome: 'superseded' } + } + throw error + } +} + +/** Reading the offer only picks the words, so a slow recovery file never holds Continue. */ +const OFFER_READ_TIMEOUT_MS = 2_000 + +/** Continue for one host: its offer, when one can be read in time, picks the words. */ +export function createInterruptedContinuation( + host: Omit, + readOffers: () => Promise +): ( + sessionId: string, + turnItemId: string +) => Promise { + return async (sessionId, turnItemId) => { + const offers = await withTimeout( + readOffers().catch(() => []), + OFFER_READ_TIMEOUT_MS, + [] + ) + return continueInterruptedStructuredAgentSessionTurn(host, { + sessionId, + turnItemId, + offer: offers.find((offer) => offer.sessionId === sessionId) ?? null, + nonce: randomUUID() + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts index 9a8b5b62a8d0..736e18c80b13 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts @@ -53,7 +53,8 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag await attach() // The attach's restart-offer withdrawal holds a lock file until it ends; snapshot after it. await hostTestRecoveryCapsuleSettled() - const files = await readdir(root, { recursive: true }) + // Order-free: recursive listing order is the runtime's, and only what exists matters. + const files = (await readdir(root, { recursive: true })).toSorted() const damaged = sqliteError('database disk image is malformed', 11) vi.spyOn(openTestJournalHostDatabase(root), 'transaction').mockImplementation(() => { throw damaged @@ -81,7 +82,7 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag error: expect.objectContaining({ message: 'database disk image is malformed' }) }) await hostTestRecoveryCapsuleSettled() - expect(await readdir(root, { recursive: true })).toEqual(files) + expect((await readdir(root, { recursive: true })).toSorted()).toEqual(files) }) it.each([ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts index 15f4e6523f0b..58510a7231b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts @@ -1,5 +1,6 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home' type LaunchEnvResolver = ( provider: AgentSessionRecord['provider'] @@ -19,7 +20,9 @@ export async function pinnedAgentSessionLaunchEnv( return { launchEnv: { ...(await resolver(params.provider)), - [params.accountHome.variable]: params.accountHome.path + ...(isLegacyAgentSessionAccountHome(params.accountHome) + ? { [params.accountHome.variable]: params.accountHome.path } + : {}) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts new file mode 100644 index 000000000000..74b5abe1bc31 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts @@ -0,0 +1,104 @@ +// The row about Orca's own stop, as each kind of client reads it. A client that predates the cause +// prints today's row, red and on screen; this build names the cause, muted, and never folds it. + +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' +import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut' +import { nativeChatTurnFold } from '../../../shared/native-chat-turn-fold' +import { orcaStopRowBody } from './structured-agent-session-orca-stop-row' + +const LEGACY_TEXT = + 'Codex stopped while this response was in progress. You can continue in this conversation.' +const TURN = agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 1 }) + +function cutChat(row: AgentJournalRenderItem['body']): AgentJournalRenderItem[] { + const scope = { kind: 'turn' as const, turnItemId: TURN } + return [ + { + itemId: agentJournalItemKey({ provider: 'orca', clientMessageId: 'user-1' }), + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } + }, + { + itemId: TURN, + revision: 2, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', turnId: 'cut', state: 'interrupted', startedAt: 1, completedAt: 5 } + }, + { + itemId: agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 2 }), + revision: 1, + sequence: 3, + observedAt: 3, + turnScope: scope, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Looking.' }] } + }, + { + itemId: agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'stale-session:session-1:shutdown-3-gen-1' + }), + revision: 1, + sequence: 4, + observedAt: 6, + turnScope: scope, + body: row + } + ] +} + +describe('the row a host writes for a cut it knows the cause of', () => { + const row = orcaStopRowBody({ agentName: 'Codex' }, 'update') + + it("keeps today's words and tone, and names its presentation and cause beside them", () => { + expect(row).toMatchObject({ + kind: 'status', + text: LEGACY_TEXT, + tone: 'error', + failure: { kind: 'providerExited' }, + presentation: 'orca-stop', + orcaStop: { cause: 'update' } + }) + }) + + it('reads, on a client that predates the cause, as the one row it prints today, on screen', () => { + const items = cutChat(row) + const read = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + expect(read).toBe(items) + expect(read.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : []))).toEqual([ + LEGACY_TEXT + ]) + // That client's fold reads only the stored tone: a red row is the turn's end, never folded. + const rows = read.map((item) => ({ + turnKey: item.itemId === read[0]!.itemId ? undefined : TURN, + role: item.body.kind === 'message' ? item.body.role : ('system' as const), + rendersProse: item.body.kind !== 'turn', + draws: true, + outlivesTurn: false, + reportsFailure: item.body.kind === 'status' && item.body.tone === 'error', + explainsTurn: false + })) + const { foldedRows } = nativeChatTurnFold({ + rows, + settledTurnKeys: new Set([TURN]), + expandedTurnKeys: new Set() + }) + expect(foldedRows.has(3)).toBe(false) + }) + + it('offers Continue on this build', () => { + expect(latestNativeChatOrcaStopCut(cutChat(row), [])).toEqual({ + turnItemId: TURN, + cause: 'update' + }) + }) + + it('names no presentation when the cause is unknown, so it reads as any owner death', () => { + expect(orcaStopRowBody({ agentName: 'Codex' }, undefined)).not.toHaveProperty('presentation') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row.ts new file mode 100644 index 000000000000..1778f7e43977 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row.ts @@ -0,0 +1,120 @@ +// The row that says why a quit or update cut a reply: the cut turn's own explanation, in the words +// every client already prints for a stopped agent, with the cause beside them for clients that +// name it (`AgentSessionOrcaStop`). + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { + AGENT_SESSION_ORCA_STOP_PRESENTATION, + isAgentSessionOrcaStopCause +} from '../../../shared/agent-session-orca-stop' +import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' +import type { + AgentJournalRenderItem, + AgentJournalStatusItem +} from '../../../shared/agent-session-journal-types' +import { + readAgentJournalTurn, + readAgentJournalTurnOutcome +} from '../../../shared/agent-session-turn-record' +import { agentTurnVerdict } from '../../../shared/agent-turn-outcome' +import { orcaShutdownRowClientMessageId } from '../../../shared/native-chat-orca-stop-cut' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' + +type OrcaStopRowJournal = Pick + +/** The row about an owner gone from under a turn: today's words and tone, which every client prints + * as it always has, and why Orca stopped when it was Orca, which a client that knows the cause names + * instead, muted. */ +export function orcaStopRowBody( + context: AgentSessionFailureWordsContext | undefined, + orcaEnd: unknown +): AgentJournalStatusItem { + return { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + ...context, + surface: 'row' + }), + tone: 'error', + ...(isAgentSessionOrcaStopCause(orcaEnd) + ? { presentation: AGENT_SESSION_ORCA_STOP_PRESENTATION, orcaStop: { cause: orcaEnd } } + : {}) + } +} + +/** The root turn running as the quit stops the child: the one its stop may cut. */ +export function runningRootTurnItemId( + journal: Pick +): string | null { + return ( + journal + .snapshot() + .items.findLast( + (item) => + isRootAgentJournalItem(item) && readAgentJournalTurn(item.body)?.state === 'running' + )?.itemId ?? null + ) +} + +function cutByNobody(item: AgentJournalRenderItem | undefined): boolean { + const turn = item ? readAgentJournalTurn(item.body) : null + return ( + turn !== null && + agentTurnVerdict({ state: turn.state, outcome: readAgentJournalTurnOutcome(turn) }) === + 'interruption' + ) +} + +/** + * Once the quit's stop has settled: when the turn running at the stop now reads cut with nobody + * asking (not finished, not a person's Stop), say why. Never throws: a row that cannot be written + * must not keep the quit from releasing the chat. + */ +export async function recordStructuredAgentSessionShutdownCut(input: { + journal: OrcaStopRowJournal + sessionId: string + fence: number + generation: string + turnItemId: string | null + trigger: AgentSessionResumeTrigger + failureTextContext?: AgentSessionFailureWordsContext + logger: StructuredAgentSessionLogger +}): Promise { + try { + const { turnItemId } = input + if (turnItemId === null) { + return + } + const clientMessageId = orcaShutdownRowClientMessageId( + input.sessionId, + input.fence, + input.generation + ) + const items = input.journal.snapshot().items + const written = items.some((item) => { + const identity = parseAgentJournalItemKey(item.itemId) + return identity?.provider === 'orca' && identity.clientMessageId === clientMessageId + }) + if (written || !cutByNobody(items.find((item) => item.itemId === turnItemId))) { + return + } + await input.journal.appendItem( + { provider: 'orca', clientMessageId }, + orcaStopRowBody(input.failureTextContext, input.trigger), + { fence: input.fence, turnScope: { kind: 'turn', turnItemId } } + ) + } catch (error) { + input.logger.warn('recording why a quit cut a reply failed', { + scope: 'shutdown-cut-row', + sessionId: input.sessionId, + error + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index a0f996c80b12..0a04d6cddfa5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -2,6 +2,10 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE +} from '../../../shared/claude-profile-routing' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { mapRuntimeError } from '../../runtime/rpc/errors' @@ -169,6 +173,18 @@ describe('a create that fails before any process spawns', () => { 'historyInOtherAccount', "This chat's history is in another Claude account. Switch back to that account to continue it." ], + [ + 'a selected Claude account whose folder is gone', + CLAUDE_PROFILE_MISSING_MESSAGE, + 'claudeAccountFolderMissing', + CLAUDE_PROFILE_MISSING_MESSAGE + ], + [ + 'a selected Claude account that could not be set up', + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + 'claudeAccountSetupFailed', + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + ], [ 'a Command setting that names no runnable program', 'the claude Command setting is not a runnable program', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 13edb107a4b4..8c98a685c7d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -29,6 +29,7 @@ import { ensureStructuredAgentSessionAgent } from './structured-agent-session-ag import { StructuredAgentSessionHost } from './structured-agent-session-host' import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { HOST_TEST_LOCATION, HOST_TEST_NOW as NOW, @@ -773,7 +774,7 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(await statusRows()).toEqual([]) }) - it('keeps a person’s message the user closed as a held card, and starts no child for it', async () => { + it('keeps a person’s message the user closed as a waiting card, and starts no child for it', async () => { const start = deferred() adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() @@ -797,6 +798,15 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(page.ok && page.page.queuedMessages?.map((card) => card.messageId)).toEqual([first]) expect(acquire).toHaveBeenCalledTimes(starts) expect(dispatch).not.toHaveBeenCalled() + // The close stopped the chat running: the card waits for its next turn, by a mark the + // re-check wrote once, when it kept the card. + const journal = conversation()!.journal + expect(structuredQueuePauses(journal).map((pause) => pause.reason)).toEqual(['restarted']) + const marks = vi.spyOn(journal, 'appendQueueReopen') + await accept('second', { person: true }) + await settleLoop() + // A later send's re-check settles nothing, so it marks nothing past that send. + expect(marks).not.toHaveBeenCalled() }) it('starts no child when closing what was queued fails, and closes it on the next wake', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index a726affd84dc..9215c4908445 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -4,6 +4,7 @@ import type { } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' +import { agentPinsAccountHome } from '../../../shared/agent-session-account-home' export function adapterSupportsCreate( adapter: StructuredAgentSessionAdapter, @@ -31,7 +32,7 @@ export function adapterSupportsCreateIfDeclared( } /** Whether this build can start the agent of `session`: the agent is registered here, its account - * variable is that agent's own (it becomes the child's environment), and every handle it holds is + * is the kind that agent pins (it becomes the child's environment), and every handle it holds is * in the transport the agent's adapter speaks. A record failing this stays readable (tab, history); * only starting its agent is refused, by the one launch admission every agent passes through. */ export function agentDrivesSession( @@ -41,7 +42,7 @@ export function agentDrivesSession( const definition = agents.definition(session.provider) return ( definition !== null && - session.accountHome.variable === definition.accountHomeVariable && + agentPinsAccountHome(definition, session.accountHome) && session.providerHandleChain.every( ({ handle }) => handle.transport === definition.handleTransport && handle.agent === definition.agent diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index 10b48321bc9a..d4050e8e39ad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -21,7 +21,7 @@ import { nativeChatComposerPrimaryAction, type NativeChatComposerPrimaryAction } from '../../../renderer/src/components/native-chat/native-chat-composer-primary-action' -import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { readQueuePublication, structuredQueueSendGate @@ -512,11 +512,17 @@ describe("the queue's next card on a history page", () => { const card = await queuedDraft('held, then released') await rig.stop() await rig.settleAccepted(working, 'stopped') - // The Resume row is written; its adoption, and so the drain behind it, waits. + // The Resume row is written; the Resume itself, and so the drain behind it, waits. let release: () => void = () => undefined - const adopt = vi - .spyOn(JournalQueuedMessages.prototype, 'adopt') - .mockImplementationOnce(() => new Promise((resolve) => (release = () => resolve(false)))) + const held = new Promise((resolve) => (release = resolve)) + const write = AgentSessionJournal.prototype.appendQueueResume + const resume = vi + .spyOn(AgentSessionJournal.prototype, 'appendQueueResume') + .mockImplementationOnce(async function (this: AgentSessionJournal, fence: number) { + const cursor = await write.call(this, fence) + await held + return cursor + }) const resumed = rig.resume() try { await eventually(async () => { @@ -525,7 +531,7 @@ describe("the queue's next card on a history page", () => { }) } finally { release() - adopt.mockRestore() + resume.mockRestore() } expect(await resumed).toMatchObject({ ok: true, value: { resumed: true } }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts index dda3d2112cd3..7ab0fcfc1f9f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -152,7 +152,8 @@ describe('/clear', () => { { messageId: firstId, state: 'waiting' }, { messageId: secondId, state: 'waiting' } ]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + // Held, unshown: nothing runs in the fresh conversation. + expect(await rig.queuePause(replacementId)).toBeNull() const body = hostTestMessage('first in the new chat') const fields = { body, delivery: 'queue-if-active' as const } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index a0e3af88c49b..fd3294ddc7d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -203,13 +203,10 @@ export async function createQueuedMessageTestRig( }) } - /** A host-process restart, as the queue sees it: the conversation closes, and - * opens afresh under a new instance id while its rows survive. The close is an eviction, whose - * Stop event ends a person's Stop pause if work runs; a quit writes none, so a test of that - * pause across a restart uses `crashRestartHostProcess`. */ + /** A host-process restart: the app quits (its own teardown runs) and a new host opens the same + * state. A quit writes no close's Stop event, so a person's Stop pause survives it. */ async function restartHostProcess(): Promise { - await host.close(SESSION, 'evict') - rotateStructuredAgentSessionHostInstanceForTests() + await quitRestartHostProcess() } /** A host process that dies with no close: a new host opens the same state directory. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index 686940c4fa77..8e9344352358 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -298,7 +298,7 @@ describe('held drafts', () => { await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() expect(await rig.queuePause()).toBeNull() - // The user's send starting its turn lifts it, and adopts the row into this instance. + // The user's send starting its turn lifts it. const next = send('user starts a new turn') await next.result expect(await rig.handoff(draftId)).toBeUndefined() @@ -420,10 +420,12 @@ describe('Stop and Delete', () => { await stop() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) await settleAccepted(working, 'a') - // Evict the handle and reopen (the history read opens the conversation at - // rest): the pause is derived from what the journal holds, so nothing drains. + // Close and reopen it (the history read opens it at rest): the derived pause still holds, so + // nothing drains; a close hides the row, as nothing runs there until its next turn. await host.close(SESSION, 'evict') - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.queuePause()).toBeNull() + const reopened = host.collaboratorsForTests().sessions.get(SESSION)!.journal + expect(structuredQueuePauses(reopened).map((pause) => pause.reason)).toContain('stopped') await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() // Send-now overrides the pause — the user acting is a release. @@ -614,15 +616,15 @@ describe('/clear', () => { if (!replacementId) { throw new Error('expected a replacement session') } - // The source's cards are spent tombstones; the replacement shows them on a - // queue paused by the clear — not "because you interrupted" — until the user - // acts: Resume, or their next send starting its turn. + // The source's cards are spent tombstones; the replacement shows them as plain waiting cards, + // held by the clear with no row (nothing runs there), until the user acts: Resume, or their + // next send starting its turn. expect(await drafts()).toHaveLength(0) expect(await drafts(replacementId)).toEqual([ { messageId: firstId, state: 'waiting' }, { messageId: secondId, state: 'waiting' } ]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await rig.queuePause(replacementId)).toBeNull() // Paused from before the first carried card lands: the idle replacement auto-sends nothing. await new Promise((resolve) => setTimeout(resolve, 250)) expect((await host.journalSnapshot(replacementId)).submissions).toHaveLength(0) @@ -663,14 +665,14 @@ describe('/clear', () => { ) }) - it("the replacement's 'cleared' pause lifts through Resume exactly like a Stop's", async () => { + it("the replacement's unshown 'cleared' pause lifts through Resume exactly like a Stop's", async () => { const [firstId] = await pausedDrafts() const cleared = await clear(hostTestOperationId()) const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined if (!replacementId) { throw new Error('expected a replacement session') } - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await rig.queuePause(replacementId)).toBeNull() const resumed = await host.queuedMessagesResume(CALLER, { envelope: envelope( {}, @@ -733,7 +735,7 @@ describe('/clear', () => { // The refusal belonged to the source's submissions; on the replacement the // text is simply a waiting draft again, behind the replacement's pause. expect(await drafts(replacementId)).toEqual([{ messageId: draftId, state: 'waiting' }]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await rig.queuePause(replacementId)).toBeNull() expect(await drafts()).toHaveLength(0) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 3acc45552aa6..bceef9cb308e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -31,6 +31,8 @@ import { } from './structured-agent-session-queued-pause' import { nextSendableQueuedCard } from '../agent-session-journal/queued-message-pause' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { agentSessionAttachmentExpiredRefusal } from './structured-agent-session-turns' +import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' /** Budget at accept, in the send schema's own unit (`Buffer.byteLength` of the * serialized blocks); refused readably rather than trimmed. */ @@ -200,6 +202,8 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + /** A person's send at a chat surface: every attachment it names must still be stored. */ + userSend?: true } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -235,16 +239,25 @@ export async function maybeQueueStructuredAgentSessionSend( } // The insert notifies through the journal's commit listener: publication and // the drain re-derive with no call here to forget. - const row = await ctx.journal.queuedMessages.insert( - { - messageId: clientMessageId, - body: params.body, - // In the session that will send it: the reducer aliases the provider's echo by exactly this. - fingerprint: agentSessionSendBodyFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance() - }, - ctx.operationReceipt - ) + let row: QueuedMessageRow + try { + row = await ctx.journal.queuedMessages.insert( + { + messageId: clientMessageId, + body: params.body, + // In the session that will send it: the reducer aliases the provider's echo by exactly this. + fingerprint: agentSessionSendBodyFingerprint(ctx.sessionId, params.body), + hostInstance: structuredAgentSessionHostInstance(), + ...(params.userSend ? { requireAttachments: true } : {}) + }, + ctx.operationReceipt + ) + } catch (error) { + if (isAgentSessionAttachmentExpiredError(error)) { + return agentSessionAttachmentExpiredRefusal() + } + throw error + } return { ok: true, value: { @@ -367,7 +380,7 @@ export class StructuredAgentSessionQueuedMessageDrain { expect: 'waiting', settledByOp: null, hostInstance: structuredAgentSessionHostInstance(), - yieldsToPause: { hostInstance: structuredAgentSessionHostInstance() } + yieldsToPause: true } ) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index b637e1fc89e4..3ba5bbb612e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -7,13 +7,12 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - type AgentSessionMutationEnvelope, - type AgentSessionMutationResult, - type AgentSessionQueuedMessageDeleteResult, - type AgentSessionQueuedMessagesResumeResult, - type AgentSessionSendResult +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionQueuedMessageDeleteResult, + AgentSessionQueuedMessagesResumeResult, + AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' @@ -117,10 +116,6 @@ export async function carryQueuedMessagesToClearReplacement( fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), carriedFrom: ctx.sessionId, - // A kept send stays held there too: no later message may release it. - ...(row.holdReason === QUEUED_MESSAGE_PAUSED_KEPT - ? { holdReason: QUEUED_MESSAGE_PAUSED_KEPT } - : {}) }) } await withdrawQueuedMessagesForOperation(ctx.journal, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index d55f7cea3fb0..418c51f5cc74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -20,11 +20,7 @@ import { } from './structured-agent-session-queued-message-rig.test-fixture' import { openRigTurnFor } from './structured-agent-session-queued-rig-turn.test-fixture' import { sameQueuePause } from './structured-agent-session-queued-publication' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { - structuredAgentSessionHostInstance, - structuredQueuePauses -} from './structured-agent-session-queued-pause' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig @@ -365,7 +361,7 @@ describe('a pause only over cards Resume could send', () => { }) describe("a restart's pause", () => { - it('once a turn ends it, stays ended when the conversation reopens', async () => { + it('a turn ends it; closing again with a card still waiting holds that card again', async () => { const working = await rig.workingSend() const first = await queuedDraft('first') const second = await queuedDraft('second') @@ -377,16 +373,17 @@ describe("a restart's pause", () => { await next.result await rig.settleAccepted(next.id, 'b') await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - // Reopened, that turn is "before this open", yet the pause it ended stays ended: - // the lift adopted the rows into this process. + // Closed again with a card still waiting, it waits again for the next turn, unshown. await rig.host.close(HOST_TEST_SESSION, 'evict') expect(await rig.queuePause()).toBeNull() expect(await rig.drafts()).toContainEqual({ messageId: second, state: 'waiting' }) + expect(derivedPauses()).toEqual(['restarted']) }) }) describe('a card handed off after a restart', () => { - it('belongs to the process that sent it: withdrawn back to waiting, it raises no restart pause', async () => { + // Returned, not waiting, when Orca restarted: the reopen holds nothing; only the Stop does. + it('sent again and withdrawn by a Stop, it waits under that Stop alone, shown; Resume lifts it', async () => { const working = await rig.workingSend() const draftId = await queuedDraft('refused, then re-sent after a restart') await rig.settleAccepted(working, 'a') @@ -396,7 +393,7 @@ describe('a card handed off after a restart', () => { expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'returned' }]) ) await rig.restartHostProcess() - // Sent again in this process, then withdrawn by a Stop before the agent had it. + // Sent again after the restart, then withdrawn by a Stop before the agent had it. // Its delivery is held, so the Stop runs ahead of the handover. const { held, release } = holdDelivery() const handedOver = rig.dispatch.mock.calls.length @@ -410,18 +407,10 @@ describe('a card handed off after a restart', () => { await eventually(async () => expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) ) - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!journal) { - throw new Error('expected the conversation open') - } - expect(journal.queuedMessages.get(draftId)?.hostInstance).toBe( - structuredAgentSessionHostInstance() - ) - // With the Stop's pause gone, nothing else holds it: no restart happened since it was sent. - await journal.appendQueueResume( - structuredAgentSessionConversationFence(rig.store, HOST_TEST_SESSION) - ) - expect(structuredQueuePauses(journal)).toEqual([]) + expect(derivedPauses()).toEqual(['stopped']) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + expect(derivedPauses()).toEqual([]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts index 6722313d0394..e59a56a7c795 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -1,6 +1,6 @@ // Whether the queue is paused, and why — derived from the journal and the cards -// (`queued-message-pause.ts`), never stored. A Stop's event and a Resume are journal -// rows; an explicit Resume lifts any pause. +// (`queued-message-pause.ts`), never stored. A Stop's event, a Resume and a reopen that found +// waiting cards are journal rows; an explicit Resume lifts any pause. import { randomUUID } from 'node:crypto' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -8,15 +8,15 @@ import type { DerivedQueuePause } from '../agent-session-journal/queued-message- import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** A per-process id, minted once per host process like the runtime's own - * `runtimeId` (`orca-runtime-runtime-id.ts`); a draft written by another - * instance pauses the queue rather than auto-sending after a restart. */ + * `runtimeId` (`orca-runtime-runtime-id.ts`), stamped on the cards this process writes or hands + * off. No pause reads it: an older build holds every card another instance wrote. */ let hostInstance = randomUUID() export function structuredAgentSessionHostInstance(): string { return hostInstance } -/** Simulates a host-process restart. Tests only. */ +/** A new stamp, as a new host process mints. Tests only; no pause reads it. */ export function rotateStructuredAgentSessionHostInstanceForTests(): string { hostInstance = randomUUID() return hostInstance @@ -26,38 +26,34 @@ type PauseJournal = Pick /** The queue's pauses in force, derived; none when the queue sends on its own. */ export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[] { - return journal.queuedMessages.pauses(hostInstance) + return journal.queuedMessages.pauses() } -/** - * Every journal publish: a restart's rows are adopted into this instance once a turn started. - * The derivation already reads them as lifted; the write keeps that answer when the handle - * reopens (its "since this conversation opened" moves). Bookkeeping: a failure is reported. - */ -export async function adoptEndedRestartPause( +/** The mark of a chat that stopped running with cards waiting — Orca quit or crashed, or the chat + * was closed — so they wait for its next turn (`queued-message-pause.ts`). Every open marks, and + * so does a person's close: the idle sweep never closes a chat with cards waiting, so its + * eviction never reopens one. `since`: where the chat stopped, for a mark written after a send + * that came later, which must still lift it. Bookkeeping, so a failure is reported and never + * thrown; the pause then starts where the mark would have gone, holding no less. */ +export async function markStructuredQueueReopen( sessionId: string, - journal: PauseJournal, - logger: StructuredAgentSessionLogger + journal: Pick, + fence: number, + logger: StructuredAgentSessionLogger, + since?: number ): Promise { try { - const { queuedMessages } = journal - const restarted = queuedMessages - .list() - .some((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) - if (restarted && queuedMessages.restartEnded()) { - await queuedMessages.adopt(hostInstance) - } + await journal.markQueueReopen(fence, since) } catch (error) { - logger.warn("adopting a restart's queued cards after a started turn failed", { - scope: 'queue-pause-adoption', + logger.warn('marking a reopened queue failed', { + scope: 'queue-reopen-mark', sessionId, error }) } } -/** Resume: a journal row that ends a Stop's or a /clear's pause, and adoption of a restart's - * rows. Returns whether the queue was paused. */ +/** Resume: a journal row that ends every pause. Returns whether the queue was paused. */ export async function resumeStructuredQueue( journal: Pick, fence: number @@ -66,6 +62,5 @@ export async function resumeStructuredQueue( return false } await journal.appendQueueResume(fence) - await journal.queuedMessages.adopt(hostInstance) return true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index af1b7e567b3f..d48d9bf27c39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -4,7 +4,6 @@ // streams from re-sending it. They ride together: a client never sees one without the others. import { - QUEUED_MESSAGE_PAUSED_KEPT, QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage, type AgentSessionQueuedMessagePausedReason, @@ -42,9 +41,8 @@ export function structuredQueueSendGate( }) } -/** Waiting and returned rows only. `paused` is a per-card hold (a failed - * conversion, or a send the host kept); a Stop or a /clear pauses the queue, published once - * beside it. */ +/** Waiting and returned rows only. `paused` is a per-card hold (a failed conversion); a person's + * Stop pauses the queue, published once beside it. */ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { const published: AgentSessionQueuedMessage[] = [] for (const row of journal.queuedMessages.list()) { @@ -72,7 +70,7 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess function isPublishedPausedReason( reason: string | null ): reason is AgentSessionQueuedMessagePausedReason { - return reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED || reason === QUEUED_MESSAGE_PAUSED_KEPT + return reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED } type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessage[] } @@ -114,18 +112,29 @@ export function readQueuePublication( gate: QueueSendGate ): QueuePublication { const queuedMessages = readPublishedQueuedMessages(journal) - // Read per emit: the pause also turns on submissions (a turn starting). Shown only over a card - // Resume would send, so its header never offers to send nothing; deleting a blocking returned - // card shows it again. After a restart nothing is shown, a Stop's or /clear's included: the - // chat's next turn lifts every pause, and the cards read as plain waiting cards until then. + // Read per emit: the pause also turns on submissions (a turn starting). Only a person's Stop is + // shown, and only over a card Resume would send, so its header never offers to send nothing; + // deleting a blocking returned card shows it again. After a /clear, a restart or a close nothing + // is shown, a Stop's from before it included: nothing runs in the chat, its next turn lifts every + // pause, and the cards read as plain waiting cards until then. A Stop the person makes after the + // reopen shows. const pauses = structuredQueuePauses(journal) - const restarted = pauses.some((pause) => pause.reason === 'restarted') - const resumable = restarted ? null : resumableQueuePause(pauses, journal.queuedMessages.list()) + const stop = pauses.find((pause) => pause.reason === 'stopped')?.since + const silent = pauses.some( + (pause) => + pause.reason === 'cleared' || + (pause.reason === 'restarted' && + (!stop || + !pause.since || + stop.epoch !== pause.since.epoch || + stop.sequence < pause.since.sequence)) + ) + const resumable = silent ? null : resumableQueuePause(pauses, journal.queuedMessages.list()) // The submissions are read only while a pause would show, never while the queue runs freely. const pause = resumable && !queuePauseLiftOnItsWay(resumable, journal.submissions()) ? resumable : null - // `restarted` is already excluded above; the test narrows the type. - const queuePause = pause && pause.reason !== 'restarted' ? { reason: pause.reason } : null + // The test narrows the type. + const queuePause = pause?.reason === 'stopped' ? { reason: pause.reason } : null const nextQueuedMessageId = nextStructuredQueuedMessage({ journal, ...gate() })?.messageId ?? null const previous = publications.get(journal) if ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index 0fa49460d5e9..7ce3d43b4798 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -14,6 +14,7 @@ export async function runQueueableStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + userSend?: true }, immediate: () => Promise> ): Promise> { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index 001e0b10786b..2b0aae62f487 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -246,7 +246,11 @@ describe("a /clear's carried cards", () => { if (!replacementId) { throw new Error(`expected a replacement session: ${JSON.stringify(cleared)}`) } - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + // Held there, unshown: nothing runs in the fresh conversation. + expect(await rig.queuePause(replacementId)).toBeNull() + expect(structuredQueuePauses(journal(replacementId)).map((pause) => pause.reason)).toEqual([ + 'cleared' + ]) // Idle there, so the person's send goes straight out rather than queueing. const text = hostTestMessage('hi') const person = rig.host.send(QUEUED_RIG_CALLER, { @@ -299,7 +303,9 @@ describe('no stored pause', () => { ...fields }) const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined - expect(replacementId && (await rig.queuePause(replacementId))).toEqual({ reason: 'cleared' }) + expect( + replacementId && structuredQueuePauses(journal(replacementId)).map((pause) => pause.reason) + ).toEqual(['cleared']) expect(pauseTables()).toBe(0) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts index af7aee44bc98..a5ddfeb64748 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -9,7 +9,6 @@ import type { } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { StructuredAgentSessionQueuedMessageDrain } from './structured-agent-session-queued-messages' -import { adoptEndedRestartPause } from './structured-agent-session-queued-pause' import { deleteQueuedStructuredAgentMessage, resumeStructuredAgentQueue, @@ -39,14 +38,9 @@ export function wireStructuredAgentSessionQueuedMessages( return { drain, /** Every journal publish: turn, submission, prompt, command and Stop - * settlements are all commits, and each re-derives the drain's gates — - * and adopts a restart's cards once a turn started. */ + * settlements are all commits, and each re-derives the drain's gates. */ onJournalActivity: (sessionId: string) => { sessions.touch(sessionId) - const journal = sessions.get(sessionId)?.journal - if (journal) { - void adoptEndedRestartPause(sessionId, journal, context().deps.logger) - } drain.schedule(sessionId) }, queuedMessageSend: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts index cfa2c7b502ee..b9c2072e874f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts @@ -9,7 +9,9 @@ import type { AgentSessionResumeMarker } from '../../../shared/agent-session-res import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' /** The message body, built once so both the send and any test read the same text. */ -export function restartContinuationBody(marker: AgentSessionResumeMarker): AgentJournalMessageItem { +export function restartContinuationBody( + marker: Pick +): AgentJournalMessageItem { return { kind: 'message', role: 'user', @@ -57,7 +59,7 @@ export function isRestartContinuationOf( export function restartContinuationEnvelope( sessionId: string, fence: number, - marker: AgentSessionResumeMarker, + marker: Pick, continuationId: string ): { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } { const body = restartContinuationBody(marker) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 67006fd51d03..92d7dea95c48 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -10,14 +10,15 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { - readAgentSessionFailureFact, - type UnreadAgentSessionFailureFact -} from '../../../shared/agent-session-failure' import { agentSessionRefusalReference, type AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { + refusedBy, + verdictOf, + type ContinuationSubmission +} from './structured-agent-session-continuation-verdict' import { agentSessionSendSubmission, type AgentSessionMutationEnvelope, @@ -85,10 +86,11 @@ export type StructuredAgentSessionContinuationHost = { } /** Binds one continuation to the host: the superseded check before dispatch, the settlement - * waiter for the verdict, and the journal note that attributes the send to Orca. */ -export function restartContinuationDeps( - host: StructuredAgentSessionContinuationHost, - marker: AgentSessionResumeMarker + * waiter for the verdict, and the journal note that attributes the send to Orca. `stillWanted` is + * asked at acceptance, inside the session lock, so the first message accepted since decides. */ +export function continuationDeps( + host: Omit, + stillWanted: () => boolean ): StructuredAgentSessionContinuationDeps { return { currentFence: host.conversationFence, @@ -96,7 +98,7 @@ export function restartContinuationDeps( host.send({ ...input, beforeRun: () => { - if (!host.stillResumable(marker)) { + if (!stillWanted()) { throw new RestartContinuationSupersededError() } } @@ -154,12 +156,6 @@ export class RestartContinuationSupersededError extends AgentSessionPreDispatchE } } -type ContinuationSubmission = { - dispatchState?: string - reason?: string | null - rejection?: UnreadAgentSessionFailureFact -} - export type StructuredAgentSessionContinuationDeps = { /** Runtime fence as it stands now; null when this host has no record of the session. */ currentFence: (sessionId: string) => number | null @@ -197,6 +193,9 @@ export type StructuredAgentSessionContinuationDeps = { /** Where a note that could not be written is reported. The note is best effort, but its failure * is not allowed to be silent — a swallowed append is how this regressed unnoticed once already. */ logger: StructuredAgentSessionLogger + /** Answer once Orca accepted the message, as a send does, not once the agent took it. A refusal + * before acceptance is then the caller's to report, not a note: a retry adds none. */ + answerAtAcceptance?: true } /** A continuation handed to its agent, or already decided. */ @@ -206,13 +205,15 @@ export type StartedStructuredAgentSessionContinuation = /** * Sends the continuation to ONE session and returns once the agent has taken it or its start - * failed — the point a restart batch counts a start as done. What the provider then answered is - * the `verdict`, awaited separately so a slow answer does not hold the batch. + * failed — the point a restart batch counts a start as done — or, with `answerAtAcceptance`, once + * Orca accepted it. What the provider then answered is the `verdict`, awaited separately so a slow + * answer does not hold the batch. */ export async function startStructuredAgentSessionContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, - marker: AgentSessionResumeMarker, + /** Only what picks the message's words; a continuation with no offer passes none. */ + marker: Pick, /** This action's continuation, as its offer recorded it. */ continuationId: string ): Promise { @@ -221,13 +222,15 @@ export async function startStructuredAgentSessionContinuation( started = await sendContinuation(deps, sessionId, marker, continuationId) } catch (error) { // The user's own message came first: nothing failed, so the chat says nothing. - if (!(error instanceof RestartContinuationSupersededError)) { + if (!(error instanceof RestartContinuationSupersededError) && !deps.answerAtAcceptance) { await noteNotContinued(deps, sessionId, 'refused') } throw error } if ('done' in started) { - await noteOutcome(deps, sessionId, started.done) + if (!(deps.answerAtAcceptance && started.done.outcome === 'refused')) { + await noteOutcome(deps, sessionId, started.done) + } return started } const { verdict } = started @@ -291,7 +294,7 @@ async function noteNotContinued( async function sendContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, - marker: AgentSessionResumeMarker, + marker: Pick, continuationId: string ): Promise { const fence = deps.currentFence(sessionId) @@ -325,54 +328,25 @@ async function sendContinuation( } } const clientMessageId = envelope.clientOperationId - const handedOver = await deps.awaitHandedOver(sessionId, clientMessageId).catch(() => undefined) + const handOver = () => deps.awaitHandedOver(sessionId, clientMessageId).catch(() => undefined) + const settle = async (handedOver: ContinuationSubmission | undefined) => + verdictOf( + sessionId, + // The send result carries the dispatch as it stood when Orca took the message, which for + // a normal successful send is `pending`, so the settled value is what decides. + (await deps.awaitSettlement(sessionId, clientMessageId).catch(() => undefined)) ?? + handedOver ?? + sent.value?.submission + ) + if (deps.answerAtAcceptance) { + // The agent's start can outlast the caller's wait; a start refused is the verdict's note. + return { verdict: async () => settle(await handOver()) } + } + const handedOver = await handOver() if (handedOver?.dispatchState === 'rejected') { return { done: refusedBy(sessionId, handedOver) } } - return { - verdict: async () => - verdictOf( - sessionId, - // The send result carries the dispatch as it stood when Orca took the message, which for - // a normal successful send is `pending`, so the settled value is what decides. - (await deps.awaitSettlement(sessionId, clientMessageId).catch(() => undefined)) ?? - handedOver ?? - sent.value?.submission - ) - } -} - -function refusedBy( - sessionId: string, - submission: ContinuationSubmission -): StructuredAgentSessionContinuationOutcome { - // A start the agent was refused files that refusal's code, which the failure guidance keys on. - const refusal = readAgentSessionFailureFact(submission.rejection)?.refusal - return { - sessionId, - outcome: 'refused', - reason: refusal?.code ?? submission.reason ?? 'agent_session_dispatch_rejected', - ...(refusal ? { refusal } : {}) - } -} - -function verdictOf( - sessionId: string, - submission: ContinuationSubmission | undefined -): StructuredAgentSessionContinuationOutcome { - const dispatch = submission?.dispatchState - if (submission && dispatch === 'rejected') { - return refusedBy(sessionId, submission) - } - if (dispatch === 'pending') { - // Still pending after settlement gave up: handed off, never confirmed. - return { sessionId, outcome: 'pending' } - } - // `unknown`, or a peer that reported no state at all: delivery is unverifiable, so this claims - // neither success nor failure — and writes no note saying the agent was asked to continue. - return dispatch === 'accepted' - ? { sessionId, outcome: 'continued' } - : { sessionId, outcome: 'unknown' } + return { verdict: () => settle(handedOver) } } /** Without the error: a failed append can carry the chat's private recovery payload. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index 4602630960a9..c99cc945d31c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -45,8 +45,8 @@ it('files nothing for a chat the user moved on in before its attempt, and spends const capsule = new AgentSessionRecoveryCapsule(root) expect(await capsule.listFailed(NOW)).toEqual([]) expect(await capsule.list(NOW)).toEqual([]) - expect(await statusNotes(host)).toEqual([]) - // No note took over, so the cut turn keeps its one notice. + // Only the quit's own row about the cut: no note took over, so it stays the turn's one notice. + expect(await statusNotes(host)).toEqual([QUIT_CUT_NOTICE]) expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index b155674babf0..89e039689a5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -17,6 +17,7 @@ import { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { interruptedRestart, + QUIT_CUT_NOTICE, statusNotes } from './structured-agent-session-restart-interruption-test-harness' import { CALLER, envelope } from './structured-agent-session-host-test-harness' @@ -385,8 +386,8 @@ it("refuses an automatic continuation when the user's message was accepted first expect(result.continued).toMatchObject([{ outcome: 'refused', reason: SUPERSEDED }]) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce(), COLD_START) expect(sentTexts(dispatch)).toEqual(['A new request']) - // Nothing the user did failed: no row reached the reader, and nothing is kept. - expect(await statusNotes(host)).toEqual([]) + // Nothing the user did failed: no row but the quit's own about the cut, and nothing is kept. + expect(await statusNotes(host)).toEqual([QUIT_CUT_NOTICE]) expect( events.some( (event) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 23d2e5897761..0d73656b3477 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -16,6 +16,7 @@ import { import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' import { interruptedRestart, + QUIT_CUT_NOTICE, startAgent, statusNotes, supersededRefusal, @@ -357,7 +358,8 @@ it("refuses a continuation quietly when the user's own message was accepted firs failed: [] }) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - expect(await statusNotes(host)).toEqual([]) + // The quit's own row about the cut, and nothing about the refused continuation. + expect(await statusNotes(host)).toEqual([QUIT_CUT_NOTICE]) expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts new file mode 100644 index 000000000000..1cbbd8236ad0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts @@ -0,0 +1,49 @@ +// What a host's restart-resume surface offers: teardown's offer recording, the offer list and its +// actions, and Continue on a cut reply. + +import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' +import type { createInterruptedContinuation } from './structured-agent-session-interrupted-continuation' +import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-restart-resume-runner' +import type { + StructuredAgentSessionRestartAudience, + StructuredAgentSessionResumeCandidate, + StructuredAgentSessionResumeFailure +} from './structured-agent-session-restart-resume-set' + +export type StructuredAgentSessionRestartResume = { + /** Teardown: begin, then per session a snapshot right before its child stops and a confirmation + * once the stop is proven, then one write of the confirmed offers. */ + beginTeardown: (trigger: AgentSessionResumeTrigger) => void + captureBeforeStop: (sessionId: string) => void + confirmStopped: (sessionId: string) => void + recordMarkers: () => Promise + list: ( + audience?: StructuredAgentSessionRestartAudience + ) => Promise + /** Offers already acted on whose agent did not carry on. Read-only; nothing here is spent. */ + listFailures: ( + audience?: StructuredAgentSessionRestartAudience + ) => Promise + /** Unnamed, continues every offer the audience sees; named, only those of them. */ + continueAfterRestart: ( + sessionIds: readonly string[] | undefined, + owner: string, + audience?: StructuredAgentSessionRestartAudience + ) => Promise<{ + resumed: StructuredAgentSessionResumeOutcome[] + continued: StructuredAgentSessionContinuationOutcome[] + sessions?: StructuredAgentSessionResumeCandidate[] + failed?: StructuredAgentSessionResumeFailure[] + }> + /** Named sessions forget their offer or failure; unnamed, every record this host lists goes (a + * newer Orca's stay). An audience limits either to the agents it sees. */ + dismiss: ( + sessionIds?: readonly string[], + audience?: StructuredAgentSessionRestartAudience + ) => Promise + /** The chat's agent proved a start: its offer ends unless the start is a resume's own. */ + onAgentStarted: (sessionId: string) => void + /** Continue on a reply an Orca stop cut off, offer or not; the send itself retires any offer. */ + continueInterrupted: ReturnType +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 6a868201baa1..120aefb6f205 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -7,10 +7,7 @@ import { AgentSessionRefusalError, agentSessionRefusalFromReference } from '../../../shared/agent-session-wire-refusals' -import type { - AgentSessionResumeMarker, - AgentSessionResumeTrigger -} from '../../../shared/agent-session-resume-marker' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import { createNewerOrcaChats, createStructuredAgentSessionRestartCandidateReaders @@ -24,21 +21,21 @@ import { createStructuredAgentSessionRestartOfferRecords } from './structured-ag import { restartRowsFor, type StructuredAgentSessionRestartAudience, - type StructuredAgentSessionResumeCandidate, - type StructuredAgentSessionResumeFailure + type StructuredAgentSessionResumeCandidate } from './structured-agent-session-restart-resume-set' import { resumeStructuredAgentSessionsFromRestart, - StructuredAgentSessionResumeAdmission, - type StructuredAgentSessionResumeOutcome + StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' +import type { StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-contract' import { - restartContinuationDeps, + continuationDeps, startStructuredAgentSessionContinuation, type StructuredAgentSessionContinuationHost, type StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' import { restartContinuationId } from './structured-agent-session-restart-continuation-envelope' +import { createInterruptedContinuation } from './structured-agent-session-interrupted-continuation' import { createStructuredAgentSessionRestartOfferWithdrawal, type StructuredAgentSessionRestartOfferSession @@ -54,40 +51,7 @@ import type { StructuredAgentSessionHostDeps } from './structured-agent-session- type LiveSession = StructuredAgentSessionRestartOfferSession -export type StructuredAgentSessionRestartResume = { - /** Teardown: begin, then per session a snapshot right before its child stops and a confirmation - * once the stop is proven, then one write of the confirmed offers. */ - beginTeardown: (trigger: AgentSessionResumeTrigger) => void - captureBeforeStop: (sessionId: string) => void - confirmStopped: (sessionId: string) => void - recordMarkers: () => Promise - list: ( - audience?: StructuredAgentSessionRestartAudience - ) => Promise - /** Offers already acted on whose agent did not carry on. Read-only; nothing here is spent. */ - listFailures: ( - audience?: StructuredAgentSessionRestartAudience - ) => Promise - /** Unnamed, continues every offer the audience sees; named, only those of them. */ - continueAfterRestart: ( - sessionIds: readonly string[] | undefined, - owner: string, - audience?: StructuredAgentSessionRestartAudience - ) => Promise<{ - resumed: StructuredAgentSessionResumeOutcome[] - continued: StructuredAgentSessionContinuationOutcome[] - sessions?: StructuredAgentSessionResumeCandidate[] - failed?: StructuredAgentSessionResumeFailure[] - }> - /** Named sessions forget their offer or failure; unnamed, every record this host lists goes (a - * newer Orca's stay). An audience limits either to the agents it sees. */ - dismiss: ( - sessionIds?: readonly string[], - audience?: StructuredAgentSessionRestartAudience - ) => Promise - /** The chat's agent proved a start: its offer ends unless the start is a resume's own. */ - onAgentStarted: (sessionId: string) => void -} +export type { StructuredAgentSessionRestartResume } export function createStructuredAgentSessionRestartResume( deps: Pick< @@ -261,7 +225,7 @@ export function createStructuredAgentSessionRestartResume( // never starts more agents at once than the runner allows; the provider's answer comes after. const action = await run(sessionIds, owner, audience, async (marker, continuationId) => { const started = await startStructuredAgentSessionContinuation( - restartContinuationDeps(continuationHost, marker), + continuationDeps(continuationHost, () => continuationHost.stillResumable(marker)), marker.sessionId, marker, continuationId @@ -327,6 +291,7 @@ export function createStructuredAgentSessionRestartResume( audience ), continueAfterRestart, - onAgentStarted: withdrawal.onAgentStarted + onAgentStarted: withdrawal.onAgentStarted, + continueInterrupted: createInterruptedContinuation(continuationHost, readMarkers) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts new file mode 100644 index 000000000000..5c82a2185a51 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts @@ -0,0 +1,267 @@ +// A quit or update that cuts a reply writes one row saying so, in the words every client already +// prints, with the cause beside them. A turn that finished, a person's Stop and an idle eviction +// write none. + +import { rm } from 'node:fs/promises' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { readAgentSessionOrcaStop } from '../../../shared/agent-session-orca-stop' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' +import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + agentSessionRuntimeIncarnation, + beginAgentSessionRuntimeIncarnationForTest +} from '../../runtime/agent-session-runtime-attribution' +import { recordAgentSessionRuntimeEnd } from '../../runtime/agent-session-runtime-end-record' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attach, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' + +const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 } +const LEGACY_TEXT = + 'Codex stopped while this response was in progress. You can continue in this conversation.' + +let host: StructuredAgentSessionHost +/** How the provider ends its open turn as it is stopped. */ +let providerEnd: Pick + +beforeEach(() => { + const state = hostTestState() + providerEnd = { state: 'interrupted', completedAt: HOST_TEST_NOW } + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + logger: createStructuredAgentSessionLogger(), + store: state.store, + adapter: { + ...adapter(), + closeSession: async () => { + const events = state.acquire.mock.calls.at(-1)?.[0]?.events + if (!events) { + throw new Error('missing provider event sink') + } + events.appendItem( + CUT_TURN, + { + kind: 'turn', + turnId: 'cut-turn', + startedAt: 1_000, + requestedAt: 1_000, + ...providerEnd + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return true + } + }, + journalDatabase: openTestJournalHostDatabase(state.root), + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + replaceHostTestState({ store: state.store, host }) +}) + +async function runningTurn(): Promise { + await attach() + const events = hostTestState().acquire.mock.calls[0]?.[0].events + if (!events) { + throw new Error('missing provider event sink') + } + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'cut-turn', ordinal: 0 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + events.appendItem( + CUT_TURN, + { kind: 'turn', turnId: 'cut-turn', state: 'running', startedAt: 1_000, requestedAt: 1_000 }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) +} + +/** The Orca that ran the turn dies with no teardown; the next one starts over the same files and + * finds the turn's owner gone. */ +async function restartAfterDeath(): Promise { + const { root } = hostTestState() + beginAgentSessionRuntimeIncarnationForTest() + const store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + logger: createStructuredAgentSessionLogger(), + store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(root), + recoveryCapsule: new AgentSessionRecoveryCapsule(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-next', + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => HOST_TEST_NOW + 60_000 + }) + replaceHostTestState({ store, host }) + await host.reconcileRestartLeases() +} + +async function reread() { + await host.restoreReadableSessions([SESSION]) + const { items } = await host.journalSnapshot(SESSION) + const turnItemId = items.find((item) => item.body.kind === 'turn')?.itemId + const stopRows = items.filter( + (item) => + item.body.kind === 'status' && readAgentSessionOrcaStop(item.body.orcaStop) !== undefined + ) + const readerErrors = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap( + (item) => (item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : []) + ) + return { items, turnItemId, stopRows, readerErrors } +} + +describe('the row a quit writes for the reply it cut', () => { + it.each(['update', 'quit'] as const)('names a %s on the cut turn, once', async (trigger) => { + await runningTurn() + + await host.flushAllStreamedEvents({ trigger }) + + const { items, turnItemId, stopRows, readerErrors } = await reread() + expect(stopRows).toHaveLength(1) + const [row] = stopRows + expect(row?.body).toMatchObject({ + kind: 'status', + text: LEGACY_TEXT, + tone: 'error', + failure: { kind: 'providerExited' }, + presentation: 'orca-stop', + orcaStop: { cause: trigger } + }) + expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId }) + // A client that reads no cause still prints exactly one row for the cut, in today's words. + expect(readerErrors).toEqual([LEGACY_TEXT]) + expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: trigger }) + }) + + it('writes nothing for a turn the provider finished during the stop', async () => { + providerEnd = { state: 'completed', outcome: 'success', completedAt: 1_500 } + await runningTurn() + + await host.flushAllStreamedEvents({ trigger: 'update' }) + + const { stopRows, readerErrors } = await reread() + expect(stopRows).toEqual([]) + expect(readerErrors).toEqual([]) + }) + + it("writes nothing for a turn a person's Stop already ended", async () => { + providerEnd = { state: 'interrupted', outcome: 'cancellation', completedAt: 1_500 } + await runningTurn() + + await host.flushAllStreamedEvents({ trigger: 'update' }) + + const { stopRows } = await reread() + expect(stopRows).toEqual([]) + }) + + it('never calls an idle eviction a restart', async () => { + await runningTurn() + + await host.close(SESSION, 'evict') + + const { items, stopRows, readerErrors } = await reread() + expect(stopRows).toEqual([]) + // The cut keeps today's derived notice, and offers no Continue. + expect(readerErrors).toEqual([LEGACY_TEXT]) + expect(latestNativeChatOrcaStopCut(items, [])).toBeNull() + }) +}) + +describe('the row a restart writes for a reply its Orca died in', () => { + it('says Orca stopped unexpectedly when the Orca before began no quit', async () => { + await runningTurn() + + await restartAfterDeath() + + const { items, turnItemId, stopRows, readerErrors } = await reread() + expect(stopRows).toHaveLength(1) + expect(stopRows[0]?.body).toMatchObject({ + text: LEGACY_TEXT, + orcaStop: { cause: 'crash' } + }) + expect(readerErrors).toEqual([LEGACY_TEXT]) + expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: 'crash' }) + }) + + it('names the update an unfinished quit began, never a crash', async () => { + await runningTurn() + // The quit recorded its end, then died before it stopped the agent. + recordAgentSessionRuntimeEnd('update', HOST_TEST_NOW) + + await restartAfterDeath() + + const { stopRows } = await reread() + expect(stopRows.map((row) => row.body)).toMatchObject([{ orcaStop: { cause: 'update' } }]) + }) + + it('names no cause when the Orca before left no record of itself', async () => { + await runningTurn() + await rm(join(hostTestState().root, 'agent-session-runtimes'), { recursive: true, force: true }) + + await restartAfterDeath() + + const { stopRows, readerErrors } = await reread() + expect(stopRows).toEqual([]) + // The cut keeps today's words. + expect(readerErrors).toEqual([LEGACY_TEXT]) + }) +}) + +describe('an agent this Orca runs', () => { + it('is recorded as held by this runtime', async () => { + await runningTurn() + expect(hostTestState().store.getRecord(SESSION)?.lease.ownerProcess?.runtime).toBe( + agentSessionRuntimeIncarnation() + ) + }) + + it('names no Orca cause when the agent dies while Orca runs', async () => { + await runningTurn() + const child = host.collaboratorsForTests().sessions.get(SESSION)!.child! + + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + reason: 'killed', + cause: 'unexpected-exit', + fence: child.fence, + acquisitionGeneration: child.generation! + }) + + await vi.waitFor(() => + expect(hostTestState().store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ + kind: 'exit-observed' + }) + ) + expect(hostTestState().store.getRecord(SESSION)?.lease.deathEvidence).not.toHaveProperty( + 'runtimeEnd' + ) + const { stopRows } = await reread() + expect(stopRows).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index b5d33be6827d..9c9722de00bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -3,6 +3,7 @@ // does a Stop of a start that never landed; a card it held, which Resume releases, and anything // sent after it end as their own. Turn rows name the send that opened them, as Codex writes them. +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { @@ -128,6 +129,13 @@ async function mailTurn(): Promise { } /** The host evicts the chat; the Stop events as its provider close finds them. */ +/** The person's Stop still pauses the reopened chat. The close hides its row, as every close of a + * chat does: nothing runs there until its next turn. */ +async function stillStopped(): Promise { + expect(await rig.queuePause()).toBeNull() + return structuredQueuePauses(journal()).some((pause) => pause.reason === 'stopped') +} + async function evictedAt(): Promise { let atClose: JournalStopEvent[] = [] rig.closeSession.mockImplementationOnce(async () => { @@ -371,7 +379,7 @@ describe('a host stop with no turn running after a Stop that named none', () => expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') expect(await evictedAt()).toEqual(['user-stop']) - expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await stillStopped()).toBe(true) expect(await rig.handoff(card)).toBeUndefined() } ) @@ -386,7 +394,7 @@ describe('a host stop with no turn running after a Stop that named none', () => expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) expect(await evictedAt()).toEqual(['user-stop']) - expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await stillStopped()).toBe(true) expect(await rig.handoff(card)).toBeUndefined() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 3f1afc89d6bc..17a274d6f5fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -251,7 +251,9 @@ describe('every Stop entry writes its event, with its reason, before it ends the await rig.host.close(HOST_TEST_SESSION, 'evict') expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // The close hides the row, as every close of a chat does; the Stop still pauses. + expect(await rig.queuePause()).toBeNull() + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toContain('stopped') }, 20_000 ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index f13b897f12bd..4c73941ee5dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -57,6 +57,8 @@ export type StructuredAgentSessionTransition = { steps: readonly StructuredAgentSessionTransitionStep[] /** Rides the sink's lifecycle budget: it ends or settles something. */ lifecycle: boolean + /** Internal admission only, for a provider's bounded tail after finalization. */ + finalTail?: true /** Announce the writes once they land, when any step wrote. */ publish: boolean } @@ -88,6 +90,7 @@ function transitionAppend( transition.steps.reduce((total, step) => total + step.reservedBytes, 0) + (transition.publish ? 1 : 0), lifecycle: transition.lifecycle, + finalTail: transition.finalTail, run: async (bound) => { const { journal, fence } = bound const wrote = await journal.appendSteps( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 4f575c1473be..200ce5a338ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -44,6 +44,7 @@ import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' import type { StructuredAgentRegistry } from './structured-agent-registry' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' @@ -86,6 +87,17 @@ function invalid( return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } +/** A message naming an attachment this host no longer stores is refused whole, before delivery. */ +export function agentSessionAttachmentExpiredRefusal(): { + ok: false + refusal: AgentSessionWireRefusal +} { + return invalid( + 'attachmentExpired', + 'A chat attachment in this message is no longer stored on the host, so it was not sent.' + ) +} + /** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown` * rather than as a rejection — unless the child had not proven its start. A dispatch to such a * child throws only when the start failed before the write (a write's own failure is an outcome, @@ -159,6 +171,9 @@ export async function performSend( ctx.operationReceipt ) } catch (error) { + if (isAgentSessionAttachmentExpiredError(error)) { + return agentSessionAttachmentExpiredRefusal() + } // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So // does a chat holding a newer Orca's rows, which only an update writes past, and a refusal the // journal already classified (a failed transaction that will not roll back). diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts index 5112b6ff74de..fe6a29cdcae6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts @@ -1,13 +1,14 @@ // A message handed to an agent whose start never answers provably never ran: its CLI takes no // message before it answers initialize. So when the chat ends then, the message is settled as a // queued one is for the same end (`journal-unsent-send-hold.ts`): a quit or a close keeps a -// person's words as a held card, a person's Stop withdraws it. Against the real host, store and +// person's words as an ordinary card that waits for the chat's next turn, a person's Stop +// withdraws it. Against the real host, store and // journal, with an agent that stays starting. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { createQueuedMessageTestRig, eventually, @@ -17,7 +18,17 @@ import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-te const words = (kind: 'hostRestarted' | 'chatClosed' | 'cancelled') => agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) -const KEPT = { state: 'waiting', paused: true } +/** A kept send: an ordinary waiting card, with no hold of its own. */ +const KEPT = { state: 'waiting' } + +/** What holds the queue, derived from the open journal. */ +function derivedPauses(): string[] { + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + return structuredQueuePauses(journal).map((pause) => pause.reason) +} let rig: QueuedMessageTestRig @@ -47,7 +58,7 @@ async function handedToHungStart(text: string): Promise { } describe('a message handed to a start that never answered, then the chat ends', () => { - it('is a held card after a quit, rejected as a restart, as a queued one is', async () => { + it('is a card after a quit, rejected as a restart, held by the reopen until a turn', async () => { const id = await handedToHungStart('kept through quit') await rig.quitRestartHostProcess() @@ -59,7 +70,8 @@ describe('a message handed to a start that never answered, then the chat ends', expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) const [card] = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.queuedMessages.list() ?? [] - expect(card).toMatchObject({ holdReason: QUEUED_MESSAGE_PAUSED_KEPT }) + expect(card).toMatchObject({ holdReason: null }) + expect(derivedPauses()).toEqual(['restarted']) // Nothing ran, so there is nothing to resume: the card alone holds the words. expect(await rig.restartOffers()).toEqual([]) }) @@ -81,7 +93,7 @@ describe('a message handed to a start that never answered, then the chat ends', }) it.each(['user-close', 'evict'] as const)( - 'is a held card after a %s, rejected as closed, as a queued one is', + 'is a card after a %s, rejected as closed, held by the reopen until a turn', async (cause) => { const id = await handedToHungStart('kept at close') await rig.host.close(SESSION, cause) @@ -93,6 +105,7 @@ describe('a message handed to a start that never answered, then the chat ends', keptAsQueuedMessageId: id }) expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(derivedPauses()).toEqual(['restarted']) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts index d3a658d42657..3f33335fcf78 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts @@ -1,23 +1,25 @@ // A message the host accepted while the agent was starting, then Orca quit or crashed, or the chat -// closed, before handing it over: it is kept as a held card at the head of the queue, never sent on -// its own, and released only by the person's own Send, Edit or Delete on it. Against the real host, -// store and journal. +// closed, before handing it over: it is kept as an ordinary card at the head of the queue, never +// sent on its own; like every card the chat closed with, it follows the chat's next accepted turn +// (or a Resume, Send now, Edit or Delete). Against the real host, store and journal. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' -import { createStructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionSendBody } from '../../../shared/structured-agent-session-send-mutation' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { journalIdentityFor } from './structured-agent-session-attach' import { attachParamsForRecord } from './structured-agent-session-conversation-open' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + structuredAgentSessionHostInstance, + structuredQueuePauses +} from './structured-agent-session-queued-pause' import { createQueuedMessageTestRig, eventually, @@ -48,7 +50,8 @@ const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRes const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) -const KEPT = { state: 'waiting', paused: true } +/** A kept send: an ordinary waiting card, with no hold of its own. */ +const KEPT = { state: 'waiting' } let rig: QueuedMessageTestRig @@ -133,33 +136,36 @@ describe('a message accepted while the agent starts, then Orca stops', () => { it.each([ { how: 'quit', restart: () => quitRestart() }, { how: 'crash', restart: async () => rig.crashRestartHostProcess() } - ])('is a held card after a $how, and its submission is rejected unseen', async ({ restart }) => { - const id = await acceptWhileStarting(sendRequest('hello after restart')) - await restart() + ])( + 'is a waiting card after a $how, and its submission is rejected unseen', + async ({ restart }) => { + const id = await acceptWhileStarting(sendRequest('hello after restart')) + await restart() - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - // Held on its own: no queue-wide pause, so no Resume to press. - expect(await rig.queuePause()).toBeNull() - expect(await rig.submission(id)).toMatchObject({ - dispatchState: 'rejected', - ...HOST_RESTARTED, - origin: 'client', - source: { kind: 'user' } - }) - const [card] = journal().queuedMessages.list() - expect(card).toMatchObject({ - messageId: id, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, - hostInstance: structuredAgentSessionHostInstance(), - body: hostTestMessage('hello after restart'), - queuedAt: { epoch: journal().epoch, sequence: expect.any(Number) } - }) - // Nothing is sent on its own, however many times the chat reopens. - await rig.host.close(SESSION, 'evict') - rig.crashRestartHostProcess() - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - expect(rig.dispatch).not.toHaveBeenCalled() - }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + // Nothing runs in the chat, so no paused row: it waits for the chat's next turn. + expect(await rig.queuePause()).toBeNull() + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED, + origin: 'client', + source: { kind: 'user' } + }) + const [card] = journal().queuedMessages.list() + expect(card).toMatchObject({ + messageId: id, + holdReason: null, + hostInstance: structuredAgentSessionHostInstance(), + body: hostTestMessage('hello after restart'), + queuedAt: { epoch: journal().epoch, sequence: expect.any(Number) } + }) + // Nothing is sent on its own, however many times the chat reopens. + await rig.host.close(SESSION, 'evict') + rig.crashRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(rig.dispatch).not.toHaveBeenCalled() + } + ) it('quit writes nothing for it: no closed-chat rejection, still queued until the next open', async () => { const id = await acceptWhileStarting(sendRequest('kept through quit')) @@ -182,33 +188,32 @@ describe('a message accepted while the agent starts, then Orca stops', () => { }) }) -describe('only an action on the card releases a kept card', () => { - it('a later message is delivered alone; Resume sends nothing; its own Send sends it once', async () => { +// R13-1: a kept send waits in order, like every card the chat closed with, and follows the +// chat's next accepted turn; no "press Send" step. +describe('the next accepted turn releases a kept card', () => { + it('a later message runs first, then the card follows it in order', async () => { const id = await acceptWhileStarting(sendRequest('the kept words')) await quitRestart() - // The person types the same words again, as one who cannot see cards would. - const retyped = rig.send('the kept words') - await retyped.result + const next = rig.send('a new request') + await next.result await eventually(async () => - expect((await rig.submission(retyped.id))?.handedOverAt).toBeDefined() + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() ) - await rig.settleAccepted(retyped.id, 'retyped') - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(dispatchedTexts()).toEqual(['the kept words']) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - - await rig.resume() - await new Promise((resolve) => setTimeout(resolve, 100)) expect(await rig.handoff(id)).toBeUndefined() - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + await rig.settleAccepted(next.id, 'next') + await eventually(() => expect(dispatchedTexts()).toEqual(['a new request', 'the kept words'])) + expect(await rig.handoff(id)).toMatchObject({ origin: 'host' }) + }) - expect(await rig.sendNow(id)).toMatchObject({ ok: true }) - const handoff = await rig.handoff(id) - expect(handoff?.clientMessageId).not.toBe(id) - expect(handoff).toMatchObject({ origin: 'client' }) - await eventually(() => expect(dispatchedTexts()).toEqual(['the kept words', 'the kept words'])) - expect(await rig.drafts()).toEqual([]) + it('a Resume sends it, and nothing sends it before', async () => { + const id = await acceptWhileStarting(sendRequest('resume me')) + await quitRestart() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rig.dispatch).not.toHaveBeenCalled() + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(() => expect(dispatchedTexts()).toEqual(['resume me'])) + expect(await rig.handoff(id)).toBeDefined() }) it('Send now sends it at once, and Delete removes it', async () => { @@ -249,15 +254,11 @@ describe('only an action on the card releases a kept card', () => { if (!page.ok) { throw new Error('history refused') } + // The sending desktop's own bubble, had it not settled yet. const lingering = { - ...createStructuredAgentSessionOutboxEntry({ - clientMessageId: id, - sessionId: SESSION, - text: 'the kept words', - attachments: [], - queuedAt: 1 - }), - state: 'rejected' as const + clientMessageId: id, + body: structuredAgentSessionSendBody('the kept words', []), + queuedAt: 1 } const shown = projectStructuredAgentSessionMessages( page.page.items, @@ -273,7 +274,7 @@ describe('only an action on the card releases a kept card', () => { expect(await rig.drafts()).toEqual([]) }) - it('a Stop on the reopened chat leaves it kept: the open settles it before the Stop runs', async () => { + it('a Stop on the reopened chat leaves its card waiting: the open settles it before the Stop runs', async () => { const id = await acceptWhileStarting(sendRequest('survives a stop')) await rig.crashRestartHostProcess() @@ -283,26 +284,33 @@ describe('only an action on the card releases a kept card', () => { expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) }) - // A Send now cut short by a quit hands the card back kept, so a retyped copy still goes alone. - // After a second restart the kept card is another process's, yet it pauses nothing else. - it('a later restart leaves the cards queued after it unpaused', async () => { + // Another restart before any turn: the card still waits, and the first turn there releases it + // ahead of the cards queued behind that turn. + it('a second restart still holds it; the next turn sends it before the cards queued after', async () => { const id = await acceptWhileStarting(sendRequest('kept twice over')) await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) rig.crashRestartHostProcess() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rig.dispatch).not.toHaveBeenCalled() const working = await rig.workingSend() const later = rig.send('queued after the restart', 'queue-if-active') expect(await later.result).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) expect(await rig.queuePause()).toBeNull() await rig.settleAccepted(working, 'working') + await eventually(() => expect(dispatchedTexts()).toEqual(['work on this', 'kept twice over'])) + await rig.settleAccepted(await rig.handoffId(id), 'kept') await eventually(() => - expect(dispatchedTexts()).toEqual(['work on this', 'queued after the restart']) + expect(dispatchedTexts()).toEqual([ + 'work on this', + 'kept twice over', + 'queued after the restart' + ]) ) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) }) - it('stays kept when its own Send is cut short by another quit', async () => { + it('its own Send cut short by another quit brings it back at the head, waiting', async () => { const id = await acceptWhileStarting(sendRequest('the kept words')) await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) @@ -321,16 +329,9 @@ describe('only an action on the card releases a kept card', () => { await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - expect(journal().queuedMessages.get(id)?.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) - const retyped = rig.send('the kept words') - await retyped.result - await eventually(async () => - expect((await rig.submission(retyped.id))?.handedOverAt).toBeDefined() - ) - await rig.settleAccepted(retyped.id, 'retyped') + expect(journal().queuedMessages.get(id)?.holdReason).toBeNull() await new Promise((resolve) => setTimeout(resolve, 100)) - expect(dispatchedTexts()).toEqual(['the kept words']) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(rig.dispatch).not.toHaveBeenCalled() }) }) @@ -437,8 +438,8 @@ describe('the queue at a quit', () => { } ) - // The card the person pushed ahead waits for their own Send; Resume sends the rest. - it('Send now on an ordinary card, cut short by a quit, returns it kept while Resume sends the rest', async () => { + // The card the person pushed ahead comes back at the head; Resume sends it, then the rest. + it('Send now on an ordinary card, cut short by a quit, returns it to the head; Resume sends both in order', async () => { await compactRunning() const first = rig.send('first queued', 'queue-if-active') await first.result @@ -455,9 +456,9 @@ describe('the queue at a quit', () => { ]) expect(await rig.queuePause()).toBeNull() await rig.resume() - await eventually(() => expect(dispatchedTexts()).toEqual(['first queued'])) - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(await rig.drafts()).toEqual([{ messageId: pushed.id, ...KEPT }]) + await eventually(() => expect(dispatchedTexts()).toEqual(['pushed ahead'])) + await rig.settleAccepted(await rig.handoffId(pushed.id), 'pushed') + await eventually(() => expect(dispatchedTexts()).toEqual(['pushed ahead', 'first queued'])) }) }) @@ -465,7 +466,7 @@ describe('the queue at a quit', () => { // chat, and the chat can be reopened from its history. describe('a message accepted while the agent starts, then the chat closes', () => { it.each(['user-close', 'evict'] as const)( - 'after a %s it is a held card the reopened chat shows, rejected as closed', + 'after a %s it is a waiting card the reopened chat shows, rejected as closed', async (cause) => { const id = await acceptWhileStarting(sendRequest('kept at close')) await closeWhileStarting(cause) @@ -488,9 +489,9 @@ describe('a message accepted while the agent starts, then the chat closes', () = .join('|') expect(texts).not.toContain('kept at close') expect(page.page.queuedMessages?.[0]).toMatchObject({ - body: hostTestMessage('kept at close'), - pausedReason: QUEUED_MESSAGE_PAUSED_KEPT + body: hostTestMessage('kept at close') }) + expect(page.page.queuedMessages?.[0]).not.toHaveProperty('paused') expect(rig.dispatch).not.toHaveBeenCalled() } ) @@ -499,7 +500,7 @@ describe('a message accepted while the agent starts, then the chat closes', () = // The desktop's outbox keeps an unconfirmed send and sends it again, under the same id, once the // app is back. That replay meets the card the open made from the same send. describe('the same send arriving again after the restart', () => { - it('a client that never asked to queue is answered from its own record: one card, still held, nothing sent', async () => { + it('a client that never asked to queue is answered from its own record: one card, still waiting, nothing sent', async () => { const request = sendRequest('sent again by the outbox') const id = await acceptWhileStarting(request) await quitRestart() @@ -521,16 +522,17 @@ describe('the same send arriving again after the restart', () => { await new Promise((resolve) => setTimeout(resolve, 100)) expect(rig.dispatch).not.toHaveBeenCalled() - // Retry on such a client sends the words under a new id: delivered once, the card still held. + // Retry on such a client sends the words under a new id, and the card follows that turn, as + // every card the chat closed with does: the same words twice, by the one rule. const retried = rig.send('sent again by the outbox') await retried.result await eventually(async () => expect((await rig.submission(retried.id))?.handedOverAt).toBeDefined() ) await rig.settleAccepted(retried.id, 'retried') - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(dispatchedTexts()).toEqual(['sent again by the outbox']) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + await eventually(() => + expect(dispatchedTexts()).toEqual(['sent again by the outbox', 'sent again by the outbox']) + ) }) it('a client that asked to queue is told the host holds it', async () => { @@ -624,8 +626,8 @@ describe('what is not kept', () => { }) }) -describe('/clear carries a kept card still held', () => { - it('a turn in the new conversation never releases it', async () => { +describe('/clear carries a kept card', () => { + it('the first turn in the new conversation releases it, after that turn', async () => { const id = await acceptWhileStarting(sendRequest('carried through clear')) await quitRestart() expect(await rig.drafts()).toHaveLength(1) @@ -639,8 +641,8 @@ describe('/clear carries a kept card still held', () => { throw new Error('expected a replacement session') } expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) - const carried = rig.host.collaboratorsForTests().sessions.get(replacementId)?.journal - expect(carried?.queuedMessages.list()[0]?.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) + // Nothing runs in the fresh conversation, so no paused row. + expect(await rig.queuePause(replacementId)).toBeNull() const body = hostTestMessage('first turn after the clear') const turn = await rig.host.send(QUEUED_RIG_CALLER, { @@ -670,9 +672,131 @@ describe('/clear carries a kept card still held', () => { clientMessageId: turnId, providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-x', ordinal: 0 } }) + await eventually(() => + expect(dispatchedTexts()).toEqual(['first turn after the clear', 'carried through clear']) + ) + }) +}) + +// One rule for a chat that was not running: a card queued while it ran drains as its turn ends, +// and a card it closed with waits for its next accepted turn, written down by the reopen's mark. +describe("the reopen's mark", () => { + it('a card queued mid-turn drains when that turn ends, with no reopen and no mark', async () => { + const marks = vi.spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + const working = await rig.workingSend() + const queued = rig.send('queued mid-turn', 'queue-if-active') + expect(await queued.result).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) + await rig.settleAccepted(working, 'working') + await eventually(() => expect(dispatchedTexts()).toEqual(['work on this', 'queued mid-turn'])) + expect(marks).not.toHaveBeenCalled() + }) + + it.each(['user-close', 'evict'] as const)( + 'a %s and a reopen in the same process: the card waits for the next turn', + async (cause) => { + const working = await rig.workingSend() + const queued = rig.send('queued before the close', 'queue-if-active') + await queued.result + await rig.host.close(SESSION, cause) + expect(await rig.drafts()).toEqual([{ messageId: queued.id, state: 'waiting' }]) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(await rig.handoff(queued.id)).toBeUndefined() + expect(dispatchedTexts()).toEqual(['work on this']) + void working + + const next = rig.send('after the reopen') + await next.result + await eventually(async () => + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(next.id, 'next') + await eventually(() => + expect(dispatchedTexts()).toEqual([ + 'work on this', + 'after the reopen', + 'queued before the close' + ]) + ) + } + ) + + // An idle eviction is Orca's own act: the person sees the same rows, and nothing new is held. + it('an idle-sweep eviction of a stopped chat writes nothing, and its Stop row stays', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ idleSweep: { idleMs: 0, intervalMs: 60 * 60 * 1000 } }) + const marks = vi.spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + const working = await rig.workingSend() + const held = rig.send('held by the Stop', 'queue-if-active') + await held.result + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'stopped') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + + await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() + + expect(rig.closeSession).toHaveBeenCalledTimes(1) + // A chat with cards waiting keeps its handle, so no reopen can mark them. + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.handoff(held.id)).toBeUndefined() + expect(marks).not.toHaveBeenCalled() + // Kept only while a card waits: once the last one leaves, the next sweep drops it as usual. + expect(await rig.deleteQueued(held.id)).toMatchObject({ ok: true, value: { deleted: true } }) + await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(false) + }) + + // The startup restore opens a visible chat through the same open as any reader. + it('a chat the startup restore reopens after a crash holds its cards', async () => { + const working = await rig.workingSend() + const queued = rig.send('queued behind work', 'queue-if-active') + await queued.result + void working + const sent = rig.dispatch.mock.calls.length + rig.crashRestartHostProcess() + await rig.host.restoreReadableSessions([SESSION]) + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) await new Promise((resolve) => setTimeout(resolve, 100)) - expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) - expect(dispatchedTexts()).toEqual(['first turn after the clear']) + expect(await rig.handoff(queued.id)).toBeUndefined() + expect(rig.dispatch).toHaveBeenCalledTimes(sent) + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['restarted']) + }) + + it('an idle chat with no waiting card is closed and reopened without writing anything', async () => { + const marks = vi.spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + const working = await rig.workingSend() + await rig.settleAccepted(working, 'working') + const rows = (await rig.host.journalSnapshot(SESSION)).cursor + await rig.host.close(SESSION, 'evict') + expect(await rig.drafts()).toEqual([]) + expect((await rig.host.journalSnapshot(SESSION)).cursor).toEqual(rows) + expect(marks).not.toHaveBeenCalled() + }) + + // Bookkeeping never gates the person: the open goes on, and the open itself is where the pause + // begins, so nothing sends by itself; the next turn releases the card, and the next open marks. + it('an open whose mark cannot be written still opens, and still holds the card', async () => { + const id = await acceptWhileStarting(sendRequest('kept, mark failed')) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const marks = vi + .spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + .mockRejectedValueOnce(new Error('disk full')) + await quitRestart() + + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(marks).toHaveBeenCalledOnce() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rig.dispatch).not.toHaveBeenCalled() + + const next = rig.send('a new request') + await next.result + await eventually(async () => + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(next.id, 'next') + await eventually(() => + expect(dispatchedTexts()).toEqual(['a new request', 'kept, mark failed']) + ) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 9664aaf65573..a5db53e5555c 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -161,7 +161,7 @@ it('answers at handover, then journals its own entry, turn and result (B1, B16)' ok: true, value: { command: 'compact', state: 'completed' } }) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) finish({ outcome: 'success' }) await vi.waitFor(async () => @@ -201,7 +201,7 @@ it('replays the reply for the same operation without running it again (B16)', as await attach() const params = compactParams() await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) finish({ outcome: 'success' }) await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ ok: true, @@ -214,7 +214,7 @@ it('holds messages sent during the command and delivers them after it, in order await attach() const params = compactParams() await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) await expect(state.host.send(CALLER, sendParams('first'))).resolves.toMatchObject({ ok: true }) await expect(state.host.send(CALLER, sendParams('second'))).resolves.toMatchObject({ ok: true }) // Nothing is handed over while the command's turn runs. @@ -227,7 +227,7 @@ it('holds messages sent during the command and delivers them after it, in order }) // Delivered even though the command failed. - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledTimes(2), { interval: 1 }) expect(state.dispatch.mock.calls.map(([input]) => input.body.blocks)).toEqual([ [{ type: 'text', text: 'first' }], [{ type: 'text', text: 'second' }] @@ -246,7 +246,7 @@ it('holds messages sent during the command and delivers them after it, in order it('hands over a message held behind the command when the command ends just as the loop stops for it', async () => { await attach() await state.host.conversationCommand(CALLER, compactParams()) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) const { journal: live } = state.host['sessions'].get(SESSION)! const activeTurnId = live.activeTurnId let ended = false @@ -269,7 +269,7 @@ it('hands over a message held behind the command when the command ends just as t await expect(state.host.send(CALLER, sendParams('held'))).resolves.toMatchObject({ ok: true }) - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) expect(ended).toBe(true) }) @@ -289,7 +289,7 @@ it('settles a command the provider refused as a failure with its reason, and mov await state.host.conversationCommand(CALLER, params) await state.host.send(CALLER, sendParams('after the refusal')) - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) expect(readAgentJournalTurn((await commandTurn(cmid))?.body)).toMatchObject({ state: 'completed', outcome: 'failure' @@ -460,7 +460,7 @@ it('leaves the command to the provider when it takes the Stop, and ends it as ca const params = compactParams() const cmid = params.envelope.clientOperationId await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) const { turnId } = structuredAgentSessionCommandTurn(cmid) await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) @@ -487,7 +487,7 @@ it('ends the command by stopping the child at a second Stop the provider never a const params = compactParams() const cmid = params.envelope.clientOperationId await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) const { turnId } = structuredAgentSessionCommandTurn(cmid) // The provider takes the interrupt and then never answers it. @@ -519,7 +519,7 @@ it('ends the command by stopping the child when the provider cannot take the Sto const params = compactParams() const cmid = params.envelope.clientOperationId await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) await expect(stop(structuredAgentSessionCommandTurn(cmid).turnId)).resolves.toMatchObject({ ok: true, @@ -534,7 +534,7 @@ it('ends the command by stopping the child when the provider cannot take the Sto ]) // The next message starts a child of its own. await state.host.send(CALLER, sendParams('after the stop')) - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) expect(state.acquire).toHaveBeenCalledTimes(2) }) @@ -543,7 +543,7 @@ it('does not stop the child for a Stop naming a command that already ended (B4)' state.cancelTurn.mockResolvedValue({ cancelled: false }) const params = compactParams() await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) finish({ outcome: 'success' }) const { itemId, turnId } = structuredAgentSessionCommandTurn(params.envelope.clientOperationId) await vi.waitFor(async () => @@ -582,7 +582,7 @@ it("answers a refused command's message before ending its turn, so a crash betwe const params = compactParams() const cmid = params.envelope.clientOperationId await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(crash).toHaveBeenCalled()) + await vi.waitFor(() => expect(crash).toHaveBeenCalled(), { interval: 1 }) crash.mockRestore() // Never an ended turn whose message still reads as in flight. @@ -603,13 +603,13 @@ it("answers a refused command's message before ending its turn, so a crash betwe it('counts a message held behind the command from its handover, not its send', async () => { await attach() await state.host.conversationCommand(CALLER, compactParams()) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) const sent = await state.host.send(CALLER, sendParams('held')) expect(sent.ok).toBe(true) await new Promise((resolve) => setTimeout(resolve, 20)) finish({ outcome: 'success' }) - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) const [dispatched] = state.dispatch.mock.calls[0]! const submission = (await journal()).submissions.find( (entry) => entry.clientMessageId === dispatched.clientMessageId @@ -653,7 +653,7 @@ it('writes one exit row when the child dies mid-command, and the loop writes not const params = compactParams() const cmid = params.envelope.clientOperationId await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) await expect(state.host.send(CALLER, sendParams('queued behind it'))).resolves.toMatchObject({ ok: true }) @@ -673,7 +673,7 @@ it('writes one exit row when the child dies mid-command, and the loop writes not expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') ) // Released from the command, the loop starts a child and delivers what waited behind it. - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) expect(state.acquire).toHaveBeenCalledTimes(2) const snapshot = await journal() expect( @@ -702,7 +702,7 @@ it('delivers the next message after a command whose child died and whose settlem const params = compactParams() const cmid = params.envelope.clientOperationId await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) const { journal: live } = state.host['sessions'].get(SESSION)! const appendLifecycleBatch = live.appendLifecycleBatch.bind(live) vi.spyOn(live, 'appendLifecycleBatch').mockImplementation((input) => @@ -729,7 +729,7 @@ it('delivers the next message after a command whose child died and whose settlem await expect(state.host.send(CALLER, sendParams('after it'))).resolves.toMatchObject({ ok: true }) - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) expect(state.acquire).toHaveBeenCalledTimes(2) expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).not.toBe('running') }) @@ -760,12 +760,12 @@ it("ignores an older build's unconfirmed compaction record, and answers its oper await expect(state.host.send(CALLER, sendParams('still works'))).resolves.toMatchObject({ ok: true }) - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) await expect(state.host.conversationCommand(CALLER, compactParams())).resolves.toMatchObject({ ok: true, value: { state: 'completed' } }) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) finish({ outcome: 'success' }) await vi.waitFor(async () => expect((await journal()).submissions.every((entry) => entry.dispatchState === 'accepted')).toBe( @@ -788,7 +788,7 @@ it('never lets a provider echo alias the command entry', async () => { await attach() const params = compactParams() await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) finish({ outcome: 'success' }) const events = state.acquire.mock.calls.at(-1)?.[0].events const echo = { provider: 'codex' as const, threadId: THREAD, turnId: 'later', ordinal: 0 } @@ -806,7 +806,7 @@ it('never lets a provider echo alias the command entry', async () => { it('refuses a /compact pressed again under a new id while one runs, and runs one pressed after it ended', async () => { await attach() await state.host.conversationCommand(CALLER, compactParams()) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) expect(await state.host.conversationCommand(CALLER, compactParams())).toMatchObject({ ok: false, @@ -820,5 +820,5 @@ it('refuses a /compact pressed again under a new id while one runs, and runs one ok: true }) ) - await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(2), { interval: 1 }) }) diff --git a/src/main/native-chat/claude-structured-managed-account-support.test.ts b/src/main/native-chat/claude-structured-managed-account-support.test.ts deleted file mode 100644 index 8d6aa55783f4..000000000000 --- a/src/main/native-chat/claude-structured-managed-account-support.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' -import { - structuredClaudeMatchesActiveManagedAccount, - type ClaudeManagedAccountGateSettings -} from './claude-structured-managed-account-support' - -function account(id: string, managedAuthRuntime: 'host' | 'wsl') { - return { - id, - email: `${id}@example.com`, - managedAuthPath: `/managed/${id}`, - managedAuthRuntime, - authMethod: 'subscription-oauth' as const, - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0 - } -} - -function settings( - overrides: Partial -): ClaudeManagedAccountGateSettings { - return { claudeManagedAccounts: [], activeClaudeManagedAccountId: null, ...overrides } -} - -describe('structuredClaudeMatchesActiveManagedAccount', () => { - it('allows an unmanaged install, where nothing claims an identity', () => { - expect(structuredClaudeMatchesActiveManagedAccount(settings({}))).toBe(true) - }) - - it('allows a selected host account, which the runtime syncs into the ambient config', () => { - expect( - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: [account('host-1', 'host')], - activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } - }) - ) - ).toBe(true) - }) - - it('refuses a WSL-only managed account, which never reaches the ambient config', () => { - expect( - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: [account('wsl-1', 'wsl')], - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } - }) - ) - ).toBe(false) - }) - - it('refuses when a host selection names an account that is WSL-bound or missing', () => { - expect( - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: [account('wsl-1', 'wsl')], - activeClaudeManagedAccountIdsByRuntime: { host: 'wsl-1', wsl: {} } - }) - ) - ).toBe(false) - expect( - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: [account('host-1', 'host')], - activeClaudeManagedAccountIdsByRuntime: { host: 'gone', wsl: {} } - }) - ) - ).toBe(false) - }) - - /** Absent and empty are the same answer: this user has no managed Claude accounts, so nothing - * claims an identity and the ambient path is legitimate. Only settings that cannot be READ are - * unknown. Treating a missing key as unknown strands profiles that simply never wrote it — the - * auth policy's own predicate takes `(accounts ?? [])` for exactly this reason. */ - it('treats an absent account list the same as an empty one', () => { - expect( - structuredClaudeMatchesActiveManagedAccount(settings({ claudeManagedAccounts: [] })) - ).toBe(true) - expect( - structuredClaudeMatchesActiveManagedAccount({ - activeClaudeManagedAccountId: null - } as unknown as ClaudeManagedAccountGateSettings) - ).toBe(true) - }) - - it('fails closed when the settings cannot be read at all', () => { - expect(structuredClaudeMatchesActiveManagedAccount(null)).toBe(false) - expect(structuredClaudeMatchesActiveManagedAccount(undefined)).toBe(false) - }) - - /** THE discriminator, and the whole of this rule. With nothing selected for the host runtime the - * settings alone cannot distinguish honest deselection from the WSL-only steady state, because - * `pruneInvalidClaudeRuntimeSelection` empties the host slot in the second case and persists it. - * So the presence of ANY WSL-bound account decides. Simplifying this to "none active -> - * supported" re-opens the auth-identity misrepresentation this gate exists to prevent. */ - it('splits none-active on whether a WSL-bound account exists at all', () => { - const noneActive = (accounts: ReturnType[]) => - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: accounts, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - }) - ) - - expect(noneActive([account('host-1', 'host')])).toBe(true) - expect(noneActive([account('host-1', 'host'), account('host-2', 'host')])).toBe(true) - expect(noneActive([account('wsl-1', 'wsl')])).toBe(false) - // Mixed list still refuses: the WSL account is present and nothing is selected. - expect(noneActive([account('host-1', 'host'), account('wsl-1', 'wsl')])).toBe(false) - }) - - /** The gate and the auth policy must resolve the SAME account. A legacy settings blob carries the - * selection only in the flat `activeClaudeManagedAccountId`, which is where the accessor's - * fall-through lives — reading the runtime map directly silently disagrees with the policy. */ - it('resolves the same account as the auth policy on a legacy flat selection', () => { - const legacy = settings({ - claudeManagedAccounts: [account('host-1', 'host')], - activeClaudeManagedAccountId: 'host-1' - }) - - expect(getSelectedClaudeAccountIdForTarget(legacy, { runtime: 'host' })).toBe('host-1') - expect(structuredClaudeMatchesActiveManagedAccount(legacy)).toBe(true) - }) - - it('agrees with the auth policy that a legacy flat WSL selection is refused', () => { - const legacy = settings({ - claudeManagedAccounts: [account('wsl-1', 'wsl')], - activeClaudeManagedAccountId: 'wsl-1' - }) - - expect(getSelectedClaudeAccountIdForTarget(legacy, { runtime: 'host' })).toBe('wsl-1') - expect(structuredClaudeMatchesActiveManagedAccount(legacy)).toBe(false) - }) -}) diff --git a/src/main/native-chat/claude-structured-managed-account-support.ts b/src/main/native-chat/claude-structured-managed-account-support.ts deleted file mode 100644 index 3b42c44c443f..000000000000 --- a/src/main/native-chat/claude-structured-managed-account-support.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { GlobalSettings } from '../../shared/global-settings-types' -import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' - -export type ClaudeManagedAccountGateSettings = Pick< - GlobalSettings, - | 'claudeManagedAccounts' - | 'activeClaudeManagedAccountId' - | 'activeClaudeManagedAccountIdsByRuntime' -> - -/** - * A structured Claude session launches against the ambient Claude config, which the account service - * keeps in sync with the selected HOST account. A WSL-bound managed account lives inside the distro - * and is never synced there, so such a session would authenticate as whatever the ambient identity - * happens to be while the UI names the WSL account — the user is told one identity and given - * another. Refuse the structured path there and let the terminal-backed one, which resolves the - * account per runtime, handle that account shape. - * - * Reads the selection through the same accessor the auth policy uses. Resolving it any other way - * lets the two disagree, and a session admitted by this gate would then run under a policy computed - * from a different account than the one approved here. - * - * Unknown answers refuse, and only genuinely unknown ones: settings that cannot be read at all, or - * an active selection this cannot resolve. An install with no managed accounts — the list empty or - * never written — claims no identity and is fine. - */ -export function structuredClaudeMatchesActiveManagedAccount( - settings: ClaudeManagedAccountGateSettings | null | undefined -): boolean { - if (!settings) { - return false - } - // Absent is the same answer as empty — this user has no managed Claude accounts, so nothing - // claims an identity and ambient auth is the truth. Only settings that cannot be READ are - // unknown, and those refuse above. The auth policy reads the list the same way. - const accounts = settings.claudeManagedAccounts ?? [] - if (accounts.length === 0) { - return true - } - const activeHostId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) - if (!activeHostId) { - // Nothing selected for the host runtime is two different states that the settings cannot tell - // apart after the fact: honest deselection, where ambient auth is the truth and the UI names no - // identity, and the WSL-only case, where the prune emptied the host slot and persisted null - // while the UI still names the WSL account. The presence of any WSL-bound account decides. - return !hasWslBoundClaudeAccount(settings) - } - const active = accounts.find((candidate) => candidate.id === activeHostId) - return active ? active.managedAuthRuntime !== 'wsl' : false -} - -/** Whether any managed Claude account lives in WSL, the account shape the gate refuses over. */ -export function hasWslBoundClaudeAccount(settings: ClaudeManagedAccountGateSettings): boolean { - return (settings.claudeManagedAccounts ?? []).some( - (candidate) => candidate.managedAuthRuntime === 'wsl' - ) -} - -/** Reads the gate's settings, answering null when they cannot be read so callers refuse. */ -export function readClaudeManagedAccountGateSettings( - getSettings: () => ClaudeManagedAccountGateSettings -): ClaudeManagedAccountGateSettings | null { - try { - return getSettings() - } catch { - return null - } -} diff --git a/src/main/native-chat/native-chat-inline-visuals-setting.test.ts b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts new file mode 100644 index 000000000000..d778f98c247b --- /dev/null +++ b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts @@ -0,0 +1,154 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution' +import { CLAUDE_PLUGIN_DIR_FLAG, type ClaudeCliFlag } from '../claude/claude-cli-flag-support' +import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' +import { + adapterFor, + fakeCodex, + identityFor +} from '../codex/codex-structured-session-adapter-fixture' +import { agentSessionRecordFixture } from './agent-session-record-test-fixture' +import { + createNativeChatVisualsDelivery, + NATIVE_CHAT_VISUALS_DIR_ENV +} from './native-chat-visuals-delivery' +import { nativeChatVisualsFolderFor } from './native-chat-visuals-folder' + +const SKILL = { pluginDir: '/app/plugin', skillsRoot: '/app/plugin/skills' } +const MANUAL = { approvalPolicy: 'on-request', sandbox: 'workspace-write' } as const +const scratch: string[] = [] +afterEach(() => { + for (const dir of scratch.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function delivery(isEnabled: () => boolean) { + const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-visuals-setting-')) + scratch.push(stateDirectory) + const prepareVisuals = createNativeChatVisualsDelivery({ + stateDirectory, + logger: { warn: vi.fn(), error: vi.fn() }, + isEnabled, + resolveSkill: async () => SKILL + }) + return { stateDirectory, prepareVisuals } +} + +describe('inline visuals launch preference', () => { + it('starts Claude without visuals while preserving configured plugins, user folders and attachment access', async () => { + const sessionId = 'claude-disabled' + const { stateDirectory, prepareVisuals } = delivery(() => false) + const supports = vi.fn(async (_flag: ClaudeCliFlag) => true) + const record = agentSessionRecordFixture({ sessionId }) + const launch = await createClaudeStructuredLaunchResolver({ + store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, + resolveWorkspacePath: async () => '/repo', + resolveCommand: () => '/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveLaunchArgs: () => ['--add-dir', '/user/notes', '--plugin-dir', '/user/plugin'], + resolveEnv: () => ({ [NATIVE_CHAT_VISUALS_DIR_ENV]: '/inherited', KEEP: 'yes' }), + resolveInheritedEnv: async () => ({ PATH: '/bin' }), + attachmentDirectory: '/state/agent-session-attachments', + hasTranscript: async () => false, + cliFlags: { supports }, + prepareVisuals + })({ identity: { ...identityFor(sessionId), agent: 'claude', providerHandle: null } }) + expect(launch.options).not.toHaveProperty('plugins') + expect(launch.options.extraArgs).toHaveProperty('plugin-dir', '/user/plugin') + expect(launch.options.additionalDirectories).toEqual([ + '/user/notes', + '/state/agent-session-attachments' + ]) + expect(launch.env).not.toHaveProperty(NATIVE_CHAT_VISUALS_DIR_ENV) + expect(launch.env?.KEEP).toBe('yes') + expect(supports.mock.calls.map(([flag]) => flag)).not.toContain(CLAUDE_PLUGIN_DIR_FLAG) + expect(existsSync(nativeChatVisualsFolderFor(stateDirectory, sessionId))).toBe(false) + }) + + it('gives new Codex chats the current preference without changing another live chat', async () => { + let enabled = true + const { stateDirectory, prepareVisuals } = delivery(() => enabled) + const codex = fakeCodex({ + 'config/read': () => ({ + config: { sandbox_workspace_write: { writable_roots: ['/user/notes'] } } + }) + }) + const resolveLaunch = createCodexStructuredLaunchResolver({ + store: { + getRecord: (sessionId) => + agentSessionRecordFixture({ + sessionId, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/codex' } + }), + pinLaunchDirectory: vi.fn() + }, + resolveWorkspacePath: async () => '/repo', + resolveCommand: () => '/bin/codex', + resolveLaunchArgs: () => [], + resolvePermissionPolicy: () => MANUAL, + resolveEnvironment: async () => ({ + [NATIVE_CHAT_VISUALS_DIR_ENV]: '/inherited', + KEEP: 'yes' + }), + prepareVisuals + }) + const adapter = adapterFor(codex, {}, [], { resolveLaunch }) + const start = (sessionId: string) => + adapter.acquire({ + identity: { + ...identityFor(sessionId), + hostId: LOCAL_EXECUTION_HOST_ID, + providerHandle: null + }, + fence: 1, + spawnToken: sessionId + }) + try { + await start('first') + const first = codex.connections[0]! + const firstCalls = [...first.calls] + const firstFolder = nativeChatVisualsFolderFor(stateDirectory, 'first') + expect(first.launch.env?.[NATIVE_CHAT_VISUALS_DIR_ENV]).toBe(firstFolder) + expect(first.calls.find(({ method }) => method === 'thread/start')?.params).toMatchObject({ + config: { 'sandbox_workspace_write.writable_roots': ['/user/notes', firstFolder] } + }) + + enabled = false + await start('disabled') + const disabled = codex.connections[1]! + expect(disabled.launch.env).not.toHaveProperty(NATIVE_CHAT_VISUALS_DIR_ENV) + expect(disabled.launch.env?.KEEP).toBe('yes') + expect(disabled.calls.map(({ method }) => method)).not.toContain('skills/extraRoots/set') + const openIndex = disabled.calls.findIndex(({ method }) => method === 'thread/start') + expect(disabled.calls.slice(0, openIndex).map(({ method }) => method)).not.toContain( + 'config/read' + ) + expect( + disabled.calls.find(({ method }) => method === 'thread/start')?.params + ).not.toHaveProperty('config') + expect(existsSync(nativeChatVisualsFolderFor(stateDirectory, 'disabled'))).toBe(false) + expect(first.closed).toBe(false) + expect(first.calls).toEqual(firstCalls) + + enabled = true + await start('reenabled') + const reenabled = codex.connections[2]! + expect( + reenabled.calls.find(({ method }) => method === 'skills/extraRoots/set')?.params + ).toEqual({ extraRoots: [SKILL.skillsRoot] }) + expect(reenabled.launch.env?.[NATIVE_CHAT_VISUALS_DIR_ENV]).toBe( + nativeChatVisualsFolderFor(stateDirectory, 'reenabled') + ) + expect(first.closed).toBe(false) + expect(first.calls).toEqual(firstCalls) + } finally { + await adapter.closeAll() + } + }) +}) diff --git a/src/main/native-chat/native-chat-visual-file-read.test.ts b/src/main/native-chat/native-chat-visual-file-read.test.ts new file mode 100644 index 000000000000..c28798e88b5f --- /dev/null +++ b/src/main/native-chat/native-chat-visual-file-read.test.ts @@ -0,0 +1,204 @@ +import { execFileSync } from 'node:child_process' +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile, link } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { NATIVE_CHAT_VISUAL_MAX_BYTES } from '../../shared/native-chat-visual-directive' +import { nativeChatVisualsFolderFor } from './native-chat-visuals-folder' +import { nativeChatVisualRevision, readNativeChatVisualFile } from './native-chat-visual-file-read' + +const posixIt = process.platform === 'win32' ? it.skip : it + +let stateDirectory: string +let folder: string + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-visual-read-')) + folder = nativeChatVisualsFolderFor(stateDirectory, 'session-alpha') + await mkdir(folder, { recursive: true }) +}) + +afterEach(async () => { + await rm(stateDirectory, { recursive: true, force: true }) +}) + +describe('nativeChatVisualsFolderFor', () => { + it('places each chat in its own hashed folder under the state directory', () => { + const alpha = nativeChatVisualsFolderFor('/state', 'session-alpha') + const beta = nativeChatVisualsFolderFor('/state', 'session-beta') + expect(alpha).toMatch(/native-chat-visuals[\\/][0-9a-f]{32}$/) + expect(alpha.startsWith(join('/state', 'native-chat-visuals'))).toBe(true) + expect(alpha).not.toBe(beta) + }) +}) + +describe('readNativeChatVisualFile', () => { + it('reads a UTF-8 visual with its revision and size', async () => { + const html = '

Grüße

' + await writeFile(join(folder, 'chart.html'), html) + const result = await readNativeChatVisualFile(folder, 'chart.html') + expect(result).toEqual({ + ok: true, + html, + revision: nativeChatVisualRevision(Buffer.from(html)), + sizeBytes: Buffer.byteLength(html) + }) + }) + + it('answers unchanged without the bytes when the client already holds the revision', async () => { + await writeFile(join(folder, 'chart.html'), '

a

') + const first = await readNativeChatVisualFile(folder, 'chart.html') + if (!first.ok) { + throw new Error('expected a read') + } + const again = await readNativeChatVisualFile(folder, 'chart.html', first.revision) + expect(again).toEqual({ ok: true, revision: first.revision, sizeBytes: 8, unchanged: true }) + + await writeFile(join(folder, 'chart.html'), '

b

') + const changed = await readNativeChatVisualFile(folder, 'chart.html', first.revision) + expect(changed).toMatchObject({ ok: true, html: '

b

' }) + }) + + it('reports a missing file or a missing folder as not_found', async () => { + expect(await readNativeChatVisualFile(folder, 'nope.html')).toEqual({ + ok: false, + error: 'not_found' + }) + const absent = nativeChatVisualsFolderFor(stateDirectory, 'session-never') + expect(await readNativeChatVisualFile(absent, 'chart.html')).toEqual({ + ok: false, + error: 'not_found' + }) + }) + + it('refuses names that are not a bare visual file name', async () => { + await mkdir(join(stateDirectory, 'other'), { recursive: true }) + await writeFile(join(stateDirectory, 'other', 'secret.html'), 'secret') + for (const name of ['../other/secret.html', '/etc/hosts', 'chart.txt', 'sub/chart.html']) { + expect(await readNativeChatVisualFile(folder, name)).toEqual({ + ok: false, + error: 'outside_folder' + }) + } + }) + + posixIt('refuses a symlink to a sibling chat folder or another workspace', async () => { + const sibling = nativeChatVisualsFolderFor(stateDirectory, 'session-beta') + await mkdir(sibling, { recursive: true }) + await writeFile(join(sibling, 'theirs.html'), '

other chat

') + const worktree = join(stateDirectory, 'worktree') + await mkdir(worktree) + await writeFile(join(worktree, 'index.html'), '

repo

') + + await symlink(join(sibling, 'theirs.html'), join(folder, 'theirs.html')) + await symlink(join(worktree, 'index.html'), join(folder, 'repo.html')) + await symlink(join(folder, 'missing-target.html'), join(folder, 'dangling.html')) + + for (const name of ['theirs.html', 'repo.html', 'dangling.html']) { + expect(await readNativeChatVisualFile(folder, name)).toEqual({ + ok: false, + error: 'outside_folder' + }) + } + }) + + posixIt('refuses a visuals folder replaced by a symlink', async () => { + const elsewhere = join(stateDirectory, 'elsewhere') + await mkdir(elsewhere) + await writeFile(join(elsewhere, 'chart.html'), '

elsewhere

') + await rm(folder, { recursive: true }) + await symlink(elsewhere, folder) + expect(await readNativeChatVisualFile(folder, 'chart.html')).toEqual({ + ok: false, + error: 'outside_folder' + }) + }) + + posixIt('refuses when the shared visuals root is a symlink', async () => { + const root = join(stateDirectory, 'native-chat-visuals') + const moved = join(stateDirectory, 'moved-root') + await rm(root, { recursive: true }) + await mkdir(join(moved, 'x'), { recursive: true }) + await symlink(moved, root) + await mkdir(folder, { recursive: true }) + await writeFile(join(folder, 'chart.html'), '

a

') + expect(await readNativeChatVisualFile(folder, 'chart.html')).toEqual({ + ok: false, + error: 'outside_folder' + }) + }) + + it('refuses a directory named like a visual', async () => { + await mkdir(join(folder, 'dir.html')) + expect(await readNativeChatVisualFile(folder, 'dir.html')).toEqual({ + ok: false, + error: 'not_a_file' + }) + }) + + posixIt('refuses a FIFO without waiting for a writer', async () => { + execFileSync('mkfifo', [join(folder, 'pipe.html')]) + expect(await readNativeChatVisualFile(folder, 'pipe.html')).toEqual({ + ok: false, + error: 'not_a_file' + }) + }) + + it('refuses a file over the byte cap and accepts one exactly at it', async () => { + await writeFile(join(folder, 'max.html'), 'a'.repeat(NATIVE_CHAT_VISUAL_MAX_BYTES)) + expect(await readNativeChatVisualFile(folder, 'max.html')).toMatchObject({ ok: true }) + await writeFile(join(folder, 'big.html'), 'a'.repeat(NATIVE_CHAT_VISUAL_MAX_BYTES + 1)) + expect(await readNativeChatVisualFile(folder, 'big.html')).toEqual({ + ok: false, + error: 'too_large' + }) + }) + + it('refuses binary and invalid UTF-8 content', async () => { + await writeFile(join(folder, 'zero-byte.html'), Buffer.from([0x3c, 0x00, 0x3e])) + await writeFile(join(folder, 'latin1.html'), Buffer.from([0x3c, 0xe9, 0x3e])) + expect(await readNativeChatVisualFile(folder, 'zero-byte.html')).toEqual({ + ok: false, + error: 'not_text' + }) + expect(await readNativeChatVisualFile(folder, 'latin1.html')).toEqual({ + ok: false, + error: 'not_text' + }) + }) + + it('reads a hard link the agent made inside its own folder', async () => { + // A hard link grants nothing a copy would not: the agent could write the same bytes itself. + await writeFile(join(folder, 'a.html'), '

a

') + await link(join(folder, 'a.html'), join(folder, 'b.html')) + expect(await readNativeChatVisualFile(folder, 'b.html')).toMatchObject({ ok: true }) + }) + + it('reads the replacement after a file is swapped for new content', async () => { + await writeFile(join(folder, 'chart.html'), '

old

') + await rm(join(folder, 'chart.html')) + await writeFile(join(folder, 'chart.html'), '

new

') + expect(await readNativeChatVisualFile(folder, 'chart.html')).toMatchObject({ + ok: true, + html: '

new

' + }) + }) + + posixIt('reports an unexpected filesystem fault without the host path', async () => { + await writeFile(join(folder, 'chart.html'), '

a

') + await chmod(join(folder, 'chart.html'), 0o000) + try { + const failure = await readNativeChatVisualFile(folder, 'chart.html').then( + () => null, + (error: unknown) => error + ) + // Root reads through the mode bits; everyone else gets the coded fault. + if (failure !== null) { + expect(String(failure)).toContain('visual_read_failed:EACCES') + expect(String(failure)).not.toContain(stateDirectory) + } + } finally { + await chmod(join(folder, 'chart.html'), 0o644) + } + }) +}) diff --git a/src/main/native-chat/native-chat-visual-file-read.ts b/src/main/native-chat/native-chat-visual-file-read.ts new file mode 100644 index 000000000000..36faa39f1ae0 --- /dev/null +++ b/src/main/native-chat/native-chat-visual-file-read.ts @@ -0,0 +1,223 @@ +// Reading one visual out of a chat's visuals folder. This is the boundary, not the renderer: the +// file must sit directly in the folder both lexically and canonically, be a regular file reached +// without a symlink, and be UTF-8 text within the byte cap. + +import { createHash } from 'node:crypto' +import { constants, type Stats } from 'node:fs' +import { lstat, open, realpath, type FileHandle } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { + NATIVE_CHAT_VISUAL_MAX_BYTES, + isNativeChatVisualFileName +} from '../../shared/native-chat-visual-directive' +import type { + AgentSessionReadVisualResult, + AgentSessionVisualReadError +} from '../../shared/rpc-contract/agent-session-visual-params' +import { isENOENT } from '../ipc/filesystem-path-containment' +import { readLocalFileBounded } from '../ipc/filesystem/local-regular-file-read' + +// Why O_NOFOLLOW: refuses a final-component symlink at open, so no swap after a check can redirect +// the read. Windows has no such flag; there the lstat and post-open identity checks hold the line. +const VISUAL_OPEN_FLAGS = + constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0) + +const REVISION_HEX_LENGTH = 32 + +class VisualReadRefusal extends Error { + constructor(readonly refusal: AgentSessionVisualReadError) { + super(refusal) + } +} + +function refuse(refusal: AgentSessionVisualReadError): never { + throw new VisualReadRefusal(refusal) +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && 'code' in error && typeof error.code === 'string' + ? error.code + : undefined +} + +function sameFile(left: Stats, right: Stats): boolean { + return left.dev === right.dev && left.ino === right.ino +} + +/** + * The folder and its parent are Orca's own directories below the trusted state directory. Either + * one replaced by a symlink would aim every read somewhere else, so a link there is refused. + */ +async function canonicalFolder(folder: string): Promise { + try { + // Parent first, so the answer for a broken path does not depend on which check settles first. + const parentStats = await lstat(dirname(folder)) + const owned = [parentStats, await lstat(folder)] + if (owned.some((stats) => stats.isSymbolicLink())) { + refuse('outside_folder') + } + if (owned.some((stats) => !stats.isDirectory())) { + refuse('not_found') + } + return await realpath(folder) + } catch (error) { + if (error instanceof VisualReadRefusal) { + throw error + } + if (isENOENT(error) || errorCode(error) === 'ENOTDIR') { + refuse('not_found') + } + throw error + } +} + +async function openVisual(candidate: string): Promise { + try { + if ((await lstat(candidate)).isSymbolicLink()) { + refuse('outside_folder') + } + return await open(candidate, VISUAL_OPEN_FLAGS) + } catch (error) { + if (error instanceof VisualReadRefusal) { + throw error + } + if (isENOENT(error) || errorCode(error) === 'ENOTDIR') { + refuse('not_found') + } + // ELOOP: the path became a symlink between the lstat and the open. + if (errorCode(error) === 'ELOOP' || errorCode(error) === 'EMLINK') { + refuse('outside_folder') + } + if (errorCode(error) === 'EISDIR') { + refuse('not_a_file') + } + throw error + } +} + +/** + * After the open: the handle must still be the file directly inside the canonical folder, and the + * folder that canonical path names must still be the real directory at Orca's own path (a symlink + * swapped in and out around the first check would otherwise aim `folderReal` elsewhere). + */ +async function assertStillContained( + folder: string, + candidate: string, + folderReal: string, + file: string, + opened: Stats +): Promise { + let resolved: string + let current: Stats + try { + const [parentNow, folderNow, canonicalFolderNow] = await Promise.all([ + lstat(dirname(folder)), + lstat(folder), + lstat(folderReal) + ]) + if ( + parentNow.isSymbolicLink() || + folderNow.isSymbolicLink() || + !sameFile(folderNow, canonicalFolderNow) + ) { + refuse('outside_folder') + } + resolved = await realpath(candidate) + current = await lstat(candidate) + } catch (error) { + if (error instanceof VisualReadRefusal) { + throw error + } + if (isENOENT(error)) { + refuse('not_found') + } + throw error + } + // Why compare names case-insensitively: a case-insensitive volume may report the stored case. + if ( + dirname(resolved) !== folderReal || + basename(resolved).toLowerCase() !== file.toLowerCase() || + current.isSymbolicLink() || + !sameFile(current, opened) + ) { + refuse('outside_folder') + } +} + +function decodeVisualText(buffer: Buffer): string { + if (buffer.includes(0)) { + refuse('not_text') + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(buffer) + } catch { + return refuse('not_text') + } +} + +export function nativeChatVisualRevision(buffer: Buffer): string { + return createHash('sha256').update(buffer).digest('hex').slice(0, REVISION_HEX_LENGTH) +} + +async function readContained( + folder: string, + file: string, + knownRevision: string | undefined +): Promise { + if (!isNativeChatVisualFileName(file)) { + refuse('outside_folder') + } + const folderReal = await canonicalFolder(folder) + const candidate = join(folderReal, file) + if (dirname(candidate) !== folderReal) { + refuse('outside_folder') + } + const handle = await openVisual(candidate) + try { + const stats = await handle.stat() + if (!stats.isFile()) { + refuse('not_a_file') + } + if (stats.size > NATIVE_CHAT_VISUAL_MAX_BYTES) { + refuse('too_large') + } + await assertStillContained(folder, candidate, folderReal, file, stats) + let buffer: Buffer + try { + buffer = await readLocalFileBounded(handle, NATIVE_CHAT_VISUAL_MAX_BYTES, stats.size) + } catch (error) { + if (error instanceof Error && error.message.startsWith('File too large')) { + refuse('too_large') + } + throw error + } + const html = decodeVisualText(buffer) + const revision = nativeChatVisualRevision(buffer) + const sizeBytes = buffer.length + return knownRevision === revision + ? { ok: true, revision, sizeBytes, unchanged: true } + : { ok: true, revision, sizeBytes, html } + } finally { + await handle.close() + } +} + +/** + * The visual `file` from `folder`, or the refusal the host observed. Unexpected filesystem faults + * still throw, so the client reads them as unavailable rather than as a verdict about the file; the + * thrown error names only the error code, never a host path. + */ +export async function readNativeChatVisualFile( + folder: string, + file: string, + knownRevision?: string +): Promise { + try { + return await readContained(folder, file, knownRevision) + } catch (error) { + if (error instanceof VisualReadRefusal) { + return { ok: false, error: error.refusal } + } + throw new Error(`visual_read_failed:${errorCode(error) ?? 'unknown'}`, { cause: error }) + } +} diff --git a/src/main/native-chat/native-chat-visuals-delivery.test.ts b/src/main/native-chat/native-chat-visuals-delivery.test.ts new file mode 100644 index 000000000000..72faec6dfdbf --- /dev/null +++ b/src/main/native-chat/native-chat-visuals-delivery.test.ts @@ -0,0 +1,201 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + NATIVE_CHAT_VISUAL_MAX_BYTES, + NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE, + parseNativeChatVisualDirectiveLine +} from '../../shared/native-chat-visual-directive' +import { NATIVE_CHAT_VISUAL_THEME_TOKENS } from '../../shared/native-chat-visual-shell' +import { ORCAD_NATIVE_CHAT_VISUALS_ARTIFACTS } from '../../shared/orcad-artifacts' +import { + createNativeChatVisualsDelivery, + NATIVE_CHAT_VISUALS_DIR_ENV, + withNativeChatVisualsEnv +} from './native-chat-visuals-delivery' +import { nativeChatVisualsFolderFor, nativeChatVisualsRootFor } from './native-chat-visuals-folder' +import { buildClaudeChildProcessEnv } from '../claude/claude-child-process-environment' +import { codexStructuredChildEnvironment } from '../codex/codex-structured-child-environment' +import { resolveProviderChildEnv } from '../provider-process/provider-process-launch' +import { + NATIVE_CHAT_VISUALS_SKILL_NAME, + resetNativeChatVisualsSkillLocationForTests, + resolveNativeChatVisualsSkillLocation +} from './native-chat-visuals-skill-location' + +const RESOURCE_DIR = join(__dirname, '..', '..', '..', 'resources', 'native-chat-visuals') +const SKILL_TEXT = readFileSync( + join(RESOURCE_DIR, 'skills', NATIVE_CHAT_VISUALS_SKILL_NAME, 'SKILL.md'), + 'utf8' +) +const SKILL = { pluginDir: '/app/plugin', skillsRoot: '/app/plugin/skills' } + +const scratch: string[] = [] +afterEach(() => { + resetNativeChatVisualsSkillLocationForTests() + for (const dir of scratch.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-visuals-delivery-')) + scratch.push(dir) + return dir +} + +const logger = () => ({ warn: vi.fn(), error: vi.fn() }) + +describe('preparing a chat for visuals', () => { + it('reads the preference for each launch and prepares nothing while it is off', async () => { + const state = tempDir() + let enabled = false + const resolveSkill = vi.fn(async () => SKILL) + const prepare = createNativeChatVisualsDelivery({ + stateDirectory: state, + logger: logger(), + isEnabled: () => enabled, + resolveSkill + }) + await expect(prepare('disabled-chat')).resolves.toBeNull() + expect(resolveSkill).not.toHaveBeenCalled() + expect(existsSync(nativeChatVisualsRootFor(state))).toBe(false) + enabled = true + const launched = await prepare('enabled-chat') + expect(launched).toEqual({ + folder: nativeChatVisualsFolderFor(state, 'enabled-chat'), + skill: SKILL + }) + enabled = false + await expect(prepare('next-chat')).resolves.toBeNull() + expect(resolveSkill).toHaveBeenCalledOnce() + expect(existsSync(nativeChatVisualsFolderFor(state, 'next-chat'))).toBe(false) + expect(existsSync(launched!.folder)).toBe(true) + }) + + it("creates the chat's own private folder and hands back the skill", async () => { + const state = tempDir() + const prepare = createNativeChatVisualsDelivery({ + stateDirectory: state, + logger: logger(), + resolveSkill: async () => SKILL + }) + const prepared = await prepare('chat-1') + expect(prepared).toEqual({ folder: nativeChatVisualsFolderFor(state, 'chat-1'), skill: SKILL }) + const mode = statSync(prepared!.folder).mode & 0o777 + expect(process.platform === 'win32' || mode === 0o700).toBe(true) + // A second launch of the same chat reuses it. + await expect(prepare('chat-1')).resolves.toEqual(prepared) + }) + + it('leaves the chat without visuals when the skill is missing or the folder fails', async () => { + const missingSkill = logger() + await expect( + createNativeChatVisualsDelivery({ + stateDirectory: tempDir(), + logger: missingSkill, + resolveSkill: async () => null + })('chat-1') + ).resolves.toBeNull() + expect(missingSkill.warn).toHaveBeenCalledOnce() + // A file where the visuals root should be makes every mkdir fail. + const state = tempDir() + writeFileSync(join(state, 'native-chat-visuals'), 'not a folder') + const failedFolder = logger() + await expect( + createNativeChatVisualsDelivery({ + stateDirectory: state, + logger: failedFolder, + resolveSkill: async () => SKILL + })('chat-1') + ).resolves.toBeNull() + expect(failedFolder.warn).toHaveBeenCalledWith( + 'native-chat visuals folder could not be prepared', + expect.objectContaining({ scope: 'nativeChatVisuals.folder', sessionId: 'chat-1' }) + ) + }) + + it("never hands a chat another chat's folder that Orca itself inherited", () => { + const inherited = { PATH: '/bin', [NATIVE_CHAT_VISUALS_DIR_ENV]: '/parent-chat' } + const visuals = { folder: '/mine', skill: SKILL } + for (const platform of ['darwin', 'win32'] as const) { + const claude = (configured: Record) => + buildClaudeChildProcessEnv(configured, { inheritedEnv: inherited, platform }) + expect(claude({})).not.toHaveProperty(NATIVE_CHAT_VISUALS_DIR_ENV) + expect(claude(withNativeChatVisualsEnv({}, visuals))[NATIVE_CHAT_VISUALS_DIR_ENV]).toBe( + '/mine' + ) + } + const codex = (withVisuals: boolean) => + resolveProviderChildEnv( + codexStructuredChildEnvironment( + { + command: 'codex', + args: ['app-server'], + cwd: '/w', + codexHome: null, + resumeThreadId: null, + ...(withVisuals ? { visuals } : {}) + }, + 'spawn-token', + 'chat-1' + ), + inherited + ) + expect(codex(false)).not.toHaveProperty(NATIVE_CHAT_VISUALS_DIR_ENV) + expect(codex(true)[NATIVE_CHAT_VISUALS_DIR_ENV]).toBe('/mine') + }) + + it('names only this chat folder to the agent', () => { + const inherited = { PATH: '/bin', [NATIVE_CHAT_VISUALS_DIR_ENV]: '/other-chat' } + expect(withNativeChatVisualsEnv(inherited, { folder: '/mine', skill: SKILL })).toEqual({ + PATH: '/bin', + [NATIVE_CHAT_VISUALS_DIR_ENV]: '/mine' + }) + expect(withNativeChatVisualsEnv(inherited, null)).toEqual({ PATH: '/bin' }) + }) +}) + +describe('the bundled skill', () => { + it('is found in a checkout, and a missing install is not remembered', async () => { + await expect(resolveNativeChatVisualsSkillLocation([tempDir()])).resolves.toBeNull() + await expect(resolveNativeChatVisualsSkillLocation([tempDir(), RESOURCE_DIR])).resolves.toEqual( + { pluginDir: RESOURCE_DIR, skillsRoot: join(RESOURCE_DIR, 'skills') } + ) + }) + + it('ships every file in the headless server build too', () => { + const shipped = ORCAD_NATIVE_CHAT_VISUALS_ARTIFACTS.map((file) => + file.slice('native-chat-visuals/'.length) + ) + expect(shipped).toEqual([ + '.claude-plugin/plugin.json', + `skills/${NATIVE_CHAT_VISUALS_SKILL_NAME}/SKILL.md` + ]) + const manifest = JSON.parse(readFileSync(join(RESOURCE_DIR, shipped[0]!), 'utf8')) + expect(manifest.name).toMatch(/^[a-z0-9-]+$/) + }) + + it('teaches the reply line the renderer parses, with its limits', () => { + const example = SKILL_TEXT.split('\n').find((line) => line.startsWith('::orca-visual{')) + expect(example && parseNativeChatVisualDirectiveLine(example)).toEqual({ + file: 'latency-by-region-7c1e.html', + title: 'Latency by region' + }) + expect(SKILL_TEXT).toContain(`At most ${NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE} per reply`) + expect(SKILL_TEXT).toContain(`under ${NATIVE_CHAT_VISUAL_MAX_BYTES / 1024} KB`) + expect(SKILL_TEXT).toContain(NATIVE_CHAT_VISUALS_DIR_ENV) + expect(SKILL_TEXT).toMatch(new RegExp(`^name: ${NATIVE_CHAT_VISUALS_SKILL_NAME}$`, 'm')) + }) + + it('names only theme variables the visual frame sets', () => { + const named = [...SKILL_TEXT.matchAll(/`(--[a-z0-9-]+)`/g)].map(([, name]) => name) + const ranges = [...SKILL_TEXT.matchAll(/`--chart-1` to `--chart-(\d)`/g)].flatMap(([, last]) => + Array.from({ length: Number(last) }, (_, index) => `--chart-${index + 1}`) + ) + expect(named.length).toBeGreaterThan(0) + const frameTokens: readonly string[] = NATIVE_CHAT_VISUAL_THEME_TOKENS + expect([...named, ...ranges].filter((name) => !frameTokens.includes(name))).toEqual([]) + }) +}) diff --git a/src/main/native-chat/native-chat-visuals-delivery.ts b/src/main/native-chat/native-chat-visuals-delivery.ts new file mode 100644 index 000000000000..662dce2b567c --- /dev/null +++ b/src/main/native-chat/native-chat-visuals-delivery.ts @@ -0,0 +1,70 @@ +// What a structured chat launch gets for inline visuals: its own writable folder, named to the agent +// in an environment variable, and where this host keeps the skill that teaches the reply line. +// Every step is best effort: a chat without visuals still starts. + +import { mkdir } from 'node:fs/promises' +import type { StructuredAgentSessionLogger } from './agent-session-wire/structured-agent-session-logger' +import { nativeChatVisualsFolderFor } from './native-chat-visuals-folder' +import { + resolveNativeChatVisualsSkillLocation, + type NativeChatVisualsSkillLocation +} from './native-chat-visuals-skill-location' + +/** The agent child's variable holding its chat's visuals folder. The skill reads it; the path is + * never written into the skill text. */ +export const NATIVE_CHAT_VISUALS_DIR_ENV = 'ORCA_CHAT_VISUALS_DIR' + +export type NativeChatVisualsLaunch = { + /** This chat's folder, created and private to this user. */ + folder: string + skill: NativeChatVisualsSkillLocation +} + +/** Prepares one chat's launch; null leaves the chat without visuals. Never throws. */ +export type PrepareNativeChatVisuals = ( + sessionId: string +) => Promise + +export function createNativeChatVisualsDelivery(deps: { + stateDirectory: string + logger: StructuredAgentSessionLogger + isEnabled?: () => boolean + resolveSkill?: () => Promise +}): PrepareNativeChatVisuals { + const resolveSkill = deps.resolveSkill ?? (() => resolveNativeChatVisualsSkillLocation()) + return async (sessionId) => { + try { + if (deps.isEnabled?.() === false) { + return null + } + const skill = await resolveSkill() + if (!skill) { + deps.logger.warn('native-chat visuals skill is missing from this install', { + scope: 'nativeChatVisuals.skill', + sessionId + }) + return null + } + const folder = nativeChatVisualsFolderFor(deps.stateDirectory, sessionId) + await mkdir(folder, { recursive: true, mode: 0o700 }) + return { folder, skill } + } catch (error) { + deps.logger.warn('native-chat visuals folder could not be prepared', { + scope: 'nativeChatVisuals.folder', + sessionId, + error + }) + return null + } + } +} + +/** `env` naming this chat's folder; without visuals, an inherited value (Orca itself started from + * a chat) is removed so the agent never writes into another chat's folder. */ +export function withNativeChatVisualsEnv( + env: Record, + visuals: NativeChatVisualsLaunch | null +): Record { + const { [NATIVE_CHAT_VISUALS_DIR_ENV]: _inherited, ...rest } = env + return visuals ? { ...rest, [NATIVE_CHAT_VISUALS_DIR_ENV]: visuals.folder } : rest +} diff --git a/src/main/native-chat/native-chat-visuals-folder.ts b/src/main/native-chat/native-chat-visuals-folder.ts new file mode 100644 index 000000000000..9e4ad9a810e7 --- /dev/null +++ b/src/main/native-chat/native-chat-visuals-folder.ts @@ -0,0 +1,26 @@ +// Where a structured chat's visuals live on the host that owns the chat: Orca-owned state beside the +// chat journal, never inside the user's workspace. + +import { createHash } from 'node:crypto' +import { join } from 'node:path' + +const NATIVE_CHAT_VISUALS_DIR_NAME = 'native-chat-visuals' + +/** Where every chat's visuals folder on this host lives. */ +export function nativeChatVisualsRootFor(stateDirectory: string): string { + return join(stateDirectory, NATIVE_CHAT_VISUALS_DIR_NAME) +} + +/** The one path segment a chat's folder is named by: hashed so any id is a safe segment. */ +export function nativeChatVisualsFolderName(sessionId: string): string { + return createHash('sha256').update(sessionId, 'utf8').digest('hex').slice(0, 32) +} + +/** + * `/native-chat-visuals/`. Keyed by the session id + * alone, the chat record's primary key, so the folder needs no workspace lookup to find or remove; + * hashed so any id is one safe path segment. + */ +export function nativeChatVisualsFolderFor(stateDirectory: string, sessionId: string): string { + return join(nativeChatVisualsRootFor(stateDirectory), nativeChatVisualsFolderName(sessionId)) +} diff --git a/src/main/native-chat/native-chat-visuals-skill-location.ts b/src/main/native-chat/native-chat-visuals-skill-location.ts new file mode 100644 index 000000000000..629b5f5a48a7 --- /dev/null +++ b/src/main/native-chat/native-chat-visuals-skill-location.ts @@ -0,0 +1,74 @@ +// Where this host's copy of the chat-visuals skill plugin lives. Resolved on the host that spawns +// the agent: a client's install path means nothing to another machine. + +import { access } from 'node:fs/promises' +import { join } from 'node:path' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' + +const PLUGIN_DIR_NAME = 'native-chat-visuals' +export const NATIVE_CHAT_VISUALS_SKILL_NAME = 'orca-chat-visuals' + +export type NativeChatVisualsSkillLocation = { + /** A Claude plugin folder: `.claude-plugin/plugin.json` plus `skills//SKILL.md`. */ + pluginDir: string + /** The plugin's `skills` folder, the shape a Codex skill root takes. */ + skillsRoot: string +} + +function candidatePluginDirs(): string[] { + const candidates: string[] = [] + if (process.resourcesPath) { + candidates.push(join(process.resourcesPath, PLUGIN_DIR_NAME)) + } + const packaged = hasAppEnvironment() && getAppEnvironment().isPackaged() + if (packaged) { + // Why: plain-Node orcad has no resourcesPath; its build copies the folder into its own root. + // Nothing else: an install must never load a skill from whatever checkout it was started in. + candidates.push(join(getAppEnvironment().getAppPath(), PLUGIN_DIR_NAME)) + return candidates + } + // Development and test hosts run from a checkout. + for (const root of [ + hasAppEnvironment() ? getAppEnvironment().getAppPath() : null, + process.cwd() + ]) { + if (root) { + candidates.push(join(root, 'resources', PLUGIN_DIR_NAME)) + } + } + return candidates +} + +async function isComplete(pluginDir: string): Promise { + try { + await Promise.all([ + access(join(pluginDir, '.claude-plugin', 'plugin.json')), + access(join(pluginDir, 'skills', NATIVE_CHAT_VISUALS_SKILL_NAME, 'SKILL.md')) + ]) + return true + } catch { + return false + } +} + +let found: NativeChatVisualsSkillLocation | null = null + +/** This install's skill plugin, or null when it is missing; only a found location is remembered. */ +export async function resolveNativeChatVisualsSkillLocation( + candidates: readonly string[] = candidatePluginDirs() +): Promise { + if (found) { + return found + } + for (const pluginDir of candidates) { + if (await isComplete(pluginDir)) { + found = { pluginDir, skillsRoot: join(pluginDir, 'skills') } + return found + } + } + return null +} + +export function resetNativeChatVisualsSkillLocationForTests(): void { + found = null +} diff --git a/src/main/native-chat/native-chat-visuals-sweep.test.ts b/src/main/native-chat/native-chat-visuals-sweep.test.ts new file mode 100644 index 000000000000..93c6bec5da11 --- /dev/null +++ b/src/main/native-chat/native-chat-visuals-sweep.test.ts @@ -0,0 +1,189 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { StructuredAgentSessionLogger } from './agent-session-wire/structured-agent-session-logger' +import { nativeChatVisualsFolderFor, nativeChatVisualsRootFor } from './native-chat-visuals-folder' +import { + scheduleNativeChatVisualsSweep, + sweepNativeChatVisualsFolders, + type NativeChatVisualsSweepDeps, + type NativeChatVisualsWorkspaceVerdict +} from './native-chat-visuals-sweep' + +const scratch: string[] = [] +afterEach(() => { + vi.useRealTimers() + for (const dir of scratch.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function stateDirectory(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-visuals-sweep-')) + scratch.push(dir) + return dir +} + +function visualsFor(state: string, sessionId: string): string { + const folder = nativeChatVisualsFolderFor(state, sessionId) + mkdirSync(folder, { recursive: true }) + writeFileSync(join(folder, 'chart.html'), '

chart

') + return folder +} + +const location = (workspaceId: string): AgentSessionExecutionLocation => ({ + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId, + workspaceKind: 'git-worktree' +}) + +function deps( + state: string, + held: readonly string[] | null, + verdicts: Record = {} +): NativeChatVisualsSweepDeps & { + logger: { warn: ReturnType> } +} { + return { + stateDirectory: state, + listHeldSessionIds: () => held, + locationOf: (sessionId) => (held?.includes(sessionId) ? location(`ws-${sessionId}`) : null), + workspaceVerdicts: () => async (where) => verdicts[where.workspaceId] ?? 'present', + logger: { warn: vi.fn(), error: vi.fn() } + } +} + +describe('the visuals folder sweep', () => { + it('removes a folder no chat record maps to and keeps every held chat', async () => { + const state = stateDirectory() + const live = visualsFor(state, 'chat-live') + const unreadable = visualsFor(state, 'chat-unreadable-row') + const orphan = visualsFor(state, 'chat-deleted') + const result = await sweepNativeChatVisualsFolders( + deps(state, ['chat-live', 'chat-unreadable-row']) + ) + expect(result).toEqual({ removed: 1, failed: 0 }) + expect(existsSync(live)).toBe(true) + expect(existsSync(unreadable)).toBe(true) + expect(existsSync(orphan)).toBe(false) + }) + + it('removes a chat folder only when its local workspace is proven removed', async () => { + const state = stateDirectory() + const removed = visualsFor(state, 'chat-a') + const unverifiable = visualsFor(state, 'chat-b') + const present = visualsFor(state, 'chat-c') + await sweepNativeChatVisualsFolders( + deps(state, ['chat-a', 'chat-b', 'chat-c'], { + 'ws-chat-a': 'removed', + 'ws-chat-b': 'unverifiable' + }) + ) + expect(existsSync(removed)).toBe(false) + expect(existsSync(unverifiable)).toBe(true) + expect(existsSync(present)).toBe(true) + }) + + it('keeps a held chat whose workspace verdict throws', async () => { + const state = stateDirectory() + const folder = visualsFor(state, 'chat-a') + await sweepNativeChatVisualsFolders({ + ...deps(state, ['chat-a']), + workspaceVerdicts: () => async () => { + throw new Error('catalog unavailable') + } + }) + expect(existsSync(folder)).toBe(true) + }) + + it('removes nothing when the held chats cannot be read', async () => { + const state = stateDirectory() + const folder = visualsFor(state, 'chat-a') + await expect(sweepNativeChatVisualsFolders(deps(state, null))).resolves.toEqual({ + removed: 0, + failed: 0 + }) + expect(existsSync(folder)).toBe(true) + }) + + it('never touches entries it did not mint, symlinks included', async () => { + const state = stateDirectory() + const root = nativeChatVisualsRootFor(state) + mkdirSync(join(root, 'notes'), { recursive: true }) + writeFileSync(join(root, 'f'.repeat(32)), 'a file, not a folder') + const target = mkdtempSync(join(tmpdir(), 'orca-visuals-target-')) + scratch.push(target) + writeFileSync(join(target, 'keep.txt'), 'keep') + symlinkSync(target, join(root, 'e'.repeat(32))) + await sweepNativeChatVisualsFolders(deps(state, [])) + expect(existsSync(join(root, 'notes'))).toBe(true) + expect(existsSync(join(root, 'f'.repeat(32)))).toBe(true) + expect(existsSync(join(root, 'e'.repeat(32)))).toBe(true) + expect(existsSync(join(target, 'keep.txt'))).toBe(true) + }) + + it('reports a removal that fails and carries on', async () => { + const state = stateDirectory() + visualsFor(state, 'chat-x') + visualsFor(state, 'chat-y') + const sweep = deps(state, []) + const result = await sweepNativeChatVisualsFolders({ + ...sweep, + remove: vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error('busy'), { code: 'EBUSY' })) + .mockResolvedValueOnce(undefined) + }) + expect(result).toEqual({ removed: 1, failed: 1 }) + expect(sweep.logger.warn).toHaveBeenCalledWith( + 'native-chat visuals folder could not be removed', + expect.objectContaining({ scope: 'nativeChatVisuals.sweep', reason: 'no-record' }) + ) + }) + + it('does nothing on a host with no visuals yet', async () => { + await expect( + sweepNativeChatVisualsFolders(deps(stateDirectory(), ['chat-a'])) + ).resolves.toEqual({ removed: 0, failed: 0 }) + }) + + it('runs after startup and again later, and stops for good', async () => { + const state = stateDirectory() + const orphan = visualsFor(state, 'chat-z') + vi.useFakeTimers() + const listHeldSessionIds = vi.fn((): string[] => []) + const stop = scheduleNativeChatVisualsSweep({ ...deps(state, []), listHeldSessionIds }) + await vi.advanceTimersByTimeAsync(60_000) + expect(listHeldSessionIds).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(60_000) + await vi.waitFor(() => expect(existsSync(orphan)).toBe(false)) + expect(listHeldSessionIds).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(6 * 60 * 60_000) + await vi.waitFor(() => expect(listHeldSessionIds).toHaveBeenCalledTimes(2)) + stop() + expect(vi.getTimerCount()).toBe(0) + }) + + it('touches nothing under a visuals root that is a symlink', async () => { + const state = stateDirectory() + const elsewhere = mkdtempSync(join(tmpdir(), 'orca-visuals-elsewhere-')) + scratch.push(elsewhere) + mkdirSync(join(elsewhere, 'a'.repeat(32))) + symlinkSync(elsewhere, nativeChatVisualsRootFor(state)) + await sweepNativeChatVisualsFolders(deps(state, [])) + expect(existsSync(join(elsewhere, 'a'.repeat(32)))).toBe(true) + }) + + it('takes one verdict snapshot per run', async () => { + const state = stateDirectory() + visualsFor(state, 'chat-a') + visualsFor(state, 'chat-b') + const workspaceVerdicts = vi.fn(() => async () => 'present' as const) + await sweepNativeChatVisualsFolders({ ...deps(state, ['chat-a', 'chat-b']), workspaceVerdicts }) + expect(workspaceVerdicts).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/native-chat/native-chat-visuals-sweep.ts b/src/main/native-chat/native-chat-visuals-sweep.ts new file mode 100644 index 000000000000..11ffab0a57dc --- /dev/null +++ b/src/main/native-chat/native-chat-visuals-sweep.ts @@ -0,0 +1,163 @@ +// How a chat's visuals folder dies. The host never deletes a chat record, so no deletion event +// exists to hang cleanup on; instead each folder's life is derived from the records every run: +// 1. a folder no chat record (readable or not) maps to is removed, so any future record +// deletion cleans up for free; +// 2. a folder whose chat ran in a local workspace this host can prove was removed is removed. +// Anything unproven is kept. A failure is logged and never blocks anything else. + +import type { Dirent } from 'node:fs' +import { lstat, readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { yieldToEventLoop } from '../../shared/event-loop-yield' +import type { StructuredAgentSessionLogger } from './agent-session-wire/structured-agent-session-logger' +import { nativeChatVisualsFolderName, nativeChatVisualsRootFor } from './native-chat-visuals-folder' + +/** `removed` only on positive proof from the host that owns the workspace. */ +export type NativeChatVisualsWorkspaceVerdict = 'present' | 'removed' | 'unverifiable' + +export type NativeChatVisualsSweepDeps = { + stateDirectory: string + /** Every chat this host holds a row for, readable or not; null when that can't be trusted. */ + listHeldSessionIds: () => readonly string[] | null + locationOf: (sessionId: string) => AgentSessionExecutionLocation | null + /** Called at most once per run, so one run judges every chat against one catalog snapshot. + * Absent: only rule 1 runs. */ + workspaceVerdicts?: () => ( + location: AgentSessionExecutionLocation + ) => Promise + logger: StructuredAgentSessionLogger + remove?: (path: string) => Promise +} + +export type NativeChatVisualsSweepResult = { removed: number; failed: number } + +// Only names this host minted: anything else in the folder is never touched. +const FOLDER_NAME = /^[0-9a-f]{32}$/ +// Bounds one run's disk work; whatever is left goes in a later run. +const MAX_REMOVALS_PER_RUN = 200 +const YIELD_EVERY = 32 + +async function listFolders(root: string): Promise { + try { + // A root that is not a real directory is not one this host made; nothing under it is touched. + if (!(await lstat(root)).isDirectory()) { + return [] + } + return await readdir(root, { withFileTypes: true }) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return [] + } + throw error + } +} + +export async function sweepNativeChatVisualsFolders( + deps: NativeChatVisualsSweepDeps, + isStopped: () => boolean = () => false +): Promise { + const result = { removed: 0, failed: 0 } + const root = nativeChatVisualsRootFor(deps.stateDirectory) + // Listed before the records are read: a folder is created only after its chat's record, so + // every folder seen here already has its record in the snapshot below. + const entries = await listFolders(root) + const held = deps.listHeldSessionIds() + if (!held || entries.length === 0) { + return result + } + const sessionByFolder = new Map( + held.map((sessionId) => [nativeChatVisualsFolderName(sessionId), sessionId]) + ) + const remove = deps.remove ?? ((path: string) => rm(path, { recursive: true, force: true })) + let verdict: ReturnType> | undefined + const verdictFor = (location: AgentSessionExecutionLocation) => + (verdict ??= deps.workspaceVerdicts?.())?.(location) + let visited = 0 + for (const entry of entries) { + if (isStopped() || result.removed + result.failed >= MAX_REMOVALS_PER_RUN) { + break + } + if (++visited % YIELD_EVERY === 0) { + await yieldToEventLoop() + } + // A Dirent describes the entry itself, so a symlink is never followed or removed here. + if (!entry.isDirectory() || !FOLDER_NAME.test(entry.name)) { + continue + } + const sessionId = sessionByFolder.get(entry.name) + const reason = + sessionId === undefined ? 'no-record' : await removedWorkspace(deps, sessionId, verdictFor) + if (!reason) { + continue + } + try { + await remove(join(root, entry.name)) + result.removed += 1 + } catch (error) { + result.failed += 1 + deps.logger.warn('native-chat visuals folder could not be removed', { + scope: 'nativeChatVisuals.sweep', + reason, + ...(sessionId ? { sessionId } : {}), + error + }) + } + } + return result +} + +async function removedWorkspace( + deps: Pick, + sessionId: string, + verdictFor: ( + location: AgentSessionExecutionLocation + ) => Promise | undefined +): Promise<'workspace-removed' | null> { + const location = deps.locationOf(sessionId) + if (!location) { + return null + } + try { + return (await verdictFor(location)) === 'removed' ? 'workspace-removed' : null + } catch { + return null + } +} + +// Why minutes, not at boot: the first sweep must not compete with session restore and the +// workspace catalog loading; after that, a few runs a day is enough for files nothing reads. +const FIRST_SWEEP_DELAY_MS = 2 * 60_000 +const SWEEP_INTERVAL_MS = 6 * 60 * 60_000 + +/** Runs the sweep after startup and then periodically; the returned stop ends both. */ +export function scheduleNativeChatVisualsSweep(deps: NativeChatVisualsSweepDeps): () => void { + let stopped = false + let timer: ReturnType | null = null + const run = async (): Promise => { + try { + await sweepNativeChatVisualsFolders(deps, () => stopped) + } catch (error) { + deps.logger.warn('native-chat visuals sweep failed', { + scope: 'nativeChatVisuals.sweep', + error + }) + } + arm(SWEEP_INTERVAL_MS) + } + const arm = (delayMs: number): void => { + if (stopped) { + return + } + timer = setTimeout(() => void run(), delayMs) + timer.unref?.() + } + arm(FIRST_SWEEP_DELAY_MS) + return () => { + stopped = true + if (timer) { + clearTimeout(timer) + } + } +} diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 802cc22baffc..5ea4b4faff81 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -21,6 +21,7 @@ import { wslCodexSessionsDirs } from './host-readable-transcript-path' import { findWslCodexSessionPath } from './wsl-codex-session-path-scan' +import { parseSshTranscriptPath } from './ssh-transcript-path' import { wslTranscriptFsRefusal, type WslTranscriptFsError } from './wsl-transcript-fs-gate' // Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads @@ -112,6 +113,10 @@ export async function resolveSessionFilePath( // stale/missing paths fall through to the id-based search. let unavailable: WslTranscriptFsError | undefined const hookPath = options.transcriptPath?.trim() + // An SSH host's transcript is read only where its hook said; never search this machine for it. + if (hookPath && parseSshTranscriptPath(hookPath)) { + return extname(hookPath) === '.jsonl' ? hookPath : null + } if (hookPath && extname(hookPath) === '.jsonl') { try { const hostReadable = await toHostReadableTranscriptPath(hookPath, { diff --git a/src/main/native-chat/ssh-transcript-fs-access.ts b/src/main/native-chat/ssh-transcript-fs-access.ts new file mode 100644 index 000000000000..2b0c4424ae52 --- /dev/null +++ b/src/main/native-chat/ssh-transcript-fs-access.ts @@ -0,0 +1,31 @@ +import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { readRemoteTranscriptRange } from '../runtime/orchestration/worker-transcript-remote-range-read' +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 } +} + +export async function readSshTranscript( + handle: SshTranscriptHandle, + buffer: Buffer, + offset: number, + length: number, + position: number +): 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 } +} diff --git a/src/main/native-chat/ssh-transcript-path.test.ts b/src/main/native-chat/ssh-transcript-path.test.ts new file mode 100644 index 000000000000..1f2f9457feed --- /dev/null +++ b/src/main/native-chat/ssh-transcript-path.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { wslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' +import { + nativeChatTranscriptPathOnExecutionHost, + parseSshTranscriptPath, + toSshTranscriptPath +} from './ssh-transcript-path' + +const REMOTE_PATH = '/home/ada/.claude/projects/p/session-1.jsonl' +// A colon, so the encoding is shown to keep the id separate from the path. +const CONNECTION_ID = 'target:1' + +function row(connectionId: string | null, transcriptPath = REMOTE_PATH) { + return { + connectionId, + providerSession: { key: 'session_id' as const, id: 'session-1', transcriptPath } + } +} + +function readsFrom(path: string | undefined) { + return path ? (parseSshTranscriptPath(path) ?? path) : path +} + +describe('nativeChatTranscriptPathOnExecutionHost', () => { + it('names the SSH host the hook store attests the session to', () => { + expect( + readsFrom( + nativeChatTranscriptPathOnExecutionHost([row(CONNECTION_ID)], 'session-1', REMOTE_PATH) + ) + ).toEqual({ connectionId: CONNECTION_ID, remotePath: REMOTE_PATH }) + }) + + it('leaves local and WSL sessions on this machine', () => { + const wsl = wslHookRelayConnectionId('Ubuntu') + + expect(nativeChatTranscriptPathOnExecutionHost([row(null)], 'session-1', REMOTE_PATH)).toBe( + REMOTE_PATH + ) + expect(nativeChatTranscriptPathOnExecutionHost([row(wsl)], 'session-1', REMOTE_PATH)).toBe( + REMOTE_PATH + ) + }) + + it('reads an SSH session on its host even when the client asks for another path', () => { + expect( + readsFrom( + nativeChatTranscriptPathOnExecutionHost( + [row(CONNECTION_ID)], + 'session-1', + '/tmp/stale.jsonl' + ) + ) + ).toEqual({ connectionId: CONNECTION_ID, remotePath: REMOTE_PATH }) + }) + + it('prefers the SSH row whose transcript the client asked for', () => { + const rows = [row(CONNECTION_ID), row('target:2', '/home/ada/other.jsonl')] + + expect( + readsFrom(nativeChatTranscriptPathOnExecutionHost(rows, 'session-1', '/home/ada/other.jsonl')) + ).toEqual({ connectionId: 'target:2', remotePath: '/home/ada/other.jsonl' }) + }) + + it('keeps a session on this machine when a local row attests the requested path', () => { + const localPath = '/home/bob/.claude/projects/p/session-1.jsonl' + const rows = [row(CONNECTION_ID), row(null, localPath)] + + expect(nativeChatTranscriptPathOnExecutionHost(rows, 'session-1', localPath)).toBe(localPath) + }) + + it('ignores an SSH-qualified path a client sends itself', () => { + const forged = toSshTranscriptPath(CONNECTION_ID, '/etc/secret.jsonl') + + expect(nativeChatTranscriptPathOnExecutionHost([], 'session-1', forged)).toBeUndefined() + }) +}) diff --git a/src/main/native-chat/ssh-transcript-path.ts b/src/main/native-chat/ssh-transcript-path.ts new file mode 100644 index 000000000000..638ef7ed40ec --- /dev/null +++ b/src/main/native-chat/ssh-transcript-path.ts @@ -0,0 +1,55 @@ +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' + +// Why: the transcript engine addresses a file by one path string. A transcript on an SSH host is +// named by its connection as well, so both travel in that string and no local file can answer. +const SSH_TRANSCRIPT_PATH_PREFIX = 'orca-ssh-transcript:' + +export type SshTranscriptLocation = { connectionId: string; remotePath: string } + +export function toSshTranscriptPath(connectionId: string, remotePath: string): string { + return `${SSH_TRANSCRIPT_PATH_PREFIX}${encodeURIComponent(connectionId)}:${remotePath}` +} + +export function parseSshTranscriptPath(path: string): SshTranscriptLocation | null { + if (!path.startsWith(SSH_TRANSCRIPT_PATH_PREFIX)) { + return null + } + const rest = path.slice(SSH_TRANSCRIPT_PATH_PREFIX.length) + const separator = rest.indexOf(':') + if (separator <= 0) { + return null + } + return { + connectionId: decodeURIComponent(rest.slice(0, separator)), + remotePath: rest.slice(separator + 1) + } +} + +type HookRow = Pick + +/** The path native chat reads: a session the hook store puts on an SSH host is read there, never here. */ +export function nativeChatTranscriptPathOnExecutionHost( + statusRows: readonly HookRow[], + sessionId: string, + transcriptPath: string | undefined +): string | undefined { + // Only the host mints this form; a client-supplied one would name a file the hook never attested. + const requested = + transcriptPath && !parseSshTranscriptPath(transcriptPath) ? transcriptPath : undefined + const rows = statusRows.filter( + (row) => row.providerSession?.id === sessionId && row.providerSession.transcriptPath + ) + // Why: the row attesting the requested path decides the host; a stale path falls back to SSH. + const row = + rows.find((candidate) => candidate.providerSession?.transcriptPath === requested) ?? + rows.find(isSshRow) + const remotePath = row?.providerSession?.transcriptPath + return row && isSshRow(row) && remotePath + ? toSshTranscriptPath(row.connectionId, remotePath) + : requested +} + +function isSshRow(row: HookRow): row is HookRow & { connectionId: string } { + return row.connectionId ? !isWslHookRelayConnectionId(row.connectionId) : false +} diff --git a/src/main/native-chat/structured-agent-command-resolution.test.ts b/src/main/native-chat/structured-agent-command-resolution.test.ts index a2fc166a1a33..c8be0c644fca 100644 --- a/src/main/native-chat/structured-agent-command-resolution.test.ts +++ b/src/main/native-chat/structured-agent-command-resolution.test.ts @@ -36,6 +36,21 @@ describe('structured agent executable resolution', () => { } ) + it('reads the Command setting of any agent, and its stock binary by the name it is given', () => { + const { command } = executable() + expect( + resolveStructuredAgentCommand('opencode', { agentCmdOverrides: { opencode: `"${command}"` } }) + ).toBe(command) + expect( + resolveStructuredAgentCommand( + 'opencode', + { agentCmdOverrides: { claude: `"${command}"` } }, + { pathEnv: '/nowhere' }, + { command: 'opencode', resolve: (name) => `/stock/${name}` } + ) + ).toBe('/stock/opencode') + }) + it('resolves a basename through the configured PATH and a home-relative path through the host home', () => { const { directory, command } = executable() const basenameCommand = join(directory, 'agent-custom') diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 9cc1f28bd010..828fd031760c 100644 --- a/src/main/native-chat/structured-agent-command-resolution.ts +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -10,7 +10,10 @@ import { } from '../../shared/tui-agent-launch-command-override' import { AgentSessionPreSpawnError } from './agent-session-wire/structured-agent-session-adapter' -type CommandSettings = Partial> +export type StructuredAgentCommandSettings = Partial< + Pick +> +type CommandSettings = StructuredAgentCommandSettings type CommandOptions = NonNullable[1]> function resolveOverride( @@ -35,14 +38,16 @@ function spawnableOn(platform: NodeJS.Platform, command: string): boolean { } /** Re-read the existing setting for every session acquisition and catalog probe. A set Command - * that names no runnable program refuses the start rather than quietly running the stock CLI. */ + * that names no runnable program refuses the start rather than quietly running the stock CLI. + * Without one, `stock` names the agent's own binary (the agent id by default). */ export function resolveStructuredAgentCommand( - agent: 'claude' | 'codex', + agent: TuiAgent, settings: CommandSettings, - options: CommandOptions = {} + options: CommandOptions = {}, + stock: { command?: string; resolve?: typeof resolveCliCommand } = {} ): string { if (!hasExplicitTuiLaunchCommand(settings, agent)) { - return resolveCliCommand(agent, options) + return (stock.resolve ?? resolveCliCommand)(stock.command ?? agent, options) } const command = resolveOverride(agent, settings, options) if (!command || !spawnableOn(options.platform ?? process.platform, command)) { diff --git a/src/main/native-chat/structured-agent-session-create-support.test.ts b/src/main/native-chat/structured-agent-session-create-support.test.ts index 61dfd787920b..a0b61c5ceadc 100644 --- a/src/main/native-chat/structured-agent-session-create-support.test.ts +++ b/src/main/native-chat/structured-agent-session-create-support.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' -import type { ClaudeManagedAccountGateSettings } from './claude-structured-managed-account-support' import { resolveStructuredAgentSessionCreateSupport } from './structured-agent-session-create-support' const LOCAL: AgentSessionExecutionLocation = { @@ -10,64 +9,11 @@ const LOCAL: AgentSessionExecutionLocation = { workspaceKind: 'git-worktree' } -function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { - return { - id, - email: `${id}@example.com`, - managedAuthPath: `/managed/${id}`, - managedAuthRuntime, - authMethod: 'subscription-oauth' as const, - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0 - } -} - -const HOST_SELECTED: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('host-1', 'host')], - activeClaudeManagedAccountId: 'host-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } -} - -const WSL_ONLY: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } -} - -function support( - overrides: Partial[0]> = {} -) { - return resolveStructuredAgentSessionCreateSupport({ - agent: 'claude', - location: LOCAL, - adapterSupportsCreate: true, - getSettings: () => HOST_SELECTED, - ...overrides - }) -} - describe('resolveStructuredAgentSessionCreateSupport', () => { - it('supports Claude under a selected host account', () => { - expect(support()).toEqual({ supported: true }) - }) - - it('refuses Claude under a WSL-only managed account', () => { - expect(support({ getSettings: () => WSL_ONLY })).toEqual({ supported: false, reason: 'wsl' }) - }) - - it('fails closed for Claude when the settings throw', () => { + it('supports what the adapter supports', () => { expect( - support({ - getSettings: () => { - throw new Error('no store') - } - }) - ).toEqual({ supported: false, reason: 'wsl' }) - }) - - it('leaves Codex to the adapter answer under the same WSL-only account', () => { - expect(support({ agent: 'codex', getSettings: () => WSL_ONLY })).toEqual({ supported: true }) + resolveStructuredAgentSessionCreateSupport({ location: LOCAL, adapterSupportsCreate: true }) + ).toEqual({ supported: true }) }) it.each([ @@ -75,22 +21,8 @@ describe('resolveStructuredAgentSessionCreateSupport', () => { ['wsl workspace', { ...LOCAL, wslDistro: 'Ubuntu' }, 'wsl'], ['unsupported agent', LOCAL, 'agent'] ] as const)('keeps the adapter refusal reason for %s', (_name, location, reason) => { - expect(support({ adapterSupportsCreate: false, location })).toEqual({ - supported: false, - reason - }) - }) - - // Command values never change the selected chat surface. - it.each([ - ['claude', 'claude-wrapper'], - ['codex', 'codex-nightly'], - ['claude', 'npx claude'], - ['codex', 'wrapper --arg'], - ['claude', '/missing/claude'], - ['codex', './codex'] - ] as const)('supports %s when this host sets launch command %s', (agent, command) => { - const settings = { ...HOST_SELECTED, agentCmdOverrides: { [agent]: command } } - expect(support({ agent, getSettings: () => settings })).toEqual({ supported: true }) + expect( + resolveStructuredAgentSessionCreateSupport({ location, adapterSupportsCreate: false }) + ).toEqual({ supported: false, reason }) }) }) diff --git a/src/main/native-chat/structured-agent-session-create-support.ts b/src/main/native-chat/structured-agent-session-create-support.ts index e15e03a1135f..37a1fc033866 100644 --- a/src/main/native-chat/structured-agent-session-create-support.ts +++ b/src/main/native-chat/structured-agent-session-create-support.ts @@ -1,27 +1,16 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { - readClaudeManagedAccountGateSettings, - structuredClaudeMatchesActiveManagedAccount, - type ClaudeManagedAccountGateSettings -} from './claude-structured-managed-account-support' export type StructuredAgentSessionCreateSupport = { supported: boolean reason?: 'agent' | 'remote' | 'wsl' } -/** - * The create-support verdict, kept out of the runtime class file because that file is `@ts-nocheck` - * — a call site there is not typechecked, so an auth-identity decision written inline would compile - * however wrong it was. The runtime hands over the two facts it owns and this decides. - */ +/** The create-support verdict: the adapter's location answer, with the reason a refusal names. */ export function resolveStructuredAgentSessionCreateSupport(input: { - agent: StructuredAgentId location: AgentSessionExecutionLocation adapterSupportsCreate: boolean - getSettings: () => ClaudeManagedAccountGateSettings }): StructuredAgentSessionCreateSupport { if (!input.adapterSupportsCreate) { return { @@ -34,16 +23,23 @@ export function resolveStructuredAgentSessionCreateSupport(input: { : 'agent' } } - // Claude only: Codex resolves its account on a different path, so its answer is untouched here. - // `wsl` is the closest existing reason — the cause is a WSL-bound account rather than a WSL - // workspace — and no client reads the field, so it stays as-is. - if ( - input.agent === 'claude' && - !structuredClaudeMatchesActiveManagedAccount( - readClaudeManagedAccountGateSettings(input.getSettings) + return { supported: true } +} + +/** Which create-support check said no: where the chat would run, or the installed agent (its + * binary and version on this host). */ +export type StructuredAgentSessionCreateSupportCheck = 'location' | 'installed-agent' + +/** One main-log line per create-support verdict that sends a launch to the terminal; names the + * check and never a path or environment value. */ +export function warnStructuredAgentSessionCreateUnsupported( + agent: StructuredAgentId, + support: StructuredAgentSessionCreateSupport, + check: StructuredAgentSessionCreateSupportCheck +): void { + if (!support.supported) { + console.warn( + `[structured-create-support] ${agent} unsupported: ${check} check refused (reason ${support.reason ?? 'none'})` ) - ) { - return { supported: false, reason: 'wsl' } } - return { supported: true } } diff --git a/src/main/native-chat/transcript-file-version.ts b/src/main/native-chat/transcript-file-version.ts index d0dfed29b5f8..07ccaf9aace1 100644 --- a/src/main/native-chat/transcript-file-version.ts +++ b/src/main/native-chat/transcript-file-version.ts @@ -1,4 +1,4 @@ -import { readTranscriptSlice, wslGatedStat } from './wsl-transcript-fs-access' +import { readTranscriptSlice, transcriptFileStat } from './wsl-transcript-fs-access' const BOUNDARY_FINGERPRINT_BYTES = 64 @@ -22,7 +22,7 @@ export async function readTranscriptFileVersion( filePath: string, signal?: AbortSignal ): Promise { - const value = await wslGatedStat(filePath, 'exact', signal) + const value = await transcriptFileStat(filePath, 'exact', signal) return { identity: `${value.dev}:${value.ino}`, size: value.size, diff --git a/src/main/native-chat/transcript-incremental-reader.ts b/src/main/native-chat/transcript-incremental-reader.ts index 558c552139d0..67bd3dce1e44 100644 --- a/src/main/native-chat/transcript-incremental-reader.ts +++ b/src/main/native-chat/transcript-incremental-reader.ts @@ -5,7 +5,7 @@ import { MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES, type NativeChatLineDecoder } from './transcript-tail-reader' -import { openTranscriptReadStream, wslGatedStat } from './wsl-transcript-fs-access' +import { openTranscriptReadStream, transcriptFileStat } from './wsl-transcript-fs-access' const APPEND_BATCH_MESSAGE_LIMIT = 40 @@ -47,7 +47,7 @@ export async function readIncrementalTranscriptMessages( onLifecycle?: (lifecycle: NativeChatTurnLifecycle) => void, signal?: AbortSignal ): Promise { - const end = (await wslGatedStat(filePath, 'exact', signal)).size + const end = (await transcriptFileStat(filePath, 'exact', signal)).size if (end <= state.offset) { return [] } diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 97ab76802d00..6cad15a7757f 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -1,4 +1,5 @@ import { readOpenCodeNativeChatTranscriptFull } from './transcript-opencode' +import { isENOENT } from '../ipc/filesystem-path-containment' import type { AgentType, NativeChatMessage, @@ -75,7 +76,7 @@ export async function readNativeChatTranscript( } catch (err) { // Why: ENOENT after a successful resolve is the same first-flush/rotation // race as an unresolved path — keep it retry-worthy (#8401). - if ((err as NodeJS.ErrnoException | null)?.code === 'ENOENT') { + if (isENOENT(err)) { return { error: errorMessage(err), notFound: true } } return { error: errorMessage(err) } diff --git a/src/main/native-chat/transcript-tail-boundary.ts b/src/main/native-chat/transcript-tail-boundary.ts index b4479ac2bd03..518c256fb56f 100644 --- a/src/main/native-chat/transcript-tail-boundary.ts +++ b/src/main/native-chat/transcript-tail-boundary.ts @@ -1,4 +1,4 @@ -import { wslGatedRead } from './wsl-transcript-fs-access' +import { readTranscriptFile } from './wsl-transcript-fs-access' import type { TranscriptFileHandle } from './wsl-transcript-fs-access' export const TAIL_CHUNK_BYTES = 64 * 1024 @@ -14,7 +14,16 @@ export async function readTranscriptByteAt( signal?: AbortSignal ): Promise { const byte = Buffer.allocUnsafe(1) - const { bytesRead } = await wslGatedRead(handle, filePath, byte, 0, 1, position, 'exact', signal) + const { bytesRead } = await readTranscriptFile( + handle, + filePath, + byte, + 0, + 1, + position, + 'exact', + signal + ) signal?.throwIfAborted() return bytesRead === 1 ? byte[0] : null } @@ -40,7 +49,7 @@ export async function findLastCompleteLineEnd( signal?.throwIfAborted() const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) - const { bytesRead } = await wslGatedRead( + const { bytesRead } = await readTranscriptFile( handle, filePath, buffer, diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index 8f486ecbe261..fb44470047bf 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -1,4 +1,5 @@ import { readOpenCodeNativeChatTranscriptTail } from './transcript-opencode' +import { isENOENT } from '../ipc/filesystem-path-containment' import type { AgentType, NativeChatMessage, @@ -25,9 +26,9 @@ import { } from './transcript-tail-boundary' import { closeTranscriptHandle, - wslGatedOpen, - wslGatedRead, - wslGatedStat + openTranscriptFile, + readTranscriptFile, + transcriptFileStat } from './wsl-transcript-fs-access' import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' @@ -72,14 +73,14 @@ export async function readNativeChatTranscriptTailFile( }> { signal?.throwIfAborted() const end = Math.min( - (await wslGatedStat(filePath, 'exact', signal)).size, + (await transcriptFileStat(filePath, 'exact', signal)).size, endOffset ?? Number.MAX_SAFE_INTEGER ) signal?.throwIfAborted() if (end === 0) { return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } } - const handle = await wslGatedOpen(filePath, 'exact', signal) + const handle = await openTranscriptFile(filePath, 'exact', signal) const lineParts: Buffer[] = [] let lineBytes = 0 let lineOversized = false @@ -112,7 +113,7 @@ export async function readNativeChatTranscriptTailFile( signal?.throwIfAborted() const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) - const { bytesRead } = await wslGatedRead( + const { bytesRead } = await readTranscriptFile( handle, filePath, buffer, @@ -292,8 +293,6 @@ export async function readNativeChatTranscriptTail( } catch (error) { signal?.throwIfAborted() const message = error instanceof Error ? error.message : String(error) - return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' - ? { error: message, notFound: true } - : { error: message } + return isENOENT(error) ? { error: message, notFound: true } : { error: message } } } diff --git a/src/main/native-chat/transcript-watcher-install-probe.ts b/src/main/native-chat/transcript-watcher-install-probe.ts index 2d3cf4858c6a..8fe3488829da 100644 --- a/src/main/native-chat/transcript-watcher-install-probe.ts +++ b/src/main/native-chat/transcript-watcher-install-probe.ts @@ -1,4 +1,4 @@ -import { wslGatedStat } from './wsl-transcript-fs-access' +import { transcriptFileStat } from './wsl-transcript-fs-access' import { WslTranscriptFsError } from './wsl-transcript-fs-gate' import { transcriptWatcherPathIsRunning } from './wsl-transcript-watcher-running-guard' @@ -10,7 +10,7 @@ export async function transcriptWatcherPathIsInstallable( return false } try { - await wslGatedStat(filePath, 'exact', signal) + await transcriptFileStat(filePath, 'exact', signal) return true } catch (error) { if (error instanceof WslTranscriptFsError) { diff --git a/src/main/native-chat/wsl-transcript-fs-access.test.ts b/src/main/native-chat/wsl-transcript-fs-access.test.ts index aaaf18f26983..e836328ca34c 100644 --- a/src/main/native-chat/wsl-transcript-fs-access.test.ts +++ b/src/main/native-chat/wsl-transcript-fs-access.test.ts @@ -43,8 +43,8 @@ import { openTranscriptReadStream, readTranscriptSlice, wslGatedLstat, - wslGatedOpen, - wslGatedRead, + openTranscriptFile, + readTranscriptFile, wslGatedReaddir, wslGatedReadFile, wslGatedStat, @@ -93,8 +93,8 @@ describe('transcript filesystem accessor off WSL UNC', () => { await wslGatedLstat(path, 'scan') await wslGatedReaddir(path, 'scan') await wslGatedReadFile(path, 'utf-8', 'scan') - const opened = await wslGatedOpen(path, 'exact') - await wslGatedRead(opened, path, Buffer.alloc(1), 0, 1, 0, 'exact') + const opened = await openTranscriptFile(path, 'exact') + await readTranscriptFile(opened, path, Buffer.alloc(1), 0, 1, 0, 'exact') const stream = openTranscriptReadStream(path, { encoding: 'utf-8' }, 'scan') expect(mocks.runTask).not.toHaveBeenCalled() @@ -151,8 +151,8 @@ describe('transcript filesystem accessor on WSL UNC', () => { handle.read.mockResolvedValue({ bytesRead: 1, buffer: Buffer.alloc(1) }) mocks.open.mockResolvedValue(handle) - const opened = await wslGatedOpen(UNC_PATH, 'exact') - await wslGatedRead(opened, UNC_PATH, Buffer.alloc(1), 0, 1, 0, 'exact') + const opened = await openTranscriptFile(UNC_PATH, 'exact') + await readTranscriptFile(opened, UNC_PATH, Buffer.alloc(1), 0, 1, 0, 'exact') for (const call of mocks.runTask.mock.calls) { expect(call[0]).toMatchObject({ dedupe: false }) @@ -268,7 +268,7 @@ describe('transcript filesystem accessor on WSL UNC', () => { ) try { const handle = fakeHandle() - const refused = wslGatedOpen(UNC_PATH, 'exact').catch((error: unknown) => error) + const refused = openTranscriptFile(UNC_PATH, 'exact').catch((error: unknown) => error) await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1) await expect(refused).resolves.toBeInstanceOf(WslTranscriptFsError) diff --git a/src/main/native-chat/wsl-transcript-fs-access.ts b/src/main/native-chat/wsl-transcript-fs-access.ts index c048fed0605b..7fcac1d89508 100644 --- a/src/main/native-chat/wsl-transcript-fs-access.ts +++ b/src/main/native-chat/wsl-transcript-fs-access.ts @@ -14,6 +14,12 @@ import { } from './wsl-transcript-fs-process-dispatch' import type { WslTranscriptFsReusableProcessCall } from './wsl-transcript-fs-process-protocol' import { wslTranscriptFsLaneKey } from './wsl-transcript-fs-route' +import { + readSshTranscript, + statSshTranscript, + type SshTranscriptHandle +} from './ssh-transcript-fs-access' +import { parseSshTranscriptPath } from './ssh-transcript-path' /** Never nest a gated call inside another — that deadlocks the scan slot. */ @@ -21,7 +27,13 @@ import { wslTranscriptFsLaneKey } from './wsl-transcript-fs-route' // healthy-but-slow transcript is not false-failed by a whole-file timeout. export const WSL_TRANSCRIPT_READ_CHUNK_BYTES = 1024 * 1024 -export type TranscriptFileHandle = FileHandle | WslTranscriptFsProcessHandle +export type TranscriptFileHandle = FileHandle | WslTranscriptFsProcessHandle | SshTranscriptHandle +/** The stat fields the transcript readers use; an SSH host answers only these. */ +export type TranscriptFileStats = Pick + +function isSshTranscriptHandle(handle: TranscriptFileHandle): handle is SshTranscriptHandle { + return 'sshTranscript' in handle +} // One request object drives both the gate's dedupe/route key and the child // call, so the two can never disagree on the operation or path. @@ -63,6 +75,16 @@ export function wslGatedStat( return runReusableFsOperation({ operation: 'stat', path }, priority, signal, () => stat(path)) } +/** Stat for the transcript readers, which may also name a file on an SSH host. */ +export function transcriptFileStat( + path: string, + priority: WslTranscriptFsTaskPriority, + signal?: AbortSignal +): Promise { + const ssh = parseSshTranscriptPath(path) + return ssh ? statSshTranscript(ssh) : wslGatedStat(path, priority, signal) +} + export function wslGatedLstat( path: string, priority: WslTranscriptFsTaskPriority, @@ -93,11 +115,15 @@ export function wslGatedReadFile( } // dedupe:false — two joiners would share one FileHandle and both close it. -export function wslGatedOpen( +export function openTranscriptFile( path: string, priority: WslTranscriptFsTaskPriority, signal?: AbortSignal ): Promise { + const ssh = parseSshTranscriptPath(path) + if (ssh) { + return Promise.resolve({ sshTranscript: ssh }) + } if (!isWslUncPath(path)) { return open(path, 'r') } @@ -121,7 +147,7 @@ export function wslGatedOpen( * buffer: a joiner would receive the first caller's buffer while its own * (often `Buffer.allocUnsafe`) stays uninitialized. */ -export function wslGatedRead( +export function readTranscriptFile( handle: TranscriptFileHandle, path: string, buffer: Buffer, @@ -131,6 +157,9 @@ export function wslGatedRead( priority: WslTranscriptFsTaskPriority, signal?: AbortSignal ): Promise<{ bytesRead: number; buffer: Buffer }> { + if (isSshTranscriptHandle(handle)) { + return readSshTranscript(handle, buffer, offset, length, position) + } // 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. if (!isWslUncPath(path) && !isWslTranscriptFsProcessHandle(handle)) { @@ -152,6 +181,9 @@ export function wslGatedRead( /** Never gated; process-owned handles retire on the client's bounded deadline. */ export function closeTranscriptHandle(handle: TranscriptFileHandle, path: string): Promise { + if (isSshTranscriptHandle(handle)) { + return Promise.resolve() + } if (isWslTranscriptFsProcessHandle(handle)) { void closeWslTranscriptFsProcess(handle).catch(() => {}) return Promise.resolve() @@ -177,10 +209,10 @@ export async function readTranscriptSlice( priority: WslTranscriptFsTaskPriority, signal?: AbortSignal ): Promise { - const handle = await wslGatedOpen(path, priority, signal) + const handle = await openTranscriptFile(path, priority, signal) try { const buffer = Buffer.allocUnsafe(length) - const { bytesRead } = await wslGatedRead( + const { bytesRead } = await readTranscriptFile( handle, path, buffer, @@ -212,7 +244,7 @@ async function* gatedChunks( priority: WslTranscriptFsTaskPriority, signal?: AbortSignal ): AsyncGenerator { - const handle = await wslGatedOpen(path, priority, signal) + const handle = await openTranscriptFile(path, priority, signal) // Why: chunk boundaries fall mid-codepoint, so decoding each slice // independently would emit U+FFFD on both sides of any straddling character. const decoder = options.encoding ? new StringDecoder(options.encoding) : null @@ -227,7 +259,7 @@ async function* gatedChunks( break } const buffer = Buffer.allocUnsafe(length) - const { bytesRead } = await wslGatedRead( + const { bytesRead } = await readTranscriptFile( handle, path, buffer, @@ -275,7 +307,7 @@ export function openTranscriptReadStream( priority: WslTranscriptFsTaskPriority, signal?: AbortSignal ): Readable { - if (!isWslUncPath(path)) { + if (!isWslUncPath(path) && !parseSshTranscriptPath(path)) { // Node destroys the stream with an AbortError on abort, matching how the // gated branch surfaces cancellation to the same consumers. return createReadStream(path, { ...options, signal }) diff --git a/src/main/native-chat/wsl-transcript-watcher-running-guard.ts b/src/main/native-chat/wsl-transcript-watcher-running-guard.ts index 50d98689744b..5c83a9d90905 100644 --- a/src/main/native-chat/wsl-transcript-watcher-running-guard.ts +++ b/src/main/native-chat/wsl-transcript-watcher-running-guard.ts @@ -4,6 +4,7 @@ import { createTranscriptNativeWatcher, type TranscriptNativeWatcher } from './transcript-native-watcher' +import { parseSshTranscriptPath } from './ssh-transcript-path' export function isWslTranscriptWatcherPath(filePath: string): boolean { return isWslUncPath(filePath) @@ -21,8 +22,8 @@ export function createRunningGuardedTranscriptNativeWatcher( onEvent: () => void, onRetry: () => void ): TranscriptNativeWatcher { - const isWslPath = isWslTranscriptWatcherPath(filePath) - if (isWslPath) { + // fs.watch cannot see an SSH host's file; the reconciliation poll stats it there instead. + if (isWslTranscriptWatcherPath(filePath) || parseSshTranscriptPath(filePath)) { return { bind: () => false, dispose: () => {}, diff --git a/src/main/opencode-usage/opencode-database-discovery.ts b/src/main/opencode-usage/opencode-database-discovery.ts index 5f7ca25415db..25fadd541e67 100644 --- a/src/main/opencode-usage/opencode-database-discovery.ts +++ b/src/main/opencode-usage/opencode-database-discovery.ts @@ -1,8 +1,11 @@ import { stat } from 'node:fs/promises' -import { basename, isAbsolute, join } from 'node:path' +import { basename, join } from 'node:path' import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' -import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory' +import { + resolveOpenCodeDataDirectory, + resolveOpenCodeDatabasePath +} from '../opencode/opencode-data-directory' import type { OpenCodeUsageProcessedDatabase } from './types' type OpenCodeDatabaseOverride = { @@ -10,20 +13,14 @@ type OpenCodeDatabaseOverride = { path: string | null } -function getOpenCodeDatabaseOverride( - dataDirectory: string, - environment: NodeJS.ProcessEnv -): OpenCodeDatabaseOverride { +function getOpenCodeDatabaseOverride(environment: NodeJS.ProcessEnv): OpenCodeDatabaseOverride { const raw = environment.OPENCODE_DB?.trim() if (!raw) { return { isConfigured: false, path: null } } - if (raw === ':memory:') { - return { isConfigured: true, path: null } - } return { isConfigured: true, - path: isAbsolute(raw) ? raw : join(dataDirectory, raw) + path: resolveOpenCodeDatabasePath(environment) } } @@ -40,7 +37,7 @@ export async function listOpenCodeDatabases( environment: NodeJS.ProcessEnv = process.env ): Promise { const dataDirectory = resolveOpenCodeDataDirectory(environment) - const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory, environment) + const databaseOverride = getOpenCodeDatabaseOverride(environment) if (databaseOverride.isConfigured) { if (!databaseOverride.path) { return [] diff --git a/src/main/opencode-usage/scanner-wsl-gate.test.ts b/src/main/opencode-usage/scanner-wsl-gate.test.ts index c8bb123c4763..b280a8638b2b 100644 --- a/src/main/opencode-usage/scanner-wsl-gate.test.ts +++ b/src/main/opencode-usage/scanner-wsl-gate.test.ts @@ -12,7 +12,8 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('../opencode/opencode-data-directory', () => ({ - resolveOpenCodeDataDirectory: mocks.resolveDataDirectory + resolveOpenCodeDataDirectory: mocks.resolveDataDirectory, + resolveOpenCodeDatabasePath: () => UNC_DATABASE })) vi.mock('node:fs/promises', async (importOriginal) => ({ ...(await importOriginal()), diff --git a/src/main/opencode/opencode-acp-environment.ts b/src/main/opencode/opencode-acp-environment.ts new file mode 100644 index 000000000000..2970ce317aee --- /dev/null +++ b/src/main/opencode/opencode-acp-environment.ts @@ -0,0 +1,69 @@ +import { restoreOrStripOverlayEnv } from '../../shared/agent-overlay-env' +import { getAppEnvironment } from '../../shared/app-environment' +import { isOpenCodeLegacySharedConfigDir } from './legacy-shared-config-dir' + +const ORCA_ENV_PREFIXES = ['ORCA_OPENCODE_', 'ORCA_DATA_ACCOUNT_'] +const ACCOUNT_ENV_KEYS = ['XDG_DATA_HOME', 'XDG_STATE_HOME', 'OPENCODE_DB', 'OPENCODE_AUTH_CONTENT'] + +/** + * A structured chat's OpenCode reports through the chat, never through Orca's terminal status + * plugin. Restores the user's own `OPENCODE_CONFIG_DIR` over the overlay a terminal pane would have + * (dropping it when none was recorded, or when it is the retired shared plugin directory), and drops + * every Orca OpenCode and data-account variable. Runs after the account is applied. Returns + * the keys the child must not inherit. + */ +export function scrubOpenCodeAcpEnvironment( + env: Record, + inherited: NodeJS.ProcessEnv, + userDataPath: string | null = orcaUserDataPath() +): string[] { + const view: Record = {} + for (const [key, value] of Object.entries(inherited)) { + if (value !== undefined) { + view[key] = value + } + } + Object.assign(view, env) + restoreOrStripOverlayEnv( + view, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + preserveExplicitPrimary: true + }, + {} + ) + const removed: string[] = [] + if ( + view.OPENCODE_CONFIG_DIR === undefined || + (userDataPath !== null && + isOpenCodeLegacySharedConfigDir(view.OPENCODE_CONFIG_DIR, userDataPath)) + ) { + delete env.OPENCODE_CONFIG_DIR + removed.push('OPENCODE_CONFIG_DIR') + } else { + env.OPENCODE_CONFIG_DIR = view.OPENCODE_CONFIG_DIR + } + // The launch environment decides these; one it leaves unset must not come from Orca's overlay. + for (const key of ACCOUNT_ENV_KEYS) { + if (env[key] === undefined) { + removed.push(key) + } + } + for (const key of new Set([...Object.keys(env), ...Object.keys(inherited)])) { + if (ORCA_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) { + delete env[key] + removed.push(key) + } + } + return removed +} + +function orcaUserDataPath(): string | null { + try { + return getAppEnvironment().getPath('userData') + } catch { + return null + } +} diff --git a/src/main/opencode/opencode-acp-stored-messages.test.ts b/src/main/opencode/opencode-acp-stored-messages.test.ts new file mode 100644 index 000000000000..43ca17f8a5d6 --- /dev/null +++ b/src/main/opencode/opencode-acp-stored-messages.test.ts @@ -0,0 +1,111 @@ +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Database from '../sqlite/sync-database' +import { readOpenCodeTranscriptPage } from '../native-chat/transcript-opencode-sqlite-query' +import { + openCodeStoredUserMessagesReader, + type OpenCodeTranscriptPageReader +} from './opencode-acp-stored-messages' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +/** An `opencode.db` under a fresh XDG data home, built by `schema`; returns that data home. */ +function dataHomeWith(schema: string): string { + const root = mkdtempSync(join(tmpdir(), 'orca-opencode-stored-')) + roots.push(root) + mkdirSync(join(root, 'opencode')) + const db = new Database(join(root, 'opencode', 'opencode.db')) + db.exec(schema) + db.close() + return root +} + +/** The bounded reader in-process, standing in for its worker. */ +const readInProcess: OpenCodeTranscriptPageReader = async (args) => readOpenCodeTranscriptPage(args) + +function readFrom(dataHome: string) { + return openCodeStoredUserMessagesReader(readInProcess)({ + env: { HOME: dataHome, USERPROFILE: dataHome, XDG_DATA_HOME: dataHome }, + providerSessionId: 'ses_1', + signal: new AbortController().signal + }) +} + +const OPENCODE_2_TABLES = `CREATE TABLE session_v2 (id TEXT PRIMARY KEY); + CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, seq INTEGER, + data TEXT, time_created INTEGER, time_updated INTEGER);` + +describe('OpenCode stored user messages', () => { + it("reads the database under the chat's own home, and only its user messages", async () => { + const readPage = vi.fn(async () => ({ + items: [ + { + rowid: 1, + fingerprint: 'a', + message: { id: 'msg_1', role: 'user', blocks: [], timestamp: 5, source: 'transcript' } + }, + { + rowid: 2, + fingerprint: 'b', + message: { + id: 'msg_2', + role: 'assistant', + blocks: [], + timestamp: 6, + source: 'transcript' + } + } + ], + hasMore: false, + beforeMessageRowId: 1 + })) + const home = join('/', 'child-home') + const read = openCodeStoredUserMessagesReader(readPage) + await expect( + read({ + env: { HOME: home, USERPROFILE: home }, + providerSessionId: 'ses_1', + signal: new AbortController().signal + }) + ).resolves.toEqual([{ id: 'msg_1', blocks: [], createdAt: 5 }]) + expect(readPage).toHaveBeenCalledWith( + expect.objectContaining({ + dbPath: join(home, '.local', 'share', 'opencode', 'opencode.db'), + sessionId: 'ses_1' + }), + expect.anything() + ) + }) + + it('reads a 2.x session from its live tables, not the frozen 1.x copy an upgrade left', async () => { + const dataHome = dataHomeWith(`${OPENCODE_2_TABLES} + CREATE TABLE session (id TEXT PRIMARY KEY); + CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, + data TEXT); + CREATE TABLE part (id TEXT, message_id TEXT, session_id TEXT, time_updated INTEGER, data TEXT); + INSERT INTO session VALUES ('ses_1'); + INSERT INTO message VALUES ('old', 'ses_1', 1, 1, '{"role":"user"}'); + INSERT INTO part VALUES ('p', 'old', 'ses_1', 1, '{"type":"text","text":"frozen"}'); + INSERT INTO session_v2 VALUES ('ses_1'); + INSERT INTO session_message VALUES + ('u1', 'ses_1', 'user', 1, '{"text":"live prompt"}', 50, 50), + ('a1', 'ses_1', 'assistant', 2, '{"content":[{"type":"text","text":"reply"}]}', 60, 60);`) + await expect(readFrom(dataHome)).resolves.toEqual([ + { id: 'opencode:u1', blocks: [{ type: 'text', text: 'live prompt' }], createdAt: 50 } + ]) + }) + + it('finds nothing in a 2.x database whose message table it does not know', async () => { + const dataHome = dataHomeWith(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY); + CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, body TEXT); + INSERT INTO session_v2 VALUES ('ses_1');`) + await expect(readFrom(dataHome)).resolves.toBeNull() + }) +}) diff --git a/src/main/opencode/opencode-acp-stored-messages.ts b/src/main/opencode/opencode-acp-stored-messages.ts new file mode 100644 index 000000000000..8a2beff56418 --- /dev/null +++ b/src/main/opencode/opencode-acp-stored-messages.ts @@ -0,0 +1,38 @@ +import { homedir } from 'node:os' +import { OPENCODE_TRANSCRIPT_MAX_WINDOW } from '../../shared/opencode-transcript-page-limit' +import type { AcpStoredUserMessagesReader } from '../acp/acp-recovery-history' +import { resolveOpenCodeDatabasePath } from './opencode-data-directory' +import type { readOpenCodeTranscriptPageViaWorker } from '../ai-vault/session-scanner-opencode-sqlite-worker-spawn' + +export type OpenCodeTranscriptPageReader = typeof readOpenCodeTranscriptPageViaWorker + +/** The latest user messages of an OpenCode session, read from the database its pinned account + * selects, through the existing bounded reader: 2.x's `session_v2` rows first, else 1.x's. */ +export function openCodeStoredUserMessagesReader( + readPage?: OpenCodeTranscriptPageReader +): AcpStoredUserMessagesReader { + return async ({ env, providerSessionId, signal }) => { + // The child's own home, which its account may name, not this process's. + const home = (process.platform === 'win32' ? env.USERPROFILE : env.HOME) || homedir() + const dbPath = resolveOpenCodeDatabasePath(env, home) + if (!dbPath) { + return null + } + const read = + readPage ?? + (await import('../ai-vault/session-scanner-opencode-sqlite-worker-spawn')) + .readOpenCodeTranscriptPageViaWorker + const page = await read( + { dbPath, sessionId: providerSessionId, limit: OPENCODE_TRANSCRIPT_MAX_WINDOW }, + signal + ) + if (!page) { + return null + } + return page.items.flatMap(({ message }) => + message.role === 'user' && message.timestamp !== null + ? [{ id: message.id, blocks: message.blocks, createdAt: message.timestamp }] + : [] + ) + } +} diff --git a/src/main/opencode/opencode-data-directory.test.ts b/src/main/opencode/opencode-data-directory.test.ts index e3b9073aa17d..df95f0b8ae91 100644 --- a/src/main/opencode/opencode-data-directory.test.ts +++ b/src/main/opencode/opencode-data-directory.test.ts @@ -2,6 +2,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { resolveOpenCodeDataDirectory, + resolveOpenCodeDatabasePath, resolveOpenCodeStorageDirectory } from './opencode-data-directory' @@ -43,4 +44,17 @@ describe('resolveOpenCodeDataDirectory', () => { join('/users/test', '.local', 'share', 'opencode', 'storage') ) }) + + it('resolves default, relative, absolute and in-memory database selections', () => { + const environment = { XDG_DATA_HOME: '/pinned' } + expect(resolveOpenCodeDatabasePath(environment)).toBe( + join('/pinned', 'opencode', 'opencode.db') + ) + expect(resolveOpenCodeDatabasePath({ ...environment, OPENCODE_DB: 'custom.db' })).toBe( + join('/pinned', 'opencode', 'custom.db') + ) + const absolute = join(process.cwd(), 'selected.db') + expect(resolveOpenCodeDatabasePath({ ...environment, OPENCODE_DB: absolute })).toBe(absolute) + expect(resolveOpenCodeDatabasePath({ ...environment, OPENCODE_DB: ':memory:' })).toBeNull() + }) }) diff --git a/src/main/opencode/opencode-data-directory.ts b/src/main/opencode/opencode-data-directory.ts index b3db5598cee5..5010311f7884 100644 --- a/src/main/opencode/opencode-data-directory.ts +++ b/src/main/opencode/opencode-data-directory.ts @@ -1,5 +1,5 @@ import { homedir } from 'node:os' -import { join } from 'node:path' +import { isAbsolute, join } from 'node:path' export function resolveOpenCodeDataDirectory( environment: NodeJS.ProcessEnv = process.env, @@ -15,3 +15,17 @@ export function resolveOpenCodeStorageDirectory( ): string { return join(resolveOpenCodeDataDirectory(environment, homeDirectory), 'storage') } + +export function resolveOpenCodeDatabasePath( + environment: NodeJS.ProcessEnv = process.env, + homeDirectory = homedir() +): string | null { + const selection = environment.OPENCODE_DB?.trim() + if (selection === ':memory:') { + return null + } + const dataDirectory = resolveOpenCodeDataDirectory(environment, homeDirectory) + return selection && isAbsolute(selection) + ? selection + : join(dataDirectory, selection || 'opencode.db') +} diff --git a/src/main/opencode/opencode-structured-account-home.test.ts b/src/main/opencode/opencode-structured-account-home.test.ts new file mode 100644 index 000000000000..34300a3fed43 --- /dev/null +++ b/src/main/opencode/opencode-structured-account-home.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import type { ManagedDataAccountsState } from '../../shared/managed-account-types' +import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' +import { + environmentForStructuredOpenCodeAccountHome, + resolveStructuredOpenCodeAccountHome +} from './opencode-structured-account-home' + +const first = '123e4567-e89b-42d3-a456-426614174000' +const second = '123e4567-e89b-42d3-a456-426614174001' + +function managedAccounts() { + let activeAccountId: string | null = first + let available = new Set([first, second]) + return { + list: (): ManagedDataAccountsState => ({ + accounts: [...available].map((id) => ({ id, label: id, integrations: [], createdAt: 0 })), + activeAccountId + }), + restoreOriginalEnvironment: (environment: Record) => + restoreManagedDataAccountEnvironment(environment), + environmentForAccount: (_provider: 'opencode' | 'devin', id: string) => { + if (!available.has(id)) { + throw new Error('Managed account not found.') + } + return { + XDG_DATA_HOME: `/profiles/${id}/data`, + XDG_STATE_HOME: `/profiles/${id}/state`, + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '' + } + }, + select: (id: string | null) => { + activeAccountId = id + }, + remove: (id: string) => { + available = new Set([...available].filter((entry) => entry !== id)) + } + } +} + +describe('structured OpenCode account binding', () => { + it('records only that the chat has no managed profile', () => { + const accounts = managedAccounts() + accounts.select(null) + expect(resolveStructuredOpenCodeAccountHome({ managedAccounts: accounts })).toEqual({ + kind: 'opencode', + locator: { kind: 'unmanaged' } + }) + }) + + it('uses the pinned managed profile after selection changes and refuses removal', () => { + const accounts = managedAccounts() + const binding = resolveStructuredOpenCodeAccountHome({ managedAccounts: accounts }) + accounts.select(second) + const environment = environmentForStructuredOpenCodeAccountHome(binding, { + managedAccounts: accounts, + baseEnvironment: { PATH: '/bin' } + }) + expect(environment.XDG_DATA_HOME).toBe(`/profiles/${first}/data`) + expect(environment.OPENCODE_DB).toBe('opencode.db') + expect(resolveStructuredOpenCodeAccountHome({ managedAccounts: accounts }).locator).toEqual({ + kind: 'managed', + managedProfileId: second + }) + accounts.remove(first) + expect(() => + environmentForStructuredOpenCodeAccountHome(binding, { + managedAccounts: accounts, + baseEnvironment: {} + }) + ).toThrow('Managed account not found.') + }) + + it("passes the user's inline credentials, relative paths and database through unchanged", () => { + const accounts = managedAccounts() + accounts.select(null) + const baseEnvironment = { + PATH: '/bin', + HOME: 'relative/home', + XDG_DATA_HOME: 'relative/data', + XDG_STATE_HOME: 'relative/state', + OPENCODE_DB: 'custom.db', + OPENCODE_AUTH_CONTENT: '{"provider":{"type":"api","key":"inline"}}' + } + const binding = resolveStructuredOpenCodeAccountHome({ managedAccounts: accounts }) + expect( + environmentForStructuredOpenCodeAccountHome(binding, { + managedAccounts: accounts, + baseEnvironment + }) + ).toEqual(baseEnvironment) + }) + + it("undoes Orca's inherited managed overlay before passing the environment through", () => { + const accounts = managedAccounts() + accounts.select(null) + const environment = environmentForStructuredOpenCodeAccountHome( + { kind: 'opencode', locator: { kind: 'unmanaged' } }, + { + managedAccounts: accounts, + baseEnvironment: { + ORCA_DATA_ACCOUNT_PROVIDER: 'opencode', + ORCA_DATA_ACCOUNT_DATA_HOME: '/old/data', + ORCA_DATA_ACCOUNT_STATE_HOME: '/old/state', + ORCA_DATA_ACCOUNT_ORIGINAL_ENV: JSON.stringify({ + XDG_DATA_HOME: '/original/data', + XDG_STATE_HOME: null, + OPENCODE_DB: 'original.db', + OPENCODE_AUTH_CONTENT: null + }), + XDG_DATA_HOME: '/old/data', + XDG_STATE_HOME: '/old/state', + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '', + PATH: '/bin' + } + } + ) + expect(environment).toEqual({ + XDG_DATA_HOME: '/original/data', + OPENCODE_DB: 'original.db', + PATH: '/bin' + }) + }) +}) diff --git a/src/main/opencode/opencode-structured-account-home.ts b/src/main/opencode/opencode-structured-account-home.ts new file mode 100644 index 000000000000..57279a8aa05b --- /dev/null +++ b/src/main/opencode/opencode-structured-account-home.ts @@ -0,0 +1,95 @@ +import { + isLegacyAgentSessionAccountHome, + type OpenCodeAgentSessionAccountHome +} from '../../shared/agent-session-account-home' +import { + getManagedDataAccountService, + type ManagedDataAccountService +} from '../managed-data-accounts/service' +import type { AcpAccountBinding } from '../acp/acp-account-binding' + +type AccountReader = Pick< + ManagedDataAccountService, + 'list' | 'restoreOriginalEnvironment' | 'environmentForAccount' +> + +function restoredEnvironment( + environment: NodeJS.ProcessEnv, + managedAccounts: Pick +): Record { + const copy = { ...environment } + managedAccounts.restoreOriginalEnvironment(copy) + delete copy.ORCA_DATA_ACCOUNT_PROVIDER + delete copy.ORCA_DATA_ACCOUNT_DATA_HOME + delete copy.ORCA_DATA_ACCOUNT_STATE_HOME + delete copy.ORCA_DATA_ACCOUNT_ORIGINAL_ENV + return copy +} + +function definedEnvironment( + environment: Record +): Record { + const defined: Record = {} + for (const [key, value] of Object.entries(environment)) { + if (value !== undefined) { + defined[key] = value + } + } + return defined +} + +/** Resolve the current selection on the execution host when creating a chat. */ +export function resolveStructuredOpenCodeAccountHome(input: { + managedAccounts: AccountReader +}): OpenCodeAgentSessionAccountHome { + const selected = input.managedAccounts.list('opencode').activeAccountId + if (selected) { + input.managedAccounts.environmentForAccount('opencode', selected) + return { kind: 'opencode', locator: { kind: 'managed', managedProfileId: selected } } + } + return { kind: 'opencode', locator: { kind: 'unmanaged' } } +} + +/** Resolve a saved binding against the current host, then apply it after inherited overlays. */ +export function environmentForStructuredOpenCodeAccountHome( + binding: OpenCodeAgentSessionAccountHome, + input: { + managedAccounts: Pick + baseEnvironment: Record + } +): Record { + const environment = restoredEnvironment(input.baseEnvironment, input.managedAccounts) + const locator = binding.locator + // Unmanaged: the user's own environment reaches OpenCode as-is. + if (locator.kind === 'unmanaged') { + return definedEnvironment(environment) + } + delete environment.XDG_DATA_HOME + delete environment.XDG_STATE_HOME + delete environment.OPENCODE_DB + delete environment.OPENCODE_AUTH_CONTENT + return { + ...definedEnvironment(environment), + ...input.managedAccounts.environmentForAccount('opencode', locator.managedProfileId) + } +} + +/** OpenCode's account over ACP: a managed profile, or the user's own environment. */ +export function openCodeAcpAccountBinding( + managedAccounts: () => AccountReader = getManagedDataAccountService +): AcpAccountBinding { + return { + pin: { accountLocatorKind: 'opencode' }, + resolve: async () => + resolveStructuredOpenCodeAccountHome({ managedAccounts: managedAccounts() }), + environment: (home, env) => { + if (isLegacyAgentSessionAccountHome(home)) { + throw new Error('OpenCode chat requires a pinned data account') + } + return environmentForStructuredOpenCodeAccountHome(home, { + managedAccounts: managedAccounts(), + baseEnvironment: env + }) + } + } +} diff --git a/src/main/window/history-gc-profile-worktree-ids.test.ts b/src/main/orca-profiles/other-profile-workspace-catalog.test.ts similarity index 79% rename from src/main/window/history-gc-profile-worktree-ids.test.ts rename to src/main/orca-profiles/other-profile-workspace-catalog.test.ts index 3c01d0f2ea23..43fd24d919b4 100644 --- a/src/main/window/history-gc-profile-worktree-ids.test.ts +++ b/src/main/orca-profiles/other-profile-workspace-catalog.test.ts @@ -10,8 +10,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { folderWorkspaceKey } from '../../shared/workspace-scope' import { importProfileStateJson } from '../persistence/profile-state/profile-state-documents' import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' -import { getOrcaProfileStateDatabaseFile } from '../orca-profiles/profile-storage-paths' -import { getOtherProfileWorktreeIdsForHistoryGc } from './history-gc-profile-worktree-ids' +import { getOrcaProfileStateDatabaseFile } from './profile-storage-paths' +import { + getOtherProfileWorktreeIdsForHistoryGc, + readOtherProfileWorkspaceCatalog +} from './other-profile-workspace-catalog' const roots: string[] = [] @@ -265,3 +268,48 @@ describe('getOtherProfileWorktreeIdsForHistoryGc', () => { }) }) }) + +describe('readOtherProfileWorkspaceCatalog', () => { + it("also names every other profile's projects", () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: { repos: [{ id: 'repo-active' }] } }, + { id: 'other', state: { repos: [{ id: 'repo-other' }, { id: '' }, null] } } + ]) + const catalog = readOtherProfileWorkspaceCatalog(root) + expect(catalog.repoIds).toEqual(new Set(['repo-other'])) + expect(catalog.unreadableProfiles).toBe(0) + }) + + it('counts a profile whose projects are not a list as unreadable', () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', state: { repos: { 'repo-other': {} } } } + ]) + expect(readOtherProfileWorkspaceCatalog(root).unreadableProfiles).toBe(1) + }) + + it('skips the running profile even when the index already names another as active', () => { + const root = userDataWithProfiles('next', [ + { id: 'running', state: { repos: [{ id: 'repo-running' }] } }, + { id: 'next', state: { repos: [{ id: 'repo-next' }] } } + ]) + const catalog = readOtherProfileWorkspaceCatalog(root, { runningProfileId: 'running' }) + expect(catalog.repoIds).toEqual(new Set(['repo-next'])) + }) + + it('counts a never-written profile as empty only when asked', () => { + const root = userDataWithProfiles('active', [{ id: 'active', state: {} }, { id: 'fresh' }]) + expect(readOtherProfileWorkspaceCatalog(root).unreadableProfiles).toBe(1) + const lenient = readOtherProfileWorkspaceCatalog(root, { neverWrittenIsEmpty: true }) + expect(lenient.unreadableProfiles).toBe(0) + expect(lenient.repoIds.size).toBe(0) + }) + + it('never counts a profile with only backups of its data file as empty', () => { + const root = userDataWithProfiles('active', [{ id: 'active', state: {} }, { id: 'lost' }]) + writeFileSync(join(root, 'profiles', 'lost', 'orca-data.json.bak.0'), '{}') + expect( + readOtherProfileWorkspaceCatalog(root, { neverWrittenIsEmpty: true }).unreadableProfiles + ).toBe(1) + }) +}) diff --git a/src/main/window/history-gc-profile-worktree-ids.ts b/src/main/orca-profiles/other-profile-workspace-catalog.ts similarity index 60% rename from src/main/window/history-gc-profile-worktree-ids.ts rename to src/main/orca-profiles/other-profile-workspace-catalog.ts index 07a115b0ad86..1ba2e91a114e 100644 --- a/src/main/window/history-gc-profile-worktree-ids.ts +++ b/src/main/orca-profiles/other-profile-workspace-catalog.ts @@ -1,13 +1,18 @@ import { lstatSync, readFileSync } from 'node:fs' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { folderWorkspaceKey } from '../../shared/workspace-scope' import { getOrcaProfileDataFile, getOrcaProfileStateDatabaseFile, getProfileUserDataPath -} from '../orca-profiles/profile-storage-paths' -import { getOrcaProfileIndexPath, readProfileIndex } from '../orca-profiles/profile-index-store' +} from './profile-storage-paths' +import { getOrcaProfileIndexPath, readProfileIndex } from './profile-index-store' import { readProfileStateDomains } from '../persistence/profile-state/profile-state-domain-reader' import { assertNoRetainedProfileStateExports } from '../persistence/profile-state/profile-state-recovery-required' +import { + PROFILE_STATE_LEGACY_BACKUP_COUNT, + profileStateLegacyBackupPath +} from '../persistence/profile-state/legacy-json/profile-state-legacy-backup-path' /** * Worktree ids owned by Orca profiles OTHER than the running one. @@ -21,7 +26,7 @@ import { assertNoRetainedProfileStateExports } from '../persistence/profile-stat * * Reading their persisted state directly is deliberate: a Store per profile would * run migrations and normalization against state another profile owns. Only the - * two id-bearing collections are read, and any unreadable profile is skipped — + * id-bearing collections are read, and any unreadable profile is skipped — * a profile whose ids cannot be established must widen the live set's * uncertainty, never narrow it, so failure here is handled by the caller * refusing to prune rather than by pruning more. @@ -30,29 +35,59 @@ export function getOtherProfileWorktreeIdsForHistoryGc(userDataPath = getProfile ids: Set unreadableProfiles: number } { + const { ids, unreadableProfiles } = readOtherProfileWorkspaceCatalog(userDataPath) + return { ids, unreadableProfiles } +} + +type ProfileWorkspaceIds = { + /** Worktree ids with metadata, and folder workspace keys. */ + ids: Set + repoIds: Set +} + +export type OtherProfileCatalogOptions = { + /** The profile this process runs, whose catalog the caller reads live. Defaults to the index's + * active profile, which a profile switch rewrites before the running one exits. */ + runningProfileId?: string + /** A profile with no state database, sidecar or data file at all has never been written and + * holds nothing; by default it counts as unreadable like a missing file anywhere else. */ + neverWrittenIsEmpty?: boolean +} + +/** The workspaces and projects every profile other than the running one holds. Any profile whose + * state can't be read is counted, never skipped silently: its ids are unknown. */ +export function readOtherProfileWorkspaceCatalog( + userDataPath = getProfileUserDataPath(), + options: OtherProfileCatalogOptions = {} +): ProfileWorkspaceIds & { unreadableProfiles: number } { const ids = new Set() + const repoIds = new Set() const index = readProfileIndex(getOrcaProfileIndexPath(userDataPath)) if (!index) { - return { ids, unreadableProfiles: 0 } + return { ids, repoIds, unreadableProfiles: 0 } } let unreadableProfiles = 0 + const running = options.runningProfileId ?? index.activeProfileId for (const profile of index.profiles) { - if (profile.id === index.activeProfileId) { + if (profile.id === running) { continue } - const collected = readProfileWorktreeIds(profile.id, userDataPath) + const collected = readProfileWorktreeIds(profile.id, userDataPath, options) if (!collected) { unreadableProfiles += 1 continue } - for (const id of collected) { - ids.add(id) - } + collected.ids.forEach((id) => ids.add(id)) + collected.repoIds.forEach((id) => repoIds.add(id)) } - return { ids, unreadableProfiles } + return { ids, repoIds, unreadableProfiles } } -function readProfileWorktreeIds(profileId: string, userDataPath: string): Set | null { +function readProfileWorktreeIds( + profileId: string, + userDataPath: string, + options: OtherProfileCatalogOptions = {} +): ProfileWorkspaceIds | null { const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) // A present database is authoritative. In particular, do not fall back to a // stale JSON export after corruption or a future schema, because that could @@ -70,9 +105,31 @@ function readProfileWorktreeIds(profileId: string, userDataPath: string): Set | null { +): ProfileWorkspaceIds | null { const domains = readProfileStateDomains(databaseFile, profileId, [ 'worktreeMeta', - 'folderWorkspaces' + 'folderWorkspaces', + 'repos' ]) if (domains.kind === 'unreadable') { return null @@ -141,10 +199,29 @@ function readProfileWorktreeIdsFromDatabase( } } } + const repoIds = repoIdsOf(domains.values.get('repos')) + return repoIds ? { ids, repoIds } : null +} + +/** Null when the collection is present but not a list: its ids are unknown. */ +function repoIdsOf(repos: unknown): Set | null { + const ids = new Set() + if (repos === undefined || repos === null) { + return ids + } + if (!Array.isArray(repos)) { + return null + } + for (const repo of repos) { + const id = isRecord(repo) ? repo.id : undefined + if (typeof id === 'string' && id) { + ids.add(id) + } + } return ids } -function readProfileWorktreeIdsFromJson(dataFile: string): Set | null { +function readProfileWorktreeIdsFromJson(dataFile: string): ProfileWorkspaceIds | null { let parsed: unknown try { parsed = JSON.parse(readFileSync(dataFile, 'utf8')) @@ -153,10 +230,10 @@ function readProfileWorktreeIdsFromJson(dataFile: string): Set | null { // thing to the caller: this profile's ids are unknown. return null } - if (!parsed || typeof parsed !== 'object') { + if (!isRecord(parsed)) { return null } - const state = parsed as { worktreeMeta?: unknown; folderWorkspaces?: unknown } + const state = parsed const ids = new Set() if (state.worktreeMeta && typeof state.worktreeMeta === 'object') { for (const id of Object.keys(state.worktreeMeta)) { @@ -165,13 +242,14 @@ function readProfileWorktreeIdsFromJson(dataFile: string): Set | null { } if (Array.isArray(state.folderWorkspaces)) { for (const workspace of state.folderWorkspaces) { - const id = (workspace as { id?: unknown } | null)?.id + const id = isRecord(workspace) ? workspace.id : undefined if (typeof id === 'string' && id) { ids.add(folderWorkspaceKey(id)) } } } - return ids + const repoIds = repoIdsOf(state.repos) + return repoIds ? { ids, repoIds } : null } function isRecord(value: unknown): value is Record { diff --git a/src/main/orcad/launcher.ts b/src/main/orcad/launcher.ts new file mode 100644 index 000000000000..4e43729084a6 --- /dev/null +++ b/src/main/orcad/launcher.ts @@ -0,0 +1,27 @@ +import { createHash } from 'node:crypto' +import { readFileSync, realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { ORCAD_SERVER_ENTRY_FILENAME } from '../../shared/orcad-artifacts' +import { assertOrcadServerRuntime, handoffToBundledOrcad } from './orcad-bundled-runtime' + +declare const ORCAD_SERVER_SHA256: string + +let loadServer: (() => void) | undefined + +try { + if (!handoffToBundledOrcad()) { + assertOrcadServerRuntime() + const entry = realpathSync(process.argv[1] ?? __filename) + const server = join(dirname(entry), ORCAD_SERVER_ENTRY_FILENAME) + if (createHash('sha256').update(readFileSync(server)).digest('hex') !== ORCAD_SERVER_SHA256) { + throw new Error('The Orca server does not match its launcher') + } + loadServer = () => createRequire(entry)(server) + } +} catch (error) { + console.error('orcad: failed to launch:', error) + process.exit(78) +} + +loadServer?.() diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts index d6744cdcb10b..5a77d2c5cca3 100644 --- a/src/main/orcad/main-preflight-order.test.ts +++ b/src/main/orcad/main-preflight-order.test.ts @@ -18,7 +18,7 @@ const { order, profileProbe } = vi.hoisted(() => { return { order, profileProbe: vi.fn(async () => {}) } }) -vi.mock('./orcad-bundled-runtime', () => ({ handoffToBundledOrcad: () => false })) +vi.mock('./orcad-bundled-runtime', () => ({ assertOrcadServerRuntime: () => {} })) vi.mock('./orcad-profile-preflight', () => ({ preflightBundledOrcadStartup: async () => { order.push('profile-admission') diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 325ada702d68..49490ffedf37 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -8,7 +8,7 @@ import { ORCAD_STARTUP_PREFLIGHT_FLAG } from '../../shared/orcad-profile-preflight' import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight' -import { handoffToBundledOrcad } from './orcad-bundled-runtime' +import { assertOrcadServerRuntime } from './orcad-bundled-runtime' import { formatOrcadNativePreflightReport, ORCAD_NATIVE_PREFLIGHT_FLAG @@ -44,7 +44,7 @@ function failStartup(error: unknown): void { process.exit(resolveOrcadExitCode(error)) } -// Why before the bundled handoff and preflights: launching or completing a stop must not start a runtime. +// One-shot launch and stop commands must skip server startup and preflights. if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) { runWindowsBreakawayLaunchIfRequested(ORCAD_WINDOWS_BREAKAWAY_CONTRACT, process.argv) } else if ( @@ -60,34 +60,33 @@ if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) { function startOrcadProcess(): void { try { - if (!handoffToBundledOrcad()) { - const flag = process.argv[2] - if (flag === ORCAD_NATIVE_PREFLIGHT_FLAG && process.argv.length === 3) { - void import('./node-pty-precondition').then(({ checkNodePtyPrecondition }) => { - const verdict = checkNodePtyPrecondition() - const report = formatOrcadNativePreflightReport(verdict.status, verdict.reason ?? null) - process.stdout.write(`${report}\n`, () => process.exit(0)) - }, failStartup) - } else if ( - (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) && - process.argv.length === 4 - ) { - void runOrcadProfilePreflight(process.argv[3], { - nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG + assertOrcadServerRuntime() + const flag = process.argv[2] + if (flag === ORCAD_NATIVE_PREFLIGHT_FLAG && process.argv.length === 3) { + void import('./node-pty-precondition').then(({ checkNodePtyPrecondition }) => { + const verdict = checkNodePtyPrecondition() + const report = formatOrcadNativePreflightReport(verdict.status, verdict.reason ?? null) + process.stdout.write(`${report}\n`, () => process.exit(0)) + }, failStartup) + } else if ( + (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) && + process.argv.length === 4 + ) { + void runOrcadProfilePreflight(process.argv[3], { + nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG + }) + // Why exit: the owner reads to EOF, so a lingering native handle must not hold the probe open. + .then(() => process.stdout.write('', () => process.exit(0))) + .catch(failStartup) + } else { + // Why: stdout is the serve readiness API; incidental diagnostics go to stderr. + reserveServeStdoutForReadiness() + void preflightBundledOrcadStartup() + .then(() => { + runOrcadNativePreflight() + return main() }) - // Why exit: the owner reads to EOF, so a lingering native handle must not hold the probe open. - .then(() => process.stdout.write('', () => process.exit(0))) - .catch(failStartup) - } else { - // Why: stdout is the serve readiness API; incidental diagnostics go to stderr. - reserveServeStdoutForReadiness() - void preflightBundledOrcadStartup() - .then(() => { - runOrcadNativePreflight() - return main() - }) - .catch(failStartup) - } + .catch(failStartup) } } catch (error) { failStartup(error) diff --git a/src/main/orcad/orcad-bundled-runtime.test.ts b/src/main/orcad/orcad-bundled-runtime.test.ts index 9fe9b96afb78..3cbfa8546c3e 100644 --- a/src/main/orcad/orcad-bundled-runtime.test.ts +++ b/src/main/orcad/orcad-bundled-runtime.test.ts @@ -1,7 +1,11 @@ import { EventEmitter } from 'node:events' import { join, resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { handoffToBundledOrcad, resolveBundledOrcadSlot } from './orcad-bundled-runtime' +import { + assertOrcadServerRuntime, + handoffToBundledOrcad, + resolveBundledOrcadSlot +} from './orcad-bundled-runtime' import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_COMPAT_ASSETS, @@ -238,3 +242,36 @@ describe('bundled Orca runtime handoff', () => { expect(process.kill).not.toHaveBeenCalled() }) }) + +describe('server runtime admission', () => { + it.each(['14.21.3', '16.20.2'])('refuses host Node %s before loading profile state', (node) => { + fixture.exists.mockReturnValue(false) + vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, node }) + expect(() => assertOrcadServerRuntime()).toThrow('requires Node.js 18') + }) + + it.each(['18.20.8', '20.19.0', '22.14.0', '23.0.0', '24.0.0', '26.0.0'])( + 'accepts unpackaged host Node %s', + (node) => { + fixture.exists.mockReturnValue(false) + vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, node }) + expect(() => assertOrcadServerRuntime()).not.toThrow() + } + ) + + it('refuses host Node 24 for a packaged slot that owns a bundled runtime', () => { + vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, node: '24.0.0' }) + expect(() => assertOrcadServerRuntime()).toThrow('Start the Orca server with its bundled') + }) + + it('accepts the packaged runtime only at its pinned version', () => { + fixture.realpath.mockImplementation((path) => + path === '/slot/orcad.js' ? path : '/real/runtime' + ) + const versions = vi.spyOn(process, 'versions', 'get') + versions.mockReturnValue({ ...process.versions, node: '24.0.0' }) + expect(() => assertOrcadServerRuntime()).toThrow(`must be Node ${NODE_RUNTIME_PIN.version}`) + versions.mockReturnValue({ ...process.versions, node: NODE_RUNTIME_PIN.version }) + expect(() => assertOrcadServerRuntime()).not.toThrow() + }) +}) diff --git a/src/main/orcad/orcad-bundled-runtime.ts b/src/main/orcad/orcad-bundled-runtime.ts index 497e8a1120f9..927018ac37b3 100644 --- a/src/main/orcad/orcad-bundled-runtime.ts +++ b/src/main/orcad/orcad-bundled-runtime.ts @@ -64,6 +64,23 @@ export function isRunningAsBundledOrcadRuntime(directory: string): boolean { return runtime !== null && realpathSync(process.execPath) === realpathSync(runtime) } +/** Refuse an old or substituted runtime before the server opens any profile state. */ +export function assertOrcadServerRuntime(): void { + if (Number(process.versions.node.split('.')[0]) < 18) { + throw new OrcadBundledRuntimeError('The Orca server requires Node.js 18 or newer') + } + const directory = resolveBundledOrcadSlot() + const runtime = resolveBundledOrcadRuntime(directory) + if (runtime && !isRunningAsBundledOrcadRuntime(directory)) { + throw new OrcadBundledRuntimeError('Start the Orca server with its bundled Node.js runtime') + } + if (runtime && process.versions.node !== NODE_RUNTIME_PIN.version) { + throw new OrcadBundledRuntimeError( + `The bundled Orca runtime must be Node ${NODE_RUNTIME_PIN.version}` + ) + } +} + /** Keep old Node service commands usable without letting a host Node open the profile. */ export function handoffToBundledOrcad(): boolean { const script = process.argv[1] diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts index c5ab26d28c3e..d8cf2f705a28 100644 --- a/src/main/orcad/orcad-health.ts +++ b/src/main/orcad/orcad-health.ts @@ -8,8 +8,7 @@ * to cross the process boundary: orcad drives it, the daemon performs it, and the verdict * travels back over the daemon's socket. */ -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' +import { hashOrcadLauncher } from '../../shared/orcad-build-identity' import process from 'node:process' import { checkDaemonHealthWithCoverage, type DaemonHealth } from '../daemon/daemon-health' import { ptySpawnHealthPlatformCoverage } from '../daemon/daemon-health-identity' @@ -58,7 +57,7 @@ export type TerminalDaemonHealth = { } export type OrcadHealth = { - /** Content hash of the running orcad bundle — the deployed build's identity. */ + /** Launcher hash understood by older clients; split launchers embed the server digest. */ buildHash: string buildVersion: string nodeVersion: string @@ -83,7 +82,7 @@ export type OrcadHealth = { } /** - * Identity of the exact bytes running. + * Launcher identity shared with clients that predate split server bundles. * * Why hash the entry and not read a version string: `ORCA_VERSION` is whatever the deploy * exported, so two different builds can carry one version. A rollback that did not actually @@ -94,7 +93,7 @@ export function computeOrcadBuildHash(entryPath = process.argv[1]): string { return 'unknown' } try { - return createHash('sha256').update(readFileSync(entryPath)).digest('hex').slice(0, 16) + return hashOrcadLauncher(entryPath) } catch { return 'unknown' } diff --git a/src/main/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts index f8f7b2c85fa6..f1523476b47e 100644 --- a/src/main/orcad/orcad-launch-contract.test.ts +++ b/src/main/orcad/orcad-launch-contract.test.ts @@ -2,6 +2,9 @@ * The two things a supervisor reads off a launch: what the arguments mean, and what an exit * code means. Both are part of the ops contract in docs/reference/orcad-operations.md. */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { ORCAD_EXIT_CONFIGURATION, @@ -10,6 +13,10 @@ import { resolveOrcadExitCode } from './orcad-entry' import { startOrcadWithLifecycle } from './orcad-lifecycle' +import { + beginAgentSessionRuntimeRecord, + readAgentSessionRuntimeEnds +} from '../runtime/agent-session-runtime-end-record' import { OrcadBindAddressError } from './orcad-bind-address' import { OrcadInstanceLockError } from './orcad-instance-lock' import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access' @@ -124,6 +131,30 @@ describe('orcad lifecycle cleanup', () => { expect(cleanupHost).toHaveBeenCalledOnce() }) + it("records the runtime's end before its stop waits on anything", async () => { + const directory = await mkdtemp(join(tmpdir(), 'orcad-runtime-end-')) + try { + beginAgentSessionRuntimeRecord(directory, 'orcad-runtime', 1) + let endSeenByCleanup: string | undefined + const handle = await startOrcadWithLifecycle( + async (registerCleanup) => { + registerCleanup(async () => { + endSeenByCleanup = readAgentSessionRuntimeEnds(directory)?.get('orcad-runtime') + }) + return {} + }, + vi.fn(async () => {}) + ) + + await handle.stop() + + // A chat whose agent dies with this stop reads as a restart, not a crash. + expect(endSeenByCleanup).toBe('quit') + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + it('keeps the host aware of failed runtime teardown so it cannot release profile admission', async () => { const failure = new Error('profile writer still running') const cleanupHost = vi.fn(async () => {}) diff --git a/src/main/orcad/orcad-launcher-isolation.integration.test.ts b/src/main/orcad/orcad-launcher-isolation.integration.test.ts new file mode 100644 index 000000000000..f52fe00b36a8 --- /dev/null +++ b/src/main/orcad/orcad-launcher-isolation.integration.test.ts @@ -0,0 +1,178 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { ORCAD_LAUNCHER_FILENAME, ORCAD_SERVER_ENTRY_FILENAME } from '../../shared/orcad-artifacts' +import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' +import { hashOrcadLauncher } from '../../shared/orcad-build-identity' +import { + locatePinnedNodeForTests, + skipForMissingInputs, + writeNodeSlotFixture +} from './orcad-node-slot-fixture' + +const pinnedNode = locatePinnedNodeForTests() +const launcherNode = + process.env.ORCA_TEST_LAUNCHER_NODE_EXECUTABLE ?? + process.env.ORCA_TEST_NODE_EXECUTABLE ?? + process.execPath +let root = '' +let launcher = '' +let inputs: string[] = [] +const SERVER_FIXTURE = [ + 'using resource = { [Symbol.dispose]() {} };', + 'console.log(JSON.stringify({ node: process.versions.node, args: process.argv.slice(2) }));' +].join('\n') + +async function buildLauncher(entry: string): Promise { + const builder = pathToFileURL(join(process.cwd(), 'config/scripts/orcad-entry-build.mjs')).href + const result = await runProcess({ + program: process.execPath, + args: [ + '--input-type=module', + '-e', + `import {buildOrcadLauncher} from ${JSON.stringify(builder)};` + + `const result=await buildOrcadLauncher(${JSON.stringify(entry)});` + + 'console.log(JSON.stringify(Object.keys(result.metafile.inputs)))' + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + expect(result.code, result.stderr).toBe(0) + return JSON.parse(result.stdout) +} + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-launcher-isolation-')) + launcher = join(root, ORCAD_LAUNCHER_FILENAME) + await writeFile(join(root, ORCAD_SERVER_ENTRY_FILENAME), SERVER_FIXTURE) + inputs = await buildLauncher(launcher) +}) + +it('changes the launcher identity when the server is rebuilt', async () => { + const directory = join(root, 'rebuilt') + await mkdir(directory) + const entry = join(directory, ORCAD_LAUNCHER_FILENAME) + const server = join(directory, ORCAD_SERVER_ENTRY_FILENAME) + await writeFile(server, SERVER_FIXTURE) + await buildLauncher(entry) + const first = hashOrcadLauncher(entry) + await writeFile(server, 'console.log("rebuilt server")') + await buildLauncher(entry) + expect(hashOrcadLauncher(entry)).not.toBe(first) +}) + +afterAll(async () => { + await rm(root, { recursive: true, force: true }) +}) + +it('keeps the server and profile-state graph outside the compatibility launcher', async () => { + expect(inputs.filter((path) => path.startsWith('src/main/'))).toEqual([ + 'src/main/orcad/orcad-app-paths.ts', + 'src/main/orcad/orcad-bundled-runtime.ts', + 'src/main/orcad/launcher.ts' + ]) + expect(inputs.some((path) => path.startsWith('node_modules/'))).toBe(false) + expect((await readFile(launcher)).length).toBeLessThan(32 * 1024) +}) + +it('runs an unpackaged server on the supported host runtime and preserves arguments', async () => { + const directory = join(root, 'unpackaged') + await mkdir(directory) + const entry = join(directory, ORCAD_LAUNCHER_FILENAME) + await writeFile( + join(directory, ORCAD_SERVER_ENTRY_FILENAME), + 'console.log(JSON.stringify({ node: process.versions.node, args: process.argv.slice(2) }))' + ) + await buildLauncher(entry) + const version = await runProcess({ program: launcherNode, args: ['-p', 'process.versions.node'] }) + const args = ['a path with spaces', '--port', '0'] + const result = await runProcess({ + program: launcherNode, + args: [entry, ...args], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ node: version.stdout.trim(), args }) +}) + +it('preserves the crash exit code when the server fails while loading', async () => { + const directory = join(root, 'server-crash') + await mkdir(directory) + const entry = join(directory, ORCAD_LAUNCHER_FILENAME) + await writeFile( + join(directory, ORCAD_SERVER_ENTRY_FILENAME), + 'throw new Error("server evaluation failed")' + ) + await buildLauncher(entry) + const result = await runProcess({ + program: process.execPath, + args: [entry], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + expect(result.code).toBe(1) + expect(result.stderr).toContain('server evaluation failed') + expect(result.stderr).not.toContain('orcad: failed to launch:') +}) + +const skip = skipForMissingInputs('artifact', pinnedNode ? [] : ['the pinned Node runtime']) + +describe.skipIf(skip)('split launcher with the real bundled runtime', () => { + it('hands off before parsing Node 24 server syntax and preserves arguments', async () => { + if (!pinnedNode) { + throw new Error('Missing pinned runtime') + } + const { slotDir } = await writeNodeSlotFixture(join(root, 'handoff'), pinnedNode) + await writeFile(join(slotDir, ORCAD_LAUNCHER_FILENAME), await readFile(launcher)) + const server = join(slotDir, ORCAD_SERVER_ENTRY_FILENAME) + await writeFile(server, SERVER_FIXTURE) + const args = ['a path with spaces', 'line one\nline two', '--port', '0'] + const result = await runProcess({ + program: launcherNode, + args: [join(slotDir, ORCAD_LAUNCHER_FILENAME), ...args], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + expect(result.code, result.stderr.slice(0, 2000)).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ node: NODE_RUNTIME_PIN.version, args }) + }) + + it('refuses changed server bytes before loading them', async () => { + if (!pinnedNode) { + throw new Error('Missing pinned runtime') + } + const { slotDir, runtime } = await writeNodeSlotFixture(join(root, 'tampered'), pinnedNode) + await writeFile(join(slotDir, ORCAD_LAUNCHER_FILENAME), await readFile(launcher)) + await writeFile( + join(slotDir, ORCAD_SERVER_ENTRY_FILENAME), + 'throw new Error("server was loaded")' + ) + const result = await runProcess({ + program: runtime, + args: [join(slotDir, ORCAD_LAUNCHER_FILENAME)], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + expect(result.code).toBe(78) + expect(result.stderr).toContain('server does not match its launcher') + expect(result.stderr).not.toContain('server was loaded') + }) + + it('refuses an old host runtime without a bundle before loading the server', async ({ skip }) => { + const version = await runProcess({ + program: launcherNode, + args: ['-p', 'process.versions.node'] + }) + if (Number(version.stdout.trim().split('.')[0]) >= 18) { + skip() + } + await writeFile(join(root, ORCAD_SERVER_ENTRY_FILENAME), 'throw new Error("server was loaded")') + const result = await runProcess({ + program: launcherNode, + args: [launcher], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + expect(result.code).toBe(78) + expect(result.stderr).toContain('requires Node.js 18') + expect(result.stderr).not.toContain('server was loaded') + }) +}) diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index 3bd3ad96889f..8af240e4f918 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -5,6 +5,7 @@ import type { OrcadManagedStopInstance } from '../../shared/orcad-stop-request' import { acquireOrcadInstanceLock } from './orcad-instance-lock' import { ORCAD_BUNDLED_LAUNCHER_ENV } from './orcad-bundled-runtime' import { resolveOrcadExitCode } from './orcad-exit-code' +import { recordAgentSessionRuntimeEnd } from '../runtime/agent-session-runtime-end-record' import { ORCAD_SHUTDOWN_DEADLINE_MS } from './orcad-stop-deadlines' import { acquireProfileStateRuntimeAdmission, @@ -78,6 +79,8 @@ export async function startOrcadWithLifecycle( // Runtime resources stop in reverse registration order before any host resource. const runtime = new OrcadRuntimeLifetime() const cleanup = createIdempotentOrcadCleanup(async () => { + // First, before any wait: chats whose agent dies with this stop read as a restart, not a crash. + recordAgentSessionRuntimeEnd('quit') let runtimeCleanupSucceeded = false try { await runtime.stop() diff --git a/src/main/orcad/orcad-local-serve-selection.test.ts b/src/main/orcad/orcad-local-serve-selection.test.ts index 708c74540500..9da11cfccf48 100644 --- a/src/main/orcad/orcad-local-serve-selection.test.ts +++ b/src/main/orcad/orcad-local-serve-selection.test.ts @@ -33,6 +33,7 @@ function slotFixture(): string { writeFileSync(join(slot, ORCAD_NODE_RUNTIME_MARKER_FILENAME), `${SHA}\n`) writeFileSync(join(slot, ORCAD_VERSION_FILENAME), '0.1.0+abc\n') writeFileSync(join(slot, 'orcad.js'), '') + writeFileSync(join(slot, 'orcad-server.js'), '') return slot } @@ -77,7 +78,7 @@ describe('orca serve runtime selection', () => { expect(selection).toEqual({ kind: 'orcad', runtime, - entry: join(slot, 'orcad.js'), + entry: join(slot, 'orcad-server.js'), version: '0.1.0+abc' }) expect(existsSync(runtime)).toBe(true) diff --git a/src/main/orcad/orcad-local-serve-selection.ts b/src/main/orcad/orcad-local-serve-selection.ts index e9fa6230984f..2d8f51769fe6 100644 --- a/src/main/orcad/orcad-local-serve-selection.ts +++ b/src/main/orcad/orcad-local-serve-selection.ts @@ -13,7 +13,11 @@ import { chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync import { dirname, join } from 'node:path' import { runProcess } from '../../shared/child-process/run-process' import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin' -import { ORCAD_VERSION_FILENAME, orcadNodeRuntimeRelativePath } from '../../shared/orcad-artifacts' +import { + ORCAD_SERVER_ENTRY_FILENAME, + ORCAD_VERSION_FILENAME, + orcadNodeRuntimeRelativePath +} from '../../shared/orcad-artifacts' import { ORCAD_NATIVE_PREFLIGHT_FLAG, parseOrcadNativePreflightReport @@ -87,7 +91,7 @@ export async function selectServeRuntime( if (!runtime) { return electron('the orcad slot names no pinned runtime') } - const entry = join(slotDir, 'orcad.js') + const entry = join(slotDir, ORCAD_SERVER_ENTRY_FILENAME) const report = parseOrcadNativePreflightReport( await (input.nativePreflight ?? runNativePreflight)(runtime, entry).catch(() => '') ) diff --git a/src/main/orcad/orcad-managed-stop.test.ts b/src/main/orcad/orcad-managed-stop.test.ts index 0683a3e48b7a..5334bec5f94e 100644 --- a/src/main/orcad/orcad-managed-stop.test.ts +++ b/src/main/orcad/orcad-managed-stop.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'no import { tmpdir } from 'node:os' import { basename, dirname, join, sep } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as WindowsAcl from '../../shared/secure-path-windows-acl' import { OrcadManagedStopCompletionSchema, type OrcadManagedStopRequest @@ -28,8 +29,38 @@ import { ORCAD_STOP_COMPLETION_POLL_MS } from './orcad-stop-deadlines' +const hardening = vi.hoisted(() => ({ pending: new Set>() })) + +vi.mock('../../shared/secure-path-windows-acl', async (importOriginal) => { + const acl = await importOriginal() + return { + ...acl, + bestEffortRestrictWindowsPath: ( + targetPath: string, + isDirectory: boolean, + onSettled?: (restricted: boolean) => void + ): void => { + let finish!: () => void + const pending = new Promise((resolve) => { + finish = resolve + }) + hardening.pending.add(pending) + acl.bestEffortRestrictWindowsPath(targetPath, isDirectory, (restricted) => { + try { + onSettled?.(restricted) + } finally { + hardening.pending.delete(pending) + finish() + } + }) + } + } +}) + const roots: string[] = [] -afterEach(() => { +afterEach(async () => { + // ACL updates must finish before their temporary directories disappear. + await Promise.all(hardening.pending) vi.restoreAllMocks() for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) diff --git a/src/main/orcad/orcad-profile-preflight.test.ts b/src/main/orcad/orcad-profile-preflight.test.ts index 809f7d839802..6d6d1187ed50 100644 --- a/src/main/orcad/orcad-profile-preflight.test.ts +++ b/src/main/orcad/orcad-profile-preflight.test.ts @@ -80,7 +80,7 @@ describe('bundled Orca startup readiness', () => { expect(fixture.run).toHaveBeenCalledOnce() expect(fixture.run).toHaveBeenCalledWith({ program: process.execPath, - args: [join('/slot', 'orcad.js'), ORCAD_STARTUP_PREFLIGHT_FLAG, expect.any(String)], + args: [join('/slot', 'orcad-server.js'), ORCAD_STARTUP_PREFLIGHT_FLAG, expect.any(String)], env: expect.objectContaining({ ORCA_BACKGROUND_LAUNCH: '1' }), timeoutMs: 90_000, maxOutputBytes: 64 * 1024, diff --git a/src/main/orcad/orcad-profile-preflight.ts b/src/main/orcad/orcad-profile-preflight.ts index c299e0ca0229..cd66fda5a00e 100644 --- a/src/main/orcad/orcad-profile-preflight.ts +++ b/src/main/orcad/orcad-profile-preflight.ts @@ -11,7 +11,7 @@ import { type OrcadProfilePreflightResponse } from '../../shared/orcad-profile-preflight' import { readOrcadArtifactIdentity } from './orcad-artifact-identity' -import { ORCAD_VERSION_FILENAME } from '../../shared/orcad-artifacts' +import { ORCAD_SERVER_ENTRY_FILENAME, ORCAD_VERSION_FILENAME } from '../../shared/orcad-artifacts' import { ORCAD_NODE_RUNTIME_IDENTITY } from '../../shared/orcad-node-runtime-identity' import { runProcess } from '../../shared/child-process/run-process' import { preflightOrcadNativeRuntime } from './orcad-runtime-native-preflight' @@ -32,7 +32,7 @@ export async function preflightBundledOrcadStartup(): Promise { // Keep disposable SQLite ownership and native state out of the serving process. const result = await runProcess({ program: process.execPath, - args: [join(directory, 'orcad.js'), ORCAD_STARTUP_PREFLIGHT_FLAG, nonce], + args: [join(directory, ORCAD_SERVER_ENTRY_FILENAME), ORCAD_STARTUP_PREFLIGHT_FLAG, nonce], env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS, maxOutputBytes: 64 * 1024, diff --git a/src/main/persistence-duplicate-repo-id-host-scope.test.ts b/src/main/persistence-duplicate-repo-id-host-scope.test.ts index 48cb826f909d..efc5abc14766 100644 --- a/src/main/persistence-duplicate-repo-id-host-scope.test.ts +++ b/src/main/persistence-duplicate-repo-id-host-scope.test.ts @@ -1,4 +1,9 @@ -import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' +import { + closeTestStores, + createSqliteTestStore, + createStore as createFreshStore, + testState +} from './persistence-test-harness' /** * The same repo id may be registered on two execution hosts (see `removeProjectForHost`). * Every deletion that resolves a *row* must therefore delete only that row: `removeProject` @@ -14,8 +19,9 @@ import type { Repo } from '../shared/repo-types' import { getDefaultPersistedState } from '../shared/constants' import { toRuntimeExecutionHostId } from '../shared/execution-host' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -52,6 +58,10 @@ async function createStoreFromState(state: Record) { JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...state }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() const { Store, initDataPath } = await import('./persistence') // Why here: userData resolves through AppEnvironment, and this must point at this @@ -96,6 +106,8 @@ function staleLocalSetupState() { } beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-dup-repo-id-')) }) diff --git a/src/main/persistence-ssh-readoption-automation-migration.test.ts b/src/main/persistence-ssh-readoption-automation-migration.test.ts index 886b5475983c..e7820b41b80c 100644 --- a/src/main/persistence-ssh-readoption-automation-migration.test.ts +++ b/src/main/persistence-ssh-readoption-automation-migration.test.ts @@ -1,4 +1,9 @@ -import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' +import { + closeTestStores, + createSqliteTestStore, + createStore as createFreshStore, + testState +} from './persistence-test-harness' /** * SSH re-adoption has to repair the automation with the workspace. * @@ -22,8 +27,9 @@ import { toSshExecutionHostId } from '../shared/execution-host' import { hostStableKey } from '../shared/automation-owner-key' import { folderWorkspaceKey } from '../shared/workspace-scope' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -142,6 +148,10 @@ async function createStoreFromState(state: Record) { JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...state }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') @@ -170,6 +180,8 @@ function readdDevBox(ssh: SshConnectionStore) { } beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-readopt-')) }) diff --git a/src/main/persistence-workspace-attachments.test.ts b/src/main/persistence-workspace-attachments.test.ts new file mode 100644 index 000000000000..99976cf9023b --- /dev/null +++ b/src/main/persistence-workspace-attachments.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + closeTestStores, + createStore, + makeRepo, + testState, + writeDataFile +} from './persistence-test-harness' +import { getWorkspaceAttachments } from '../shared/workspace-attachments' +import type { WorkspaceAttachment } from '../shared/worktree/types' +import { mergeWorktree } from './ipc/worktree-metadata-merge' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => false + } +})) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) +const pr = (number: number): WorkspaceAttachment => ({ provider: 'github', type: 'pr', number }) + +describe('workspace attachment persistence', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-attachments-')) + }) + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('round-trips collections and active selection through host-qualified metadata and git projections', async () => { + writeDataFile({ repos: [makeRepo({ id: 'repo', path: '/work', connectionId: 'test' })] }) + const store = await createStore() + store.setWorktreeMetaForHost('repo::/work', 'ssh:test', { linkedPR: 7 }) + store.setWorktreeMetaForHost('repo::/work', 'ssh:test', { linkedPR: 8 }) + store.flush() + const restored = await createStore() + const meta = restored.getWorktreeMetaForHost('repo::/work', 'ssh:test') + expect(meta?.linkedItems).toEqual([pr(7), pr(8)]) + expect(meta?.linkedPR).toBe(8) + const projected = mergeWorktree( + 'repo', + { path: '/work', head: 'abc', branch: 'feature', isBare: false, isMainWorktree: false }, + meta + ) + expect(projected.linkedItems).toEqual([pr(7), pr(8)]) + expect( + restored.setWorktreeMetaForHost('repo::/work', 'ssh:test', { linkedItems: [pr(7)] }).linkedPR + ).toBe(7) + restored.flush() + expect( + (await createStore()).getWorktreeMetaForHost('repo::/work', 'ssh:test')?.linkedItems + ).toEqual([pr(7)]) + }) + + it('loads scalar-only metadata and repairs malformed duplicate collection entries', async () => { + writeDataFile({ + worktreeMeta: { + legacy: { linkedPR: 7 }, + repaired: { linkedPR: 7, linkedItems: [pr(7), null, {}, pr(7), pr(8)] } + } + }) + const store = await createStore() + expect(getWorkspaceAttachments(store.getWorktreeMeta('legacy'))).toEqual([pr(7)]) + expect(store.getWorktreeMeta('repaired')?.linkedItems).toEqual([pr(7), pr(8)]) + store.setWorktreeMeta('legacy', { linkedPR: 8 }) + expect(store.getWorktreeMeta('legacy')?.linkedItems).toEqual([pr(7), pr(8)]) + }) + + it('retains multiple repo-less folder tasks and clears the active task when its collection is emptied', async () => { + const store = await createStore() + const group = store.createProjectGroup({ + name: 'Docs', + parentPath: '/docs', + createdFrom: 'folder-scan' + }) + const task = (number: number) => ({ + provider: 'github' as const, + type: 'issue' as const, + number, + title: `Issue ${number}`, + url: `https://github.com/a/docs/issues/${number}` + }) + const workspace = store.createFolderWorkspace({ projectGroupId: group.id, linkedTask: task(7) }) + store.updateFolderWorkspace(workspace.id, { linkedTask: task(8) }) + expect(workspace.linkedItems).toHaveLength(2) + store.flush() + const restored = await createStore() + expect(restored.getFolderWorkspace(workspace.id)?.linkedItems).toHaveLength(2) + expect(restored.updateFolderWorkspace(workspace.id, { linkedItems: [] })?.linkedTask).toBeNull() + restored.flush() + expect((await createStore()).getFolderWorkspace(workspace.id)?.linkedItems).toEqual([]) + }) + it('applies stale client add/remove deltas atomically for worktrees and folders', async () => { + const store = await createStore() + store.setWorktreeMeta('wt', { linkedItems: [pr(1)] }) + const firstClient = { linkedItemsBase: [pr(1)], linkedItems: [pr(1), pr(2)] } + const secondClient = { linkedItemsBase: [pr(1)], linkedItems: [pr(3)] } + store.setWorktreeMeta('wt', firstClient) + store.setWorktreeMeta('wt', secondClient) + expect(store.getWorktreeMeta('wt')?.linkedItems).toEqual([pr(2), pr(3)]) + expect(store.getWorktreeMeta('wt')).not.toHaveProperty('linkedItemsBase') + const group = store.createProjectGroup({ + name: 'Folder', + parentPath: '/docs', + createdFrom: 'folder-scan' + }) + const folder = store.createFolderWorkspace({ projectGroupId: group.id, linkedItems: [pr(1)] }) + store.updateFolderWorkspace(folder.id, firstClient) + store.updateFolderWorkspace(folder.id, secondClient) + expect(folder.linkedItems).toEqual([pr(2), pr(3)]) + expect(folder).not.toHaveProperty('linkedItemsBase') + store.flush() + const restored = await createStore() + expect(restored.getWorktreeMeta('wt')?.linkedItems).toEqual([pr(2), pr(3)]) + expect(restored.getFolderWorkspace(folder.id)?.linkedItems).toEqual([pr(2), pr(3)]) + }) + + it('keeps the current review target when a stale collection edit resolved another review', async () => { + const store = await createStore() + const currentTarget = { remoteName: 'origin', branchName: 'pr-2' } + store.setWorktreeMeta('wt', { linkedItems: [pr(2)], linkedPR: 2, pushTarget: currentTarget }) + store.setWorktreeMeta('wt', { + linkedItemsBase: [], + linkedItems: [pr(1)], + linkedPR: 1, + linkedItemsSelectionChanged: false, + pushTarget: { remoteName: 'origin', branchName: 'pr-1' } + }) + expect(store.getWorktreeMeta('wt')).toMatchObject({ + linkedPR: 2, + linkedItems: [pr(2), pr(1)], + pushTarget: currentTarget + }) + store.setWorktreeMeta('wt', { + linkedItemsBase: [pr(2), pr(1)], + linkedItems: [pr(2), pr(1)], + linkedPR: 1, + linkedItemsSelectionChanged: true, + pushTarget: { remoteName: 'origin', branchName: 'pr-1' } + }) + expect(store.getWorktreeMeta('wt')).toMatchObject({ + linkedPR: 1, + pushTarget: { remoteName: 'origin', branchName: 'pr-1' } + }) + }) +}) diff --git a/src/main/persistence-workspace-pinned-automation-fence.test.ts b/src/main/persistence-workspace-pinned-automation-fence.test.ts index 1eaf04739a9c..e0ea55afd4d0 100644 --- a/src/main/persistence-workspace-pinned-automation-fence.test.ts +++ b/src/main/persistence-workspace-pinned-automation-fence.test.ts @@ -1,6 +1,8 @@ import { closeTestStores, createSqliteTestStore, + createStore as createFreshStore, + testState, readPersistedStateJson, writePersistedStateJson } from './persistence-test-harness' @@ -27,8 +29,9 @@ import { AUTOMATION_OWNER_CONFLICT_CODES } from '../shared/automation-owner-conf import { getDefaultPersistedState } from '../shared/constants' import { folderWorkspaceKey } from '../shared/workspace-scope' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -118,6 +121,10 @@ async function createStoreFromState(state: Record) { JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...state }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') @@ -161,6 +168,8 @@ function createPinnedAutomation(store: { } beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-pinned-fence-')) }) diff --git a/src/main/persistence-workspace-repin-automation-owner.test.ts b/src/main/persistence-workspace-repin-automation-owner.test.ts index 4f17c14ec47b..5eb0bcef44bd 100644 --- a/src/main/persistence-workspace-repin-automation-owner.test.ts +++ b/src/main/persistence-workspace-repin-automation-owner.test.ts @@ -1,6 +1,8 @@ import { closeTestStores, createSqliteTestStore, + createStore as createFreshStore, + testState, readPersistedStateJson, writePersistedStateJson } from './persistence-test-harness' @@ -27,8 +29,9 @@ import { AUTOMATION_ORPHAN_ISSUES } from '../shared/automation-list-scope' import { getDefaultPersistedState } from '../shared/constants' import { folderWorkspaceKey } from '../shared/workspace-scope' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -142,6 +145,10 @@ async function loadStore(state: Record) { JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...state }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') @@ -161,6 +168,8 @@ function repinWorkspace(connectionId: string, sshTargets?: SshTarget[]): void { } beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-workspace-repin-')) }) diff --git a/src/main/persistence-worktree-lineage-and-backups.test.ts b/src/main/persistence-worktree-lineage-and-backups.test.ts index 0c38ca2f32f9..ef5ba9c2f898 100644 --- a/src/main/persistence-worktree-lineage-and-backups.test.ts +++ b/src/main/persistence-worktree-lineage-and-backups.test.ts @@ -277,67 +277,6 @@ describe('Store', () => { }) }) - describe('claudeLivePtySessionIds', () => { - it('persists added ids across reloads and removes them durably', async () => { - const store = await createStore() - - store.addClaudeLivePtySessionId('claude-session-1') - store.addClaudeLivePtySessionId('claude-session-2') - store.addClaudeLivePtySessionId('claude-session-1') - - expect(store.getClaudeLivePtySessionIds()).toEqual(['claude-session-1', 'claude-session-2']) - - const reloaded = await createStore() - expect(reloaded.getClaudeLivePtySessionIds()).toEqual([ - 'claude-session-1', - 'claude-session-2' - ]) - - reloaded.removeClaudeLivePtySessionId('claude-session-1') - reloaded.flush() - - const reloadedAgain = await createStore() - expect(reloadedAgain.getClaudeLivePtySessionIds()).toEqual(['claude-session-2']) - }) - - it('drops malformed persisted entries on load', async () => { - writeDataFile({ - schemaVersion: 1, - claudeLivePtySessionIds: ['valid-id', '', 42, null, 'valid-id', 'x'.repeat(513)] - }) - - const store = await createStore() - - expect(store.getClaudeLivePtySessionIds()).toEqual(['valid-id']) - }) - - it('keeps the newest ids when an oversized persisted list is loaded', async () => { - writeDataFile({ - schemaVersion: 1, - claudeLivePtySessionIds: Array.from({ length: 205 }, (_, index) => `claude-${index}`) - }) - - const store = await createStore() - - const ids = store.getClaudeLivePtySessionIds() - expect(ids).toHaveLength(200) - expect(ids[0]).toBe('claude-5') - expect(ids[199]).toBe('claude-204') - }) - - it('caps the persisted id list', async () => { - const store = await createStore() - for (let index = 0; index < 205; index += 1) { - store.addClaudeLivePtySessionId(`claude-session-${index}`) - } - - const ids = store.getClaudeLivePtySessionIds() - expect(ids).toHaveLength(200) - expect(ids[0]).toBe('claude-session-5') - expect(ids[199]).toBe('claude-session-204') - }) - }) - describe('retired JSON backups', () => { it.each(['sync', 'async'] as const)( 'leaves legacy JSON and backups unchanged during %s SQL writes', diff --git a/src/main/persistence-worktree-name-retirement.test.ts b/src/main/persistence-worktree-name-retirement.test.ts index ebe4db5feec8..3bb572e9a345 100644 --- a/src/main/persistence-worktree-name-retirement.test.ts +++ b/src/main/persistence-worktree-name-retirement.test.ts @@ -1,4 +1,9 @@ -import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' +import { + closeTestStores, + createSqliteTestStore, + createStore as createFreshStore, + testState +} from './persistence-test-harness' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -10,8 +15,9 @@ import type { SshTarget } from '../shared/ssh-types' import { MAX_RETIREMENT_NAMESPACES } from './worktree-retirement-namespace' import { getRuntimeOwnedSshTargetId } from './ssh/ssh-connection-store' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -61,10 +67,16 @@ async function createStore(persisted: Record = {}) { JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...persisted }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } return reloadStore() } beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-worktree-name-retirement-')) }) diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts index 2f0ccdc8165f..7f72f2117aba 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts @@ -2,7 +2,6 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' -import { MAX_CLAUDE_LIVE_PTY_SESSION_IDS } from '../restoring-sessions/pane-alias-normalization' import { MAX_REMOVED_SSH_TARGET_TOMBSTONES, capRemovedSshTargetTombstones, @@ -19,7 +18,6 @@ export type SshTargetStateOperations = { state: PersistedState protectedSecrets: Pick scheduleSave: () => void - flush: () => void } export function getSshTargets(state: PersistedState): SshTarget[] { @@ -92,41 +90,6 @@ export function removeSshTarget(operations: SshTargetStateOperations, id: string operations.scheduleSave() } -export function getClaudeLivePtySessionIds(state: PersistedState): string[] { - return [...(state.claudeLivePtySessionIds ?? [])] -} - -export function addClaudeLivePtySessionId( - operations: SshTargetStateOperations, - sessionId: string -): void { - if (sessionId.length === 0 || sessionId.length > 512) { - return - } - const ids = operations.state.claudeLivePtySessionIds ?? [] - if (ids.includes(sessionId)) { - return - } - // Why: drop oldest at the cap — stale ids get pruned against the daemon at startup, so only recency matters. - operations.state.claudeLivePtySessionIds = [...ids, sessionId].slice( - -MAX_CLAUDE_LIVE_PTY_SESSION_IDS - ) - // Why: flush sync so a force-quit right after a Claude spawn still seeds the live-PTY gate next launch. - operations.flush() -} - -export function removeClaudeLivePtySessionId( - operations: SshTargetStateOperations, - sessionId: string -): void { - const ids = operations.state.claudeLivePtySessionIds ?? [] - if (!ids.includes(sessionId)) { - return - } - operations.state.claudeLivePtySessionIds = ids.filter((id) => id !== sessionId) - operations.scheduleSave() -} - export function getDeletedSshConfigAliases(state: PersistedState): string[] { return [...(state.deletedSshConfigAliases ?? [])] } diff --git a/src/main/persistence/loading-store/durable-settings-write.test.ts b/src/main/persistence/loading-store/durable-settings-write.test.ts new file mode 100644 index 000000000000..3a415c8bd667 --- /dev/null +++ b/src/main/persistence/loading-store/durable-settings-write.test.ts @@ -0,0 +1,96 @@ +import { expect, it, vi } from 'vitest' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +it('waits for the SQLite write before returning or publishing the preference', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + const onChanged = vi.fn() + store.onSettingsChanged(onChanged) + let settled = false + const pending = store.updateSettingsAndFlush( + { alwaysForceDeleteWorktrees: true }, + { notifyListeners: true, originWebContentsId: 7 } + ) + void pending.then(() => { + settled = true + }) + await started.promise + expect(settled).toBe(false) + expect(onChanged).not.toHaveBeenCalled() + store.updateSettings({ theme: 'dark' }) + release.resolve() + expect((await pending).theme).toBe('dark') + expect(readState().settings.alwaysForceDeleteWorktrees).toBe(true) + expect(onChanged).toHaveBeenCalledWith( + { alwaysForceDeleteWorktrees: true }, + expect.objectContaining({ alwaysForceDeleteWorktrees: true }), + 7 + ) +}) + +it('rejects a failed disk write and rolls back without publishing the preference', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const previous = store.getSettings().alwaysForceDeleteWorktrees + vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('Disk full')) + const onChanged = vi.fn() + store.onSettingsChanged(onChanged) + await expect( + store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true }, { notifyListeners: true }) + ).rejects.toThrow('Disk full') + expect(store.getSettings().alwaysForceDeleteWorktrees).toBe(previous) + expect(readState().settings.alwaysForceDeleteWorktrees).toBe(previous) + expect(onChanged).not.toHaveBeenCalled() +}) + +it('rolls back the preference while retaining unrelated concurrent settings edits', async () => { + const { store, authority } = await createWorkerMaintenanceFixture() + const previous = store.getSettings().alwaysForceDeleteWorktrees + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => { + started.resolve() + await release.promise + throw new Error('Disk full') + }) + const pending = store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true }) + const rejected = expect(pending).rejects.toThrow('Disk full') + await started.promise + store.updateSettings({ theme: 'dark' }) + release.resolve() + await rejected + expect(store.getSettings()).toMatchObject({ + alwaysForceDeleteWorktrees: previous, + theme: 'dark' + }) +}) + +it('removes previously absent optional keys after a failed preference payload write', async () => { + const { store, authority } = await createWorkerMaintenanceFixture() + delete store.getSettings().editorWordWrap + const previous = store.getSettings().alwaysForceDeleteWorktrees + vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('Disk full')) + await expect( + store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true, editorWordWrap: false }) + ).rejects.toThrow('Disk full') + expect(Object.hasOwn(store.getSettings(), 'editorWordWrap')).toBe(false) + expect(store.getSettings().alwaysForceDeleteWorktrees).toBe(previous) +}) diff --git a/src/main/persistence/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts index 9800b541373a..6279b47f2b11 100644 --- a/src/main/persistence/loading-store/metadata-lineage-operations.ts +++ b/src/main/persistence/loading-store/metadata-lineage-operations.ts @@ -1,3 +1,4 @@ +import type { WorkspaceAttachmentMutation } from '../../../shared/workspace-attachment-mutation' import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' import type { WorktreeMeta } from '../../../shared/worktree/meta-types' @@ -77,7 +78,7 @@ export class MetadataLineageOperations { setWorktreeMetaForHost( worktreeId: string, executionHostId: ExecutionHostId, - meta: Partial + meta: Partial & WorkspaceAttachmentMutation ): WorktreeMeta { return setWorktreeMetaForHostOperation( this[metadataLineageOperationsContext].runtime, @@ -108,7 +109,10 @@ export class MetadataLineageOperations { ) } - setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { + setWorktreeMeta( + worktreeId: string, + meta: Partial & WorkspaceAttachmentMutation + ): WorktreeMeta { const state = this[metadataLineageOperationsContext].runtime.state const stored = state.worktreeMeta[worktreeId] const executionHostId = meta.hostId ?? stored?.hostId diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts index 1a3ba807c5ab..7045f922de88 100644 --- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -12,7 +12,6 @@ import { normalizeSshTarget } from '../leasing-ssh-ptys/ssh-normalization' import { - normalizeClaudeLivePtySessionIds, normalizeLegacyPaneKeyAliasEntries, normalizeMigrationUnsupportedPtyEntries } from '../restoring-sessions/pane-alias-normalization' @@ -102,7 +101,6 @@ export function normalizeLoadedProfileState( .map(normalizeSshRemotePtyLease) .filter((lease): lease is SshRemotePtyLease => lease !== null), sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, - claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( parsed.migrationUnsupportedPtyEntries ), diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts index 153155189611..e3760a5d5f68 100644 --- a/src/main/persistence/loading-store/profile-preferences.ts +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -25,6 +25,7 @@ type ProfilePreferencesRuntime = Pick< | 'githubCacheDirty' | 'githubCacheGeneration' | 'protectedSecrets' + | 'runDurableMutation' | 'settingsChangeListeners' | 'state' | 'uiChangeListeners' @@ -74,6 +75,52 @@ export class ProfilePreferences { return updateSettingsOperation(getSettingsMutationOperations(this), updates, options) } + async updateSettingsAndFlush( + updates: Partial, + options: { notifyListeners?: boolean; originWebContentsId?: number } = {} + ): Promise { + const { runtime } = this[profilePreferencesContext] + let changedUpdates: Partial = {} + await runtime.runDurableMutation(() => { + const previous = runtime.state.settings + const next = this.updateSettings(updates) + const previousEntries = new Map(Object.entries(previous)) + const updateKeys = new Set(Object.keys(updates)) + changedUpdates = Object.fromEntries( + Object.entries(next).filter( + ([key, value]) => updateKeys.has(key) && !Object.is(previousEntries.get(key), value) + ) + ) + return { + value: undefined, + rollback: () => { + const currentEntries = new Map(Object.entries(runtime.state.settings)) + const nextEntries = new Map(Object.entries(next)) + const restoredUpdates = Object.fromEntries( + Object.entries(previous).filter( + ([key]) => + updateKeys.has(key) && Object.is(currentEntries.get(key), nextEntries.get(key)) + ) + ) + const restoredSettings = { ...runtime.state.settings, ...restoredUpdates } + for (const key of updateKeys) { + if ( + !previousEntries.has(key) && + Object.is(currentEntries.get(key), nextEntries.get(key)) + ) { + Reflect.deleteProperty(restoredSettings, key) + } + } + runtime.state.settings = restoredSettings + } + } + }) + if (options.notifyListeners && Object.keys(changedUpdates).length > 0) { + notifySettingsChanged(this, changedUpdates, options.originWebContentsId) + } + return this.getSettings() + } + getUI(): PersistedState['ui'] { return getPersistedUI( this[profilePreferencesContext].runtime.state, diff --git a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts index 9cf3d1ec2d1c..9e2bed18bc01 100644 --- a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts +++ b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts @@ -93,16 +93,6 @@ describe('SQLite durability barriers with another selective write pending', () = }) }) - it('commits Claude live-PTY admission before returning', () => { - const state = fixture() - state.pendSession() - state.store.addClaudeLivePtySessionId('claude-session') - expect(state.read('claudeLivePtySessionIds')).toMatchObject({ - kind: 'value', - value: ['claude-session'] - }) - }) - it('commits SSH lease admission before returning', () => { const state = fixture() state.pendSession() diff --git a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts index 2ad1c882f23b..ce412cf4633d 100644 --- a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts +++ b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts @@ -232,7 +232,7 @@ describe('placement on the binding write is inert', () => { it(`${scenario.name}: memory and saved state match a write without placement`, async () => { const baseline = await bindAndSave(scenario, undefined) expect(baseline.result).toBe(true) - expect(placementAttributes).toEqual(['absent']) + expect(placementAttributes).toEqual([scenario.prebind ? 'leaf_present' : 'absent']) const cases = scenario.placements('') for (const [index, [, agreement]] of cases.entries()) { const placed = await bindAndSave(scenario, (leaf) => scenario.placements(leaf)[index][0]) diff --git a/src/main/persistence/loading-store/pty-binding-refusals.ts b/src/main/persistence/loading-store/pty-binding-refusals.ts index 6576ac3d0070..9de369b76829 100644 --- a/src/main/persistence/loading-store/pty-binding-refusals.ts +++ b/src/main/persistence/loading-store/pty-binding-refusals.ts @@ -25,7 +25,7 @@ export function ptyBindingIsRefused( bindingWorktreeId: string, paneKey: string, /** Every host partition: a close is recorded where the tab lived, which need not be where this - * binding lands (a relay reattach binds into `local`). */ + * binding lands (older relay reattaches left SSH panes in `local`). */ partitions: readonly TerminalSessionPartition[] ): boolean { if (args.expectedSourceBinding) { diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts index ce81cea373b8..ad12108129ef 100644 --- a/src/main/persistence/loading-store/ssh-profile-operations.ts +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -1,17 +1,14 @@ import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' import { type SshTargetStateOperations, - addClaudeLivePtySessionId as addClaudeLivePtySessionIdOperation, addDeletedSshConfigAlias as addDeletedSshConfigAliasOperation, addRemovedSshTargetTombstone as addRemovedSshTargetTombstoneOperation, addSshTarget as addSshTargetOperation, clearDeletedSshConfigAliases as clearDeletedSshConfigAliasesOperation, - getClaudeLivePtySessionIds as getClaudeLivePtySessionIdsOperation, getDeletedSshConfigAliases as getDeletedSshConfigAliasesOperation, getRemovedSshTargetTombstones as getRemovedSshTargetTombstonesOperation, getSshTarget as getSshTargetOperation, getSshTargets as getSshTargetsOperation, - removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation, removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation, removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation, releaseRemovedSshTargetTombstone as releaseRemovedSshTargetTombstoneOperation, @@ -26,22 +23,17 @@ import { allocateSshTargetGeneration as allocateSshTargetGenerationOperation } f import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' -import type { WriteFlushBarrierOperations } from './write-flush-barriers' import type { RepoLifecycleOperations } from './repo-lifecycle-operations' import { syncProjectHostSetupCompatibilityState } from './repo-lifecycle-operations' import { scheduleSave } from './write-scheduling' import { forgetSshConnectionGeneration } from '../../ssh/ssh-connection-generation' -type SshProfileOperationsRuntime = Pick< - StoreRuntimeState, - 'dirtyProfileStateDomains' | 'protectedSecrets' | 'state' -> +type SshProfileOperationsRuntime = Pick const sshProfileOperationsContext = Symbol('SshProfileOperations') type SshProfileOperationsContext = { runtime: SshProfileOperationsRuntime scheduling: WriteSchedulingOperations - flushBarriers: WriteFlushBarrierOperations repos: RepoLifecycleOperations } @@ -51,10 +43,9 @@ export class SshProfileOperations { constructor( runtime: SshProfileOperationsRuntime, scheduling: WriteSchedulingOperations, - flushBarriers: WriteFlushBarrierOperations, repos: RepoLifecycleOperations ) { - this[sshProfileOperationsContext] = { runtime, scheduling, flushBarriers, repos } + this[sshProfileOperationsContext] = { runtime, scheduling, repos } } getSshTargets(): SshTarget[] { @@ -88,18 +79,6 @@ export class SshProfileOperations { ) } - getClaudeLivePtySessionIds(): string[] { - return getClaudeLivePtySessionIdsOperation(this[sshProfileOperationsContext].runtime.state) - } - - addClaudeLivePtySessionId(sessionId: string): void { - addClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) - } - - removeClaudeLivePtySessionId(sessionId: string): void { - removeClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) - } - getDeletedSshConfigAliases(): string[] { return getDeletedSshConfigAliasesOperation(this[sshProfileOperationsContext].runtime.state) } @@ -148,13 +127,7 @@ export function getSshTargetStateOperations(owner: SshProfileOperations): SshTar return { state: owner[sshProfileOperationsContext].runtime.state, protectedSecrets: owner[sshProfileOperationsContext].runtime.protectedSecrets, - scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling), - flush: () => { - owner[sshProfileOperationsContext].runtime.dirtyProfileStateDomains?.add( - 'claudeLivePtySessionIds' - ) - owner[sshProfileOperationsContext].flushBarriers.flush() - } + scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling) } } diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index 4ba2e58881ac..cf3063ad37c8 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -183,7 +183,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { const mobileTabSelections = new MobileTabSelectionPersistence(runtime, scheduling) const sparsePresets = new SparsePresetPersistence(runtime, scheduling) const ptyBindings = new PtyBindingPersistenceOperations(runtime, sessions) - const sshProfiles = new SshProfileOperations(runtime, scheduling, flushBarriers, repos) + const sshProfiles = new SshProfileOperations(runtime, scheduling, repos) const retiredWorktreeNames = new RetiredWorktreeNamePersistence(runtime, scheduling) const sshLeases = new SshLeaseRecoveryOperations( runtime, diff --git a/src/main/persistence/loading-store/worktree-identity-metadata.ts b/src/main/persistence/loading-store/worktree-identity-metadata.ts index 9515986f86a9..d1f2dfd3deed 100644 --- a/src/main/persistence/loading-store/worktree-identity-metadata.ts +++ b/src/main/persistence/loading-store/worktree-identity-metadata.ts @@ -1,3 +1,4 @@ +import type { WorkspaceAttachmentMutation } from '../../../shared/workspace-attachment-mutation' import { randomUUID } from 'node:crypto' import type { WorktreeMeta } from '../../../shared/worktree/meta-types' import type { ExecutionHostId } from '../../../shared/execution-host' @@ -210,7 +211,7 @@ export function setWorktreeMetaForHost( scheduling: WriteSchedulingOperations, worktreeId: string, executionHostId: ExecutionHostId, - meta: Partial + meta: Partial & WorkspaceAttachmentMutation ): WorktreeMeta { const state = runtime.state migrateLegacyWorktreeMetadata(state, worktreeId, executionHostId) diff --git a/src/main/persistence/loading-store/worktree-meta-write-normalization.ts b/src/main/persistence/loading-store/worktree-meta-write-normalization.ts index d513122ec6a0..7b101ff73c56 100644 --- a/src/main/persistence/loading-store/worktree-meta-write-normalization.ts +++ b/src/main/persistence/loading-store/worktree-meta-write-normalization.ts @@ -7,6 +7,8 @@ import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses' import type { WorktreeMeta } from '../../../shared/worktree/meta-types' import { WORKTREE_META_PERSISTED_DEFAULTS } from '../../../shared/worktree/meta-persisted-defaults' import { normalizeGitHubPRSuppressionUpdate } from '../../../shared/worktree/github-pr-suppression' +import type { WorkspaceAttachmentMutation } from '../../../shared/workspace-attachment-mutation' +import { normalizeWorkspaceAttachmentUpdate } from '../../../shared/workspace-attachments' type WorktreeMetaIdentity = { instanceId: string @@ -31,12 +33,13 @@ function createDefaultWorktreeMeta(): WorktreeMeta { /** Merge and normalize the metadata shape shared by legacy and identity-keyed writes. */ export function mergeWorktreeMetaForWrite( existing: WorktreeMeta | undefined, - updates: Partial, + updates: Partial & WorkspaceAttachmentMutation, identity?: WorktreeMetaIdentity ): WorktreeMeta { + const normalizedUpdates = normalizeWorkspaceAttachmentUpdate(existing, updates) const updated = { ...(existing ?? createDefaultWorktreeMeta()), - ...normalizeGitHubPRSuppressionUpdate(updates), + ...normalizeGitHubPRSuppressionUpdate(normalizedUpdates), ...identity } updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) diff --git a/src/main/persistence/profile-state/profile-state-writer-close.test.ts b/src/main/persistence/profile-state/profile-state-writer-close.test.ts index a082a1cb4ade..cf8b6a442585 100644 --- a/src/main/persistence/profile-state/profile-state-writer-close.test.ts +++ b/src/main/persistence/profile-state/profile-state-writer-close.test.ts @@ -68,6 +68,7 @@ it.each([true, false])( await waitForPoll() vi.advanceTimersByTime(TIMEOUT_MS) await waitForPoll() + await waitForPoll() expect(worker.terminate).toHaveBeenCalledOnce() expect(settled).toBe(false) worker.emit('exit', 1) diff --git a/src/main/persistence/profile-state/profile-state-writer-deadline.test.ts b/src/main/persistence/profile-state/profile-state-writer-deadline.test.ts index 3a60c019717c..95bd251d6cbb 100644 --- a/src/main/persistence/profile-state/profile-state-writer-deadline.test.ts +++ b/src/main/persistence/profile-state/profile-state-writer-deadline.test.ts @@ -13,6 +13,11 @@ import { const TIMEOUT_MS = 30_000 +async function drainTimeoutCheck(): Promise { + await waitForPoll() + await waitForPoll() +} + afterEach(() => { vi.useRealTimers() publishSystemResume() @@ -48,6 +53,16 @@ function harness() { } describe('profile state writer deadline', () => { + it('lets a queued reply clear the deadline after the first poll', async () => { + const { deadline, onTimeout, unsubscribe, run } = harness() + run(TIMEOUT_MS) + setImmediate(() => deadline.clear()) + await drainTimeoutCheck() + expect(onTimeout).not.toHaveBeenCalled() + expect(unsubscribe).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + it('times out a request after allowing queued replies to drain', async () => { const { onTimeout, onGrace, unsubscribe, run } = harness() run(TIMEOUT_MS - 1) @@ -55,7 +70,7 @@ describe('profile state writer deadline', () => { run(1) expect(onGrace).not.toHaveBeenCalled() expect(onTimeout).not.toHaveBeenCalled() - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ overdueMs: 0, graces: 0, powerState: 'awake' }) ) @@ -74,7 +89,7 @@ describe('profile state writer deadline', () => { run(TIMEOUT_MS - 1) expect(onTimeout).not.toHaveBeenCalled() run(1) - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ overdueMs: 0, graces: 1 }) ) @@ -85,7 +100,7 @@ describe('profile state writer deadline', () => { stall(PROFILE_STATE_WRITER_OVERDUE_GRACE_MS - 1) run(TIMEOUT_MS) expect(onGrace).not.toHaveBeenCalled() - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).toHaveBeenCalledOnce() }) @@ -99,7 +114,7 @@ describe('profile state writer deadline', () => { stall(60 * 60_000) run(TIMEOUT_MS) expect(onGrace).toHaveBeenCalledTimes(PROFILE_STATE_WRITER_MAX_GRACES) - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ graces: PROFILE_STATE_WRITER_MAX_GRACES }) ) @@ -114,7 +129,7 @@ describe('profile state writer deadline', () => { run(TIMEOUT_MS - 1) expect(onTimeout).not.toHaveBeenCalled() run(1) - await waitForPoll() + await drainTimeoutCheck() expect(onGrace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ graces: 1 })) expect(onTimeout).toHaveBeenCalledOnce() }) @@ -129,7 +144,7 @@ describe('profile state writer deadline', () => { expect(onGrace).toHaveBeenCalledTimes(PROFILE_STATE_WRITER_MAX_GRACES) expect(onTimeout).not.toHaveBeenCalled() run(TIMEOUT_MS) - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ powerState: 'suspended' }) ) @@ -148,7 +163,7 @@ describe('profile state writer deadline', () => { power().onResume() } run(1) - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ graces: PROFILE_STATE_WRITER_MAX_GRACES }) ) @@ -170,7 +185,7 @@ describe('profile state writer deadline', () => { run(TIMEOUT_MS) // An exhausted resume must not cancel the final queued timeout check. power().onResume() - await waitForPoll() + await drainTimeoutCheck() expect(onGrace).toHaveBeenCalledTimes(PROFILE_STATE_WRITER_MAX_GRACES) expect(onTimeout).toHaveBeenCalledOnce() expect(vi.getTimerCount()).toBe(0) @@ -183,7 +198,7 @@ describe('profile state writer deadline', () => { const { deadline, onTimeout, unsubscribe, run } = harness() run(TIMEOUT_MS) deadline.clear() - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).not.toHaveBeenCalled() expect(unsubscribe).toHaveBeenCalledOnce() expect(vi.getTimerCount()).toBe(0) @@ -193,10 +208,10 @@ describe('profile state writer deadline', () => { const { onTimeout, run, power } = harness() run(TIMEOUT_MS) power().onResume() - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).not.toHaveBeenCalled() run(TIMEOUT_MS) - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).toHaveBeenCalledOnce() expect(vi.getTimerCount()).toBe(0) }) diff --git a/src/main/persistence/profile-state/profile-state-writer-deadline.ts b/src/main/persistence/profile-state/profile-state-writer-deadline.ts index fc17faea8b7d..9bc2db835004 100644 --- a/src/main/persistence/profile-state/profile-state-writer-deadline.ts +++ b/src/main/persistence/profile-state/profile-state-writer-deadline.ts @@ -97,13 +97,15 @@ export function createProfileStateWriterDeadline( ) { return } - // Even an on-time timer can run before a reply already queued by the worker. + // Windows can deliver a queued worker reply after the first poll following a stall. timeoutCheck = setImmediate(() => { - timeoutCheck = undefined - if (active) { - clear() - onTimeout(expiry) - } + timeoutCheck = setImmediate(() => { + timeoutCheck = undefined + if (active) { + clear() + onTimeout(expiry) + } + }) }) } // Subscription replays the current state synchronously; only later resumes re-arm. diff --git a/src/main/persistence/profile-state/profile-state-writer-electron-fixture.ts b/src/main/persistence/profile-state/profile-state-writer-electron-fixture.ts index e67832e8fc7e..12f946c27bb2 100644 --- a/src/main/persistence/profile-state/profile-state-writer-electron-fixture.ts +++ b/src/main/persistence/profile-state/profile-state-writer-electron-fixture.ts @@ -38,19 +38,29 @@ async function run(): Promise { const counters = new SharedArrayBuffer(8) const counts = new Int32Array(counters) const failures: string[] = [] + let phase = 'initialize' const initialization = { databasePath, profileId, revision: 0, counters } const client = new ProfileStateWriteWorkerClient(initialization, { workerPath: join(root, 'observed-worker.cjs'), timeoutMs, - onFailure: (error) => failures.push(error.message) + onFailure: (error) => { + failures.push(error.message) + console.error('[writer-fixture] failure', { + phase, + queuedReplies: Atomics.load(counts, 0), + workerStarts: Atomics.load(counts, 1) + }) + } }) const write = (marker: string) => client.writeSerializedDomains([{ domain: 'ui', payload: JSON.stringify({ marker }) }]) const revisions: number[] = [] try { await client.ready + phase = 'before' assert.equal(await write('before'), 1) for (let cycle = 0; cycle < 4; cycle += 1) { + phase = `stall-${cycle}` let pending: Promise | undefined await new Promise((resolve, reject) => { // Dispatch from check so the overdue timer runs before the next poll. @@ -76,6 +86,7 @@ async function run(): Promise { } revisions.push(await pending) } + phase = 'after' assert.equal(await write('after'), 6) } finally { await client.close() diff --git a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts index 22da252e13ba..0ec27d76aee9 100644 --- a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts +++ b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts @@ -9,6 +9,8 @@ import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-i import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { removeWorkspaceSessionOwnerEverywhere } from './session-owner-removal' +import type { WorkspaceAttachmentMutation } from '../../../shared/workspace-attachment-mutation' +import { normalizeWorkspaceAttachmentUpdate } from '../../../shared/workspace-attachments' export type FolderWorkspaceMutationOperations = { state: PersistedState @@ -56,6 +58,7 @@ export class FolderWorkspacePersistenceOperations { name?: string folderPath?: string | null linkedTask?: FolderWorkspace['linkedTask'] + linkedItems?: FolderWorkspace['linkedItems'] linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] connectionId?: string | null creatorProvenance?: FolderWorkspace['creatorProvenance'] @@ -76,6 +79,14 @@ export class FolderWorkspacePersistenceOperations { const now = Date.now() const linkedTask = normalizeWorkspaceLinkedItem(input.linkedTask) const sourceContext = normalizeStoredTaskSourceContext(input.linkedTaskSourceContext) + const attachmentUpdates = + input.linkedItems === undefined + ? undefined + : normalizeWorkspaceAttachmentUpdate(undefined, { + linkedItems: input.linkedItems, + linkedWorkItem: linkedTask, + linkedTaskSourceContext: sourceContext + }) const workspace: FolderWorkspace = { id: randomUUID(), projectGroupId: group.id, @@ -83,10 +94,13 @@ export class FolderWorkspacePersistenceOperations { folderPath, connectionId: input.connectionId ?? group.connectionId ?? null, ...(input.creatorProvenance ? { creatorProvenance: input.creatorProvenance } : {}), - linkedTask, - linkedTaskSourceContext: isWorkspaceLinkedItemSourceContextMatch(linkedTask, sourceContext) - ? sourceContext - : null, + linkedTask: attachmentUpdates ? (attachmentUpdates.linkedWorkItem ?? null) : linkedTask, + ...(attachmentUpdates ? { linkedItems: attachmentUpdates.linkedItems } : {}), + linkedTaskSourceContext: attachmentUpdates + ? attachmentUpdates.linkedTaskSourceContext + : isWorkspaceLinkedItemSourceContextMatch(linkedTask, sourceContext) + ? sourceContext + : null, comment: '', isArchived: false, isUnread: false, @@ -113,6 +127,7 @@ export class FolderWorkspacePersistenceOperations { | 'name' | 'folderPath' | 'linkedTask' + | 'linkedItems' | 'linkedTaskSourceContext' | 'comment' | 'isArchived' @@ -127,12 +142,35 @@ export class FolderWorkspacePersistenceOperations { | 'lastActivityAt' | 'diffComments' > - > + > & + WorkspaceAttachmentMutation ): FolderWorkspace | null { const workspace = this.getFolderWorkspace(id) if (!workspace) { return null } + const linkedUpdates = normalizeWorkspaceAttachmentUpdate( + { + linkedItems: workspace.linkedItems, + linkedWorkItem: workspace.linkedTask, + linkedTaskSourceContext: workspace.linkedTaskSourceContext + }, + { + linkedItemsBase: updates.linkedItemsBase, + linkedItems: updates.linkedItems, + linkedWorkItem: updates.linkedTask, + linkedTaskSourceContext: updates.linkedTaskSourceContext + } + ) + if (linkedUpdates.linkedItems !== undefined) { + workspace.linkedItems = linkedUpdates.linkedItems + } + if (linkedUpdates.linkedWorkItem !== undefined) { + updates = { ...updates, linkedTask: linkedUpdates.linkedWorkItem } + } + if (linkedUpdates.linkedTaskSourceContext !== undefined) { + updates = { ...updates, linkedTaskSourceContext: linkedUpdates.linkedTaskSourceContext } + } if (updates.name !== undefined) { workspace.name = normalizeFolderWorkspaceName(updates.name, workspace.name) } diff --git a/src/main/persistence/restoring-sessions/pane-alias-normalization.ts b/src/main/persistence/restoring-sessions/pane-alias-normalization.ts index c7e88c480217..6b9dca94c923 100644 --- a/src/main/persistence/restoring-sessions/pane-alias-normalization.ts +++ b/src/main/persistence/restoring-sessions/pane-alias-normalization.ts @@ -40,33 +40,9 @@ export function legacyMigrationUnsupportedRowsToAliasEntries( return aliasEntries } -// Why: bounds a corrupt/bloated persisted list — the gate only needs the few Claude sessions a daemon can keep alive. -export const MAX_CLAUDE_LIVE_PTY_SESSION_IDS = 200 - // Why: bound removed-SSH-target history so remove/re-add churn can't grow the file unbounded. export const MAX_REMOVED_SSH_TARGET_TOMBSTONES = 50 -export function normalizeClaudeLivePtySessionIds(value: unknown): string[] { - if (!Array.isArray(value)) { - return [] - } - // Why: scan newest-first so the cap keeps the most recent ids, matching addClaudeLivePtySessionId's eviction policy. - const ids: string[] = [] - for (let index = value.length - 1; index >= 0; index -= 1) { - const entry = value[index] - if (typeof entry !== 'string' || entry.length === 0 || entry.length > 512) { - continue - } - if (!ids.includes(entry)) { - ids.push(entry) - } - if (ids.length >= MAX_CLAUDE_LIVE_PTY_SESSION_IDS) { - break - } - } - return ids.toReversed() -} - export function normalizeMigrationUnsupportedPtyEntries( value: unknown ): MigrationUnsupportedPtyEntry[] { diff --git a/src/main/persistence/restoring-sessions/session-owner-removal.ts b/src/main/persistence/restoring-sessions/session-owner-removal.ts index 5fac53546c4c..1cb7a44e004d 100644 --- a/src/main/persistence/restoring-sessions/session-owner-removal.ts +++ b/src/main/persistence/restoring-sessions/session-owner-removal.ts @@ -131,3 +131,24 @@ export function removeWorkspaceSessionOwners( ) return next } + +export function removeWorkspaceSessionOwnersEverywhere( + state: Pick, + ownerKeys: ReadonlySet +): void { + if (ownerKeys.size === 0) { + return + } + state.workspaceSession = removeWorkspaceSessionOwners(state.workspaceSession, ownerKeys)! + const partitions = state.workspaceSessionsByHostId + if (!partitions) { + return + } + const next: Record = {} + for (const [hostId, partition] of Object.entries(partitions)) { + if (partition) { + next[hostId] = removeWorkspaceSessionOwners(partition, ownerKeys)! + } + } + state.workspaceSessionsByHostId = next +} diff --git a/src/main/persistence/terminal-topology/terminal-owner-invariants.ts b/src/main/persistence/terminal-topology/terminal-owner-invariants.ts index a5311486a06f..fad25738ea31 100644 --- a/src/main/persistence/terminal-topology/terminal-owner-invariants.ts +++ b/src/main/persistence/terminal-topology/terminal-owner-invariants.ts @@ -24,7 +24,7 @@ export type TerminalLeafOwner = { export type TerminalOwnerConflictReason = | 'pty_bound_to_other_leaf' | 'leaf_in_other_tab' - // Kept apart: the relay reattach writes SSH panes into `local`, so this may be one moved surface. + // Kept apart: older relay reattaches left SSH panes in `local`, so this may be one moved surface. | 'leaf_in_other_tab_on_other_host' /** `runtime:` partitions belong to a remote Orca server and are written only by its tab sync. */ @@ -83,7 +83,7 @@ export function isSameTerminal( /** * The saved leaf a binding into `hostId` would duplicate, if any. The same tab:leaf in two - * partitions is one surface: the relay reattach still binds an SSH pane into `local`. + * partitions is one surface: older relay reattaches left SSH panes in `local`. */ export function findTerminalBindingConflict( binding: { tabId: string; leafId: string; ptyId: string; incarnationId?: string }, diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts index 0a104592d94f..3376859499e2 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts @@ -2,7 +2,10 @@ import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placem import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' -/** Low-cardinality: it lands on the `persistence.pty-binding` span. */ +/** + * Low-cardinality: it lands on the `persistence.pty-binding` span. `absent` counts new leaves with + * no placement, which only the mint and graft can place; it should read zero before they go. + */ export type TerminalPanePlacementAgreement = | 'absent' | 'leaf_present' @@ -24,14 +27,14 @@ export function terminalPanePlacementAgreement( tabId: string, leafId: string ): TerminalPanePlacementAgreement { - if (!placement) { - return 'absent' - } const tabExists = session.tabsByWorktree?.[worktreeId]?.some((tab) => tab.id === tabId) === true const root = session.terminalLayoutsByTabId?.[tabId]?.root ?? null if (tabExists && layoutContainsLeafId(root, leafId)) { return 'leaf_present' } + if (!placement) { + return 'absent' + } switch (placement.kind) { case 'new-tab': return tabExists ? 'tab_exists' : 'agrees' diff --git a/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts b/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts new file mode 100644 index 000000000000..297a99a3eb0b --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts @@ -0,0 +1,167 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { buildAgentResumeStartupPlan } from '../../../shared/tui-agent-resume-startup' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection' +import type { Store } from '../loading-store/store' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID as WORKTREE, + openTopologyStore, + reopenTopologyStore +} from './terminal-topology-profile-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +// The mirror refactor moves sleeping records into main's own commits and makes window saves +// presentation-only; a slept agent must still be resumable after quit and relaunch. + +const TAB = 'tab-agent' +const LEAF = '11111111-1111-4111-8111-111111111111' +const PTY = `${WORKTREE}@@0a1b2c3d` +const PANE_KEY = `${TAB}:${LEAF}` + +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function record(origin: 'worktree-sleep' | 'quit', capturedAt: number): SleepingAgentSessionRecord { + return { + paneKey: PANE_KEY, + tabId: TAB, + worktreeId: WORKTREE, + agent: 'codex', + providerSession: { key: 'session_id', id: `session-${capturedAt}` }, + prompt: 'finish the task', + state: 'waiting', + capturedAt, + updatedAt: capturedAt, + origin + } +} + +/** The window's session after it spawned an agent pane, as its debounced save sends it. */ +function windowSessionWithAgentPane(store: Store): WorkspaceSessionState { + const session = structuredClone(store.getWorkspaceSession()) + session.tabsByWorktree = { + ...session.tabsByWorktree, + [WORKTREE]: [ + { + id: TAB, + ptyId: PTY, + worktreeId: WORKTREE, + title: 'codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'codex' + } + ] + } + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [TAB]: { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: PTY } + } + } + return session +} + +async function storeWithAgentPane() { + const directory = mkdtempSync(join(tmpdir(), 'orca-sleep-quit-resume-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + const window = windowSessionWithAgentPane(store) + store.setWorkspaceSession(structuredClone(window)) + await expect( + store.persistPtyBinding({ worktreeId: WORKTREE, tabId: TAB, leafId: LEAF, ptyId: PTY }) + ).resolves.toBe(true) + return { directory, store, window } +} + +/** What main holds for the slept pane, and what the window would show from its slice. */ +function heldForPane(store: Store) { + const session = store.getWorkspaceSession() + const slice = projectTerminalTopologySlice(session, LOCAL_EXECUTION_HOST_ID, WORKTREE) + return { + record: session.sleepingAgentSessionsByPaneKey?.[PANE_KEY], + sliceRecord: slice.sleeping[PANE_KEY], + tabs: slice.tabs.map((tab) => tab.id), + binding: slice.layouts[TAB]?.ptyIdsByLeafId?.[LEAF] + } +} + +function resumeCommand(held: SleepingAgentSessionRecord | undefined): string | undefined { + return held + ? buildAgentResumeStartupPlan({ + agent: held.agent, + providerSession: held.providerSession, + cmdOverrides: {}, + platform: 'linux' + })?.launchCommand + : undefined +} + +describe('sleep → quit → resume', () => { + it('keeps the quit capture over the periodic one through quit and relaunch', async () => { + const { directory, store, window } = await storeWithAgentPane() + // The agent is slept; the periodic save carries the first capture. + window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('worktree-sleep', 1) } + store.setWorkspaceSession(structuredClone(window)) + // Quit: the window's quit capture rides only on the synchronous before-unload stage. + window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('quit', 2) } + store.stageWorkspaceSessionBeforeUnload(structuredClone(window)) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const expected = { + record: record('quit', 2), + sliceRecord: record('quit', 2), + tabs: [TAB], + binding: PTY + } + expect(heldForPane(relaunched)).toEqual(expected) + // The relaunched window's first save is its hydrated copy; the record stays. + relaunched.setWorkspaceSession(structuredClone(relaunched.getWorkspaceSession())) + expect(heldForPane(relaunched)).toEqual(expected) + expect(resumeCommand(heldForPane(relaunched).record)).toBe("codex 'resume' 'session-2'") + } finally { + await relaunched.freezeWritesAsync() + } + }) + + it('keeps a record that only the before-unload stage carried', async () => { + const { directory, store, window } = await storeWithAgentPane() + window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('quit', 3) } + store.stageWorkspaceSessionBeforeUnload(structuredClone(window)) + + const relaunched = await reopenTopologyStore(store, directory) + try { + expect(heldForPane(relaunched)).toEqual({ + record: record('quit', 3), + sliceRecord: record('quit', 3), + tabs: [TAB], + binding: PTY + }) + expect(resumeCommand(heldForPane(relaunched).record)).toBe("codex 'resume' 'session-3'") + } finally { + await relaunched.freezeWritesAsync() + } + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts new file mode 100644 index 000000000000..04a48908298f --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts @@ -0,0 +1,529 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mulberry32 } from '../../../shared/agent-tui-ansi-fuzz-stream' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { TerminalPaneLayoutNode, TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection' +import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target' +import { collectTerminalLeafOwners, isSameTerminal } from './terminal-owner-invariants' +import { closeLeafOrTab } from './terminal-topology-commit' +import { + emptyTerminalSessionProfile, + FIXTURE_FOLDER_WORKTREE_ID, + FIXTURE_GIT_WORKTREE_ID, + openTopologyStore, + reopenTopologyStore +} from './terminal-topology-profile-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +/** + * Model test for terminal layout as main holds it. Seeded gesture sequences run against main's + * real Store, written the way today's desktop window writes them; a plain reference model says + * what main must hold after each one. Making the window a mirror of main changes who writes, not + * this outcome, so every seed must stay green through that refactor. + * Replay one seed: FUZZ_SEED= FUZZ_ITERATIONS=1 pnpm test . + */ + +const WORKTREES = [ + FIXTURE_GIT_WORKTREE_ID, + FIXTURE_FOLDER_WORKTREE_ID, + FLOATING_TERMINAL_WORKTREE_ID +] +const FIRST_SEED = Number(process.env.FUZZ_SEED ?? 0x9417) +const ITERATIONS = Number(process.env.FUZZ_ITERATIONS ?? 6) +const OPS_PER_SEED = 40 + +type ModelPane = { ptyId: string | undefined; sleeping: boolean } +/** What main must hold: tab → leaf → binding, per worktree, in tab order. */ +type Model = Map }> + +const directories: string[] = [] + +function makeIds(random: () => number) { + const hex = (length: number): string => + Array.from({ length }, () => Math.floor(random() * 16).toString(16)).join('') + return { + leaf: () => `${hex(8)}-${hex(4)}-4${hex(3)}-8${hex(3)}-${hex(12)}`, + tab: () => `tab-${hex(8)}`, + pty: (worktreeId: string) => `${worktreeId}@@${hex(8)}` + } +} + +function leafIds(node: TerminalPaneLayoutNode | null | undefined): string[] { + if (!node) { + return [] + } + return node.type === 'leaf' ? [node.leafId] : [...leafIds(node.first), ...leafIds(node.second)] +} + +function withoutLeaf(node: TerminalPaneLayoutNode, leafId: string): TerminalPaneLayoutNode | null { + if (node.type === 'leaf') { + return node.leafId === leafId ? null : node + } + const first = withoutLeaf(node.first, leafId) + const second = withoutLeaf(node.second, leafId) + if (!first || !second) { + return first ?? second + } + return { ...node, first, second } +} + +function sleepingRecord( + worktreeId: string, + tabId: string, + leafId: string +): SleepingAgentSessionRecord { + return { + paneKey: `${tabId}:${leafId}`, + tabId, + worktreeId, + agent: 'codex', + providerSession: { key: 'session_id', id: `session-${leafId}` }, + prompt: 'model', + state: 'waiting', + capturedAt: 1, + updatedAt: 1, + origin: 'quit' + } +} + +/** The desktop window's copy of the session: it authors tabs and layouts and saves them whole. */ +class WindowSession { + constructor(public session: WorkspaceSessionState) {} + + addTab(worktreeId: string, tabId: string, leafId: string): void { + const tabs = this.session.tabsByWorktree[worktreeId] ?? [] + const tab: TerminalTab = { + id: tabId, + ptyId: null, + worktreeId, + title: `Terminal ${tabs.length + 1}`, + customTitle: null, + color: null, + sortOrder: tabs.length, + createdAt: 1 + } + this.session.tabsByWorktree = { ...this.session.tabsByWorktree, [worktreeId]: [...tabs, tab] } + this.setLayout(tabId, { type: 'leaf', leafId }, {}) + } + + setLayout(tabId: string, root: TerminalPaneLayoutNode, ptyIdsByLeafId: Record) { + this.session.terminalLayoutsByTabId = { + ...this.session.terminalLayoutsByTabId, + [tabId]: { + root, + activeLeafId: leafIds(root)[0] ?? null, + expandedLeafId: null, + ptyIdsByLeafId + } + } + } + + bind(tabId: string, leafId: string, ptyId: string): void { + const layout = this.session.terminalLayoutsByTabId[tabId] + if (layout?.root) { + this.setLayout(tabId, layout.root, { ...layout.ptyIdsByLeafId, [leafId]: ptyId }) + } + this.setTabPtyId(tabId, ptyId) + } + + /** `clearTabPtyId` on exit: the tab's live id goes; the layout keeps the binding as a resume hint. */ + setTabPtyId(tabId: string, ptyId: string | null): void { + this.session.tabsByWorktree = Object.fromEntries( + Object.entries(this.session.tabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab) => (tab.id === tabId ? { ...tab, ptyId } : tab)) + ]) + ) + } + + removeTab(tabId: string): void { + this.session.tabsByWorktree = Object.fromEntries( + Object.entries(this.session.tabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.filter((tab) => tab.id !== tabId) + ]) + ) + const { [tabId]: _removed, ...layouts } = this.session.terminalLayoutsByTabId + void _removed + this.session.terminalLayoutsByTabId = layouts + this.setSleeping( + Object.fromEntries( + Object.entries(this.session.sleepingAgentSessionsByPaneKey ?? {}).filter( + ([paneKey]) => !paneKey.startsWith(`${tabId}:`) + ) + ) + ) + } + + setSleeping(records: Record): void { + this.session.sleepingAgentSessionsByPaneKey = records + } + + snapshot(): WorkspaceSessionState { + return structuredClone(this.session) + } +} + +/** Fails on a terminal bound to two leaves or a leaf in two tabs, naming both owners. */ +function expectOwnerInvariants(session: WorkspaceSessionState, context: string): void { + const owners = collectTerminalLeafOwners({ hostId: LOCAL_EXECUTION_HOST_ID, session }) + const describe = (owner: (typeof owners)[number]): string => + `${owner.worktreeId} ${owner.tab.id}:${owner.leafId} → ${owner.ptyId ?? 'unbound'}` + const breaches: string[] = [] + for (const [index, left] of owners.entries()) { + for (const right of owners.slice(index + 1)) { + if (left.leafId === right.leafId && left.tab.id !== right.tab.id) { + breaches.push(`leaf in two tabs: ${describe(left)} | ${describe(right)}`) + } else if (left.leafId !== right.leafId && isSameTerminal(left, right)) { + breaches.push(`terminal on two leaves: ${describe(left)} | ${describe(right)}`) + } + } + } + expect(breaches, context).toEqual([]) +} + +/** Main's projected slices, reduced to what the model states. */ +function projectedTopology(session: WorkspaceSessionState) { + return Object.fromEntries( + WORKTREES.map((worktreeId) => { + const slice = projectTerminalTopologySlice(session, LOCAL_EXECUTION_HOST_ID, worktreeId) + const tabs = slice.tabs.map((tab) => { + const layout = slice.layouts[tab.id] + return { + tabId: tab.id, + leaves: Object.fromEntries( + leafIds(layout?.root).map((leafId) => [leafId, layout?.ptyIdsByLeafId?.[leafId]]) + ) + } + }) + return [worktreeId, { tabs, sleeping: Object.keys(slice.sleeping).sort() }] + }) + ) +} + +function expectedTopology(model: Model) { + return Object.fromEntries( + WORKTREES.map((worktreeId) => { + const entries = [...model.entries()].filter(([, tab]) => tab.worktreeId === worktreeId) + const tabs = entries.map(([tabId, tab]) => ({ + tabId, + leaves: Object.fromEntries([...tab.leaves].map(([leafId, pane]) => [leafId, pane.ptyId])) + })) + const sleeping = entries + .flatMap(([tabId, tab]) => + [...tab.leaves] + .filter(([, pane]) => pane.sleeping) + .map(([leafId]) => `${tabId}:${leafId}`) + ) + .sort() + return [worktreeId, { tabs, sleeping }] + }) + ) +} + +type Op = + | 'new_tab' + | 'split' + | 'close_pane' + | 'close_tab' + | 'move_pane' + | 'pty_exit' + | 'respawn' + | 'sleep' + | 'wake' + | 'restart' + | 'quit_restart' + +const OPS: readonly Op[] = [ + 'new_tab', + 'new_tab', + 'split', + 'split', + 'close_pane', + 'close_tab', + 'move_pane', + 'pty_exit', + 'respawn', + 'sleep', + 'wake', + 'restart', + 'quit_restart' +] + +async function runSeed(seed: number): Promise { + const random = mulberry32(seed) + const pick = (items: readonly T[]): T | undefined => + items.length === 0 ? undefined : items[Math.floor(random() * items.length)] + const ids = makeIds(random) + const directory = mkdtempSync(join(tmpdir(), 'orca-topology-model-')) + directories.push(directory) + let store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + const window = new WindowSession(structuredClone(store.getWorkspaceSession())) + const model: Model = new Map() + const log: string[] = [] + const save = (): void => store.setWorkspaceSession(window.snapshot()) + const panes = () => + [...model].flatMap(([tabId, tab]) => + [...tab.leaves].map(([leafId, pane]) => ({ tabId, leafId, tab, pane })) + ) + const splitTabs = () => [...model].filter(([, tab]) => tab.leaves.size > 1) + + const bind = async (worktreeId: string, tabId: string, leafId: string, ptyId: string) => { + await expect(store.persistPtyBinding({ worktreeId, tabId, leafId, ptyId })).resolves.toBe(true) + window.bind(tabId, leafId, ptyId) + model.get(tabId)!.leaves.set(leafId, { ptyId, sleeping: false }) + } + + /** `closeTerminalSurfaceFromRenderer`: the window removed the surface; main commits the close. */ + const commitClose = async (worktreeId: string, target: TerminalSurfaceCloseTarget) => { + const refusal = await store.runDurableMutation( + closeLeafOrTab({ + worktreeId, + target, + options: { allowMissing: true, force: true, closedByLayoutOwner: true, reason: 'user' }, + requestedSession: store.getWorkspaceSession(), + ownerMatches: () => true, + hostId: () => LOCAL_EXECUTION_HOST_ID, + getSession: (hostId) => store.getWorkspaceSession(hostId), + setSession: (session, hostId) => store.setWorkspaceSession(session, hostId), + onClosed: () => {} + }) + ) + expect(refusal).toBeUndefined() + } + + const reopen = async (stageQuit: boolean): Promise => { + if (stageQuit) { + store.stageWorkspaceSessionBeforeUnload(window.snapshot()) + } + store = await reopenTopologyStore(store, directory) + window.session = structuredClone(store.getWorkspaceSession()) + } + + for (let step = 0; step < OPS_PER_SEED; step++) { + const op = pick(OPS)! + switch (op) { + case 'new_tab': { + const worktreeId = pick(WORKTREES)! + const tabId = ids.tab() + const leafId = ids.leaf() + const ptyId = ids.pty(worktreeId) + // pty:spawn can beat the window's debounced layout save. + const spawnFirst = random() < 0.5 + log.push( + `${op} ${worktreeId} ${tabId}:${leafId} ${ptyId}${spawnFirst ? ' spawn-first' : ''}` + ) + model.set(tabId, { worktreeId, leaves: new Map() }) + window.addTab(worktreeId, tabId, leafId) + if (!spawnFirst) { + save() + } + await bind(worktreeId, tabId, leafId, ptyId) + save() + break + } + case 'split': { + const target = pick(panes()) + if (!target) { + continue + } + const leafId = ids.leaf() + const ptyId = ids.pty(target.tab.worktreeId) + const spawnFirst = random() < 0.5 + const direction = random() < 0.5 ? 'vertical' : 'horizontal' + log.push( + `${op} ${target.tabId}:${target.leafId} → ${leafId} ${ptyId}${spawnFirst ? ' spawn-first' : ''}` + ) + const layout = window.session.terminalLayoutsByTabId[target.tabId]! + window.setLayout( + target.tabId, + { + type: 'split', + direction, + first: layout.root!, + second: { type: 'leaf', leafId } + }, + layout.ptyIdsByLeafId ?? {} + ) + if (!spawnFirst) { + save() + } + await bind(target.tab.worktreeId, target.tabId, leafId, ptyId) + save() + break + } + case 'close_pane': { + const [tabId, tab] = pick(splitTabs()) ?? [] + const leafId = tab && pick([...tab.leaves.keys()]) + if (!tabId || !tab || !leafId) { + continue + } + log.push(`${op} ${tabId}:${leafId}`) + const layout = window.session.terminalLayoutsByTabId[tabId]! + const { [leafId]: _closed, ...bindings } = layout.ptyIdsByLeafId ?? {} + void _closed + window.setLayout(tabId, withoutLeaf(layout.root!, leafId)!, bindings) + const { [`${tabId}:${leafId}`]: _record, ...sleeping } = + window.session.sleepingAgentSessionsByPaneKey ?? {} + void _record + window.setSleeping(sleeping) + tab.leaves.delete(leafId) + await commitClose(tab.worktreeId, { kind: 'pane', tabId, leafId }) + save() + break + } + case 'close_tab': { + const tabId = pick([...model.keys()]) + if (!tabId) { + continue + } + log.push(`${op} ${tabId}`) + const { worktreeId } = model.get(tabId)! + window.removeTab(tabId) + model.delete(tabId) + await commitClose(worktreeId, { kind: 'tab', tabId }) + save() + break + } + case 'move_pane': { + const [sourceTabId, source] = pick(splitTabs()) ?? [] + const leafId = source && pick([...source.leaves.keys()]) + if (!sourceTabId || !source || !leafId) { + continue + } + const targetTabId = ids.tab() + const pane = source.leaves.get(leafId)! + log.push(`${op} ${sourceTabId}:${leafId} → ${targetTabId}`) + await expect( + store.moveTerminalLeafToNewTab({ + worktreeId: source.worktreeId, + sourceTabId, + targetTabId, + leafId, + ptyId: pane.ptyId ?? null + }) + ).resolves.toEqual({ status: 'moved', ptyId: pane.ptyId ?? null }) + // The window then shows what main moved, as the drag-out does today. + const layout = window.session.terminalLayoutsByTabId[sourceTabId]! + const { [leafId]: movedPty, ...bindings } = layout.ptyIdsByLeafId ?? {} + window.setLayout(sourceTabId, withoutLeaf(layout.root!, leafId)!, bindings) + window.addTab(source.worktreeId, targetTabId, leafId) + if (movedPty) { + window.bind(targetTabId, leafId, movedPty) + } + const records = { ...window.session.sleepingAgentSessionsByPaneKey } + const record = records[`${sourceTabId}:${leafId}`] + if (record) { + delete records[`${sourceTabId}:${leafId}`] + records[`${targetTabId}:${leafId}`] = { + ...record, + paneKey: `${targetTabId}:${leafId}`, + tabId: targetTabId + } + window.setSleeping(records) + } + source.leaves.delete(leafId) + model.set(targetTabId, { + worktreeId: source.worktreeId, + leaves: new Map([[leafId, pane]]) + }) + save() + break + } + case 'pty_exit': { + const target = pick(panes().filter(({ pane }) => pane.ptyId)) + if (!target) { + continue + } + log.push(`${op} ${target.tabId}:${target.leafId}`) + // Main keeps an exited pane's binding; only the window's live id goes. + window.setTabPtyId(target.tabId, null) + save() + break + } + case 'respawn': { + const target = pick(panes()) + if (!target) { + continue + } + const ptyId = ids.pty(target.tab.worktreeId) + log.push(`${op} ${target.tabId}:${target.leafId} ${ptyId}`) + const sleeping = target.pane.sleeping + await bind(target.tab.worktreeId, target.tabId, target.leafId, ptyId) + target.tab.leaves.get(target.leafId)!.sleeping = sleeping + save() + break + } + case 'sleep': + case 'wake': { + const target = pick(panes().filter(({ pane }) => pane.sleeping === (op === 'wake'))) + if (!target) { + continue + } + log.push(`${op} ${target.tabId}:${target.leafId}`) + const paneKey = `${target.tabId}:${target.leafId}` + const records = { ...window.session.sleepingAgentSessionsByPaneKey } + if (op === 'sleep') { + records[paneKey] = sleepingRecord(target.tab.worktreeId, target.tabId, target.leafId) + } else { + delete records[paneKey] + } + window.setSleeping(records) + target.pane.sleeping = op === 'sleep' + save() + break + } + case 'restart': + case 'quit_restart': { + log.push(op) + await reopen(op === 'quit_restart') + break + } + } + const context = `seed ${seed}, step ${step}:\n${log.join('\n')}` + const session = store.getWorkspaceSession() + expectOwnerInvariants(session, context) + expect(projectedTopology(session), context).toEqual(expectedTopology(model)) + } + + // The saved profile, read back cold, holds the same layout. + await reopen(false) + const context = `seed ${seed}, after reopen:\n${log.join('\n')}` + expectOwnerInvariants(store.getWorkspaceSession(), context) + expect(projectedTopology(store.getWorkspaceSession()), context).toEqual(expectedTopology(model)) + await store.freezeWritesAsync() + return log +} + +describe('terminal layout model (main session)', () => { + afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('keeps one tab per pane, one pane per terminal, and the expected layout across random gestures', async () => { + const ranOps = new Set() + for (let iteration = 0; iteration < ITERATIONS; iteration++) { + const log = await runSeed(FIRST_SEED + iteration) + for (const entry of log) { + ranOps.add(entry.split(' ')[0]!) + } + } + // Precondition: the seeds reached every gesture, so a green run is not a quiet one. + if (ITERATIONS >= 6) { + expect([...ranOps].sort()).toEqual([...new Set(OPS)].sort()) + } + }, 60_000) +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-profile-fixture.ts b/src/main/persistence/terminal-topology/terminal-topology-profile-fixture.ts new file mode 100644 index 000000000000..d6bb5fdda26f --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-profile-fixture.ts @@ -0,0 +1,46 @@ +import { join } from 'node:path' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { DelayedAuthority } from '../loading-store/profile-state-delayed-authority-fixture' +import { Store } from '../loading-store/store' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' + +/** A worktree of the fixture's registered repo, so load keeps its rows. */ +export const FIXTURE_GIT_WORKTREE_ID = 'repo-local::/fixture/local' +export const FIXTURE_FOLDER_WORKTREE_ID = 'repo-local::/fixture/local::workspace:folder-1' + +/** The cutover fixture profile with no terminal session yet. */ +export function emptyTerminalSessionProfile(): string { + const state = buildProfileStateCutoverFixture() + return JSON.stringify({ + ...state, + // A fixed install id: load mints a random one when it is missing. + settings: { + ...state.settings, + telemetry: { existedBeforeTelemetryRelease: true, optedIn: null, installId: 'install-1' } + }, + workspaceSession: getDefaultWorkspaceSession(), + workspaceSessionsByHostId: {} + }) +} + +/** Opens a real Store on SQLite in `directory`, seeding it first when `seed` is given. */ +export async function openTopologyStore(directory: string, seed?: string): Promise { + const inner = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology') + if (seed) { + inner.writeSerializedState(Buffer.from(seed)) + } + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: new DelayedAuthority(inner) + }) + await store.flushPendingOrThrowAsync() + return store +} + +/** Quit and relaunch: drain pending writes, close, and load the profile cold. */ +export async function reopenTopologyStore(store: Store, directory: string): Promise { + await store.flushPendingOrThrowAsync() + await store.freezeWritesAsync() + return openTopologyStore(directory) +} diff --git a/src/main/persistence/tracking-repos/project-group-operations.ts b/src/main/persistence/tracking-repos/project-group-operations.ts index 47a8166e9675..d17eaf47ca97 100644 --- a/src/main/persistence/tracking-repos/project-group-operations.ts +++ b/src/main/persistence/tracking-repos/project-group-operations.ts @@ -6,7 +6,7 @@ import { normalizeProjectGroupName } from '../../../shared/project-groups' import { folderWorkspaceKey } from '../../../shared/workspace-scope' -import { removeWorkspaceSessionOwnerEverywhere } from '../restoring-sessions/session-owner-removal' +import { removeWorkspaceSessionOwnersEverywhere } from '../restoring-sessions/session-owner-removal' export type ProjectGroupMutationOperations = { state: PersistedState @@ -105,11 +105,11 @@ export class ProjectGroupPersistenceOperations { for (const workspace of this.state.folderWorkspaces ?? []) { if (deletedGroupIds.has(workspace.projectGroupId)) { removedFolderWorkspaceKeys.add(folderWorkspaceKey(workspace.id)) - // Every partition, not just the local blob: the same reason `removeFolderWorkspace` does. - removeWorkspaceSessionOwnerEverywhere(this.state, folderWorkspaceKey(workspace.id)) this.removeWorkspaceLineageForFolderParent(workspace.id) } } + // Every partition, not just the local blob: the same reason `removeFolderWorkspace` does. + removeWorkspaceSessionOwnersEverywhere(this.state, removedFolderWorkspaceKeys) this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter( (workspace) => !deletedGroupIds.has(workspace.projectGroupId) ) diff --git a/src/main/persistence/tracking-repos/project-group-session-batch.test.ts b/src/main/persistence/tracking-repos/project-group-session-batch.test.ts new file mode 100644 index 000000000000..04fbee3c9dcd --- /dev/null +++ b/src/main/persistence/tracking-repos/project-group-session-batch.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import { createProjectGroup } from '../../../shared/project-groups' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields' +import { ProjectGroupPersistenceOperations } from './project-group-operations' + +function workspace(id: string, projectGroupId: string): FolderWorkspace { + return { + id, + projectGroupId, + name: id, + folderPath: '/workspace', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } +} + +function session(owners: string[]): WorkspaceSessionState { + const result = getDefaultWorkspaceSession() + result.activeWorkspaceKey = folderWorkspaceKey(owners[0]) + result.activeWorktreeId = folderWorkspaceKey(owners[0]) + result.activeWorktreeIdsOnShutdown = owners.map(folderWorkspaceKey) + result.lastVisitedAtByWorktreeId = {} + result.defaultTerminalTabsAppliedByWorktreeId = {} + result.terminalPtyIncarnationsByPaneKey = {} + result.terminalTopologyRevisionByRepoId = { unrelated: 7 } + for (const id of owners) { + const owner = folderWorkspaceKey(id) + result.tabsByWorktree[owner] = [ + { + ...createMinimalPersistedTerminalTab({ + worktreeId: owner, + tabId: id, + ptyId: `pty-${id}`, + existingTabCount: 0 + }), + createdAt: 0 + } + ] + result.terminalLayoutsByTabId[id] = { + root: { type: 'leaf', leafId: `leaf-${id}` }, + activeLeafId: `leaf-${id}`, + expandedLeafId: null, + buffersByLeafId: { [`leaf-${id}`]: 'scrollback'.repeat(1024) } + } + result.lastVisitedAtByWorktreeId[`ssh:builder|${owner}`] = 1 + result.defaultTerminalTabsAppliedByWorktreeId[owner] = true + result.terminalPtyIncarnationsByPaneKey[`${id}:leaf-${id}`] = `incarnation-${id}` + } + return result +} + +function fixture(folderCount: number) { + const state = getDefaultPersistedState('/home/test') + state.projectGroups = [ + { ...createProjectGroup({ name: 'Root', createdFrom: 'manual', tabOrder: 0 }), id: 'root' }, + { + ...createProjectGroup({ name: 'Child', createdFrom: 'manual', tabOrder: 1 }), + id: 'child', + parentGroupId: 'root' + }, + { ...createProjectGroup({ name: 'Other', createdFrom: 'manual', tabOrder: 2 }), id: 'other' } + ] + const removedIds = Array.from({ length: folderCount }, (_, index) => `removed-${index}`) + state.folderWorkspaces = [ + ...removedIds.map((id, index) => workspace(id, index % 2 ? 'child' : 'root')), + workspace('kept', 'other') + ] + state.workspaceSession = session([...removedIds, 'kept']) + state.workspaceSessionsByHostId = { + 'ssh:builder': session([...removedIds, 'kept']), + 'runtime:remote': session([...removedIds, 'kept']) + } + const scheduleSave = vi.fn() + const removeWorkspaceLineageForFolderParent = vi.fn() + const selected = new Set([...removedIds, 'kept'].map(folderWorkspaceKey)) + const pruneMobileClientTabSelections = vi.fn((matches: (owner: string) => boolean) => { + for (const owner of selected) { + if (matches(owner)) { + selected.delete(owner) + } + } + }) + const operations = new ProjectGroupPersistenceOperations({ + state, + scheduleSave, + removeWorkspaceLineageForFolderParent, + pruneMobileClientTabSelections + }) + return { + state, + removedIds, + selected, + operations, + scheduleSave, + removeWorkspaceLineageForFolderParent, + pruneMobileClientTabSelections + } +} + +afterEach(() => vi.restoreAllMocks()) + +describe('project group session removal', () => { + it('copies each host session once when deleting many folder workspaces', () => { + const { operations } = fixture(24) + const clone = vi.spyOn(globalThis, 'structuredClone') + expect(operations.deleteProjectGroup('root')).toBe(true) + expect(clone).toHaveBeenCalledTimes(3) + }) + + it('removes the entire subtree from local spill and remote sessions while preserving other owners', () => { + const setup = fixture(8) + const kept = session(['kept']) + kept.activeWorkspaceKey = null + kept.activeWorktreeId = null + expect(setup.operations.deleteProjectGroup('root')).toBe(true) + expect(setup.state.workspaceSession).toEqual(kept) + expect(setup.state.workspaceSessionsByHostId).toEqual({ + 'ssh:builder': kept, + 'runtime:remote': kept + }) + expect(setup.state.projectGroups?.map((group) => group.id)).toEqual(['other']) + expect(setup.state.folderWorkspaces.map((entry) => entry.id)).toEqual(['kept']) + expect(setup.selected).toEqual(new Set([folderWorkspaceKey('kept')])) + expect(setup.removeWorkspaceLineageForFolderParent.mock.calls).toEqual( + setup.removedIds.map((id) => [id]) + ) + expect(setup.pruneMobileClientTabSelections).toHaveBeenCalledTimes(1) + expect(setup.scheduleSave).toHaveBeenCalledTimes(1) + }) + + it('preserves session references when the deleted group has no folder workspaces', () => { + const { state, operations, scheduleSave } = fixture(0) + const local = state.workspaceSession + const hosts = state.workspaceSessionsByHostId + const clone = vi.spyOn(globalThis, 'structuredClone') + expect(operations.deleteProjectGroup('root')).toBe(true) + expect(state.workspaceSession).toBe(local) + expect(state.workspaceSessionsByHostId).toBe(hosts) + expect(clone).not.toHaveBeenCalled() + expect(scheduleSave).toHaveBeenCalledTimes(1) + }) + + it('does not prune or save when the requested group does not exist', () => { + const setup = fixture(8) + const before = structuredClone(setup.state) + expect(setup.operations.deleteProjectGroup('missing')).toBe(false) + expect(setup.state).toEqual(before) + expect(setup.removeWorkspaceLineageForFolderParent).not.toHaveBeenCalled() + expect(setup.pruneMobileClientTabSelections).not.toHaveBeenCalled() + expect(setup.scheduleSave).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts index df4c030f4588..34d47eb208e6 100644 --- a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts +++ b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts @@ -21,6 +21,7 @@ import { } from '../loading-store/worktree-identity-metadata' import type { WorktreeMeta } from '../../../shared/worktree/meta-types' import { fillDefaultWorktreeMetaFields } from '../../../shared/worktree/meta-persisted-defaults' +import { getWorkspaceAttachments } from '../../../shared/workspace-attachments' // Why: worktrees deleted outside Orca orphan their worktreeMeta, so the map grew monotonically (63% dead on a heavy install). // GC stays narrow: local-host entries only (a local existsSync would falsely condemn SSH/WSL remote paths) and only after a 30-day idle grace. @@ -99,6 +100,14 @@ function normalizeLinkedMetadata(meta: WorktreeMeta): boolean { ? sourceContext : null let changed = false + const linkedItems = getWorkspaceAttachments(meta) + if ( + meta.linkedItems !== undefined && + JSON.stringify(meta.linkedItems) !== JSON.stringify(linkedItems) + ) { + meta.linkedItems = linkedItems + changed = true + } if (!areWorkspaceLinkedItemsEqual(meta.linkedWorkItem, linkedWorkItem)) { meta.linkedWorkItem = linkedWorkItem changed = true diff --git a/src/main/pi/__fixtures__/command-only.jsonl b/src/main/pi/__fixtures__/command-only.jsonl new file mode 100644 index 000000000000..e3cefacfc160 --- /dev/null +++ b/src/main/pi/__fixtures__/command-only.jsonl @@ -0,0 +1,20 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"medium\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/command-only/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000009\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[{\"id\":\"gpt-5.3-codex-spark\",\"name\":\"GPT-5.3 Codex Spark\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\"],\"cost\":{\"input\":1.75,\"output\":14,\"cacheRead\":0.175,\"cacheWrite\":0},\"contextWindow\":128000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true},\"type\":\"chat\"},{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-luna\",\"name\":\"GPT-5.6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.2,\"output\":1.2,\"cacheRead\":0.02,\"cacheWrite\":0.25,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.4,\"output\":1.8,\"cacheRead\":0.04,\"cacheWrite\":0.5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-sol\",\"name\":\"GPT-5.6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":4,\"output\":20,\"cacheRead\":0.4,\"cacheWrite\":5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":8,\"output\":30,\"cacheRead\":0.8,\"cacheWrite\":10}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-terra\",\"name\":\"GPT-5.6 Terra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":12,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":18,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-astra\",\"name\":\"GPT-6 Astra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":10,\"output\":50,\"cacheRead\":1,\"cacheWrite\":12.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":20,\"output\":75,\"cacheRead\":2,\"cacheWrite\":25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-luna\",\"name\":\"GPT-6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.1,\"output\":0.5,\"cacheRead\":0.01,\"cacheWrite\":0.125,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.2,\"output\":0.75,\"cacheRead\":0.02,\"cacheWrite\":0.25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-sol\",\"name\":\"GPT-6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"set_model\", \"provider\": \"openai-codex\", \"modelId\": \"gpt-5.5\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"set_model\",\"success\":true,\"data\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"set_thinking_level\", \"level\": \"off\"}"} +{"dir":"out","raw":"{\"type\":\"thinking_level_changed\",\"level\":\"off\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"set_thinking_level\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q7\", \"type\": \"prompt\", \"message\": \"/qa-local\"}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000010\",\"method\":\"setStatus\",\"statusKey\":\"qa-capture\",\"statusText\":\"local command\"}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000011\",\"method\":\"setTitle\",\"title\":\"RPC capture\"}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000012\",\"method\":\"notify\",\"message\":\"command completed without a model call\",\"notifyType\":\"info\"}"} +{"dir":"out","raw":"{\"id\":\"q7\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}}"} +{"dir":"in","raw":"{\"id\": \"q8\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q8\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/command-only/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000009\",\"autoCompactionEnabled\":false,\"messageCount\":0,\"pendingMessageCount\":0}}"} diff --git a/src/main/pi/__fixtures__/compaction.jsonl b/src/main/pi/__fixtures__/compaction.jsonl new file mode 100644 index 000000000000..c5cde2edb144 --- /dev/null +++ b/src/main/pi/__fixtures__/compaction.jsonl @@ -0,0 +1,99 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"medium\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-then-compact/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000049\",\"autoCompactionEnabled\":false,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[{\"id\":\"gpt-5.3-codex-spark\",\"name\":\"GPT-5.3 Codex Spark\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\"],\"cost\":{\"input\":1.75,\"output\":14,\"cacheRead\":0.175,\"cacheWrite\":0},\"contextWindow\":128000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true},\"type\":\"chat\"},{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-luna\",\"name\":\"GPT-5.6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.2,\"output\":1.2,\"cacheRead\":0.02,\"cacheWrite\":0.25,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.4,\"output\":1.8,\"cacheRead\":0.04,\"cacheWrite\":0.5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-sol\",\"name\":\"GPT-5.6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":4,\"output\":20,\"cacheRead\":0.4,\"cacheWrite\":5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":8,\"output\":30,\"cacheRead\":0.8,\"cacheWrite\":10}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-terra\",\"name\":\"GPT-5.6 Terra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":12,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":18,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-astra\",\"name\":\"GPT-6 Astra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":10,\"output\":50,\"cacheRead\":1,\"cacheWrite\":12.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":20,\"output\":75,\"cacheRead\":2,\"cacheWrite\":25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-luna\",\"name\":\"GPT-6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.1,\"output\":0.5,\"cacheRead\":0.01,\"cacheWrite\":0.125,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.2,\"output\":0.75,\"cacheRead\":0.02,\"cacheWrite\":0.25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-sol\",\"name\":\"GPT-6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"set_model\", \"provider\": \"openai-codex\", \"modelId\": \"gpt-5.5\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"set_model\",\"success\":true,\"data\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"set_thinking_level\", \"level\": \"off\"}"} +{"dir":"out","raw":"{\"type\":\"thinking_level_changed\",\"level\":\"off\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"set_thinking_level\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q7\", \"type\": \"prompt\", \"message\": \"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}"} +{"dir":"out","raw":"{\"id\":\"q7\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}}"} +{"dir":"out","raw":"{\"type\":\"agent_start\"}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-then-compact\\n\"},\"timestamp\":1791281787030,\"toolsAdded\":[{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-then-compact\\n\"},\"timestamp\":1791281787030,\"toolsAdded\":[{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791281787029}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791281787029}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1791281787039,\"responseId\":\"captured.id.000055.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_start\",\"contentIndex\":0,\"id\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"{\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"command\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\":\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"printf\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" capture\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"-start\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\";\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" sleep\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" \"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"20\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\";\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" printf\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" capture\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"-f\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"inish\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\",\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"timeout\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\":\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"25\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"}\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_end\",\"contentIndex\":0,\"toolCall\":{\"type\":\"toolCall\",\"id\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":222,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":255,\"cost\":{\"input\":0.00111,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0021000000000000003}},\"stopReason\":\"toolUse\",\"timestamp\":1791281787039,\"responseId\":\"captured.id.000055.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_start\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}"} +{"dir":"in","raw":"{\"id\": \"q8\", \"type\": \"prompt\", \"message\": \"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\", \"streamingBehavior\": \"steer\"}"} +{"dir":"in","raw":"{\"id\": \"q9\", \"type\": \"prompt\", \"message\": \"Then reply FOLLOWUP_CONSUMED on the next turn.\", \"streamingBehavior\": \"followUp\"}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25},\"partialResult\":{\"content\":[]}}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"],\"followUp\":[]}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"],\"followUp\":[\"Then reply FOLLOWUP_CONSUMED on the next turn.\"]}"} +{"dir":"out","raw":"{\"id\":\"q8\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"queued\"}}"} +{"dir":"out","raw":"{\"id\":\"q9\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"queued\"}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25},\"partialResult\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-start\"}],\"details\":{}}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25},\"partialResult\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"details\":{}}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_end\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"structuredContent\":{\"output\":\"capture-startcapture-finish\",\"truncated\":false,\"exit_code\":0,\"wall_time_seconds\":20}},\"isError\":false}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791281821986}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791281821986}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":222,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":255,\"cost\":{\"input\":0.00111,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0021000000000000003}},\"stopReason\":\"toolUse\",\"timestamp\":1791281787039,\"responseId\":\"captured.id.000055.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},\"toolResults\":[{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791281821986}]}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[],\"followUp\":[\"Then reply FOLLOWUP_CONSUMED on the next turn.\"]}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791281801961}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791281801961}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1791281821988}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_start\",\"contentIndex\":0}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"STE\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"ER\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"_CON\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"SUM\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"ED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_end\",\"contentIndex\":0,\"content\":\"STEER_CONSUMED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000058.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":293,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":302,\"cost\":{\"input\":0.0014650000000000002,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0017350000000000002}},\"stopReason\":\"stop\",\"timestamp\":1791281821988,\"responseId\":\"captured.id.000059.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000058.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":293,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":302,\"cost\":{\"input\":0.0014650000000000002,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0017350000000000002}},\"stopReason\":\"stop\",\"timestamp\":1791281821988,\"responseId\":\"captured.id.000059.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},\"toolResults\":[]}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[],\"followUp\":[]}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791281801961}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791281801961}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1791281825232}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_start\",\"contentIndex\":0}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"FOLLOW\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"UP\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"_CON\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"SUM\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":320,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":329,\"cost\":{\"input\":0.0016,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0018700000000000001}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"ED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":320,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":329,\"cost\":{\"input\":0.0016,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0018700000000000001}},\"assistantMessageEvent\":{\"type\":\"text_end\",\"contentIndex\":0,\"content\":\"FOLLOWUP_CONSUMED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000060.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":320,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":329,\"cost\":{\"input\":0.0016,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0018700000000000001}},\"stopReason\":\"stop\",\"timestamp\":1791281825232,\"responseId\":\"captured.id.000061.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000060.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":320,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":329,\"cost\":{\"input\":0.0016,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0018700000000000001}},\"stopReason\":\"stop\",\"timestamp\":1791281825232,\"responseId\":\"captured.id.000061.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},\"toolResults\":[]}"} +{"dir":"out","raw":"{\"type\":\"agent_end\",\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-then-compact\\n\"},\"timestamp\":1791281787030,\"toolsAdded\":[{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791281787029},{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":222,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":255,\"cost\":{\"input\":0.00111,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0021000000000000003}},\"stopReason\":\"toolUse\",\"timestamp\":1791281787039,\"responseId\":\"captured.id.000055.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791281821986},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791281801961},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000058.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":293,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":302,\"cost\":{\"input\":0.0014650000000000002,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0017350000000000002}},\"stopReason\":\"stop\",\"timestamp\":1791281821988,\"responseId\":\"captured.id.000059.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791281801961},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000060.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":320,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":329,\"cost\":{\"input\":0.0016,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0018700000000000001}},\"stopReason\":\"stop\",\"timestamp\":1791281825232,\"responseId\":\"captured.id.000061.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}],\"willRetry\":false}"} +{"dir":"in","raw":"{\"id\": \"q10\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"type\":\"agent_settled\"}"} +{"dir":"out","raw":"{\"id\":\"q10\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-then-compact/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000049\",\"autoCompactionEnabled\":false,\"messageCount\":8,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q11\", \"type\": \"get_messages\"}"} +{"dir":"out","raw":"{\"id\":\"q11\",\"type\":\"response\",\"command\":\"get_messages\",\"success\":true,\"data\":{\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-then-compact\\n\"},\"timestamp\":1791281787030,\"toolsAdded\":[{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791281787029},{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":222,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":255,\"cost\":{\"input\":0.00111,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0021000000000000003}},\"stopReason\":\"toolUse\",\"timestamp\":1791281787039,\"responseId\":\"captured.id.000055.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791281821986},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791281801961},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000058.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":293,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":302,\"cost\":{\"input\":0.0014650000000000002,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0017350000000000002}},\"stopReason\":\"stop\",\"timestamp\":1791281821988,\"responseId\":\"captured.id.000059.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791281801961},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000060.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":320,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":329,\"cost\":{\"input\":0.0016,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0018700000000000001}},\"stopReason\":\"stop\",\"timestamp\":1791281825232,\"responseId\":\"captured.id.000061.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q12\", \"type\": \"compact\"}"} +{"dir":"out","raw":"{\"type\":\"compaction_start\",\"reason\":\"manual\"}"} +{"dir":"out","raw":"{\"type\":\"compaction_end\",\"reason\":\"manual\",\"result\":{\"summary\":\"## Goal\\nSummarize the provided conversation for continuity.\\n\\n## Constraints & Preferences\\n- User required the assistant to run exactly: `printf capture-start; sleep 20; printf capture-finish`\\n- User specified not to background the command.\\n- After tool completion, user instructed to reply `STEER_CONSUMED` instead of `ORIGINAL_FINISHED`.\\n\\n## Progress\\n### Done\\n- [x] Ran `printf capture-start; sleep 20; printf capture-finish` via bash without backgrounding.\\n- [x] Tool output was `capture-startcapture-finish`.\\n- [x] Replied `STEER_CONSUMED`.\\n\\n### In Progress\\n- [ ] None.\\n\\n### Blocked\\n- None.\\n\\n## Key Decisions\\n- **Follow latest user instruction**: Replied `STEER_CONSUMED` after the tool completed, replacing the earlier requested `ORIGINAL_FINISHED`.\\n\\n## Next Steps\\n1. Continue from the completed state if further user instructions are provided.\\n\\n## Critical Context\\n- Exact command run: `printf capture-start; sleep 20; printf capture-finish`\\n- Tool output: `capture-startcapture-finish`\\n- Final assistant reply: `STEER_CONSUMED`\\n\\n---\\n\\n**Turn Context (split turn):**\\n\\n## Original Request\\nThe user instructed: “Then reply FOLLOWUP_CONSUMED on the next turn.”\\n\\n## Progress So Far\\n- No work has been completed beyond recording the instruction.\\n\\n## Context Needed to Continue\\n- On the next turn, the assistant should reply exactly: `FOLLOWUP_CONSUMED`\",\"firstKeptEntryId\":\"77988921\",\"tokensBefore\":329,\"estimatedTokensAfter\":559,\"usage\":{\"input\":574,\"output\":317,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":891,\"cost\":{\"input\":0.00287,\"output\":0.009510000000000001,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.01238}},\"details\":{\"readFiles\":[],\"modifiedFiles\":[]}},\"aborted\":false,\"willRetry\":false}"} +{"dir":"out","raw":"{\"id\":\"q12\",\"type\":\"response\",\"command\":\"compact\",\"success\":true,\"data\":{\"summary\":\"## Goal\\nSummarize the provided conversation for continuity.\\n\\n## Constraints & Preferences\\n- User required the assistant to run exactly: `printf capture-start; sleep 20; printf capture-finish`\\n- User specified not to background the command.\\n- After tool completion, user instructed to reply `STEER_CONSUMED` instead of `ORIGINAL_FINISHED`.\\n\\n## Progress\\n### Done\\n- [x] Ran `printf capture-start; sleep 20; printf capture-finish` via bash without backgrounding.\\n- [x] Tool output was `capture-startcapture-finish`.\\n- [x] Replied `STEER_CONSUMED`.\\n\\n### In Progress\\n- [ ] None.\\n\\n### Blocked\\n- None.\\n\\n## Key Decisions\\n- **Follow latest user instruction**: Replied `STEER_CONSUMED` after the tool completed, replacing the earlier requested `ORIGINAL_FINISHED`.\\n\\n## Next Steps\\n1. Continue from the completed state if further user instructions are provided.\\n\\n## Critical Context\\n- Exact command run: `printf capture-start; sleep 20; printf capture-finish`\\n- Tool output: `capture-startcapture-finish`\\n- Final assistant reply: `STEER_CONSUMED`\\n\\n---\\n\\n**Turn Context (split turn):**\\n\\n## Original Request\\nThe user instructed: “Then reply FOLLOWUP_CONSUMED on the next turn.”\\n\\n## Progress So Far\\n- No work has been completed beyond recording the instruction.\\n\\n## Context Needed to Continue\\n- On the next turn, the assistant should reply exactly: `FOLLOWUP_CONSUMED`\",\"firstKeptEntryId\":\"77988921\",\"tokensBefore\":329,\"estimatedTokensAfter\":559,\"usage\":{\"input\":574,\"output\":317,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":891,\"cost\":{\"input\":0.00287,\"output\":0.009510000000000001,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.01238}},\"details\":{\"readFiles\":[],\"modifiedFiles\":[]}}}"} +{"dir":"in","raw":"{\"id\": \"q13\", \"type\": \"get_session_stats\"}"} +{"dir":"out","raw":"{\"id\":\"q13\",\"type\":\"response\",\"command\":\"get_session_stats\",\"success\":true,\"data\":{\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-then-compact/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000049\",\"userMessages\":3,\"assistantMessages\":3,\"toolCalls\":1,\"toolResults\":1,\"totalMessages\":8,\"tokens\":{\"input\":1409,\"output\":368,\"cacheRead\":0,\"cacheWrite\":0,\"total\":1777},\"cost\":0.018085,\"contextUsage\":{\"tokens\":null,\"contextWindow\":272000,\"percent\":null}}}"} diff --git a/src/main/pi/__fixtures__/dialogs.jsonl b/src/main/pi/__fixtures__/dialogs.jsonl new file mode 100644 index 000000000000..11ffe8aa0681 --- /dev/null +++ b/src/main/pi/__fixtures__/dialogs.jsonl @@ -0,0 +1,26 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"medium\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/dialogs/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000003\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[{\"id\":\"gpt-5.3-codex-spark\",\"name\":\"GPT-5.3 Codex Spark\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\"],\"cost\":{\"input\":1.75,\"output\":14,\"cacheRead\":0.175,\"cacheWrite\":0},\"contextWindow\":128000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true},\"type\":\"chat\"},{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-luna\",\"name\":\"GPT-5.6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.2,\"output\":1.2,\"cacheRead\":0.02,\"cacheWrite\":0.25,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.4,\"output\":1.8,\"cacheRead\":0.04,\"cacheWrite\":0.5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-sol\",\"name\":\"GPT-5.6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":4,\"output\":20,\"cacheRead\":0.4,\"cacheWrite\":5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":8,\"output\":30,\"cacheRead\":0.8,\"cacheWrite\":10}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-terra\",\"name\":\"GPT-5.6 Terra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":12,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":18,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-astra\",\"name\":\"GPT-6 Astra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":10,\"output\":50,\"cacheRead\":1,\"cacheWrite\":12.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":20,\"output\":75,\"cacheRead\":2,\"cacheWrite\":25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-luna\",\"name\":\"GPT-6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.1,\"output\":0.5,\"cacheRead\":0.01,\"cacheWrite\":0.125,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.2,\"output\":0.75,\"cacheRead\":0.02,\"cacheWrite\":0.25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-sol\",\"name\":\"GPT-6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"set_model\", \"provider\": \"openai-codex\", \"modelId\": \"gpt-5.5\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"set_model\",\"success\":true,\"data\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"set_thinking_level\", \"level\": \"off\"}"} +{"dir":"out","raw":"{\"type\":\"thinking_level_changed\",\"level\":\"off\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"set_thinking_level\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q7\", \"type\": \"prompt\", \"message\": \"/qa-dialogs\"}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000004\",\"method\":\"confirm\",\"title\":\"RPC capture confirm\",\"message\":\"Continue the capture?\"}"} +{"dir":"in","raw":"{\"id\": \"00000000-0000-4000-8000-000000000004\", \"type\": \"extension_ui_response\", \"confirmed\": true}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000005\",\"method\":\"select\",\"title\":\"RPC capture select\",\"options\":[\"Alpha\",\"Beta\"]}"} +{"dir":"in","raw":"{\"id\": \"00000000-0000-4000-8000-000000000005\", \"type\": \"extension_ui_response\", \"value\": \"Beta\"}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000006\",\"method\":\"input\",\"title\":\"RPC capture input\",\"placeholder\":\"Type a value\"}"} +{"dir":"in","raw":"{\"id\": \"00000000-0000-4000-8000-000000000006\", \"type\": \"extension_ui_response\", \"value\": \"\"}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000007\",\"method\":\"editor\",\"title\":\"RPC capture editor\",\"prefill\":\"one\\ntwo\"}"} +{"dir":"in","raw":"{\"id\": \"00000000-0000-4000-8000-000000000007\", \"type\": \"extension_ui_response\", \"value\": \"one\\nchanged
line
end\"}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000008\",\"method\":\"notify\",\"message\":\"{\\\"yes\\\":true,\\\"choice\\\":\\\"Beta\\\",\\\"input\\\":\\\"\\\",\\\"edited\\\":\\\"one\\\\nchanged
line
end\\\"}\",\"notifyType\":\"info\"}"} +{"dir":"out","raw":"{\"id\":\"q7\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}}"} +{"dir":"in","raw":"{\"id\": \"q12\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q12\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/dialogs/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000003\",\"autoCompactionEnabled\":false,\"messageCount\":0,\"pendingMessageCount\":0}}"} diff --git a/src/main/pi/__fixtures__/provider-fetch-error.jsonl b/src/main/pi/__fixtures__/provider-fetch-error.jsonl new file mode 100644 index 000000000000..22f2b663a781 --- /dev/null +++ b/src/main/pi/__fixtures__/provider-fetch-error.jsonl @@ -0,0 +1,32 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"medium\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/basic-r2/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000017\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[{\"id\":\"gpt-5.3-codex-spark\",\"name\":\"GPT-5.3 Codex Spark\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\"],\"cost\":{\"input\":1.75,\"output\":14,\"cacheRead\":0.175,\"cacheWrite\":0},\"contextWindow\":128000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true},\"type\":\"chat\"},{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-luna\",\"name\":\"GPT-5.6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.2,\"output\":1.2,\"cacheRead\":0.02,\"cacheWrite\":0.25,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.4,\"output\":1.8,\"cacheRead\":0.04,\"cacheWrite\":0.5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-sol\",\"name\":\"GPT-5.6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":4,\"output\":20,\"cacheRead\":0.4,\"cacheWrite\":5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":8,\"output\":30,\"cacheRead\":0.8,\"cacheWrite\":10}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-terra\",\"name\":\"GPT-5.6 Terra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":12,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":18,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-astra\",\"name\":\"GPT-6 Astra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":10,\"output\":50,\"cacheRead\":1,\"cacheWrite\":12.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":20,\"output\":75,\"cacheRead\":2,\"cacheWrite\":25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-luna\",\"name\":\"GPT-6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.1,\"output\":0.5,\"cacheRead\":0.01,\"cacheWrite\":0.125,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.2,\"output\":0.75,\"cacheRead\":0.02,\"cacheWrite\":0.25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-sol\",\"name\":\"GPT-6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"set_model\", \"provider\": \"openai-codex\", \"modelId\": \"gpt-5.5\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"set_model\",\"success\":true,\"data\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"set_thinking_level\", \"level\": \"off\"}"} +{"dir":"out","raw":"{\"type\":\"thinking_level_changed\",\"level\":\"off\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"set_thinking_level\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q7\", \"type\": \"prompt\", \"message\": \"Reply exactly RPC_CAPTURE_OK.\"}"} +{"dir":"out","raw":"{\"id\":\"q7\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}}"} +{"dir":"out","raw":"{\"type\":\"agent_start\"}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/basic-r2\\n\"},\"timestamp\":1791279374657,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/basic-r2\\n\"},\"timestamp\":1791279374657,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly RPC_CAPTURE_OK.\"}],\"timestamp\":1791279374657}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly RPC_CAPTURE_OK.\"}],\"timestamp\":1791279374657}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279374699,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279385277,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket error\",\"stack\":\"Error: WebSocket error\\n at extractWebSocketError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:973:12)\\n at WebSocket.onError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:845:18)\\n at [nodejs.internal.kHybridDispatch] (node:internal/event_target:856:20)\\n at WebSocket.dispatchEvent (node:internal/event_target:789:26)\\n at fireEvent (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/util.js:69:10)\\n at #onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:630:7)\\n at Object.onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:99:45)\\n at failWebsocketConnection (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:319:13)\\n at Object.processResponse (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:104:11)\\n at /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/fetch/index.js:1120:19\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3761}}],\"errorMessage\":\"fetch failed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279374699,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279385277,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket error\",\"stack\":\"Error: WebSocket error\\n at extractWebSocketError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:973:12)\\n at WebSocket.onError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:845:18)\\n at [nodejs.internal.kHybridDispatch] (node:internal/event_target:856:20)\\n at WebSocket.dispatchEvent (node:internal/event_target:789:26)\\n at fireEvent (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/util.js:69:10)\\n at #onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:630:7)\\n at Object.onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:99:45)\\n at failWebsocketConnection (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:319:13)\\n at Object.processResponse (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:104:11)\\n at /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/fetch/index.js:1120:19\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3761}}],\"errorMessage\":\"fetch failed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279374699,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279385277,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket error\",\"stack\":\"Error: WebSocket error\\n at extractWebSocketError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:973:12)\\n at WebSocket.onError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:845:18)\\n at [nodejs.internal.kHybridDispatch] (node:internal/event_target:856:20)\\n at WebSocket.dispatchEvent (node:internal/event_target:789:26)\\n at fireEvent (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/util.js:69:10)\\n at #onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:630:7)\\n at Object.onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:99:45)\\n at failWebsocketConnection (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:319:13)\\n at Object.processResponse (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:104:11)\\n at /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/fetch/index.js:1120:19\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3761}}],\"errorMessage\":\"fetch failed\",\"thinkingLevel\":\"off\"},\"toolResults\":[]}"} +{"dir":"out","raw":"{\"type\":\"agent_end\",\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/basic-r2\\n\"},\"timestamp\":1791279374657,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly RPC_CAPTURE_OK.\"}],\"timestamp\":1791279374657},{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279374699,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279385277,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket error\",\"stack\":\"Error: WebSocket error\\n at extractWebSocketError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:973:12)\\n at WebSocket.onError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:845:18)\\n at [nodejs.internal.kHybridDispatch] (node:internal/event_target:856:20)\\n at WebSocket.dispatchEvent (node:internal/event_target:789:26)\\n at fireEvent (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/util.js:69:10)\\n at #onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:630:7)\\n at Object.onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:99:45)\\n at failWebsocketConnection (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:319:13)\\n at Object.processResponse (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:104:11)\\n at /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/fetch/index.js:1120:19\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3761}}],\"errorMessage\":\"fetch failed\",\"thinkingLevel\":\"off\"}],\"willRetry\":false}"} +{"dir":"in","raw":"{\"id\": \"q8\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"type\":\"agent_settled\"}"} +{"dir":"out","raw":"{\"id\":\"q8\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/basic-r2/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000017\",\"autoCompactionEnabled\":false,\"messageCount\":3,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q9\", \"type\": \"get_session_stats\"}"} +{"dir":"out","raw":"{\"id\":\"q9\",\"type\":\"response\",\"command\":\"get_session_stats\",\"success\":true,\"data\":{\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/basic-r2/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000017\",\"userMessages\":1,\"assistantMessages\":1,\"toolCalls\":0,\"toolResults\":0,\"totalMessages\":3,\"tokens\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0},\"cost\":0,\"contextUsage\":{\"tokens\":820,\"contextWindow\":272000,\"percent\":0.3014705882352941}}}"} +{"dir":"in","raw":"{\"id\": \"q10\", \"type\": \"get_messages\"}"} +{"dir":"out","raw":"{\"id\":\"q10\",\"type\":\"response\",\"command\":\"get_messages\",\"success\":true,\"data\":{\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/basic-r2\\n\"},\"timestamp\":1791279374657,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly RPC_CAPTURE_OK.\"}],\"timestamp\":1791279374657},{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279374699,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279385277,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket error\",\"stack\":\"Error: WebSocket error\\n at extractWebSocketError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:973:12)\\n at WebSocket.onError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:845:18)\\n at [nodejs.internal.kHybridDispatch] (node:internal/event_target:856:20)\\n at WebSocket.dispatchEvent (node:internal/event_target:789:26)\\n at fireEvent (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/util.js:69:10)\\n at #onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:630:7)\\n at Object.onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:99:45)\\n at failWebsocketConnection (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:319:13)\\n at Object.processResponse (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:104:11)\\n at /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/fetch/index.js:1120:19\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3761}}],\"errorMessage\":\"fetch failed\",\"thinkingLevel\":\"off\"}]}}"} diff --git a/src/main/pi/__fixtures__/provider-socket-error.jsonl b/src/main/pi/__fixtures__/provider-socket-error.jsonl new file mode 100644 index 000000000000..f54d5804761d --- /dev/null +++ b/src/main/pi/__fixtures__/provider-socket-error.jsonl @@ -0,0 +1,32 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"medium\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/tool-bun/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000018\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[{\"id\":\"gpt-5.3-codex-spark\",\"name\":\"GPT-5.3 Codex Spark\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\"],\"cost\":{\"input\":1.75,\"output\":14,\"cacheRead\":0.175,\"cacheWrite\":0},\"contextWindow\":128000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true},\"type\":\"chat\"},{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-luna\",\"name\":\"GPT-5.6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.2,\"output\":1.2,\"cacheRead\":0.02,\"cacheWrite\":0.25,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.4,\"output\":1.8,\"cacheRead\":0.04,\"cacheWrite\":0.5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-sol\",\"name\":\"GPT-5.6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":4,\"output\":20,\"cacheRead\":0.4,\"cacheWrite\":5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":8,\"output\":30,\"cacheRead\":0.8,\"cacheWrite\":10}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-terra\",\"name\":\"GPT-5.6 Terra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":12,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":18,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-astra\",\"name\":\"GPT-6 Astra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":10,\"output\":50,\"cacheRead\":1,\"cacheWrite\":12.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":20,\"output\":75,\"cacheRead\":2,\"cacheWrite\":25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-luna\",\"name\":\"GPT-6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.1,\"output\":0.5,\"cacheRead\":0.01,\"cacheWrite\":0.125,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.2,\"output\":0.75,\"cacheRead\":0.02,\"cacheWrite\":0.25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-sol\",\"name\":\"GPT-6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"set_model\", \"provider\": \"openai-codex\", \"modelId\": \"gpt-5.5\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"set_model\",\"success\":true,\"data\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"set_thinking_level\", \"level\": \"off\"}"} +{"dir":"out","raw":"{\"type\":\"thinking_level_changed\",\"level\":\"off\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"set_thinking_level\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q7\", \"type\": \"prompt\", \"message\": \"Use the write tool to create capture.txt containing exactly fixture-ok. Then use read to read capture.txt. Reply exactly TOOL_CAPTURE_OK.\"}"} +{"dir":"out","raw":"{\"id\":\"q7\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}}"} +{"dir":"out","raw":"{\"type\":\"agent_start\"}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/tool-bun\\n\"},\"timestamp\":1791279674452,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/tool-bun\\n\"},\"timestamp\":1791279674452,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use the write tool to create capture.txt containing exactly fixture-ok. Then use read to read capture.txt. Reply exactly TOOL_CAPTURE_OK.\"}],\"timestamp\":1791279674451}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use the write tool to create capture.txt containing exactly fixture-ok. Then use read to read capture.txt. Reply exactly TOOL_CAPTURE_OK.\"}],\"timestamp\":1791279674451}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279674486,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279689495,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket connect timeout after 15000ms\",\"stack\":\"Error: WebSocket connect timeout after 15000ms\\n at (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:859:26)\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3869}}],\"errorMessage\":\"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279674486,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279689495,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket connect timeout after 15000ms\",\"stack\":\"Error: WebSocket connect timeout after 15000ms\\n at (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:859:26)\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3869}}],\"errorMessage\":\"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279674486,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279689495,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket connect timeout after 15000ms\",\"stack\":\"Error: WebSocket connect timeout after 15000ms\\n at (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:859:26)\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3869}}],\"errorMessage\":\"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()\",\"thinkingLevel\":\"off\"},\"toolResults\":[]}"} +{"dir":"out","raw":"{\"type\":\"agent_end\",\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/tool-bun\\n\"},\"timestamp\":1791279674452,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use the write tool to create capture.txt containing exactly fixture-ok. Then use read to read capture.txt. Reply exactly TOOL_CAPTURE_OK.\"}],\"timestamp\":1791279674451},{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279674486,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279689495,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket connect timeout after 15000ms\",\"stack\":\"Error: WebSocket connect timeout after 15000ms\\n at (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:859:26)\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3869}}],\"errorMessage\":\"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()\",\"thinkingLevel\":\"off\"}],\"willRetry\":false}"} +{"dir":"in","raw":"{\"id\": \"q8\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"type\":\"agent_settled\"}"} +{"dir":"out","raw":"{\"id\":\"q8\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/tool-bun/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000018\",\"autoCompactionEnabled\":false,\"messageCount\":3,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q9\", \"type\": \"get_session_stats\"}"} +{"dir":"out","raw":"{\"id\":\"q9\",\"type\":\"response\",\"command\":\"get_session_stats\",\"success\":true,\"data\":{\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/tool-bun/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000018\",\"userMessages\":1,\"assistantMessages\":1,\"toolCalls\":0,\"toolResults\":0,\"totalMessages\":3,\"tokens\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0},\"cost\":0,\"contextUsage\":{\"tokens\":847,\"contextWindow\":272000,\"percent\":0.3113970588235294}}}"} +{"dir":"in","raw":"{\"id\": \"q10\", \"type\": \"get_messages\"}"} +{"dir":"out","raw":"{\"id\":\"q10\",\"type\":\"response\",\"command\":\"get_messages\",\"success\":true,\"data\":{\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/tool-bun\\n\"},\"timestamp\":1791279674452,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use the write tool to create capture.txt containing exactly fixture-ok. Then use read to read capture.txt. Reply exactly TOOL_CAPTURE_OK.\"}],\"timestamp\":1791279674451},{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279674486,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279689495,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket connect timeout after 15000ms\",\"stack\":\"Error: WebSocket connect timeout after 15000ms\\n at (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:859:26)\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3869}}],\"errorMessage\":\"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()\",\"thinkingLevel\":\"off\"}]}}"} diff --git a/src/main/pi/__fixtures__/signed-out.jsonl b/src/main/pi/__fixtures__/signed-out.jsonl new file mode 100644 index 000000000000..0c01eb5cc175 --- /dev/null +++ b/src/main/pi/__fixtures__/signed-out.jsonl @@ -0,0 +1,12 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"unknown\",\"name\":\"unknown\",\"api\":\"unknown\",\"provider\":\"unknown\",\"baseUrl\":\"\",\"reasoning\":false,\"input\":[],\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0},\"contextWindow\":0,\"maxTokens\":0},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/signed-out/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000013\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"prompt\", \"message\": \"Reply exactly SIGNED_OUT_CHECK.\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"prompt\",\"success\":false,\"error\":\"No API key found for the selected model.\\n\\nUse /login to log into a provider via OAuth or API key. See:\\n /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-coding-agent/docs/providers.md\\n /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-coding-agent/docs/models.md\"}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"unknown\",\"name\":\"unknown\",\"api\":\"unknown\",\"provider\":\"unknown\",\"baseUrl\":\"\",\"reasoning\":false,\"input\":[],\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0},\"contextWindow\":0,\"maxTokens\":0},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/signed-out/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000013\",\"autoCompactionEnabled\":false,\"messageCount\":0,\"pendingMessageCount\":0}}"} diff --git a/src/main/pi/__fixtures__/steer-followup.jsonl b/src/main/pi/__fixtures__/steer-followup.jsonl new file mode 100644 index 000000000000..c747d395d65d --- /dev/null +++ b/src/main/pi/__fixtures__/steer-followup.jsonl @@ -0,0 +1,93 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"medium\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-followup-r2/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000015\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[{\"id\":\"gpt-5.3-codex-spark\",\"name\":\"GPT-5.3 Codex Spark\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\"],\"cost\":{\"input\":1.75,\"output\":14,\"cacheRead\":0.175,\"cacheWrite\":0},\"contextWindow\":128000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true},\"type\":\"chat\"},{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-luna\",\"name\":\"GPT-5.6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.2,\"output\":1.2,\"cacheRead\":0.02,\"cacheWrite\":0.25,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.4,\"output\":1.8,\"cacheRead\":0.04,\"cacheWrite\":0.5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-sol\",\"name\":\"GPT-5.6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":4,\"output\":20,\"cacheRead\":0.4,\"cacheWrite\":5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":8,\"output\":30,\"cacheRead\":0.8,\"cacheWrite\":10}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-terra\",\"name\":\"GPT-5.6 Terra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":12,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":18,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-astra\",\"name\":\"GPT-6 Astra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":10,\"output\":50,\"cacheRead\":1,\"cacheWrite\":12.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":20,\"output\":75,\"cacheRead\":2,\"cacheWrite\":25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-luna\",\"name\":\"GPT-6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.1,\"output\":0.5,\"cacheRead\":0.01,\"cacheWrite\":0.125,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.2,\"output\":0.75,\"cacheRead\":0.02,\"cacheWrite\":0.25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-sol\",\"name\":\"GPT-6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"set_model\", \"provider\": \"openai-codex\", \"modelId\": \"gpt-5.5\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"set_model\",\"success\":true,\"data\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"set_thinking_level\", \"level\": \"off\"}"} +{"dir":"out","raw":"{\"type\":\"thinking_level_changed\",\"level\":\"off\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"set_thinking_level\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q7\", \"type\": \"prompt\", \"message\": \"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}"} +{"dir":"out","raw":"{\"id\":\"q7\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}}"} +{"dir":"out","raw":"{\"type\":\"agent_start\"}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-followup-r2\\n\"},\"timestamp\":1791280240975,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-followup-r2\\n\"},\"timestamp\":1791280240975,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791280240975}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791280240975}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1791280240987}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_start\",\"contentIndex\":0,\"id\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"{\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"command\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\":\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"printf\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" capture\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"-start\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\";\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" sleep\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" \"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"20\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\";\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" printf\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" capture\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"-f\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"inish\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\",\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"timeout\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\":\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"30\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"}\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_end\",\"contentIndex\":0,\"toolCall\":{\"type\":\"toolCall\",\"id\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30}}}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":606,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":639,\"cost\":{\"input\":0.00303,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00402}},\"stopReason\":\"toolUse\",\"timestamp\":1791280240987,\"responseId\":\"captured.id.000003.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_start\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30}}"} +{"dir":"in","raw":"{\"id\": \"q8\", \"type\": \"prompt\", \"message\": \"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\", \"streamingBehavior\": \"steer\"}"} +{"dir":"in","raw":"{\"id\": \"q9\", \"type\": \"prompt\", \"message\": \"Then reply FOLLOWUP_CONSUMED on the next turn.\", \"streamingBehavior\": \"followUp\"}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30},\"partialResult\":{\"content\":[]}}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"],\"followUp\":[]}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"],\"followUp\":[\"Then reply FOLLOWUP_CONSUMED on the next turn.\"]}"} +{"dir":"out","raw":"{\"id\":\"q8\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"queued\"}}"} +{"dir":"out","raw":"{\"id\":\"q9\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"queued\"}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30},\"partialResult\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-start\"}],\"details\":{}}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30},\"partialResult\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"details\":{}}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_end\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"structuredContent\":{\"output\":\"capture-startcapture-finish\",\"truncated\":false,\"exit_code\":0,\"wall_time_seconds\":20}},\"isError\":false}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791280270461}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791280270461}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":606,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":639,\"cost\":{\"input\":0.00303,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00402}},\"stopReason\":\"toolUse\",\"timestamp\":1791280240987,\"responseId\":\"captured.id.000003.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},\"toolResults\":[{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791280270461}]}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[],\"followUp\":[\"Then reply FOLLOWUP_CONSUMED on the next turn.\"]}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791280250446}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791280250446}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1791280270464,\"responseId\":\"captured.id.000004.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_start\",\"contentIndex\":0}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"STE\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"ER\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"_CON\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"SUM\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"ED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":677,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":686,\"cost\":{\"input\":0.0033850000000000004,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0036550000000000003}},\"assistantMessageEvent\":{\"type\":\"text_end\",\"contentIndex\":0,\"content\":\"STEER_CONSUMED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000005.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":677,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":686,\"cost\":{\"input\":0.0033850000000000004,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0036550000000000003}},\"stopReason\":\"stop\",\"timestamp\":1791280270464,\"responseId\":\"captured.id.000004.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000005.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":677,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":686,\"cost\":{\"input\":0.0033850000000000004,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0036550000000000003}},\"stopReason\":\"stop\",\"timestamp\":1791280270464,\"responseId\":\"captured.id.000004.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},\"toolResults\":[]}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[],\"followUp\":[]}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791280250446}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791280250446}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1791280279250}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_start\",\"contentIndex\":0}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"FOLLOW\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"UP\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"_CON\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"SUM\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"ED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_end\",\"contentIndex\":0,\"content\":\"FOLLOWUP_CONSUMED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000006.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":704,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":713,\"cost\":{\"input\":0.00352,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00379}},\"stopReason\":\"stop\",\"timestamp\":1791280279250,\"responseId\":\"captured.id.000007.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000006.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":704,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":713,\"cost\":{\"input\":0.00352,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00379}},\"stopReason\":\"stop\",\"timestamp\":1791280279250,\"responseId\":\"captured.id.000007.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},\"toolResults\":[]}"} +{"dir":"out","raw":"{\"type\":\"agent_end\",\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-followup-r2\\n\"},\"timestamp\":1791280240975,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791280240975},{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":606,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":639,\"cost\":{\"input\":0.00303,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00402}},\"stopReason\":\"toolUse\",\"timestamp\":1791280240987,\"responseId\":\"captured.id.000003.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791280270461},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791280250446},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000005.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":677,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":686,\"cost\":{\"input\":0.0033850000000000004,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0036550000000000003}},\"stopReason\":\"stop\",\"timestamp\":1791280270464,\"responseId\":\"captured.id.000004.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791280250446},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000006.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":704,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":713,\"cost\":{\"input\":0.00352,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00379}},\"stopReason\":\"stop\",\"timestamp\":1791280279250,\"responseId\":\"captured.id.000007.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}],\"willRetry\":false}"} +{"dir":"in","raw":"{\"id\": \"q10\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"type\":\"agent_settled\"}"} +{"dir":"out","raw":"{\"id\":\"q10\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-followup-r2/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000015\",\"autoCompactionEnabled\":false,\"messageCount\":8,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q11\", \"type\": \"get_messages\"}"} +{"dir":"out","raw":"{\"id\":\"q11\",\"type\":\"response\",\"command\":\"get_messages\",\"success\":true,\"data\":{\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-followup-r2\\n\"},\"timestamp\":1791280240975,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791280240975},{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":606,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":639,\"cost\":{\"input\":0.00303,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00402}},\"stopReason\":\"toolUse\",\"timestamp\":1791280240987,\"responseId\":\"captured.id.000003.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791280270461},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791280250446},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000005.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":677,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":686,\"cost\":{\"input\":0.0033850000000000004,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0036550000000000003}},\"stopReason\":\"stop\",\"timestamp\":1791280270464,\"responseId\":\"captured.id.000004.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791280250446},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000006.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":704,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":713,\"cost\":{\"input\":0.00352,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00379}},\"stopReason\":\"stop\",\"timestamp\":1791280279250,\"responseId\":\"captured.id.000007.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}]}}"} diff --git a/src/main/pi/__fixtures__/stop-mid-tool.jsonl b/src/main/pi/__fixtures__/stop-mid-tool.jsonl new file mode 100644 index 000000000000..6f68cddfd61c --- /dev/null +++ b/src/main/pi/__fixtures__/stop-mid-tool.jsonl @@ -0,0 +1,64 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"medium\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/stop-tool-bun/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000016\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[{\"id\":\"gpt-5.3-codex-spark\",\"name\":\"GPT-5.3 Codex Spark\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\"],\"cost\":{\"input\":1.75,\"output\":14,\"cacheRead\":0.175,\"cacheWrite\":0},\"contextWindow\":128000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true},\"type\":\"chat\"},{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-luna\",\"name\":\"GPT-5.6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.2,\"output\":1.2,\"cacheRead\":0.02,\"cacheWrite\":0.25,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.4,\"output\":1.8,\"cacheRead\":0.04,\"cacheWrite\":0.5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-sol\",\"name\":\"GPT-5.6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":4,\"output\":20,\"cacheRead\":0.4,\"cacheWrite\":5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":8,\"output\":30,\"cacheRead\":0.8,\"cacheWrite\":10}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-terra\",\"name\":\"GPT-5.6 Terra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":12,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":18,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-astra\",\"name\":\"GPT-6 Astra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":10,\"output\":50,\"cacheRead\":1,\"cacheWrite\":12.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":20,\"output\":75,\"cacheRead\":2,\"cacheWrite\":25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-luna\",\"name\":\"GPT-6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.1,\"output\":0.5,\"cacheRead\":0.01,\"cacheWrite\":0.125,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.2,\"output\":0.75,\"cacheRead\":0.02,\"cacheWrite\":0.25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-sol\",\"name\":\"GPT-6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"set_model\", \"provider\": \"openai-codex\", \"modelId\": \"gpt-5.5\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"set_model\",\"success\":true,\"data\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"set_thinking_level\", \"level\": \"off\"}"} +{"dir":"out","raw":"{\"type\":\"thinking_level_changed\",\"level\":\"off\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"set_thinking_level\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q7\", \"type\": \"prompt\", \"message\": \"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}"} +{"dir":"out","raw":"{\"id\":\"q7\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}}"} +{"dir":"out","raw":"{\"type\":\"agent_start\"}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/stop-tool-bun\\n\"},\"timestamp\":1791279790624,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/stop-tool-bun\\n\"},\"timestamp\":1791279790624,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791279790624}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791279790624}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1791279790635}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_start\",\"contentIndex\":0,\"id\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"{\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"command\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\":\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"printf\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" capture\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"-start\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\";\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" sleep\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" \"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"20\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\";\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" printf\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" capture\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"-f\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"inish\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\",\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"timeout\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\":\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"25\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"}\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_end\",\"contentIndex\":0,\"toolCall\":{\"type\":\"toolCall\",\"id\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":604,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":637,\"cost\":{\"input\":0.00302,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00401}},\"stopReason\":\"toolUse\",\"timestamp\":1791279790635,\"responseId\":\"captured.id.000010.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_start\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25},\"partialResult\":{\"content\":[]}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25},\"partialResult\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-start\"}],\"details\":{}}}"} +{"dir":"in","raw":"{\"id\": \"q8\", \"type\": \"abort\"}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_end\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-start\\n\\nCommand aborted\"}],\"details\":{}},\"isError\":true}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-start\\n\\nCommand aborted\"}],\"details\":{},\"isError\":true,\"timestamp\":1791279805864}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-start\\n\\nCommand aborted\"}],\"details\":{},\"isError\":true,\"timestamp\":1791279805864}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":604,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":637,\"cost\":{\"input\":0.00302,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00401}},\"stopReason\":\"toolUse\",\"timestamp\":1791279790635,\"responseId\":\"captured.id.000010.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},\"toolResults\":[{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-start\\n\\nCommand aborted\"}],\"details\":{},\"isError\":true,\"timestamp\":1791279805864}]}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"The operation was aborted.\",\"timestamp\":1791279805865,\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"The operation was aborted.\",\"timestamp\":1791279805865,\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"The operation was aborted.\",\"timestamp\":1791279805865,\"thinkingLevel\":\"off\"},\"toolResults\":[]}"} +{"dir":"out","raw":"{\"type\":\"agent_end\",\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/stop-tool-bun\\n\"},\"timestamp\":1791279790624,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791279790624},{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":604,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":637,\"cost\":{\"input\":0.00302,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00401}},\"stopReason\":\"toolUse\",\"timestamp\":1791279790635,\"responseId\":\"captured.id.000010.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-start\\n\\nCommand aborted\"}],\"details\":{},\"isError\":true,\"timestamp\":1791279805864},{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"The operation was aborted.\",\"timestamp\":1791279805865,\"thinkingLevel\":\"off\"}],\"willRetry\":false}"} +{"dir":"out","raw":"{\"type\":\"agent_settled\"}"} +{"dir":"out","raw":"{\"id\":\"q8\",\"type\":\"response\",\"command\":\"abort\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q9\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q9\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/stop-tool-bun/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000016\",\"autoCompactionEnabled\":false,\"messageCount\":5,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q10\", \"type\": \"get_messages\"}"} +{"dir":"out","raw":"{\"id\":\"q10\",\"type\":\"response\",\"command\":\"get_messages\",\"success\":true,\"data\":{\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/stop-tool-bun\\n\"},\"timestamp\":1791279790624,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791279790624},{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":604,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":637,\"cost\":{\"input\":0.00302,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00401}},\"stopReason\":\"toolUse\",\"timestamp\":1791279790635,\"responseId\":\"captured.id.000010.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-start\\n\\nCommand aborted\"}],\"details\":{},\"isError\":true,\"timestamp\":1791279805864},{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"The operation was aborted.\",\"timestamp\":1791279805865,\"thinkingLevel\":\"off\"}]}}"} diff --git a/src/main/pi/rpc-agent-definition.ts b/src/main/pi/rpc-agent-definition.ts new file mode 100644 index 000000000000..bbbd2d8429a9 --- /dev/null +++ b/src/main/pi/rpc-agent-definition.ts @@ -0,0 +1,21 @@ +import type { DirectoryAccountAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' + +export const PI_RPC_AGENT: DirectoryAccountAgentDefinition = { + agent: 'pi', + handleTransport: 'jsonl-rpc', + accountHomeVariable: 'PI_CODING_AGENT_DIR', + capabilities: { + rewind: false, + compact: true, + threadGoal: false, + contextUsage: true, + imagePrompts: true, + steering: 'inject', + approvalEnforcement: 'provider' + }, + restingOptions: { + acceptsKey: (key) => key === 'model' || key === 'effort', + fallbackModels: () => null, + effortDefaultsToModel: false + } +} diff --git a/src/main/pi/rpc-capture-replay.test.ts b/src/main/pi/rpc-capture-replay.test.ts new file mode 100644 index 000000000000..a216583e8218 --- /dev/null +++ b/src/main/pi/rpc-capture-replay.test.ts @@ -0,0 +1,155 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane' +import { PiRpcTurns } from './rpc-turns' +import { piRpcDialogPresentation } from './rpc-extension-dialogs' +import { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' + +const envelope = z.object({ dir: z.enum(['in', 'out']), raw: z.string() }) +const record = z.looseObject({ type: z.string() }) +function capture(name: string) { + return readFileSync(join(import.meta.dirname, '__fixtures__', `${name}.jsonl`), 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => { + const parsed = envelope.parse(JSON.parse(line)) + return { dir: parsed.dir, frame: record.parse(JSON.parse(parsed.raw)) } + }) +} +const disposals: (() => void)[] = [] +afterEach(async () => { + disposals.splice(0).forEach((dispose) => dispose()) + await closeProviderTimelineRigs() + vi.useRealTimers() +}) + +async function replay(name: string) { + const frames = capture(name) + const states = frames.filter( + ({ dir, frame }) => dir === 'out' && frame.type === 'response' && frame.command === 'get_state' + ) + const stats = frames.findLast( + ({ dir, frame }) => + dir === 'out' && frame.type === 'response' && frame.command === 'get_session_stats' + )?.frame.data + const rig = await openProviderTimelineRig({ agent: 'pi', sessionId: 'session-timeline' }) + const accepted = vi.fn(), + failed = vi.fn(), + idle = vi.fn(), + settled = vi.fn() + const lane = new JsonlRpcTimelineLane({ + sink: rig.sink, + sessionId: 'session-timeline', + agent: 'pi', + generation: 'gen-pi', + namespace: 'pi-session', + pauseReading: vi.fn(), + resumeReading: vi.fn(), + onInputAccepted: accepted, + onFailed: failed + }) + const turns = new PiRpcTurns({ + lane, + generation: 'gen-pi', + send: vi.fn(async () => {}), + request: vi.fn(async (command: string) => + command === 'get_state' ? states.at(-1)?.frame.data : stats + ), + settled, + failed, + idle + }) + disposals.push(() => { + turns.end() + lane.dispose() + }) + let sequence = 0 + for (const { dir, frame } of frames) { + if (dir === 'in' && frame.type === 'prompt') { + await turns.submit(`send-${++sequence}`, 100, frame) + } + if (dir === 'in' && frame.type === 'abort') { + turns.stop() + } + if (dir === 'out' && (frame.type !== 'response' || frame.command === 'prompt')) { + turns.receive(frame) + } + } + await new Promise((resolve) => setImmediate(resolve)) + lane.flush() + const rows = await rig.rows() + return { rows, turns: await rig.turns(), accepted, failed, idle, settled } +} + +describe('scrubbed Pi 1.0.4 RPC captures', () => { + it('records tool output, streamed text, and all three consumed inputs in one settled run', async () => { + const result = await replay('steer-followup') + expect(result.failed).not.toHaveBeenCalled() + expect(result.accepted.mock.calls.map(([id]) => id)).toEqual(['send-1', 'send-2', 'send-3']) + expect(result.turns).toHaveLength(1) + expect(result.turns[0]?.outcome).toBe('success') + expect( + result.rows.some((row) => row.body.kind === 'tool-call' && row.body.state === 'completed') + ).toBe(true) + const texts = result.rows.flatMap((row) => + row.body.kind === 'message' + ? row.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ) + expect(texts.join('\n')).toContain('STEER_CONSUMED') + expect(texts.join('\n')).toContain('FOLLOWUP_CONSUMED') + }) + it.each(['provider-fetch-error', 'provider-socket-error'])( + 'marks captured %s as failure with the provider diagnostic', + async (name) => { + const result = await replay(name) + expect(result.failed).not.toHaveBeenCalled() + expect(result.turns[0]?.outcome).toBe('failure') + expect( + result.rows.filter( + (row) => row.body.kind === 'status' && row.body.failure?.kind === 'providerRejected' + ) + ).toHaveLength(1) + } + ) + it('treats the captured abort error as cancellation', async () => { + const result = await replay('stop-mid-tool') + expect(result.failed).not.toHaveBeenCalled() + expect(result.turns[0]?.outcome).toBe('cancellation') + expect(result.rows.some((row) => row.body.kind === 'status' && row.body.failure)).toBe(false) + }) + it('settles a handled extension command with no agent events', async () => { + const result = await replay('command-only') + expect(result.failed).not.toHaveBeenCalled() + expect(result.turns[0]?.outcome).toBe('success') + expect(result.accepted).toHaveBeenCalledWith('send-1') + }) + it('preserves empty answers and literal Unicode separators in captured dialogs', () => { + const dialogs = capture('dialogs').flatMap(({ dir, frame }) => + dir === 'out' && frame.type === 'extension_ui_request' ? [frame] : [] + ) + const input = piRpcDialogPresentation(dialogs.find((frame) => frame.method === 'input')) + const editor = piRpcDialogPresentation(dialogs.find((frame) => frame.method === 'editor')) + expect( + input?.reply({ + kind: 'answers', + answers: [{ questionId: String(input.id), optionIds: [], other: '' }] + }) + ).toEqual({ value: '' }) + const body = editor?.body + expect(body?.kind === 'question' && body.freeTextInput?.initialValue).toContain('\n') + expect( + editor?.reply({ + kind: 'answers', + answers: [ + { questionId: String(editor.id), optionIds: [], other: 'first\u2028second\u2029last' } + ] + }) + ).toEqual({ value: 'first\u2028second\u2029last' }) + }) +}) diff --git a/src/main/pi/rpc-compaction.ts b/src/main/pi/rpc-compaction.ts new file mode 100644 index 000000000000..728c5c604d11 --- /dev/null +++ b/src/main/pi/rpc-compaction.ts @@ -0,0 +1,45 @@ +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { + AgentSessionCommandAdmission, + StructuredAgentSessionCommandRun +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' +import type { PiRpcSession } from './rpc-session' + +export async function compactPiRpcSession( + session: PiRpcSession, + command: StructuredAgentSessionCommandRun +): Promise { + session.turns.beginCommand(command) + try { + await session.connection.request('compact', {}, { timeoutMs: null }) + session.turns.commandCompleted() + return { state: 'accepted', providerIdentity: null } + } catch (error) { + if (!(error instanceof JsonlRpcResponseError)) { + throw error + } + if (error.message === 'Nothing to compact (session too small)') { + session.lane.apply([ + { + type: 'item.close', + item: `compact-noop:${command.turnId}`, + body: { kind: 'status', tone: 'warning', text: error.message } + } + ]) + session.turns.commandCompleted() + return { state: 'accepted', providerIdentity: null } + } + session.turns.commandRejected() + return { + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { + detail: providerDiagnostic(error.message, 'person') + }), + { agentName: 'Pi', surface: 'rejection' } + ) + } + } +} diff --git a/src/main/pi/rpc-context-usage.ts b/src/main/pi/rpc-context-usage.ts new file mode 100644 index 000000000000..9f9b19eaaedf --- /dev/null +++ b/src/main/pi/rpc-context-usage.ts @@ -0,0 +1,108 @@ +import { z } from 'zod' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { piRpcUsageSchema, type PiRpcModel } from './rpc-protocol' + +const statsSchema = z.object({ + contextUsage: z + .object({ + tokens: z.number().nonnegative().nullable().optional(), + contextWindow: z.number().positive(), + percent: z.number().nullable().optional() + }) + .optional() +}) + +export class PiRpcContextUsage { + private model?: PiRpcModel + private compactedTokens?: number + + compacting(): ProviderTimelineEvent[] { + this.compactedTokens = undefined + return [{ type: 'context.usage', usage: { used: { kind: 'unknown', capturedAt: Date.now() } } }] + } + + compacted(result: unknown): void { + const parsed = z.object({ estimatedTokensAfter: z.number().nonnegative() }).safeParse(result) + this.compactedTokens = parsed.success ? parsed.data.estimatedTokensAfter : undefined + } + + setModel(model: PiRpcModel | undefined, at: number): ProviderTimelineEvent[] { + const changed = model?.id !== this.model?.id || model?.provider !== this.model?.provider + this.model = model + return model?.contextWindow && model.contextWindow > 0 + ? [ + { + type: 'context.usage', + usage: { + window: { tokens: model.contextWindow, capturedAt: at }, + ...(changed ? { used: { kind: 'unknown' as const, capturedAt: at } } : {}) + } + } + ] + : [] + } + + live(value: unknown, at: number): ProviderTimelineEvent[] { + const usage = piRpcUsageSchema.safeParse(value) + if (!usage.success || Object.values(usage.data).every((tokens) => tokens === 0)) { + return [] + } + this.compactedTokens = undefined + return [ + { + type: 'context.usage', + usage: { + used: { + kind: 'estimate', + usage: { + inputTokens: usage.data.input, + outputTokens: usage.data.output, + cacheCreationInputTokens: usage.data.cacheWrite, + cacheReadInputTokens: usage.data.cacheRead + }, + capturedAt: at + } + } + } + ] + } + + stats(value: unknown, at: number): ProviderTimelineEvent[] { + const parsed = statsSchema.safeParse(value) + const context = parsed.success ? parsed.data.contextUsage : undefined + if (!context) { + return [] + } + return [ + { + type: 'context.usage', + usage: { + window: { tokens: context.contextWindow, capturedAt: at }, + used: + context.tokens == null + ? this.compactedTokens === undefined + ? { kind: 'unknown', capturedAt: at } + : { + kind: 'estimate', + usage: { + inputTokens: this.compactedTokens, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0 + }, + capturedAt: at + } + : { + kind: 'report', + model: this.model ? `${this.model.provider}/${this.model.id}` : 'unknown', + usedTokens: context.tokens, + windowTokens: context.contextWindow, + percentage: context.percent ?? (context.tokens / context.contextWindow) * 100, + categories: [], + capturedAt: at + } + } + } + ] + } +} diff --git a/src/main/pi/rpc-dialog-callbacks.ts b/src/main/pi/rpc-dialog-callbacks.ts new file mode 100644 index 000000000000..c9b2f02fa058 --- /dev/null +++ b/src/main/pi/rpc-dialog-callbacks.ts @@ -0,0 +1,98 @@ +import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' +import { + AgentSessionPromptAnswerRejectedError, + AgentSessionPromptUnavailableError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane' +import type { JsonlRpcRecord } from '../jsonl-rpc/peer' +import { piRpcDialogPresentation, type PiRpcDialogPresentation } from './rpc-extension-dialogs' + +type Callback = { presentation: PiRpcDialogPresentation; claimed: boolean; bytes: number } +const DECORATION = new Set(['notify', 'setStatus', 'setWidget', 'setTitle', 'setEditorText']) + +export class PiRpcDialogCallbacks { + private readonly pending = new Map() + private pendingBytes = 0 + constructor( + private readonly lane: JsonlRpcTimelineLane, + private readonly send: (frame: JsonlRpcRecord) => Promise, + private readonly onFailed: (error: Error) => void + ) {} + + receive(frame: JsonlRpcRecord): void { + if (typeof frame.method === 'string' && DECORATION.has(frame.method)) { + return + } + const presentation = piRpcDialogPresentation(frame) + if (!presentation) { + if (typeof frame.id === 'string' || typeof frame.id === 'number') { + void this.send({ type: 'extension_ui_response', id: frame.id, cancelled: true }).catch( + (error: unknown) => + this.onFailed(error instanceof Error ? error : new Error(String(error))) + ) + } + return + } + const key = String(presentation.id) + const bytes = Buffer.byteLength(JSON.stringify(presentation.body)) + if ( + this.pending.has(key) || + this.pending.size >= 64 || + this.pendingBytes + bytes > 8 * 1024 * 1024 + ) { + throw new Error('Pi dialog identity or capacity violation') + } + this.pending.set(key, { presentation, claimed: false, bytes }) + this.pendingBytes += bytes + this.lane.apply([{ type: 'request.open', request: key, body: presentation.body }]) + } + + async respond( + itemId: string, + response: AgentSessionPromptResponse | null, + commit: () => Promise, + answer = true + ): Promise { + const entry = [...this.pending].find(([key]) => this.lane.isRequestRow(itemId, key)) + if (!entry || entry[1].claimed) { + throw new AgentSessionPromptUnavailableError(itemId) + } + const [key, callback] = entry + let reply: Record + try { + reply = callback.presentation.reply(response) + } catch (error) { + throw new AgentSessionPromptAnswerRejectedError( + error instanceof Error ? error.message : 'Invalid Pi dialog answer' + ) + } + callback.claimed = true + try { + await commit() + if (this.pending.get(key) !== callback) { + throw new AgentSessionPromptUnavailableError(itemId) + } + if (answer) { + await this.send({ type: 'extension_ui_response', id: callback.presentation.id, ...reply }) + } + this.pending.delete(key) + this.pendingBytes -= callback.bytes + } catch (error) { + callback.claimed = false + throw error + } + } + + cancelAll(): void { + for (const [key, callback] of this.pending) { + this.lane.apply([{ type: 'request.withdrawn', request: key }]) + void this.send({ + type: 'extension_ui_response', + id: callback.presentation.id, + cancelled: true + }).catch(() => {}) + } + this.pending.clear() + this.pendingBytes = 0 + } +} diff --git a/src/main/pi/rpc-extension-dialogs.ts b/src/main/pi/rpc-extension-dialogs.ts new file mode 100644 index 000000000000..0f2f37e1c7d9 --- /dev/null +++ b/src/main/pi/rpc-extension-dialogs.ts @@ -0,0 +1,102 @@ +import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' +import type { ProviderTimelineRequestBody } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { piRpcDialogSchema } from './rpc-protocol' + +const pendingResolution = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +export type PiRpcDialogPresentation = { + id: string | number + body: ProviderTimelineRequestBody + reply: (response: AgentSessionPromptResponse | null) => Record +} + +/** Terminal decoration is deliberately absent from the chat projection. */ +export function piRpcDialogPresentation(value: unknown): PiRpcDialogPresentation | null { + const parsed = piRpcDialogSchema.safeParse(value) + if (!parsed.success) { + return null + } + const dialog = parsed.data + if (dialog.method === 'confirm') { + return { + id: dialog.id, + body: { + kind: 'approval', + title: dialog.title, + detail: dialog.message ?? null, + options: [ + { id: 'yes', label: 'Yes' }, + { id: 'no', label: 'No' } + ], + resolution: pendingResolution + }, + reply: (response) => { + if (response === null) { + return { cancelled: true } + } + if (response.kind !== 'option' || !['yes', 'no'].includes(response.optionId)) { + throw new Error('Confirmation must select an offered option') + } + return { confirmed: response.optionId === 'yes' } + } + } + } + const questionId = String(dialog.id) + const options = + dialog.method === 'select' + ? dialog.options.map((label, index) => ({ + id: `option-${index}`, + label: label || 'Empty value' + })) + : [] + return { + id: dialog.id, + body: { + kind: 'question', + question: [dialog.title, dialog.message].filter((text) => text !== undefined).join('\n\n'), + options, + resolution: pendingResolution, + ...(dialog.method === 'select' + ? {} + : { + freeTextQuestionId: questionId, + freeTextInput: { + allowEmpty: true, + ...(dialog.method === 'editor' + ? { multiline: true, initialValue: dialog.prefill ?? '' } + : {}), + ...(dialog.placeholder === undefined ? {} : { placeholder: dialog.placeholder }) + } + }) + }, + reply: (response) => { + if (response === null) { + return { cancelled: true } + } + if (response.kind !== 'answers' || response.answers.length !== 1) { + throw new Error('Dialog requires one answer') + } + const answer = response.answers[0] + if (dialog.method === 'select') { + const selected = options.findIndex((option) => option.id === answer.optionIds[0]) + if (answer.optionIds.length !== 1 || answer.other !== undefined || selected === -1) { + throw new Error('Selection must choose an offered option') + } + return { value: dialog.options[selected] } + } + if ( + answer.questionId !== questionId || + answer.optionIds.length !== 0 || + answer.other === undefined + ) { + throw new Error('Dialog requires a text answer') + } + return { value: answer.other } + } + } +} diff --git a/src/main/pi/rpc-idle-check.ts b/src/main/pi/rpc-idle-check.ts new file mode 100644 index 000000000000..5ffe453ac846 --- /dev/null +++ b/src/main/pi/rpc-idle-check.ts @@ -0,0 +1,57 @@ +import { piRpcIdle, piRpcStateSchema, type PiRpcState } from './rpc-protocol' + +type IdleCheckDeps = { + request: () => Promise + current: (revision: number) => boolean + settled: (state: PiRpcState) => void + failed: (error: Error) => void +} + +/** Detached compaction and new input invalidate an older settled signal and its idle reply. */ +export class PiRpcIdleCheck { + private readonly timers = new Set>() + private ended = false + constructor(private readonly deps: IdleCheckDeps) {} + + schedule(revision: number, attempt = 0): void { + queueMicrotask(() => { + if (this.ended || !this.deps.current(revision)) { + return + } + void this.deps + .request() + .then((value) => { + if (this.ended || !this.deps.current(revision)) { + return + } + const state = piRpcStateSchema.parse(value) + if (piRpcIdle(state)) { + this.deps.settled(state) + } + }) + .catch((error: unknown) => { + if (this.ended || !this.deps.current(revision)) { + return + } + if (attempt >= 2) { + this.deps.failed(error instanceof Error ? error : new Error(String(error))) + return + } + const timer = setTimeout(() => { + this.timers.delete(timer) + this.schedule(revision, attempt + 1) + }, 100) + timer.unref() + this.timers.add(timer) + }) + }) + } + + dispose(): void { + this.ended = true + for (const timer of this.timers) { + clearTimeout(timer) + } + this.timers.clear() + } +} diff --git a/src/main/pi/rpc-launch-resolution.test.ts b/src/main/pi/rpc-launch-resolution.test.ts new file mode 100644 index 000000000000..38132a8d13b9 --- /dev/null +++ b/src/main/pi/rpc-launch-resolution.test.ts @@ -0,0 +1,227 @@ +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { agentSessionProviderHandleKey } from '../../shared/agent-session-provider-handle' +import { closeTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' +import { buildPiRpcLaunch } from './rpc-launch' +import { createPiRpcLaunchResolver, piRpcProviderLink } from './rpc-launch-resolution' + +const probeVersion = vi.fn(async (..._input: unknown[]) => true) + +const identity: AgentSessionJournalIdentity = { + sessionId: 'session-pi-resolve', + workspaceId: 'folder-1', + hostId: 'local', + agent: 'pi', + providerHandle: null +} +let root: string +beforeEach(async () => { + probeVersion.mockReset().mockResolvedValue(true) + root = await mkdtemp(join(tmpdir(), 'orca-pi-resolver-')) +}) +afterEach(async () => { + closeTestJournalHostDatabase(root) + await rm(root, { recursive: true, force: true }) +}) + +async function setup() { + const store = await openTestAgentSessionRecordStore(root) + const { record } = await store.reserveOwner({ + sessionId: identity.sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' + }, + provider: 'pi', + accountHome: { variable: 'PI_CODING_AGENT_DIR', path: '/host/account' }, + expectedFence: null, + spawnToken: 'spawn-pi', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: '1800000000000-00000000000000000000000000000001', + fingerprint: 'pi-create' + }, + now: 1_800_000_000_000 + }) + const workspace = join(root, 'workspace') + await mkdir(workspace) + const resolveEnvironment = vi.fn(async () => ({ + PATH: '/host/bin', + HOME: '/host/home', + PI_CODING_AGENT_DIR: '/inherited/wrong' + })) + const resolveCommand = vi.fn((..._input: unknown[]) => '/host/bin/pi') + const settings: { agentCmdOverrides: Record } = { agentCmdOverrides: {} } + const resolver = createPiRpcLaunchResolver({ + store, + resolveWorkspacePath: async () => workspace, + resolveEnvironment, + resolveCommandSettings: () => settings, + resolveCommand, + probeVersion + }) + const prior = ( + file: string, + origin: AgentSessionProviderHandleLink['origin'] = 'created' + ): AgentSessionProviderHandleLink => ({ + linkId: 'prior-link', + handle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file }, + origin, + mintedAtFence: 1, + observedAt: 100 + }) + const withPrior = (link: AgentSessionProviderHandleLink) => { + vi.spyOn(store, 'getRecord').mockReturnValue({ ...record, providerHandleChain: [link] }) + } + return { store, record, workspace, resolver, resolveCommand, settings, prior, withPrior } +} + +describe('Pi host launch resolution', () => { + it('refuses acquisition if the selected binary was replaced by an unsupported version', async () => { + const h = await setup() + probeVersion.mockResolvedValue(false) + await expect(h.resolver(identity)).rejects.toThrow('structured_agent_session_unsupported') + expect(probeVersion).toHaveBeenCalledWith( + { + program: '/host/bin/pi', + cwd: h.workspace, + env: { PATH: '/host/bin', HOME: '/host/home', PI_CODING_AGENT_DIR: '/host/account' } + }, + expect.any(Function) + ) + }) + + it('uses the runtime workspace, binary and account home for a new folder session', async () => { + const h = await setup() + const launch = await h.resolver(identity) + expect(launch).toMatchObject({ + command: '/host/bin/pi', + cwd: h.workspace, + env: { PATH: '/host/bin', HOME: '/host/home', PI_CODING_AGENT_DIR: '/host/account' }, + previous: null, + fullAccess: true + }) + expect(h.resolveCommand).toHaveBeenCalledWith('pi', { + pathEnv: '/host/bin', + homePath: '/host/home' + }) + }) + + it('spawns the binary the Command setting names, and refuses one that is not runnable', async () => { + const h = await setup() + h.settings.agentCmdOverrides = { pi: `"${process.execPath}"` } + await expect(h.resolver(identity)).resolves.toMatchObject({ command: process.execPath }) + expect(h.resolveCommand).not.toHaveBeenCalled() + + probeVersion.mockClear() + h.settings.agentCmdOverrides = { pi: '/missing/pi' } + await expect(h.resolver(identity)).rejects.toMatchObject({ + reason: 'agentCommandNotRunnable' + }) + expect(probeVersion).not.toHaveBeenCalled() + }) + + it('resumes the same directory and forks a session from a different directory', async () => { + const h = await setup() + const same = join(root, 'same.jsonl') + await writeFile(same, `${JSON.stringify({ type: 'session', cwd: h.workspace })}\n`) + h.withPrior(h.prior(same)) + const resumed = await h.resolver(identity) + expect(resumed.sessionFile).toBe(same) + expect(resumed.forkFile).toBeUndefined() + expect(piRpcProviderLink(resumed, same, 2, 'next', 200).origin).toBe('resumed') + expect(() => piRpcProviderLink(resumed, join(root, 'wrong.jsonl'), 2, 'bad', 200)).toThrow( + 'different session file' + ) + + const elsewhere = join(root, 'elsewhere.jsonl') + await writeFile(elsewhere, `${JSON.stringify({ type: 'session', cwd: join(root, 'other') })}\n`) + h.withPrior(h.prior(elsewhere)) + const forked = await h.resolver(identity) + expect(forked.forkFile).toBe(elsewhere) + expect(forked.sessionFile).toBeUndefined() + expect(piRpcProviderLink(forked, join(root, 'fork.jsonl'), 2, 'fork', 200)).toMatchObject({ + origin: 'forked', + forkedFromKey: agentSessionProviderHandleKey(h.prior(elsewhere).handle) + }) + expect(() => piRpcProviderLink(forked, elsewhere, 2, 'bad', 200)).toThrow('did not fork') + }) + + it('resumes the stored session file after Orca restarts and reopens the store', async () => { + const h = await setup() + const file = join(root, 'conversation.jsonl') + await writeFile(file, `${JSON.stringify({ type: 'session', cwd: h.workspace })}\n`) + const fence = h.record.lease.runtimeFence + await h.store.commitProcessIdentity({ + sessionId: identity.sessionId, + fence, + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-pi' }, + now: 1_800_000_000_001 + }) + await h.store.proveOwner({ + sessionId: identity.sessionId, + fence, + link: { ...h.prior(file), mintedAtFence: fence }, + now: 1_800_000_000_002 + }) + // A restart: the store is closed and a fresh one reads the record back from disk. + closeTestJournalHostDatabase(root) + const reopened = await openTestAgentSessionRecordStore(root) + const launch = await createPiRpcLaunchResolver({ + store: reopened, + resolveWorkspacePath: async () => h.workspace, + resolveEnvironment: async () => ({ PATH: '/host/bin', HOME: '/host/home' }), + resolveCommand: () => '/host/bin/pi', + probeVersion + })(identity) + expect(launch.sessionFile).toBe(file) + expect(buildPiRpcLaunch(launch).args).toEqual(['--mode', 'rpc', '--session', file]) + }) + + it('distinguishes an unsaved creation from an existing session that could not restore', async () => { + const h = await setup() + const missing = join(root, 'missing.jsonl') + h.withPrior(h.prior(missing)) + const unsaved = await h.resolver(identity) + expect(unsaved.replacement).toBe('unsaved') + expect(piRpcProviderLink(unsaved, join(root, 'new.jsonl'), 2, 'new', 200)).toMatchObject({ + origin: 'created', + supersedesKey: agentSessionProviderHandleKey(h.prior(missing).handle) + }) + h.withPrior(h.prior(missing, 'resumed')) + const lost = await h.resolver(identity) + expect(lost.replacement).toBe('restore-failed') + expect( + piRpcProviderLink(lost, join(root, 'replacement.jsonl'), 2, 'replace', 200) + ).toMatchObject({ replaces: { reason: 'restore-failed', replacedAt: 200 } }) + }) + + it('refuses a different execution host and a foreign account home before resolving paths', async () => { + const h = await setup() + const record = h.store.getRecord(identity.sessionId) + if (!record) { + throw new Error('record missing') + } + vi.spyOn(h.store, 'getRecord').mockReturnValue({ + ...record, + location: { ...record.location, executionHostId: 'ssh:remote' } + }) + await expect(h.resolver(identity)).rejects.toThrow('another execution host') + vi.spyOn(h.store, 'getRecord').mockReturnValue({ + ...record, + accountHome: { variable: 'CODEX_HOME', path: '/wrong' } + }) + await expect(h.resolver(identity)).rejects.toThrow('account home') + expect(h.resolveCommand).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pi/rpc-launch-resolution.ts b/src/main/pi/rpc-launch-resolution.ts new file mode 100644 index 000000000000..0bc63833b5e7 --- /dev/null +++ b/src/main/pi/rpc-launch-resolution.ts @@ -0,0 +1,211 @@ +import { open, realpath } from 'node:fs/promises' +import { isAbsolute, normalize } from 'node:path' +import { z } from 'zod' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { + agentSessionProviderHandleChainHead, + agentSessionProviderHandleKey, + type AgentSessionProviderHandleLink +} from '../../shared/agent-session-provider-handle' +import { isLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { isStableCliVersionOnLine, probeAgentCliVersion } from '../agent-cli-version-probe' +import { + resolveStructuredAgentCommand, + type StructuredAgentCommandSettings +} from '../native-chat/structured-agent-command-resolution' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' +import type { PiRpcLaunchOptions } from './rpc-launch' + +export type PiRpcResolvedLaunch = PiRpcLaunchOptions & { + previous: AgentSessionProviderHandleLink | null + replacement?: 'unsaved' | 'restore-failed' +} +export type PiRpcLaunchResolverDeps = { + store: AgentSessionRecordStore + resolveWorkspacePath: (id: string) => Promise + resolveEnvironment: () => Promise + /** The user's Pi Command setting, re-read per acquisition; none runs the stock binary. */ + resolveCommandSettings?: () => StructuredAgentCommandSettings + resolveCommand?: typeof resolveCliCommand + probeVersion?: typeof probeAgentCliVersion + resolveFullAccess?: () => boolean +} + +// Stable releases from 0.84.0 emit agent_settled after retries and detached compaction; older +// Pi (the 0.73.x package included) keeps the terminal chat. +const PI_RPC_RELEASE_LINES = [ + { major: 0, floor: '0.84.0' }, + { major: 1, floor: '1.0.0' } +] as const + +function supportsPiRpcVersion(version: string): boolean { + return PI_RPC_RELEASE_LINES.some((line) => isStableCliVersionOnLine(version, line)) +} + +/** The binary a Pi launch with `env` spawns; create support resolves through here too. */ +export function resolvePiRpcCommand( + env: Readonly>, + commandSettings: StructuredAgentCommandSettings = {}, + resolve?: typeof resolveCliCommand +): string { + const homePath = env.HOME ?? env.USERPROFILE + return resolveStructuredAgentCommand( + 'pi', + commandSettings, + { pathEnv: env.PATH ?? env.Path ?? null, ...(homePath ? { homePath } : {}) }, + resolve ? { resolve } : {} + ) +} + +export function piRpcVersionSupported( + launch: { command: string; cwd: string; env: Record }, + probe: typeof probeAgentCliVersion = probeAgentCliVersion +): Promise { + return probe({ program: launch.command, cwd: launch.cwd, env: launch.env }, supportsPiRpcVersion) +} + +const headerSchema = z.object({ type: z.literal('session'), cwd: z.string().min(1) }) + +async function sessionDirectory(file: string): Promise { + let descriptor + try { + descriptor = await open(file, 'r') + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return null + } + throw error + } + try { + const buffer = Buffer.alloc(64 * 1024) + const { bytesRead } = await descriptor.read(buffer, 0, buffer.length, 0) + const data = buffer.subarray(0, bytesRead).toString('utf8') + const lf = data.indexOf('\n') + if (lf === -1) { + throw new Error('Pi session header is incomplete or oversized') + } + return headerSchema.parse(JSON.parse(data.slice(0, lf))).cwd + } finally { + await descriptor.close() + } +} + +async function sameDirectory(left: string, right: string): Promise { + const canonical = async (value: string): Promise => + realpath(value).catch(() => normalize(value)) + const [a, b] = await Promise.all([canonical(left), canonical(right)]) + return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b +} + +/** Reads the execution host's durable record; never resolves another host's files or account. */ +export function createPiRpcLaunchResolver( + deps: PiRpcLaunchResolverDeps +): (identity: AgentSessionJournalIdentity) => Promise { + return async (identity) => { + const record = deps.store.getRecord(identity.sessionId) + if (!record || record.provider !== 'pi') { + throw new Error('Pi session record unavailable') + } + if ( + record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID || + record.location.wslDistro !== null + ) { + throw new Error('Pi structured session belongs to another execution host') + } + if (process.platform === 'win32' && !isWindowsProcessStartTimeAvailable()) { + throw new Error('Pi structured sessions require Windows process creation-time proof') + } + const accountHome = record.accountHome + if ( + !isLegacyAgentSessionAccountHome(accountHome) || + accountHome.variable !== 'PI_CODING_AGENT_DIR' + ) { + throw new Error('Pi account home does not match its binary') + } + const cwd = await deps.resolveWorkspacePath(record.location.workspaceId) + const env: Record = {} + for (const [key, value] of Object.entries(await deps.resolveEnvironment())) { + if (value !== undefined) { + env[key] = value + } + } + env.PI_CODING_AGENT_DIR = accountHome.path + const previous = agentSessionProviderHandleChainHead(record.providerHandleChain) + let sessionFile: string | undefined + let forkFile: string | undefined + let replacement: PiRpcResolvedLaunch['replacement'] + if (previous) { + if ( + previous.handle.transport !== 'jsonl-rpc' || + previous.handle.agent !== 'pi' || + !isAbsolute(previous.handle.nativeId) + ) { + throw new Error('Pi resume handle is not a host session file') + } + const source = previous.handle.nativeId + const directory = await sessionDirectory(source) + if (directory === null) { + replacement = previous.origin === 'created' ? 'unsaved' : 'restore-failed' + } else if (await sameDirectory(directory, cwd)) { + sessionFile = source + } else { + forkFile = source + } + } + const command = resolvePiRpcCommand(env, deps.resolveCommandSettings?.(), deps.resolveCommand) + // Again at every launch: the binary on PATH may have changed since the chat was created. + if (!(await piRpcVersionSupported({ command, cwd, env }, deps.probeVersion))) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + return { + command, + cwd, + env, + fullAccess: deps.resolveFullAccess?.() ?? true, + previous, + ...(sessionFile ? { sessionFile } : {}), + ...(forkFile ? { forkFile } : {}), + ...(replacement ? { replacement } : {}) + } + } +} + +export function piRpcProviderLink( + launch: PiRpcResolvedLaunch, + file: string, + fence: number, + linkId: string, + at: number +): AgentSessionProviderHandleLink { + if (!isAbsolute(file)) { + throw new Error('Pi did not return an absolute session file') + } + const previous = launch.previous + if (launch.sessionFile && file !== launch.sessionFile) { + throw new Error('Pi resumed a different session file') + } + if (launch.forkFile && file === launch.forkFile) { + throw new Error('Pi did not fork the session for its new directory') + } + const key = previous ? agentSessionProviderHandleKey(previous.handle) : undefined + return { + linkId, + handle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file }, + origin: launch.forkFile ? 'forked' : launch.sessionFile ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: at, + ...(launch.forkFile && key ? { forkedFromKey: key } : {}), + ...(launch.replacement === 'unsaved' && key + ? { supersedesKey: key, ...(previous?.replaces ? { replaces: previous.replaces } : {}) } + : {}), + ...(launch.replacement === 'restore-failed' && key + ? { replaces: { key, reason: 'restore-failed', replacedAt: at } } + : {}) + } +} diff --git a/src/main/pi/rpc-launch.test.ts b/src/main/pi/rpc-launch.test.ts new file mode 100644 index 000000000000..678e84dadf36 --- /dev/null +++ b/src/main/pi/rpc-launch.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { resolveProviderChildEnv } from '../provider-process/provider-process-launch' +import { buildPiRpcLaunch } from './rpc-launch' + +const options = { command: 'host-resolved-pi', cwd: '/host/folder', fullAccess: true } +describe('Pi RPC launch', () => { + it('uses the supplied host binary and folder, and lets Pi choose its own session store', () => { + expect(buildPiRpcLaunch(options)).toMatchObject({ + command: options.command, + cwd: options.cwd, + args: ['--mode', 'rpc'] + }) + }) + + it.each([ + '/host/.pi/agent/sessions/session.jsonl', + 'C:\\Users\\dev\\.pi\\agent\\sessions\\session.jsonl' + ])('passes the native session file unchanged: %s', (sessionFile) => { + expect(buildPiRpcLaunch({ ...options, sessionFile }).args).toEqual([ + '--mode', + 'rpc', + '--session', + sessionFile + ]) + }) + + it('retains selected account and user extension args, but strips inherited pane hook identities after overlays', () => { + const launch = buildPiRpcLaunch({ + ...options, + extraArgs: ['--extension', '/host/my extension.ts'], + env: { + PI_CODING_AGENT_DIR: '/host/account', + ORCA_PANE_KEY: 'other-pane', + ORCA_AGENT_PANE: 'alias' + } + }) + const env = resolveProviderChildEnv(launch, { + ORCA_AGENT_HOOK_TOKEN: 'inherited', + API_KEY: 'retained' + }) + expect(env).toEqual({ API_KEY: 'retained', PI_CODING_AGENT_DIR: '/host/account' }) + expect(launch.args).toEqual(['--mode', 'rpc', '--extension', '/host/my extension.ts']) + }) + + it.each([ + ['--provider', 'anthropic', '--model', 'model-id'], + ['--provider=anthropic', '--model=model-id'], + ['--provider=anthropic', '-m', 'model-id'], + ['--model', 'anthropic/model-id'] + ])('accepts paired model/provider selections: %j', (...extraArgs) => { + expect(buildPiRpcLaunch({ ...options, extraArgs }).args).toEqual([ + '--mode', + 'rpc', + ...extraArgs + ]) + }) + + it.each([['--provider', 'anthropic'], ['--provider=anthropic'], ['--model'], ['--model=']])( + 'rejects incomplete provider/model args: %j', + (...extraArgs) => { + expect(() => buildPiRpcLaunch({ ...options, extraArgs })).toThrow(/requires/) + } + ) + + it.each([ + '--mode=json', + '--print', + '-p', + '--no-session', + '--session=other', + '--resume', + '--continue' + ])('rejects arguments that bypass the chat mode or its session handle: %s', (arg) => { + expect(() => buildPiRpcLaunch({ ...options, extraArgs: [arg] })).toThrow('conflicts') + }) + + it('rejects a supervised permissions request instead of silently running in full access', () => { + expect(() => buildPiRpcLaunch({ ...options, fullAccess: false })).toThrow('full access only') + }) + + it('rejects an empty supplied resume handle', () => { + expect(() => buildPiRpcLaunch({ ...options, sessionFile: '' })).toThrow( + 'requires a session file' + ) + }) + + it('scrubs inherited structured identity and injects only this acquisition identity', () => { + const inherited = { + ORCA_AGENT_SESSION_ID: 'stale', + ORCA_STRUCTURED_SESSION: '1', + ORCA_TERMINAL_HANDLE: 'structworker_stale', + ORCA_AGENT_SESSION_SPAWN_TOKEN: 'old-token' + } + expect(resolveProviderChildEnv(buildPiRpcLaunch(options), inherited)).toEqual({}) + const childEnv = resolveProviderChildEnv( + buildPiRpcLaunch({ + ...options, + env: inherited, + structuredSession: { id: 'this-session', spawnToken: 'this-token' } + }), + inherited + ) + expect(childEnv).toMatchObject({ + ORCA_AGENT_SESSION_ID: 'this-session', + ORCA_STRUCTURED_SESSION: '1', + ORCA_AGENT_SESSION_SPAWN_TOKEN: 'this-token' + }) + expect(childEnv).not.toHaveProperty('ORCA_TERMINAL_HANDLE') + }) +}) diff --git a/src/main/pi/rpc-launch.ts b/src/main/pi/rpc-launch.ts new file mode 100644 index 000000000000..98fba1dfb930 --- /dev/null +++ b/src/main/pi/rpc-launch.ts @@ -0,0 +1,119 @@ +import { + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ORCA_SCRUB_SAFE_PANE_ENV +} from '../../shared/agent-hook-scrub-safe-env' +import { + AGENT_HOOK_RUNTIME_ENV_KEYS, + ORCA_AGENT_SESSION_CALLER_ENV_KEYS +} from '../ipc/pty/host-env/spawn-env-keys' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' + +export type PiRpcLaunchOptions = { + /** Binary and paths are resolved by the execution host before building the launch. */ + command: string + cwd: string + fullAccess: boolean + extraArgs?: readonly string[] + env?: Record + sessionFile?: string + forkFile?: string + structuredSession?: { id: string; spawnToken: string } +} + +const CHILD_ENV_TO_DELETE: readonly string[] = [ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN', + ORCA_SCRUB_SAFE_PANE_ENV, + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ...AGENT_HOOK_RUNTIME_ENV_KEYS +] +const CALLER_ENV_TO_DELETE = [ + ...ORCA_AGENT_SESSION_CALLER_ENV_KEYS, + 'ORCA_TERMINAL_HANDLE', + 'ORCA_AGENT_SESSION_SPAWN_TOKEN' +] + +/** Pi stores its own sessions; an explicit session file is shared with terminal resumes. */ +export function buildPiRpcLaunch(options: PiRpcLaunchOptions): ProviderProcessLaunch { + if (!options.fullAccess) { + throw new Error('Pi structured chat supports full access only') + } + if (options.sessionFile !== undefined && !options.sessionFile.trim()) { + throw new Error('Pi resume requires a session file') + } + if ( + options.forkFile !== undefined && + (!options.forkFile.trim() || options.sessionFile !== undefined) + ) { + throw new Error('Pi fork requires one source session file') + } + const env = { ...options.env } + for (const key of CALLER_ENV_TO_DELETE) { + delete env[key] + } + const childEnv = options.structuredSession + ? { + ...structuredSessionChildIdentityEnv(options.structuredSession.id, env), + ORCA_AGENT_SESSION_SPAWN_TOKEN: options.structuredSession.spawnToken + } + : env + const args = [...(options.extraArgs ?? [])] + let provider = false + let model = false + for (let index = 0; index < args.length; index++) { + const arg = args[index] + const flag = arg.split('=')[0] + if (flag === '--provider' || flag === '--model' || flag === '-m') { + const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : args[++index] + if (!value || value.startsWith('-')) { + throw new Error(`Pi ${flag} requires a value`) + } + if (flag === '--provider') { + provider = true + } else { + model = true + } + } else if ( + [ + '--mode', + '--print', + '-p', + '--no-session', + '--session', + '-r', + '--resume', + '-c', + '--continue', + '--fork' + ].includes(flag) + ) { + throw new Error(`Pi ${flag} conflicts with the structured chat launch`) + } + } + if (provider && !model) { + throw new Error('Pi --provider requires --model') + } + return { + command: options.command, + cwd: options.cwd, + args: [ + '--mode', + 'rpc', + ...args, + ...(options.sessionFile ? ['--session', options.sessionFile] : []), + ...(options.forkFile ? ['--fork', options.forkFile] : []) + ], + env: childEnv, + envToDelete: [ + ...CHILD_ENV_TO_DELETE, + ...(options.structuredSession + ? Object.hasOwn(childEnv, 'ORCA_TERMINAL_HANDLE') + ? [] + : ['ORCA_TERMINAL_HANDLE'] + : CALLER_ENV_TO_DELETE) + ] + } +} diff --git a/src/main/pi/rpc-location.test.ts b/src/main/pi/rpc-location.test.ts new file mode 100644 index 000000000000..b3c500ba45ff --- /dev/null +++ b/src/main/pi/rpc-location.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { structuredAgentRuntimeRegistration } from '../runtime/structured-agent-runtime-registrations' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' +import { PiRpcSessionAdapter } from './rpc-session-adapter' + +vi.mock('../windows/windows-process-table', () => ({ isWindowsProcessStartTimeAvailable: vi.fn() })) + +const originalPlatform = process.platform +afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + vi.resetAllMocks() +}) + +const local: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' +} + +describe('Pi execution host support', () => { + it.each([false, true])( + 'requires Windows process identity proof at both support seams (%s)', + (proof) => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.mocked(isWindowsProcessStartTimeAvailable).mockReturnValue(proof) + const adapter = new PiRpcSessionAdapter({ + resolveLaunch: async () => { + throw new Error('A location check must not acquire a process') + }, + onLifecycle: vi.fn(), + onSettled: vi.fn(), + onIdle: vi.fn(), + logger: { warn: vi.fn(), error: vi.fn() } + }) + expect(structuredAgentRuntimeRegistration('pi')?.supportsLocation(local)).toBe(proof) + expect(adapter.supportsCreate(local, 'pi')).toBe(proof) + } + ) + + it('refuses another execution host and WSL without probing local process identity', () => { + const registration = structuredAgentRuntimeRegistration('pi') + expect(registration?.supportsLocation({ ...local, executionHostId: 'runtime:remote' })).toBe( + false + ) + expect(registration?.supportsLocation({ ...local, wslDistro: 'Ubuntu' })).toBe(false) + expect(isWindowsProcessStartTimeAvailable).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pi/rpc-message-dialog-shapes.test.ts b/src/main/pi/rpc-message-dialog-shapes.test.ts new file mode 100644 index 000000000000..26dc5b781214 --- /dev/null +++ b/src/main/pi/rpc-message-dialog-shapes.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { PiRpcMessages } from './rpc-messages' +import { PiRpcContextUsage } from './rpc-context-usage' +import { piRpcDialogPresentation } from './rpc-extension-dialogs' + +describe('recorded Pi message and extension shapes', () => { + it('publishes top-level live usage before the assistant message ends', () => { + const frameSchema = z.looseObject({ + type: z.string(), + usage: z + .object({ + input: z.number(), + output: z.number(), + cacheRead: z.number(), + cacheWrite: z.number() + }) + .optional() + }) + const frames = readFileSync( + join(import.meta.dirname, '__fixtures__', 'steer-followup.jsonl'), + 'utf8' + ) + .split('\n') + .filter(Boolean) + .map((line) => { + const envelope = z.object({ raw: z.string() }).parse(JSON.parse(line)) + return frameSchema.parse(JSON.parse(envelope.raw)) + }) + const update = frames.find( + (frame) => + frame.type === 'message_update' && + frame.usage && + Object.values(frame.usage).some((tokens) => tokens > 0) + ) + expect(update).toBeDefined() + const messages = new PiRpcMessages('generation', new PiRpcContextUsage()) + expect(messages.message(update, 123)).toContainEqual({ + type: 'context.usage', + usage: { + used: { + kind: 'estimate', + usage: { + inputTokens: update?.usage?.input, + outputTokens: update?.usage?.output, + cacheCreationInputTokens: update?.usage?.cacheWrite, + cacheReadInputTokens: update?.usage?.cacheRead + }, + capturedAt: 123 + } + } + }) + }) + + it.each(['input', 'editor', 'select'])('preserves the provider message for %s', (method) => { + const dialog = piRpcDialogPresentation({ + id: 'dialog', + method, + title: 'Choose', + message: 'Use staging', + options: ['value'] + }) + expect(dialog?.body).toMatchObject({ kind: 'question', question: 'Choose\n\nUse staging' }) + }) + + it('labels an empty select value while delivering its original string', () => { + const dialog = piRpcDialogPresentation({ + id: 'select', + method: 'select', + title: 'Choose', + options: [''] + }) + expect(dialog?.body).toMatchObject({ options: [{ id: 'option-0', label: 'Empty value' }] }) + expect( + dialog?.reply({ kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['option-0'] }] }) + ).toEqual({ value: '' }) + }) +}) diff --git a/src/main/pi/rpc-messages.ts b/src/main/pi/rpc-messages.ts new file mode 100644 index 000000000000..c463f49c8e1d --- /dev/null +++ b/src/main/pi/rpc-messages.ts @@ -0,0 +1,162 @@ +import { z } from 'zod' +import type { AgentJournalToolCallItem } from '../../shared/agent-session-journal-types' +import { + boundPayload, + boundToolInput, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { piRpcMessageEventSchema, piRpcToolEventSchema } from './rpc-protocol' +import type { PiRpcContextUsage } from './rpc-context-usage' + +const resultSchema = z.object({ + content: z.array(z.object({ type: z.string(), text: z.string().optional() })) +}) + +/** Pi does not name assistant messages; ordinals are local to this exact child. */ +export class PiRpcMessages { + private ordinal = 0 + private active?: number + private readonly text = new Map() + private readonly tools = new Map() + + constructor( + private readonly generation: string, + private readonly context: PiRpcContextUsage + ) {} + + reset(): void { + this.active = undefined + this.text.clear() + this.tools.clear() + } + + message(value: unknown, at: number): ProviderTimelineEvent[] { + const parsed = piRpcMessageEventSchema.safeParse(value) + if (!parsed.success) { + throw new Error('Invalid Pi message event') + } + const event = parsed.data + const message = event.message ?? event.assistantMessageEvent?.partial + if (message && message.role !== 'assistant') { + return [] + } + if (event.type === 'message_start') { + this.active = ++this.ordinal + this.text.clear() + return [] + } + const events = this.context.live(event.usage ?? message?.usage, at) + this.active ??= ++this.ordinal + const update = event.assistantMessageEvent + if ( + event.type === 'message_update' && + update?.delta !== undefined && + ['text_delta', 'thinking_delta'].includes(update.type) + ) { + const channel = update.type === 'text_delta' ? 'assistant' : 'reasoning' + const index = update.contentIndex ?? 0 + const item = this.key(index, channel) + if (this.text.size >= 128 && !this.text.has(item)) { + throw new Error('Pi message stream capacity exceeded') + } + this.text.set(item, { index, channel }) + events.push({ type: 'text.delta', item: { id: item }, channel, text: update.delta }) + } + if (event.type === 'message_end' && message) { + const content = + typeof message.content === 'string' + ? [{ type: 'text', text: message.content }] + : message.content + content.forEach((block, index) => { + const channel = + block.type === 'text' ? 'assistant' : block.type === 'thinking' ? 'reasoning' : undefined + if (!channel) { + return + } + const item = this.key(index, channel) + const text = channel === 'assistant' ? block.text : block.thinking + if (text !== undefined) { + // A snapshot without deltas still opens the same message row. + if (!this.text.has(item) && text) { + events.push({ type: 'text.delta', item: { id: item }, channel, text }) + } + events.push({ type: 'text.close', item: { id: item }, text }) + this.text.delete(item) + } + }) + for (const item of this.text.keys()) { + events.push({ type: 'text.close', item: { id: item } }) + } + this.text.clear() + this.active = undefined + } + return events + } + + tool(value: unknown): ProviderTimelineEvent[] { + const parsed = piRpcToolEventSchema.safeParse(value) + if (!parsed.success) { + throw new Error('Invalid Pi tool event') + } + const event = parsed.data + const previous = this.tools.get(event.toolCallId) + const result = event.result ?? event.partialResult + const content = resultSchema.safeParse(result) + const output = + result === undefined + ? undefined + : content.success + ? content.data.content + .flatMap((block) => (block.text === undefined ? [] : [block.text])) + .join('\n') + : JSON.stringify(result) + const state = + event.type === 'tool_execution_end' ? (event.isError ? 'failed' : 'completed') : 'running' + const body: AgentJournalToolCallItem = { + kind: 'tool-call', + name: event.toolName, + callId: event.toolCallId, + input: + event.args === undefined + ? (previous?.input ?? null) + : boundToolInput(event.args, DEFAULT_JOURNAL_PAYLOAD_LIMITS), + state, + ...(output === undefined + ? previous?.output + ? { output: previous.output } + : {} + : { + output: boundPayload(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + }) + } + if (state === 'running') { + if (this.tools.size >= 128 && !previous) { + throw new Error('Pi tool stream capacity exceeded') + } + this.tools.set(event.toolCallId, body) + if ( + [...this.tools.values()].reduce( + (bytes, tool) => bytes + Buffer.byteLength(JSON.stringify(tool)), + 0 + ) > + 1024 * 1024 + ) { + throw new Error('Pi tool stream byte capacity exceeded') + } + } else { + this.tools.delete(event.toolCallId) + } + return [ + { + type: state === 'running' ? (previous ? 'item.update' : 'item.open') : 'item.close', + item: `tool:${event.toolCallId}`, + body + } + ] + } + + private key(index: number, channel: string): string { + return `message:${this.generation}:${this.active}:${index}:${channel}` + } +} diff --git a/src/main/pi/rpc-options.test.ts b/src/main/pi/rpc-options.test.ts new file mode 100644 index 000000000000..5beaefb54ca1 --- /dev/null +++ b/src/main/pi/rpc-options.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest' +import { + applyPiRpcSessionOption, + parsePiModelOptionId, + readPiRpcCommands, + readPiRpcSessionOptions, + type PiRpcRequester +} from './rpc-options' + +function requester( + answers: Record +): PiRpcRequester & { request: ReturnType } { + return { request: vi.fn(async (command: string) => answers[command]) } +} + +describe('Pi RPC session options', () => { + it('reads provider-qualified models and the reported thinking level', async () => { + const rpc = requester({ + get_available_models: { + models: [ + { + provider: 'openai-codex', + id: 'gpt-6.1-sol', + name: 'GPT-6.1 Sol', + reasoning: true, + thinkingLevelMap: { off: null, low: 'low', medium: 'medium', high: 'high' } + }, + { provider: 'other', id: 'gpt-6.1-sol', name: 'Other Sol', reasoning: false } + ] + }, + get_state: { + model: { provider: 'openai-codex', id: 'gpt-6.1-sol' }, + thinkingLevel: 'medium', + sessionFile: '/sessions/current.jsonl', + isStreaming: false, + isCompacting: false + } + }) + const result = await readPiRpcSessionOptions(rpc) + expect(rpc.request.mock.calls.map((call) => call[0])).toEqual([ + 'get_available_models', + 'get_state' + ]) + expect(result.current).toEqual({ + model: 'openai-codex/gpt-6.1-sol', + effort: 'medium', + confirmed: ['model', 'effort'] + }) + expect(result.models.map((model) => [model.id, model.isDefault])).toEqual([ + ['openai-codex/gpt-6.1-sol', false], + ['other/gpt-6.1-sol', false] + ]) + expect(result.models[0]?.efforts.map((effort) => effort.value)).toEqual([ + 'minimal', + 'low', + 'medium', + 'high' + ]) + expect(result.models[1]?.efforts).toEqual([]) + }) + + it('sends both provider and model ID and stores only successful explicit selections', async () => { + const rpc = requester({ set_model: {}, set_thinking_level: undefined }) + const selected = new Map() + await expect( + applyPiRpcSessionOption(rpc, selected, 'model', 'openai-codex/gpt-6.1-sol') + ).resolves.toEqual({ model: 'openai-codex/gpt-6.1-sol' }) + await expect(applyPiRpcSessionOption(rpc, selected, 'effort', 'high')).resolves.toEqual({ + model: 'openai-codex/gpt-6.1-sol', + effort: 'high' + }) + expect(rpc.request.mock.calls).toEqual([ + ['set_model', { provider: 'openai-codex', modelId: 'gpt-6.1-sol' }], + ['set_thinking_level', { level: 'high' }] + ]) + expect(() => parsePiModelOptionId('openai-codex')).toThrow() + await expect(applyPiRpcSessionOption(rpc, selected, 'model', '/gpt-6.1-sol')).rejects.toThrow() + expect(selected.get('model')).toBe('openai-codex/gpt-6.1-sol') + const rejected = requester({}) + rejected.request.mockRejectedValueOnce(new Error('provider refused model')) + await expect( + applyPiRpcSessionOption(rejected, selected, 'model', 'other/new-model') + ).rejects.toThrow('provider refused model') + expect(selected.get('model')).toBe('openai-codex/gpt-6.1-sol') + }) + + it('rejects malformed provider data and reports only Pi-listed commands', async () => { + const malformed = requester({ get_available_models: { models: [{ id: 'missing-provider' }] } }) + await expect(readPiRpcSessionOptions(malformed)).rejects.toThrow() + const rpc = requester({ + get_commands: { + commands: [ + { name: 'review', description: 'Review changes', source: 'extension' }, + { name: 'search', source: 'skill' } + ] + } + }) + expect(await readPiRpcCommands(rpc)).toEqual({ + commands: [ + { name: 'review', kind: 'command', description: 'Review changes' }, + { name: 'search', kind: 'skill' } + ] + }) + }) + + it('handles a signed-out state with no model', async () => { + const rpc = requester({ + get_available_models: { models: [] }, + get_state: { + model: null, + thinkingLevel: 'off', + sessionFile: '/sessions/current.jsonl', + isStreaming: false, + isCompacting: false + } + }) + expect((await readPiRpcSessionOptions(rpc)).current).toEqual({ + model: '', + effort: 'off', + confirmed: ['effort'] + }) + }) + + it('keeps ordinary thinking levels when the map only supplies overrides', async () => { + const rpc = requester({ + get_available_models: { + models: [ + { + provider: 'openai-codex', + id: 'gpt-5.5', + reasoning: true, + thinkingLevelMap: { minimal: 'low', xhigh: 'xhigh' } + } + ] + }, + get_state: { sessionFile: '/sessions/current.jsonl', isStreaming: false, isCompacting: false } + }) + expect( + (await readPiRpcSessionOptions(rpc)).models[0]?.efforts.map(({ value }) => value) + ).toEqual(['off', 'minimal', 'low', 'medium', 'high', 'xhigh']) + }) +}) diff --git a/src/main/pi/rpc-options.ts b/src/main/pi/rpc-options.ts new file mode 100644 index 000000000000..9b59bec398ad --- /dev/null +++ b/src/main/pi/rpc-options.ts @@ -0,0 +1,119 @@ +import { z } from 'zod' +import type { + AgentSessionCommandsResult, + AgentSessionOptionsResult +} from '../../shared/agent-session-wire' +import { piRpcModelSchema, piRpcStateSchema } from './rpc-protocol' + +export type PiRpcRequester = { + request( + command: string, + params?: Record, + options?: { timeoutMs?: number | null } + ): Promise +} + +const modelSchema = piRpcModelSchema.safeExtend({ + provider: z.string().min(1), + id: z.string().min(1), + thinkingLevelMap: z.record(z.string(), z.string().nullable()).optional() +}) +const modelsSchema = z.object({ models: z.array(modelSchema) }) +const stateSchema = piRpcStateSchema.safeExtend({ + model: modelSchema.nullish() +}) +const commandsSchema = z.object({ + commands: z.array( + z.object({ + name: z.string().min(1), + description: z.string().optional(), + source: z.string().optional() + }) + ) +}) + +const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const + +export function piModelOptionId(provider: string, modelId: string): string { + if (!provider || !modelId || provider.includes('/')) { + throw new Error('Pi model requires an explicit provider and model ID') + } + return `${provider}/${modelId}` +} + +export function parsePiModelOptionId(value: string): { provider: string; modelId: string } { + const slash = value.indexOf('/') + if (slash < 1 || slash === value.length - 1) { + throw new Error('Pi model requires an explicit provider and model ID') + } + return { provider: value.slice(0, slash), modelId: value.slice(slash + 1) } +} + +export async function readPiRpcSessionOptions( + rpc: PiRpcRequester +): Promise { + const models = modelsSchema.parse(await rpc.request('get_available_models')).models + const state = stateSchema.parse(await rpc.request('get_state')) + const currentModel = state.model + const currentId = currentModel ? piModelOptionId(currentModel.provider, currentModel.id) : '' + const listed = models.map((model) => { + const id = piModelOptionId(model.provider, model.id) + const efforts = + model.reasoning !== true + ? [] + : THINKING_LEVELS.filter((level) => { + const mapped = model.thinkingLevelMap?.[level] + return ( + mapped !== null && ((level !== 'xhigh' && level !== 'max') || mapped !== undefined) + ) + }).map((level) => ({ value: level, label: level })) + return { + id, + label: model.name || id, + isDefault: false, + efforts + } + }) + return { + models: listed, + current: { + model: currentId, + ...(state.thinkingLevel ? { effort: state.thinkingLevel } : {}), + confirmed: [...(currentId ? ['model'] : []), ...(state.thinkingLevel ? ['effort'] : [])] + } + } +} + +export async function applyPiRpcSessionOption( + rpc: PiRpcRequester, + selected: Map, + key: string, + value: string +): Promise>> { + if (key === 'model') { + const model = parsePiModelOptionId(value) + modelSchema.parse({ provider: model.provider, id: model.modelId }) + await rpc.request('set_model', model) + } else if (key === 'effort') { + if (!THINKING_LEVELS.some((level) => level === value)) { + throw new Error(`Unsupported Pi thinking level: ${value}`) + } + await rpc.request('set_thinking_level', { level: value }) + } else { + throw new Error(`Unsupported Pi option: ${key}`) + } + selected.set(key, value) + return Object.fromEntries(selected) +} + +export async function readPiRpcCommands(rpc: PiRpcRequester): Promise { + const { commands } = commandsSchema.parse(await rpc.request('get_commands')) + return { + commands: commands.map((command) => ({ + name: command.name, + kind: command.source === 'skill' ? 'skill' : 'command', + ...(command.source === undefined ? { kindUnspecified: true as const } : {}), + ...(command.description ? { description: command.description } : {}) + })) + } +} diff --git a/src/main/pi/rpc-prompt-delivery.test.ts b/src/main/pi/rpc-prompt-delivery.test.ts new file mode 100644 index 000000000000..471f9ecf0bc0 --- /dev/null +++ b/src/main/pi/rpc-prompt-delivery.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PiRpcPromptDelivery } from './rpc-prompt-delivery' + +const deliveries: PiRpcPromptDelivery[] = [] +afterEach(() => { + for (const delivery of deliveries.splice(0)) { + delivery.end() + } + vi.useRealTimers() +}) +function rig() { + const send = vi.fn(async () => {}), + accepted = vi.fn(), + settled = vi.fn(), + beforeWrite = vi.fn() + const delivery = new PiRpcPromptDelivery({ + send, + accepted, + settled, + beforeWrite, + commandOnly: vi.fn(), + rejectedAfterAcceptance: vi.fn(), + failed: vi.fn() + }) + deliveries.push(delivery) + return { delivery, send, accepted, settled, beforeWrite } +} +const queued = { + type: 'response', + command: 'prompt', + success: true, + data: { disposition: 'queued' } +} + +describe('Pi pending prompt delivery bounds and ownership', () => { + it('bounds queued prompts even after their acknowledgements arrive', async () => { + const h = rig() + for (let i = 0; i < 128; i++) { + await h.delivery.submit(`prompt:${i}`, i, { type: 'prompt', message: 'queued' }) + h.delivery.reply(queued) + } + const before = vi.fn(async () => {}) + expect( + await h.delivery.submit('overflow', 129, { type: 'prompt', message: 'overflow' }, before) + ).toMatchObject({ state: 'rejected', rejection: { kind: 'queueFull' } }) + expect(before).not.toHaveBeenCalled() + expect(h.send).toHaveBeenCalledTimes(128) + h.delivery.consumeNext() + await expect( + h.delivery.submit('after-consumption', 130, { type: 'prompt', message: 'next' }) + ).resolves.toEqual({ state: 'admitted' }) + }) + + it('bounds retained retry payload bytes before journal dispatch admission', async () => { + const h = rig() + const frame = { type: 'prompt', message: 'x'.repeat(8 * 1024 * 1024 - 128) } + for (let i = 0; i < 4; i++) { + await h.delivery.submit(`large:${i}`, i, frame) + } + const before = vi.fn(async () => {}) + expect(await h.delivery.submit('overflow', 5, frame, before)).toMatchObject({ + state: 'rejected', + rejection: { kind: 'queueFull' } + }) + expect(before).not.toHaveBeenCalled() + h.delivery.consumeNext() + await expect(h.delivery.submit('fits-after-consumption', 6, frame)).resolves.toEqual({ + state: 'admitted' + }) + }) + + it('does not consume an input whose dispatch commit has not finished', async () => { + const h = rig() + const pending = Promise.withResolvers() + const submitted = h.delivery.submit( + 'pending', + 1, + { type: 'prompt', message: 'pending' }, + () => pending.promise + ) + expect(h.delivery.consumeNext()).toBe(false) + expect(h.beforeWrite).not.toHaveBeenCalled() + pending.resolve() + await submitted + expect(h.delivery.consumeNext()).toBe(true) + expect(h.accepted).toHaveBeenCalledWith('pending', 1) + }) + + it('associates a later run with the prompt actually written while an auth retry waits', async () => { + vi.useFakeTimers() + const h = rig() + await h.delivery.submit('retrying', 1, { type: 'prompt', message: 'first' }) + h.delivery.reply({ + type: 'response', + command: 'prompt', + success: false, + error: 'No API key found for provider' + }) + await h.delivery.submit('second', 2, { type: 'prompt', message: 'second' }) + h.delivery.consumeNext() + expect(h.accepted).toHaveBeenCalledExactlyOnceWith('second', 2) + h.delivery.reply(queued) + await vi.advanceTimersByTimeAsync(250) + h.delivery.consumeNext() + expect(h.accepted).toHaveBeenLastCalledWith('retrying', 1) + }) + + it('settles a pending auth retry as unknown on process teardown', async () => { + vi.useFakeTimers() + const h = rig() + await h.delivery.submit('retrying', 1, { type: 'prompt', message: 'first' }) + h.delivery.reply({ + type: 'response', + command: 'prompt', + success: false, + error: 'No API key found for provider' + }) + h.delivery.end() + expect(h.settled).toHaveBeenCalledWith( + 'retrying', + expect.objectContaining({ state: 'unknown' }) + ) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/pi/rpc-prompt-delivery.ts b/src/main/pi/rpc-prompt-delivery.ts new file mode 100644 index 000000000000..eef0f51c0bfd --- /dev/null +++ b/src/main/pi/rpc-prompt-delivery.ts @@ -0,0 +1,203 @@ +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { JsonlRpcRecord } from '../jsonl-rpc/peer' +import { piRpcPromptReplySchema } from './rpc-protocol' + +type Submission = { + id: string + at: number + frame?: JsonlRpcRecord + bytes: number + accepted: boolean + retries: number +} +type DeliveryDeps = { + send: (frame: JsonlRpcRecord) => Promise + accepted: (id: string, at: number) => void + commandOnly: () => void + rejectedAfterAcceptance: (error: string) => void + settled: (id: string, outcome: AgentSessionDispatchOutcome) => void + failed: (error: Error) => void + beforeWrite?: () => void + refused?: () => void +} + +/** Idless prompt acknowledgements are FIFO and may wait indefinitely on extension dialogs. */ +export class PiRpcPromptDelivery { + private readonly acknowledgements: Submission[] = [] + private readonly waiting: Submission[] = [] + private readonly retries = new Set>() + private readonly pending = new Set() + private heldBytes = 0 + private ended = false + constructor(private readonly deps: DeliveryDeps) {} + get holdsDispatch(): boolean { + return this.waiting.length > 0 || this.retries.size > 0 + } + + async submit( + id: string, + at: number, + frame: JsonlRpcRecord, + before?: () => Promise + ): Promise { + if (this.ended) { + throw new Error('Pi prompt queue unavailable') + } + const bytes = Buffer.byteLength(JSON.stringify(frame)) + if (this.pending.size >= 128 || this.heldBytes + bytes > 32 * 1024 * 1024) { + return { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('queueFull'), { + agentName: 'Pi', + surface: 'rejection' + }) + } + } + const submission: Submission = { id, at, frame, bytes, accepted: false, retries: 0 } + this.pending.add(submission) + this.heldBytes += bytes + try { + await before?.() + if (this.ended) { + throw new Error('Pi ended before writing the prompt') + } + this.deps.beforeWrite?.() + this.acknowledgements.push(submission) + this.waiting.push(submission) + await this.deps.send(frame) + return { state: 'admitted' } + } catch { + this.remove(submission) + this.deps.refused?.() + return { state: 'unknown', reason: 'Pi prompt write did not settle' } + } + } + + consumeNext(): boolean { + const submission = this.waiting[0] + if (!submission) { + return false + } + this.accept(submission) + return true + } + + reply(frame: JsonlRpcRecord): void { + const reply = piRpcPromptReplySchema.parse(frame) + const submission = this.acknowledgements.shift() + if (!submission) { + throw new Error('Pi prompt reply has no request') + } + if (!reply.success) { + if ( + !submission.accepted && + reply.error?.startsWith('No API key found for ') && + submission.retries++ < 8 + ) { + this.removeWaiting(submission) + const timer = setTimeout(() => { + this.retries.delete(timer) + if (this.ended) { + return + } + const frame = submission.frame + if (!frame) { + this.deps.failed(new Error('Pi retry lost its unaccepted prompt')) + return + } + this.acknowledgements.push(submission) + this.waiting.push(submission) + void this.deps + .send(frame) + .catch((error: unknown) => + this.deps.failed(error instanceof Error ? error : new Error(String(error))) + ) + }, 250) + timer.unref() + this.retries.add(timer) + return + } + this.remove(submission) + const fact = agentSessionFailureFact( + reply.error?.startsWith('No API key found for ') ? 'notSignedIn' : 'providerRejected', + { detail: providerDiagnostic(reply.error ?? 'Pi rejected the prompt', 'person') } + ) + this.deps.settled(submission.id, { + state: 'rejected', + ...agentSessionFailureWords(fact, { agentName: 'Pi', surface: 'rejection' }) + }) + if (submission.accepted) { + this.deps.rejectedAfterAcceptance(reply.error ?? 'Pi rejected the prompt') + } else { + this.deps.refused?.() + } + return + } + this.releaseFrame(submission) + if (reply.data?.disposition === 'handled' || reply.data?.agentInvoked === false) { + this.accept(submission) + this.deps.commandOnly() + } + this.finishIfConsumed(submission) + } + + end(): void { + this.ended = true + for (const timer of this.retries) { + clearTimeout(timer) + } + this.retries.clear() + for (const submission of this.pending) { + if (!submission.accepted) { + this.deps.settled(submission.id, { + state: 'unknown', + reason: 'Pi ended before confirming delivery' + }) + } + this.releaseFrame(submission) + } + this.waiting.length = 0 + this.acknowledgements.length = 0 + this.pending.clear() + } + + private accept(submission: Submission): void { + if (submission.accepted) { + return + } + submission.accepted = true + this.removeWaiting(submission) + this.releaseFrame(submission) + this.finishIfConsumed(submission) + this.deps.accepted(submission.id, submission.at) + } + private remove(submission: Submission): void { + for (const list of [this.waiting, this.acknowledgements]) { + const index = list.indexOf(submission) + if (index !== -1) { + list.splice(index, 1) + } + } + this.releaseFrame(submission) + this.pending.delete(submission) + } + private removeWaiting(submission: Submission): void { + const index = this.waiting.indexOf(submission) + if (index !== -1) { + this.waiting.splice(index, 1) + } + } + private releaseFrame(submission: Submission): void { + if (submission.frame) { + this.heldBytes -= submission.bytes + submission.frame = undefined + } + } + private finishIfConsumed(submission: Submission): void { + if (!this.waiting.includes(submission) && !this.acknowledgements.includes(submission)) { + this.pending.delete(submission) + } + } +} diff --git a/src/main/pi/rpc-prompt.test.ts b/src/main/pi/rpc-prompt.test.ts new file mode 100644 index 000000000000..f73dd02e5508 --- /dev/null +++ b/src/main/pi/rpc-prompt.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt' + +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/lXcAAAAASUVORK5CYII=', + 'base64' +) + +function message(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks } +} + +describe('Pi RPC prompt preparation', () => { + it('encodes host-read image bytes and sends steer/followUp as prompt behavior', async () => { + const readLocal = vi.fn(async () => PNG) + const body = message([ + { type: 'text', text: 'Look at this' }, + { type: 'image-ref', path: '/workspace/screenshot.png' }, + { type: 'text', text: 'then reply' } + ]) + expect(await preparePiRpcPrompt(body, 'steer', { readLocal })).toEqual({ + type: 'prompt', + message: 'Look at this\nthen reply', + streamingBehavior: 'steer', + images: [{ type: 'image', data: PNG.toString('base64'), mimeType: 'image/png' }] + }) + expect(readLocal).toHaveBeenCalledWith('/workspace/screenshot.png') + expect( + (await preparePiRpcPrompt(message([{ type: 'text', text: 'next' }]), 'followUp')) + .streamingBehavior + ).toBe('followUp') + }) + + it('rejects URL refs with a typed unsupported attachment failure', async () => { + const readLocal = vi.fn(async () => PNG) + const rejected = await preparePiRpcPrompt( + message([{ type: 'image-ref', url: 'https://example.test/image.png' }]), + undefined, + { readLocal } + ).catch((error: unknown) => error) + expect(rejected).toBeInstanceOf(PiRpcPromptError) + expect(rejected).toMatchObject({ + failure: { kind: 'attachmentInvalid', attachment: { reason: 'unsupportedType' } } + }) + expect(readLocal).not.toHaveBeenCalled() + }) + + it('checks local image MIME against bytes and rejects empty prompts', async () => { + const readLocal = vi.fn(async () => PNG) + await expect( + preparePiRpcPrompt(message([{ type: 'image-ref', path: '/a.jpg' }]), undefined, { readLocal }) + ).rejects.toThrow() + await expect(preparePiRpcPrompt(message([{ type: 'text', text: '' }]))).rejects.toThrow() + }) + + it('rejects a prompt beyond the RPC writer budget', async () => { + const oversizedText = 'x'.repeat(32 * 1024 * 1024) + await expect( + preparePiRpcPrompt(message([{ type: 'text', text: oversizedText }])) + ).rejects.toThrow('RPC write limit') + }) +}) diff --git a/src/main/pi/rpc-prompt.ts b/src/main/pi/rpc-prompt.ts new file mode 100644 index 000000000000..655c96167c5f --- /dev/null +++ b/src/main/pi/rpc-prompt.ts @@ -0,0 +1,174 @@ +import { extname } from 'node:path' +import { resolveJsonlRpcPeerOptions } from '../jsonl-rpc/peer-limits' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { NativeChatImageRefBlock } from '../../shared/native-chat-types' +import { + agentSessionFailureFact, + type AgentSessionAttachmentProblem, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import { + ClaudeDispatchContentError, + readClaudeImage +} from '../claude/claude-structured-dispatch-content' + +const MAX_IMAGE_BYTES = 5 * 1024 * 1024 +const MAX_IMAGE_COUNT = 20 +const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024 +const MAX_PROMPT_WIRE_BYTES = resolveJsonlRpcPeerOptions().maxLineBytes - 1 +const MIME_BY_EXTENSION: Readonly> = { + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp' +} + +export type PiRpcPrompt = Record & { + type: 'prompt' + message: string + images: { type: 'image'; data: string; mimeType: string }[] + streamingBehavior?: 'steer' | 'followUp' +} + +type ImageReads = { + readLocal(path: string): Promise +} + +export class PiRpcPromptError extends Error { + constructor( + message: string, + readonly failure: SubmissionRejectionFact + ) { + super(message) + this.name = 'PiRpcPromptError' + } +} + +function attachmentError( + message: string, + attachment: AgentSessionAttachmentProblem +): PiRpcPromptError { + return new PiRpcPromptError(message, agentSessionFailureFact('attachmentInvalid', { attachment })) +} + +function imageMime(bytes: Uint8Array): string | null { + if ( + bytes.length >= 8 && + Buffer.from(bytes.subarray(0, 8)).equals(Buffer.from('89504e470d0a1a0a', 'hex')) + ) { + return 'image/png' + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg' + } + if ( + bytes.length >= 6 && + Buffer.from(bytes.subarray(0, 6)) + .toString('ascii') + .match(/^GIF8[79]a$/) + ) { + return 'image/gif' + } + if ( + bytes.length >= 12 && + Buffer.from(bytes.subarray(0, 4)).toString('ascii') === 'RIFF' && + Buffer.from(bytes.subarray(8, 12)).toString('ascii') === 'WEBP' + ) { + return 'image/webp' + } + return null +} + +async function readImage( + block: NativeChatImageRefBlock, + reads: ImageReads +): Promise<{ bytes: Buffer; mimeType: string }> { + if (block.url) { + throw attachmentError('Pi does not accept image URLs', { reason: 'unsupportedType' }) + } + if (!block.path) { + throw attachmentError('Pi image needs a local path', { reason: 'noSource' }) + } + const mimeType = MIME_BY_EXTENSION[extname(block.path).toLowerCase()] + if (!mimeType) { + throw attachmentError('Pi image file type is unsupported', { reason: 'unsupportedType' }) + } + let bytes: Buffer + try { + bytes = await reads.readLocal(block.path) + } catch (error) { + if (error instanceof ClaudeDispatchContentError) { + throw new PiRpcPromptError('Pi image could not be read', error.failure) + } + throw new PiRpcPromptError( + 'Pi image could not be read', + agentSessionFailureFact('attachmentUnreadable') + ) + } + if (bytes.byteLength === 0) { + throw attachmentError('Pi image is empty', { reason: 'empty' }) + } + if (bytes.byteLength > MAX_IMAGE_BYTES) { + throw attachmentError('Pi image exceeds the size limit', { + reason: 'tooLarge', + limit: MAX_IMAGE_BYTES + }) + } + if (imageMime(bytes) !== mimeType) { + throw attachmentError('Pi image file did not contain a supported image', { + reason: 'unsupportedType' + }) + } + return { bytes, mimeType } +} + +export async function preparePiRpcPrompt( + body: AgentJournalMessageItem, + streamingBehavior?: PiRpcPrompt['streamingBehavior'], + reads: ImageReads = { readLocal: readClaudeImage } +): Promise { + if (body.role !== 'user') { + throw new Error('Pi prompt must be a user message') + } + const texts: string[] = [] + const images: PiRpcPrompt['images'] = [] + let totalImageBytes = 0 + for (const block of body.blocks) { + if (block.type === 'text' && block.text.length > 0) { + texts.push(block.text) + } else if (block.type === 'image-ref') { + if (images.length >= MAX_IMAGE_COUNT) { + throw attachmentError('Pi prompt has too many images', { + reason: 'tooMany', + limit: MAX_IMAGE_COUNT + }) + } + const { bytes, mimeType } = await readImage(block, reads) + totalImageBytes += bytes.byteLength + if (totalImageBytes > MAX_TOTAL_IMAGE_BYTES) { + throw attachmentError('Pi images exceed the total size limit', { + reason: 'totalTooLarge', + limit: MAX_TOTAL_IMAGE_BYTES + }) + } + images.push({ type: 'image', data: bytes.toString('base64'), mimeType }) + } + } + if (texts.length === 0 && images.length === 0) { + throw new PiRpcPromptError('Pi prompt is empty', agentSessionFailureFact('emptyMessage')) + } + const prompt: PiRpcPrompt = { + type: 'prompt', + message: texts.join('\n'), + images, + ...(streamingBehavior ? { streamingBehavior } : {}) + } + if (Buffer.byteLength(JSON.stringify(prompt), 'utf8') > MAX_PROMPT_WIRE_BYTES) { + throw new PiRpcPromptError( + 'Pi prompt exceeds the RPC write limit', + agentSessionFailureFact('historyTooLarge') + ) + } + return prompt +} diff --git a/src/main/pi/rpc-protocol.ts b/src/main/pi/rpc-protocol.ts new file mode 100644 index 000000000000..b4aeab23a4ac --- /dev/null +++ b/src/main/pi/rpc-protocol.ts @@ -0,0 +1,109 @@ +import { z } from 'zod' + +export const piRpcModelSchema = z.looseObject({ + id: z.string(), + provider: z.string(), + name: z.string().optional(), + contextWindow: z.number().nonnegative().optional(), + reasoning: z.boolean().optional(), + input: z.array(z.string()).optional() +}) +export type PiRpcModel = z.infer + +export const piRpcStateSchema = z.looseObject({ + model: piRpcModelSchema.nullish(), + thinkingLevel: z.string().optional(), + sessionFile: z.string().min(1), + sessionId: z.string().optional(), + isStreaming: z.boolean(), + isCompacting: z.boolean(), + pendingMessageCount: z.number().int().nonnegative().optional() +}) +export type PiRpcState = z.infer + +export function piRpcIdle(state: PiRpcState): boolean { + return !state.isStreaming && !state.isCompacting && (state.pendingMessageCount ?? 0) === 0 +} + +export const piRpcContentSchema = z.looseObject({ + type: z.string(), + text: z.string().optional(), + thinking: z.string().optional() +}) +export const piRpcUsageSchema = z.object({ + input: z.number().nonnegative(), + output: z.number().nonnegative(), + cacheRead: z.number().nonnegative(), + cacheWrite: z.number().nonnegative() +}) +export const piRpcMessageSchema = z.looseObject({ + role: z.string(), + content: z.union([z.string(), z.array(piRpcContentSchema)]), + stopReason: z.string().optional(), + errorMessage: z.string().optional(), + usage: piRpcUsageSchema.optional(), + timestamp: z.number().optional() +}) +export const piRpcMessageEventSchema = z.object({ + type: z.enum(['message_start', 'message_update', 'message_end']), + message: piRpcMessageSchema.optional(), + usage: piRpcUsageSchema.optional(), + assistantMessageEvent: z + .looseObject({ + type: z.string(), + delta: z.string().optional(), + contentIndex: z.number().int().nonnegative().optional(), + partial: piRpcMessageSchema.optional() + }) + .optional() +}) +export type PiRpcMessageEvent = z.infer + +export const piRpcToolEventSchema = z.object({ + type: z.enum(['tool_execution_start', 'tool_execution_update', 'tool_execution_end']), + toolCallId: z.string(), + toolName: z.string(), + args: z.unknown().optional(), + partialResult: z.unknown().optional(), + result: z.unknown().optional(), + isError: z.boolean().optional() +}) +export type PiRpcToolEvent = z.infer + +export const piRpcPromptReplySchema = z.object({ + type: z.literal('response'), + command: z.literal('prompt'), + success: z.boolean(), + error: z.string().optional(), + data: z + .object({ + disposition: z.enum(['started', 'queued', 'handled']).optional(), + agentInvoked: z.boolean().optional() + }) + .optional() +}) + +export const piRpcDialogSchema = z.discriminatedUnion('method', [ + z.object({ + id: z.union([z.string(), z.number()]), + method: z.literal('confirm'), + title: z.string(), + message: z.string().optional() + }), + z.object({ + id: z.union([z.string(), z.number()]), + method: z.literal('select'), + title: z.string(), + message: z.string().optional(), + options: z.array(z.string()).min(1).max(64) + }), + z.object({ + id: z.union([z.string(), z.number()]), + method: z.enum(['input', 'editor']), + title: z.string(), + message: z.string().optional(), + placeholder: z.string().optional(), + prefill: z.string().optional() + }) +]) +export type PiRpcDialog = z.infer diff --git a/src/main/pi/rpc-retry-activity.ts b/src/main/pi/rpc-retry-activity.ts new file mode 100644 index 000000000000..a5b9713dd4ce --- /dev/null +++ b/src/main/pi/rpc-retry-activity.ts @@ -0,0 +1,23 @@ +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { JsonlRpcRecord } from '../jsonl-rpc/peer' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' + +/** Retries occupy the existing activity surface until the provider resumes or exhausts them. */ +export function piRpcRetryActivity(frame: JsonlRpcRecord): ProviderTimelineEvent { + return { + type: 'activity', + text: + frame.type === 'auto_retry_start' + ? agentSessionFailureWords( + agentSessionFailureFact('providerRetrying', { + detail: + typeof frame.errorMessage === 'string' + ? providerDiagnostic(frame.errorMessage, 'person') + : undefined + }), + { agentName: 'Pi', surface: 'row' } + ).text + : null + } +} diff --git a/src/main/pi/rpc-runtime-registration.test.ts b/src/main/pi/rpc-runtime-registration.test.ts new file mode 100644 index 000000000000..b993c63db6a8 --- /dev/null +++ b/src/main/pi/rpc-runtime-registration.test.ts @@ -0,0 +1,107 @@ +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { PI_RPC_RUNTIME_REGISTRATION } from './rpc-runtime-registration' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) + +type LaunchInput = Parameters>[0] +function supportsLaunch(launch: LaunchInput): Promise { + const check = PI_RPC_RUNTIME_REGISTRATION.supportsLaunch + if (!check) { + throw new Error('Pi registers a launch check') + } + return check(launch) +} +let root: string +let stockPi: string + +function prints(stdout: string, result: Partial>> = {}) { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + timedOut: false, + stdout, + stderr: '', + ...result + }) +} + +beforeEach(async () => { + vi.mocked(runProcess).mockReset() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'info').mockImplementation(() => {}) + root = await mkdtemp(join(tmpdir(), 'orca-pi-support-')) + stockPi = join(root, process.platform === 'win32' ? 'pi.cmd' : 'pi') + await writeFile(stockPi, '') + await chmod(stockPi, 0o755) +}) +afterEach(async () => { + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +const input = (commandSettings: LaunchInput['commandSettings'] = {}): LaunchInput => ({ + cwd: '/host/workspace', + env: { PATH: root, HOME: '/host/home' }, + commandSettings +}) + +describe('Pi launch support before session creation', () => { + it.each(['0.84.0', '0.99.0', '1.0.0', '1.0.4\n', 'pi v1.12.3'])( + 'accepts stable Pi from 0.84.0: %s', + async (out) => { + prints(out) + await expect(supportsLaunch(input())).resolves.toBe(true) + expect(runProcess).toHaveBeenCalledWith({ + program: stockPi, + cwd: '/host/workspace', + env: { PATH: root, HOME: '/host/home' }, + args: ['--version'], + timeoutMs: 5_000, + maxOutputBytes: 4_096, + killOnOutputLimit: true + }) + } + ) + + it.each([ + '0.73.1', + '0.83.9', + '0.84.0-rc.1', + '1.0.0-rc.1', + '1.1.0-preview.1', + '2.0.0', + 'unknown', + '' + ])('keeps the terminal chat for an older, prerelease or unknown version: %s', async (out) => { + prints(out) + await expect(supportsLaunch(input())).resolves.toBe(false) + }) + + it.each([ + { code: 1, timedOut: false, outputTruncated: false }, + { code: 0, timedOut: true, outputTruncated: false }, + { code: 0, timedOut: false, outputTruncated: true } + ])('does not infer support from incomplete or failed output: %j', async (result) => { + prints('1.0.4', result) + await expect(supportsLaunch(input())).resolves.toBe(false) + }) + + it('probes the binary the Command setting names, not the stock one', async () => { + prints('1.0.4') + await expect( + supportsLaunch(input({ agentCmdOverrides: { pi: `"${process.execPath}"` } })) + ).resolves.toBe(true) + expect(runProcess).toHaveBeenCalledWith(expect.objectContaining({ program: process.execPath })) + }) + + it('leaves an unrunnable Command setting to the launch to refuse', async () => { + await expect(supportsLaunch(input({ agentCmdOverrides: { pi: '/missing/pi' } }))).resolves.toBe( + true + ) + expect(runProcess).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pi/rpc-runtime-registration.ts b/src/main/pi/rpc-runtime-registration.ts new file mode 100644 index 000000000000..fca0a4b411f0 --- /dev/null +++ b/src/main/pi/rpc-runtime-registration.ts @@ -0,0 +1,73 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { agentSessionAccountHome } from '../../shared/agent-session-account-home' +import { isAgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' +import type { + StructuredAgentAdapterContext, + StructuredAgentRuntimeAdapter, + StructuredAgentRuntimeRegistration +} from '../runtime/structured-agent-runtime-registrations' +import { PI_RPC_AGENT } from './rpc-agent-definition' +import { + createPiRpcLaunchResolver, + piRpcVersionSupported, + resolvePiRpcCommand +} from './rpc-launch-resolution' +import { PiRpcSessionAdapter } from './rpc-session-adapter' + +function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { + const { deps } = context + return new PiRpcSessionAdapter({ + resolveLaunch: createPiRpcLaunchResolver({ + store: context.store, + resolveWorkspacePath: deps.resolveWorkspacePath, + resolveEnvironment: async () => ({ + ...(await context.environment.resolveBaseEnvironment()), + ...deps.resolveAgentLaunchEnv?.('pi') + }), + ...(deps.resolveAgentCommandSettings + ? { resolveCommandSettings: deps.resolveAgentCommandSettings } + : {}) + }), + ...(deps.openPiConnection ? { openConnection: deps.openPiConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), + onLifecycle: context.deliverLifecycle, + onSettled: ({ sessionId, clientMessageId, outcome }) => { + if (outcome.state === 'admitted') { + return + } + context.followUps.onDispatchSettledLate({ + sessionId, + clientMessageId, + ...(outcome.state === 'accepted' ? { providerIdentity: outcome.providerIdentity } : outcome) + }) + }, + onIdle: context.followUps.releaseUnansweredDispatches, + logger: deps.logger + }) +} + +export const PI_RPC_RUNTIME_REGISTRATION: StructuredAgentRuntimeRegistration = { + definition: PI_RPC_AGENT, + createAdapter: createPiRpcAdapter, + supportsLocation: supportsSupervisedProviderChildLocation, + supportsLaunch: async ({ cwd, env, commandSettings }) => { + let command: string + try { + command = resolvePiRpcCommand(env, commandSettings) + } catch (error) { + // An unrunnable Command setting is the launch's refusal to state, not a terminal. + if (isAgentSessionPreSpawnError(error)) { + return true + } + throw error + } + return piRpcVersionSupported({ command, cwd, env }) + }, + resolveAccountHome: async ({ launchEnv }) => + agentSessionAccountHome( + PI_RPC_AGENT, + launchEnv.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent') + ) +} diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts new file mode 100644 index 000000000000..023e972a4481 --- /dev/null +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -0,0 +1,562 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' +import { + AgentSessionAcquisitionRootExitObservedError, + type StructuredAgentSessionAcquireInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { JsonlRpcAgentConnectionOptions } from '../jsonl-rpc/agent-connection' +import type { JsonlRpcRecord } from '../jsonl-rpc/peer' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' +import type { PiRpcConnection } from './rpc-session' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { PiRpcSessionAdapter } from './rpc-session-adapter' + +const sessionId = 'session-timeline' +const file = '/host/account/sessions/session.jsonl' +const state = { + sessionFile: file, + isStreaming: false, + isCompacting: false, + model: { provider: 'anthropic', id: 'model-1' } +} +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } + await closeProviderTimelineRigs() +}) + +class FakeConnection implements PiRpcConnection { + pid = 4123 + closed = false + rootVerdict: 'live' | 'unverifiable' | 'exited' = 'live' + processless = false + deferExit = false + lastCloseResult: PiRpcConnection['lastCloseResult'] = null + closeResult: Awaited> = { root: 'exited', tree: 'exited' } + readonly sent: JsonlRpcRecord[] = [] + readonly requests: string[] = [] + private readonly exitListeners: (() => void)[] = [] + requestOverride?: (command: string) => Promise | undefined + abortRequest?: () => void + constructor(readonly handlers: JsonlRpcAgentConnectionOptions) {} + async request(command: string): Promise { + this.requests.push(command) + const override = this.requestOverride?.(command) + if (override) { + return override + } + if (command === 'get_state') { + return state + } + if (command === 'get_available_models') { + return { models: [state.model] } + } + if (command === 'get_commands') { + return { commands: [{ name: 'help' }] } + } + if (command === 'set_thinking_level') { + throw new Error('saved effort unavailable') + } + return {} + } + async send(frame: JsonlRpcRecord): Promise { + this.sent.push(frame) + } + async close(): Promise>> { + this.abortRequest?.() + this.closed = true + this.rootVerdict = this.closeResult.root + this.lastCloseResult = this.closeResult + if (this.closeResult.root === 'exited' && !this.deferExit) { + this.exit() + } + return this.closeResult + } + pauseReading(): void {} + resumeReading(): void {} + onExit(listener: () => void): void { + if (this.rootVerdict === 'exited') { + listener() + } else { + this.exitListeners.push(listener) + } + } + receive(frame: JsonlRpcRecord): void { + this.handlers.onRecord?.(frame) + } + exit(error = new Error('child exited')): void { + this.closed = true + this.rootVerdict = 'exited' + for (const listener of this.exitListeners.splice(0)) { + listener() + } + this.handlers.onExit?.(error, { + expected: false, + exit: { code: 1, signal: null, processless: false } + }) + } +} + +async function setup( + options: Readonly> = {}, + eventSink?: (sink: StructuredAgentSessionEventSink) => StructuredAgentSessionEventSink +) { + const rig = await openProviderTimelineRig({ agent: 'pi', sessionId }) + const connections: FakeConnection[] = [] + const lifecycle = vi.fn(), + settled = vi.fn(), + idle = vi.fn() + const onSpawned = vi.fn(async (_process: AgentSessionProcessIdentity) => { + expect(connections.at(-1)?.requests).toEqual([]) + }) + const input: StructuredAgentSessionAcquireInput = { + identity: { + sessionId, + workspaceId: 'folder-1', + hostId: 'local', + agent: 'pi', + providerHandle: null + }, + fence: 7, + spawnToken: 'spawn-token', + options, + events: eventSink?.(rig.eventSink) ?? rig.eventSink, + onSpawned + } + const resolveLaunch = vi.fn(async () => ({ + command: '/host/bin/pi', + cwd: '/host/folder', + fullAccess: true, + previous: null + })) + const openConnection = vi.fn( + (_launch: ProviderProcessLaunch, handlers: JsonlRpcAgentConnectionOptions) => { + const connection = new FakeConnection(handlers) + connections.push(connection) + return connection + } + ) + const adapter = new PiRpcSessionAdapter({ + resolveLaunch, + readProcessStartTime: async () => 12345, + openConnection, + onLifecycle: lifecycle, + onSettled: settled, + onIdle: idle, + logger: { warn: vi.fn(), error: vi.fn() } + }) + const acquired = await adapter.acquire(input) + const connection = connections[0] + if (!connection) { + throw new Error('connection missing') + } + cleanups.push(async () => { + await adapter.closeAll().catch(() => {}) + await adapter.drainObservedExits() + adapter.acknowledgeSessionRelease(sessionId) + }) + return { + adapter, + connection, + rig, + acquired, + onSpawned, + lifecycle, + settled, + idle, + input, + resolveLaunch, + openConnection, + connections + } +} + +describe('Pi RPC session ownership and delivery', () => { + it('does not spawn a start cancelled while resolving its workspace', async () => { + const h = await setup() + await h.adapter.closeSession(sessionId) + const launch = await h.resolveLaunch.mock.results[0]?.value + const resolving = Promise.withResolvers>() + h.resolveLaunch.mockImplementationOnce(() => resolving.promise) + const controller = new AbortController() + const started = h.adapter.acquire({ ...h.input, fence: 8, signal: controller.signal }) + await Promise.resolve() + controller.abort(new Error('Pi closed while starting')) + await expect(started).rejects.toThrow('closed while starting') + expect(h.connections).toHaveLength(1) + await expect(h.adapter.acquire({ ...h.input, fence: 9 })).resolves.toMatchObject({ + link: { origin: 'created' } + }) + resolving.resolve(launch!) + await Promise.resolve() + expect(h.connections).toHaveLength(2) + }) + + it('kills the child and rejects a stalled startup when the host aborts it', async () => { + const h = await setup() + await h.adapter.closeSession(sessionId) + const controller = new AbortController() + const opened = Promise.withResolvers() + const reply = Promise.withResolvers() + h.openConnection.mockImplementationOnce((_launch, handlers) => { + const connection = new FakeConnection(handlers) + connection.requestOverride = (command) => + command === 'get_state' ? reply.promise : undefined + connection.abortRequest = () => reply.reject(new Error('closed while starting')) + h.connections.push(connection) + opened.resolve(connection) + return connection + }) + const started = h.adapter.acquire({ ...h.input, fence: 8, signal: controller.signal }) + const child = await opened.promise + await Promise.resolve() + controller.abort(new Error('Pi closed while starting')) + await expect(started).rejects.toThrow('closed while starting') + expect(child.rootVerdict).toBe('exited') + }) + + it('retains final events when exit publication times out under journal backpressure', async () => { + let blocked = true + const h = await setup({}, (sink) => ({ + ...sink, + tryAppendTransition: (transition) => + blocked + ? { accepted: false, reason: 'backpressure' } + : sink.tryAppendTransition!(transition) + })) + vi.useFakeTimers() + h.connection.receive({ + type: 'extension_ui_request', + id: 'last-dialog', + method: 'input', + title: 'Final tail' + }) + h.connection.exit() + await vi.advanceTimersByTimeAsync(2_001) + expect(h.lifecycle).toHaveBeenCalledOnce() + h.adapter.acknowledgeSessionRelease(sessionId) + let completed = false + const drained = h.adapter.drainObservedExits().then(() => { + completed = true + }) + await Promise.resolve() + expect(completed).toBe(false) + blocked = false + await vi.advanceTimersByTimeAsync(250) + await drained + expect( + (await h.rig.rows()).some( + (row) => row.body.kind === 'question' && row.body.question === 'Final tail' + ) + ).toBe(true) + vi.useRealTimers() + }) + it('lets a new acquisition proceed while the released child drains final stdout', async () => { + const h = await setup() + h.connection.deferExit = true + await h.adapter.closeSession(sessionId) + h.adapter.acknowledgeSessionRelease(sessionId) + await expect(h.adapter.acquire({ ...h.input, fence: 8 })).resolves.toMatchObject({ + link: { origin: 'created' } + }) + let drained = false + const delivery = h.adapter.drainObservedExits().then(() => { + drained = true + }) + await Promise.resolve() + expect(drained).toBe(false) + h.connection.exit() + await delivery + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ fence: 7, cause: 'requested-close' }) + ) + }) + + it('preserves an unexpected failure cause during forced cleanup', async () => { + const h = await setup() + await h.adapter.forceCloseSession(sessionId) + await h.adapter.drainObservedExits() + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'unexpected-exit', reason: 'Pi event sink failed' }) + ) + }) + it('records the spawned child before the first startup RPC and links the native file', async () => { + const h = await setup({ effort: 'high', unknown: 'old-value' }) + expect(h.onSpawned).toHaveBeenCalledWith({ + hostId: 'local', + pid: 4123, + processStartTimeMs: 12345, + spawnToken: 'spawn-token' + }) + expect(h.acquired.process).toEqual(h.onSpawned.mock.calls[0]?.[0]) + expect(h.acquired.link).toMatchObject({ + handle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file }, + origin: 'created', + mintedAtFence: 7 + }) + expect(h.adapter.readOptionRestoreFailures(sessionId)).toEqual(['effort', 'unknown']) + expect(h.connection.requests).toContain('get_commands') + }) + + it('admits a send before Pi confirms it and settles only after journal acceptance', async () => { + const h = await setup() + const result = await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'send-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + }) + expect(result).toEqual({ state: 'admitted' }) + expect(h.settled).not.toHaveBeenCalled() + h.connection.receive({ type: 'agent_start' }) + expect(h.settled).toHaveBeenCalledWith({ + sessionId, + clientMessageId: 'send-1', + fence: 7, + outcome: { + state: 'accepted', + providerIdentity: { provider: 'orca', clientMessageId: 'send-1' } + } + }) + expect(h.connection.sent[0]).toMatchObject({ type: 'prompt', message: 'hello' }) + }) + + it('rejects stale fences and a Stop for a different turn before sending abort', async () => { + const h = await setup() + await expect( + h.adapter.dispatch({ + sessionId, + fence: 6, + clientMessageId: 'stale', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'x' }] } + }) + ).rejects.toThrow('not live under this fence') + await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'send-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'x' }] } + }) + h.connection.receive({ type: 'agent_start' }) + expect( + await h.adapter.cancelTurn({ + sessionId, + fence: 7, + turnId: 'other', + resolveLiveTurnId: () => h.rig.assembler.openTurnId + }) + ).toEqual({ cancelled: false }) + expect(h.connection.requests).not.toContain('abort') + expect((await h.adapter.cancelTurn({ sessionId, fence: 7 })).cancelled).toBe(true) + expect(h.connection.requests).toContain('abort') + expect(await h.adapter.closeSession(sessionId)).toBe(true) + expect(h.connection.closed).toBe(true) + }) + + it('keeps a pending manual compaction cancellable and reports root/tree proof honestly', async () => { + const h = await setup() + const turnId = 'manual-compact' + const command = { + clientMessageId: 'compact-1', + turnId, + identity: { provider: 'orca' as const, clientMessageId: 'compact-1' }, + resultIdentity: { provider: 'orca' as const, clientMessageId: 'compact-result' }, + running: { kind: 'turn' as const, turnId, state: 'running' as const } + } + let release: (() => void) | undefined + h.connection.requestOverride = (name) => + name === 'compact' + ? new Promise((resolve) => { + release = resolve + }) + : undefined + const compact = h.adapter.compact({ sessionId, fence: 7, command }) + expect((await h.adapter.cancelTurn({ sessionId, fence: 7 })).cancelled).toBe(true) + expect(h.connection.requests).toContain('abort') + release?.() + await compact + h.connection.closeResult = { root: 'exited', tree: 'unverifiable' } + await expect(h.adapter.closeSession(sessionId)).rejects.toBeInstanceOf( + AgentSessionAcquisitionRootExitObservedError + ) + }) + + it('ends the host command row under its original identity after a no-op compaction', async () => { + const h = await setup() + const command = { + clientMessageId: 'compact-1', + turnId: 'manual-compact', + identity: { provider: 'orca' as const, clientMessageId: 'compact-turn' }, + resultIdentity: { provider: 'orca' as const, clientMessageId: 'compact-result' }, + running: { kind: 'turn' as const, turnId: 'manual-compact', state: 'running' as const } + } + h.rig.journal.appendItem(command.identity, agentJournalTurnBody(command.running), { + fence: 1, + turnScope: { kind: 'thread' } + }) + h.connection.requestOverride = (name) => + name === 'compact' + ? Promise.reject(new JsonlRpcResponseError(name, 'Nothing to compact (session too small)')) + : undefined + expect(await h.adapter.compact({ sessionId, fence: 7, command })).toEqual({ + state: 'accepted', + providerIdentity: null + }) + await Promise.resolve() + await Promise.resolve() + expect( + (await h.rig.rows()).find((row) => row.itemId === agentJournalItemKey(command.identity))?.body + ).toMatchObject({ kind: 'turn', state: 'completed', outcome: 'success' }) + expect( + (await h.rig.rows()).some((row) => row.body.kind === 'status' && row.body.tone === 'warning') + ).toBe(true) + }) + + it('answers a live dialog once after commit and does not claim it after dismissal', async () => { + const h = await setup() + h.connection.receive({ + type: 'extension_ui_request', + id: 'input-1', + method: 'editor', + title: 'Edit', + prefill: 'draft' + }) + const row = (await h.rig.rows()).find((item) => item.body.kind === 'question') + expect(row?.body).toMatchObject({ + kind: 'question', + freeTextInput: { allowEmpty: true, initialValue: 'draft' } + }) + if (!row) { + throw new Error('dialog row missing') + } + let release: (() => void) | undefined + const commit = vi.fn( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const response = { + kind: 'answers' as const, + answers: [{ questionId: 'input-1', optionIds: [], other: '' }] + } + const first = h.adapter.answerPrompt({ + sessionId, + fence: 7, + itemId: row.itemId, + kind: 'question', + response, + commit + }) + await expect( + h.adapter.answerPrompt({ + sessionId, + fence: 7, + itemId: row.itemId, + kind: 'question', + response, + commit: vi.fn() + }) + ).rejects.toThrow('no longer waiting') + release?.() + await first + expect(commit).toHaveBeenCalledTimes(1) + expect(h.connection.sent).toContainEqual({ + type: 'extension_ui_response', + id: 'input-1', + value: '' + }) + await expect( + h.adapter.dismissPrompt({ + sessionId, + fence: 7, + itemId: row.itemId, + answer: true, + commit: vi.fn() + }) + ).rejects.toThrow('no longer waiting') + }) + + it('releases a failed dialog commit for a later dismissal, with one provider reply', async () => { + const h = await setup() + h.connection.receive({ + type: 'extension_ui_request', + id: 2, + method: 'confirm', + title: 'Continue?' + }) + const row = (await h.rig.rows()).find((item) => item.body.kind === 'approval') + if (!row) { + throw new Error('approval row missing') + } + await expect( + h.adapter.answerPrompt({ + sessionId, + fence: 7, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'yes' }, + commit: async () => { + throw new Error('CAS lost') + } + }) + ).rejects.toThrow('CAS lost') + expect(h.connection.sent).toEqual([]) + let release: (() => void) | undefined + const dismiss = h.adapter.dismissPrompt({ + sessionId, + fence: 7, + itemId: row.itemId, + answer: true, + commit: () => + new Promise((resolve) => { + release = resolve + }) + }) + await expect( + h.adapter.answerPrompt({ + sessionId, + fence: 7, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'no' }, + commit: vi.fn() + }) + ).rejects.toThrow('no longer waiting') + release?.() + await dismiss + expect(h.connection.sent).toEqual([{ type: 'extension_ui_response', id: 2, cancelled: true }]) + }) + + it('reports unexpected child exit once and preserves an unproven root on close', async () => { + const h = await setup() + h.connection.exit(new Error('provider died')) + await h.adapter.drainObservedExits() + h.connection.exit(new Error('duplicate exit')) + await h.adapter.drainObservedExits() + expect(h.lifecycle).toHaveBeenCalledTimes(1) + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ended', + sessionId, + fence: 7, + acquisitionGeneration: h.acquired.acquisitionGeneration, + cause: 'unexpected-exit', + reason: 'provider died' + }) + ) + + const other = await setup() + other.connection.closeResult = { root: 'unverifiable', tree: null } + expect(await other.adapter.closeSession(sessionId)).toBe(false) + }) +}) diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts new file mode 100644 index 000000000000..c746d15b2ebd --- /dev/null +++ b/src/main/pi/rpc-session-adapter.ts @@ -0,0 +1,287 @@ +import { randomUUID } from 'node:crypto' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { + AgentSessionPreSpawnError, + AgentSessionAcquisitionRootExitObservedError, + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionExitProvenError, + type AgentSessionAcquisition, + type StructuredAgentSessionAcquireInput, + type StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { providerSpawnedProcessIdentity } from '../provider-process/provider-spawned-process-identity' +import { ProviderAcquisitionStarts } from '../provider-process/provider-acquisition-starts' +import { compactPiRpcSession } from './rpc-compaction' +import { buildPiRpcLaunch } from './rpc-launch' +import { piRpcProviderLink, type PiRpcResolvedLaunch } from './rpc-launch-resolution' +import { PiRpcSession, type PiRpcSessionDeps, type PiRpcConnection } from './rpc-session' +import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt' +import { applyPiRpcSessionOption, readPiRpcSessionOptions } from './rpc-options' +import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' +import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content' + +export type PiRpcSessionAdapterDeps = PiRpcSessionDeps & { + resolveLaunch: ( + identity: StructuredAgentSessionAcquireInput['identity'] + ) => Promise + readProcessStartTime?: (pid: number) => Promise +} + +export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { + private readonly sessions = new Map() + private readonly acquiring = new Set() + private readonly starts = new ProviderAcquisitionStarts() + private readonly retiring = new Set>() + constructor(private readonly deps: PiRpcSessionAdapterDeps) {} + + supportsLocation = supportsSupervisedProviderChildLocation + supportsCreate: NonNullable = ( + location, + agent + ) => agent === 'pi' && this.supportsLocation(location) + + async acquire(input: StructuredAgentSessionAcquireInput): Promise { + const id = input.identity.sessionId + if (this.acquiring.has(id)) { + throw new AgentSessionPreSpawnError(new Error('Pi session already owns a child')) + } + this.acquiring.add(id) + const attempt = this.starts.begin(input.signal) + let session: PiRpcSession | undefined + try { + if (attempt.signal.aborted) { + throw new AgentSessionPreSpawnError(new Error('Pi closed while starting')) + } + if (!(await this.starts.stopFailed(id))) { + throw new AgentSessionAcquisitionExitUnprovenError( + new Error('Previous Pi start has not exited') + ) + } + const previous = this.sessions.get(id) + if (previous?.connection.closed && previous.connection.rootVerdict === 'exited') { + this.acknowledgeSessionRelease(id) + } else if (previous) { + throw new AgentSessionPreSpawnError(new Error('Pi session already owns a child')) + } + let launch: PiRpcResolvedLaunch + try { + launch = await waitForPromiseWithSignal( + this.deps.resolveLaunch(input.identity), + attempt.signal + ) + } catch (error) { + throw new AgentSessionPreSpawnError(error) + } + if (attempt.signal.aborted) { + throw new AgentSessionPreSpawnError(new Error('Pi closed while starting')) + } + const spec = buildPiRpcLaunch({ + ...launch, + structuredSession: { id, spawnToken: input.spawnToken } + }) + session = new PiRpcSession(input, randomUUID(), spec, this.deps) + this.sessions.set(id, session) + this.starts.track(attempt, session.connection) + const spawned = providerSpawnedProcessIdentity( + input, + 'Pi RPC', + this.deps.readProcessStartTime + ) + if (session.connection.pid !== undefined) { + await spawned.onSpawned(session.connection.pid) + } + const process = await spawned.read(session.connection.pid) + const file = await waitForPromiseWithSignal(session.start(), attempt.signal) + if (session.connection.closed || attempt.signal.aborted) { + throw new Error('Pi exited while starting') + } + return { + process, + acquisitionGeneration: session.generation, + link: piRpcProviderLink(launch, file, input.fence, randomUUID(), Date.now()) + } + } catch (error) { + if (!session) { + throw error instanceof AgentSessionPreSpawnError || + error instanceof AgentSessionAcquisitionExitUnprovenError + ? error + : new AgentSessionPreSpawnError(error) + } + const result = await session.close(false).catch(() => null) + if (result?.root === 'exited') { + if (session.connection.processless) { + throw new AgentSessionAcquisitionExitProvenError(error) + } + throw new AgentSessionAcquisitionRootExitObservedError(error) + } + this.starts.retainFailed(id, session.connection) + throw new AgentSessionAcquisitionExitUnprovenError(error) + } finally { + this.starts.end(attempt) + this.acquiring.delete(id) + } + } + + dispatch: StructuredAgentSessionAdapter['dispatch'] = async (input) => { + const session = this.session(input.sessionId, input.fence) + let prompt + try { + prompt = await preparePiRpcPrompt(input.body) + } catch (error) { + this.deps.logger.warn('Pi prompt could not be prepared', { + scope: 'pi-prompt', + sessionId: input.sessionId, + error + }) + return { + state: 'rejected', + ...agentSessionFailureWords( + error instanceof PiRpcPromptError || error instanceof ClaudeDispatchContentError + ? error.failure + : agentSessionFailureFact('attachmentUnreadable'), + { agentName: 'Pi', surface: 'rejection' } + ) + } + } + return session.turns.submit( + input.clientMessageId, + input.requestedAt ?? Date.now(), + { ...prompt }, + input.beforeDispatch + ) + } + + compact: NonNullable = async (input) => + compactPiRpcSession(this.session(input.sessionId, input.fence), input.command) + + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = async (input) => { + const session = this.session(input.sessionId, input.fence) + const turnId = input.resolveLiveTurnId ? input.resolveLiveTurnId() : session.lane.openTurnId + if (input.turnId !== undefined && input.turnId !== turnId) { + return { cancelled: false } + } + if (!turnId && !session.turns.holdsDispatch) { + return { cancelled: false } + } + session.turns.stop() + session.dialogs.cancelAll() + await session.connection.request('abort', {}, { timeoutMs: 2_000 }) + return { cancelled: true, ...(turnId ? { turnId } : {}) } + } + stopEndsSession(): boolean { + return true + } + awaitStoppedRequestEnd: NonNullable = + async (id, at) => { + const session = this.sessions.get(id) + const turn = session?.lane.openTurnId + if (!session) { + return + } + let timer: ReturnType | undefined + try { + if (turn) { + await Promise.race([ + session.lane.whenTurnLeaves(turn), + new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(0, at + 2_000 - Date.now())) + timer.unref() + }) + ]) + } + } finally { + clearTimeout(timer) + } + try { + await session.connection.request('get_state', {}, { timeoutMs: 1_000 }) + } catch (error) { + this.deps.logger.warn('Pi checkpoint could not be read before stopping', { + scope: 'pi-stop-checkpoint', + sessionId: id, + error + }) + } + } + routePromptCancel(): { kind: 'dismiss' } { + return { kind: 'dismiss' } + } + dismissPrompt: NonNullable = (input) => + this.session(input.sessionId, input.fence).dialogs.respond( + input.itemId, + null, + input.commit, + input.answer + ) + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => + this.session(input.sessionId, input.fence).dialogs.respond( + input.itemId, + input.response, + input.commit + ) + setOption: StructuredAgentSessionAdapter['setOption'] = (input) => { + const session = this.session(input.sessionId, input.fence) + return applyPiRpcSessionOption(session.connection, session.selected, input.key, input.value) + } + readOptions: NonNullable = (input) => + readPiRpcSessionOptions(this.session(input.sessionId, input.fence).connection) + readCommands = (id: string) => this.sessions.get(id)?.commands + readOptionRestoreFailures(id: string): readonly string[] { + return this.sessions.get(id)?.skipped ?? [] + } + holdsDispatch(id: string): boolean { + return this.sessions.get(id)?.turns.holdsDispatch ?? false + } + + async closeSession(id: string, requested = true): Promise { + const session = this.sessions.get(id) + if (!session) { + return true + } + const result = await session.close(requested) + if (result.root !== 'exited') { + return false + } + if (result.tree !== 'exited') { + throw new AgentSessionAcquisitionRootExitObservedError(new Error('Pi root exit observed')) + } + return true + } + releaseAcquisition(input: { sessionId: string }): Promise { + return this.closeSession(input.sessionId) + } + forceCloseSession(id: string): Promise { + this.sessions.get(id)?.fail(new Error('Pi event sink failed')) + return this.closeSession(id, false) + } + disposeSession(id: string): Promise { + return this.closeSession(id) + } + acknowledgeSessionRelease(id: string): void { + const session = this.sessions.get(id) + if (!session?.connection.closed || session.connection.rootVerdict !== 'exited') { + return + } + this.sessions.delete(id) + const retirement = session.retire() + this.retiring.add(retirement) + void retirement.then(() => this.retiring.delete(retirement)) + } + async closeAll(): Promise { + await Promise.all([...this.sessions.keys()].map((id) => this.closeSession(id))) + } + async drainObservedExits(): Promise { + await Promise.all([ + ...this.retiring, + ...[...this.sessions.values()].map((session) => session.drainObservedExit()) + ]) + } + private session(id: string, fence: number): PiRpcSession { + const session = this.sessions.get(id) + if (!session || session.input.fence !== fence || session.connection.closed) { + throw new Error('Pi session is not live under this fence') + } + return session + } +} diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts new file mode 100644 index 000000000000..7796ae3a1024 --- /dev/null +++ b/src/main/pi/rpc-session.ts @@ -0,0 +1,273 @@ +import type { + AgentSessionOptionsResult, + AgentSessionSlashCommand +} from '../../shared/agent-session-wire' +import type { + StructuredAgentSessionAcquireInput, + StructuredAgentSessionLifecycleEvent, + AgentSessionDispatchOutcome +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane' +import { + JsonlRpcAgentConnection, + type JsonlRpcAgentConnectionOptions +} from '../jsonl-rpc/agent-connection' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import { PiRpcTurns } from './rpc-turns' +import { PiRpcDialogCallbacks } from './rpc-dialog-callbacks' +import { piRpcStateSchema } from './rpc-protocol' +import { applyPiRpcSessionOption, readPiRpcCommands, readPiRpcSessionOptions } from './rpc-options' + +export type PiRpcConnection = Pick< + JsonlRpcAgentConnection, + | 'request' + | 'send' + | 'close' + | 'pauseReading' + | 'resumeReading' + | 'pid' + | 'closed' + | 'rootVerdict' + | 'processless' + | 'lastCloseResult' + | 'onExit' +> +export type PiRpcSessionDeps = { + openConnection?: ( + launch: ProviderProcessLaunch, + handlers: JsonlRpcAgentConnectionOptions + ) => PiRpcConnection + onLifecycle: (event: StructuredAgentSessionLifecycleEvent) => void + onSettled: (input: { + sessionId: string + clientMessageId: string + fence: number + outcome: AgentSessionDispatchOutcome + }) => void + onIdle: (input: { sessionId: string }) => void + logger: StructuredAgentSessionLogger +} + +/** One child owns its transport, dialect, dialogs and timeline, including shutdown retries. */ +export class PiRpcSession { + readonly connection: PiRpcConnection + readonly lane: JsonlRpcTimelineLane + readonly turns: PiRpcTurns + readonly dialogs: PiRpcDialogCallbacks + readonly selected = new Map() + readonly skipped: string[] = [] + commands?: AgentSessionSlashCommand[] + options?: AgentSessionOptionsResult + requestedClose = false + private publishedExit = false + private failedCause?: Error + private exitDelivery: Promise = Promise.resolve() + private releaseAfterExit?: () => void + + constructor( + readonly input: StructuredAgentSessionAcquireInput, + readonly generation: string, + launch: ProviderProcessLaunch, + private readonly deps: PiRpcSessionDeps + ) { + const sink = input.events && providerTimelineSink(input.events) + if (!sink) { + throw new Error('Pi structured chat requires the shared timeline sink') + } + this.lane = new JsonlRpcTimelineLane({ + sink, + sessionId: input.identity.sessionId, + agent: 'pi', + generation, + namespace: input.identity.sessionId, + pauseReading: () => this.connection?.pauseReading(), + resumeReading: () => this.connection?.resumeReading(), + onFailed: (reason) => this.fail(new Error(reason)), + onInputAccepted: (clientMessageId) => + this.settle(clientMessageId, { + state: 'accepted', + providerIdentity: { provider: 'orca', clientMessageId } + }) + }) + this.turns = new PiRpcTurns({ + lane: this.lane, + generation, + send: (frame) => this.connection.send(frame), + request: (command, params, options) => this.connection.request(command, params, options), + settled: (id, outcome) => this.settle(id, outcome), + idle: () => { + this.dialogs.cancelAll() + deps.onIdle({ sessionId: input.identity.sessionId }) + }, + failed: (error) => this.fail(error), + diagnostic: (error) => + deps.logger.warn('Pi context usage could not be read', { + scope: 'pi-context-usage', + sessionId: input.identity.sessionId, + error + }) + }) + this.dialogs = new PiRpcDialogCallbacks( + this.lane, + (frame) => this.connection.send(frame), + (error) => this.fail(error) + ) + this.connection = ( + deps.openConnection ?? ((spec, handlers) => new JsonlRpcAgentConnection(spec, handlers)) + )(launch, { + onRecord: (frame) => + frame.type === 'extension_ui_request' + ? this.dialogs.receive(frame) + : this.turns.receive(frame), + onClose: (error) => this.fail(error), + onDiagnostic: (message) => + deps.logger.warn(message, { scope: 'pi-rpc', sessionId: input.identity.sessionId }), + onExit: (error) => { + this.exitDelivery = this.exit(error) + .catch((cause: unknown) => + deps.logger.error('Pi exit publication failed', { + scope: 'pi-exit', + sessionId: input.identity.sessionId, + error: cause + }) + ) + .finally(() => this.releaseAfterExit?.()) + } + }) + } + + async start(): Promise { + const state = piRpcStateSchema.parse(await this.connection.request('get_state')) + this.lane.apply(this.turns.context.setModel(state.model ?? undefined, Date.now())) + for (const [key, value] of Object.entries(this.input.options ?? {})) { + if (!['model', 'effort'].includes(key)) { + this.skipped.push(key) + continue + } + try { + await applyPiRpcSessionOption(this.connection, this.selected, key, value) + } catch (error) { + this.skipped.push(key) + this.deps.logger.warn('Pi rejected a saved option', { + scope: 'pi-option-restore', + sessionId: this.input.identity.sessionId, + key, + error + }) + } + } + this.options = await readPiRpcSessionOptions(this.connection) + try { + this.commands = (await readPiRpcCommands(this.connection)).commands + } catch (error) { + this.deps.logger.warn('Pi commands could not be read', { + scope: 'pi-commands', + sessionId: this.input.identity.sessionId, + error + }) + } + return state.sessionFile + } + + async close(requested = true): ReturnType { + this.requestedClose ||= requested + this.dialogs.cancelAll() + return this.connection.close() + } + + fail(error: Error): void { + this.failedCause ??= error + this.deps.logger.error('Pi RPC session failed', { + scope: 'pi-rpc', + sessionId: this.input.identity.sessionId, + error + }) + void this.close(false) + .then((result) => { + if (result.root !== 'exited') { + this.deps.logger.warn('Pi process exit is unverifiable', { + scope: 'pi-stop', + sessionId: this.input.identity.sessionId + }) + } + }) + .catch((cause: unknown) => + this.deps.logger.error('Pi process cleanup failed', { + scope: 'pi-stop', + sessionId: this.input.identity.sessionId, + error: cause + }) + ) + } + + drainObservedExit(): Promise { + return this.exitDelivery + } + + retire(): Promise { + return new Promise((resolve) => { + this.releaseAfterExit = () => { + this.releaseAfterExit = undefined + void this.lane.drained().then(() => { + this.lane.dispose() + resolve() + }) + } + if (this.publishedExit) { + void this.exitDelivery.then(() => this.releaseAfterExit?.()) + } + }) + } + + private async exit(error: Error): Promise { + if (this.publishedExit) { + return + } + this.publishedExit = true + this.dialogs.cancelAll() + this.turns.end() + this.lane.finalize() + this.lane.apply([ + { type: 'session.ended', verdict: { state: 'interrupted', completedAt: Date.now() } } + ]) + this.lane.flush() + let timer: ReturnType | undefined + try { + await Promise.race([ + this.lane.drained(), + new Promise((resolve) => { + timer = setTimeout(() => { + this.deps.logger.warn('Pi final timeline did not drain before exit publication', { + scope: 'pi-exit-drain', + sessionId: this.input.identity.sessionId + }) + resolve() + }, 2_000) + timer.unref() + }) + ]) + } finally { + clearTimeout(timer) + } + this.deps.onLifecycle({ + type: 'ended', + sessionId: this.input.identity.sessionId, + fence: this.input.fence, + acquisitionGeneration: this.generation, + observedAt: Date.now(), + cause: this.requestedClose ? 'requested-close' : 'unexpected-exit', + reason: (this.failedCause ?? error).message + }) + } + + private settle(clientMessageId: string, outcome: AgentSessionDispatchOutcome): void { + this.deps.onSettled({ + sessionId: this.input.identity.sessionId, + clientMessageId, + fence: this.input.fence, + outcome + }) + } +} diff --git a/src/main/pi/rpc-turn-races.test.ts b/src/main/pi/rpc-turn-races.test.ts new file mode 100644 index 000000000000..9a17d1d6e17c --- /dev/null +++ b/src/main/pi/rpc-turn-races.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane' +import { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { PiRpcTurns } from './rpc-turns' +import { PiRpcPromptDelivery } from './rpc-prompt-delivery' + +const state = (overrides: Record = {}) => ({ + sessionFile: '/host/session.jsonl', + isStreaming: false, + isCompacting: false, + pendingMessageCount: 0, + ...overrides +}) +const pending: (() => void)[] = [] +afterEach(async () => { + pending.splice(0).forEach((end) => end()) + await closeProviderTimelineRigs() + vi.useRealTimers() +}) + +async function setup(request = vi.fn(async (_command: string): Promise => state())) { + const rig = await openProviderTimelineRig({ agent: 'pi', sessionId: 'session-timeline' }) + const accepted = vi.fn(), + failed = vi.fn(), + idle = vi.fn(), + settled = vi.fn() + const lane = new JsonlRpcTimelineLane({ + sink: rig.sink, + sessionId: 'session-timeline', + agent: 'pi', + generation: 'generation-1', + namespace: 'session-timeline', + pauseReading: vi.fn(), + resumeReading: vi.fn(), + onInputAccepted: accepted, + onFailed: failed + }) + const send = vi.fn(async (_frame: Record) => {}) + const turns = new PiRpcTurns({ + lane, + generation: 'generation-1', + send, + request, + settled, + failed, + idle + }) + pending.push(() => { + turns.end() + lane.dispose() + }) + const start = async () => { + await turns.submit('send-1', 100, { type: 'prompt', message: 'hello' }) + turns.receive({ type: 'agent_start' }) + } + const flush = async () => { + await Promise.resolve() + await Promise.resolve() + lane.flush() + } + return { rig, lane, turns, request, accepted, failed, idle, settled, start, flush, send } +} + +describe('Pi turn settlement races', () => { + it('chooses steering after asynchronous dispatch admission finishes', async () => { + const h = await setup() + await h.turns.submit('first', 1, { type: 'prompt', message: 'first' }) + const gate = Promise.withResolvers() + const second = h.turns.submit( + 'second', + 2, + { type: 'prompt', message: 'second', streamingBehavior: 'followUp' }, + () => gate.promise + ) + h.turns.receive({ type: 'agent_start' }) + gate.resolve() + await second + expect(h.send.mock.calls[1]?.[0]).toMatchObject({ + message: 'second', + streamingBehavior: 'steer' + }) + }) + it('waits for agent_settled and a host idle probe after agent_end', async () => { + const h = await setup() + await h.start() + h.turns.receive({ type: 'agent_end' }) + await h.flush() + expect(h.request).not.toHaveBeenCalled() + expect(h.idle).not.toHaveBeenCalled() + expect(await h.rig.turns()).toHaveLength(1) + expect((await h.rig.turns())[0]?.state).toBe('running') + h.turns.receive({ type: 'agent_settled' }) + await h.flush() + expect(h.request).toHaveBeenCalledWith('get_state') + expect((await h.rig.turns())[0]?.outcome).toBe('success') + expect(h.idle).toHaveBeenCalledTimes(1) + }) + + it('invalidates an old idle probe when detached compaction starts after settlement', async () => { + let release: ((value: unknown) => void) | undefined + const request = vi.fn( + (_command: string) => + new Promise((resolve) => { + release = resolve + }) + ) + const h = await setup(request) + await h.start() + h.turns.receive({ type: 'agent_settled' }) + await Promise.resolve() + expect(request).toHaveBeenCalledTimes(1) + h.turns.receive({ type: 'auto_compaction_start' }) + release?.(state()) + await h.flush() + expect(h.idle).not.toHaveBeenCalled() + expect((await h.rig.turns())[0]?.state).toBe('running') + h.turns.receive({ type: 'auto_compaction_end' }) + await Promise.resolve() + expect(request.mock.calls.filter(([command]) => command === 'get_state')).toHaveLength(2) + release?.(state()) + await h.flush() + expect((await h.rig.turns())[0]?.outcome).toBe('success') + }) + + it('keeps a busy generation open and rejects a probe made stale by new activity', async () => { + let release: ((value: unknown) => void) | undefined + const request = vi.fn( + (_command: string) => + new Promise((resolve) => { + release = resolve + }) + ) + const h = await setup(request) + await h.start() + h.turns.receive({ type: 'agent_settled' }) + await Promise.resolve() + release?.(state({ isStreaming: true })) + await h.flush() + expect(h.idle).not.toHaveBeenCalled() + h.turns.receive({ type: 'agent_settled' }) + await Promise.resolve() + h.turns.receive({ type: 'message_update', message: { role: 'assistant', content: [] } }) + release?.(state()) + await h.flush() + expect(h.idle).not.toHaveBeenCalled() + h.turns.receive({ type: 'agent_settled' }) + await Promise.resolve() + release?.(state()) + await h.flush() + expect((await h.rig.turns())[0]?.outcome).toBe('success') + }) + + it('settles a provider-handled local command without agent events', async () => { + const h = await setup() + await h.turns.submit('local-command', 100, { type: 'prompt', message: '/help' }) + h.turns.receive({ + type: 'response', + command: 'prompt', + success: true, + data: { disposition: 'handled', agentInvoked: false } + }) + await h.flush() + expect(h.accepted).toHaveBeenCalledWith('local-command') + expect((await h.rig.turns())[0]?.outcome).toBe('success') + expect(h.idle).toHaveBeenCalledTimes(1) + }) + + it('keeps exhausted auto retry as failure, while successful retry clears it', async () => { + for (const succeeded of [false, true]) { + const h = await setup() + await h.start() + h.turns.receive({ + type: 'message_end', + message: { + role: 'assistant', + content: [], + stopReason: 'error', + errorMessage: 'provider failed' + } + }) + h.turns.receive({ type: 'auto_retry_start' }) + h.turns.receive({ type: 'auto_retry_end', success: succeeded }) + if (succeeded) { + h.turns.receive({ + type: 'message_end', + message: { role: 'assistant', content: [], stopReason: 'stop' } + }) + } + h.turns.receive({ type: 'agent_settled' }) + await h.flush() + expect((await h.rig.turns())[0]?.outcome).toBe(succeeded ? 'success' : 'failure') + } + }) +}) + +describe('Pi prompt acknowledgement retries', () => { + it('retries only the exact transient auth prefix eight times at 250 ms', async () => { + vi.useFakeTimers() + const send = vi.fn(async () => {}) + const settled = vi.fn() + const delivery = new PiRpcPromptDelivery({ + send, + settled, + accepted: vi.fn(), + commandOnly: vi.fn(), + rejectedAfterAcceptance: vi.fn(), + failed: vi.fn() + }) + await delivery.submit('send-1', 100, { type: 'prompt', message: 'hello' }) + for (let attempt = 0; attempt < 8; attempt++) { + delivery.reply({ + type: 'response', + command: 'prompt', + success: false, + error: 'No API key found for provider' + }) + await vi.advanceTimersByTimeAsync(249) + expect(send).toHaveBeenCalledTimes(attempt + 1) + await vi.advanceTimersByTimeAsync(1) + expect(send).toHaveBeenCalledTimes(attempt + 2) + } + delivery.reply({ + type: 'response', + command: 'prompt', + success: false, + error: 'No API key found for provider' + }) + await vi.advanceTimersByTimeAsync(250) + expect(send).toHaveBeenCalledTimes(9) + expect(settled).toHaveBeenCalledWith('send-1', expect.objectContaining({ state: 'rejected' })) + await delivery.submit('send-2', 101, { type: 'prompt', message: 'again' }) + delivery.reply({ + type: 'response', + command: 'prompt', + success: false, + error: 'No API key found elsewhere' + }) + await vi.advanceTimersByTimeAsync(250) + expect(send).toHaveBeenCalledTimes(10) + delivery.end() + }) +}) diff --git a/src/main/pi/rpc-turns.ts b/src/main/pi/rpc-turns.ts new file mode 100644 index 000000000000..3bb0ff959d2d --- /dev/null +++ b/src/main/pi/rpc-turns.ts @@ -0,0 +1,306 @@ +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionCommandRun +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { JsonlRpcRecord } from '../jsonl-rpc/peer' +import type { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane' +import { PiRpcContextUsage } from './rpc-context-usage' +import { PiRpcMessages } from './rpc-messages' +import { PiRpcPromptDelivery } from './rpc-prompt-delivery' +import { PiRpcIdleCheck } from './rpc-idle-check' +import { piRpcRetryActivity } from './rpc-retry-activity' +import { piRpcMessageSchema } from './rpc-protocol' + +export type PiRpcTurnDeps = { + lane: JsonlRpcTimelineLane + generation: string + send: (frame: JsonlRpcRecord) => Promise + request: ( + command: string, + params?: Record, + options?: { timeoutMs?: number | null } + ) => Promise + settled: (id: string, outcome: AgentSessionDispatchOutcome) => void + idle: () => void + failed: (error: Error) => void + diagnostic?: (error: unknown) => void +} + +/** Prompt replies can be idless and arrive after dialogs; they have no elapsed-time deadline. */ +export class PiRpcTurns { + readonly context = new PiRpcContextUsage() + private readonly messages: PiRpcMessages + private readonly delivery: PiRpcPromptDelivery + private readonly idleCheck: PiRpcIdleCheck + private revision = 0 + private turn = 0 + private active?: string + private settling = false + private compacting = false + private stopped = false + private ended = false + private failure?: string + private lastStopReason?: string + + constructor(private readonly deps: PiRpcTurnDeps) { + this.messages = new PiRpcMessages(deps.generation, this.context) + this.idleCheck = new PiRpcIdleCheck({ + request: () => deps.request('get_state'), + current: (revision) => + !this.ended && this.settling && !this.compacting && revision === this.revision, + settled: (state) => { + this.deps.lane.apply(this.context.setModel(state.model ?? undefined, Date.now())) + this.finish() + }, + failed: deps.failed + }) + this.delivery = new PiRpcPromptDelivery({ + send: (frame) => + deps.send({ ...frame, streamingBehavior: this.working ? 'steer' : 'followUp' }), + settled: deps.settled, + failed: deps.failed, + accepted: (id, at) => { + this.open(at) + this.deps.lane.apply([{ type: 'input.accepted', clientMessageId: id, requestedAt: at }]) + }, + commandOnly: () => { + this.skipInitialUserEcho = false + this.settling = true + this.probe() + }, + rejectedAfterAcceptance: (error) => { + this.failure = error + }, + beforeWrite: () => this.touch(), + refused: () => { + if (this.active) { + this.settling = true + this.probe() + } + } + }) + } + + get holdsDispatch(): boolean { + return this.delivery.holdsDispatch + } + get working(): boolean { + return this.active !== undefined + } + + async submit( + id: string, + at: number, + frame: JsonlRpcRecord, + before?: () => Promise + ): Promise { + if (this.ended) { + throw new Error('Pi session ended before dispatch') + } + return this.delivery.submit(id, at, frame, before) + } + + receive(frame: JsonlRpcRecord): void { + if (this.ended) { + return + } + const at = Date.now() + switch (frame.type) { + case 'response': + if (frame.command === 'prompt') { + this.delivery.reply(frame) + } + return + case 'agent_start': + if (!this.active && !this.delivery.holdsDispatch) { + throw new Error('Pi started an unsolicited agent run') + } + this.touch() + this.open(at) + this.skipInitialUserEcho = this.delivery.consumeNext() + return + case 'message_start': + case 'message_update': + case 'message_end': { + this.touch() + const message = piRpcMessageSchema.safeParse(frame.message) + if (message.success && message.data.role === 'user' && frame.type === 'message_end') { + // agent_start already accepted the first prompt; user echoes consume queued inputs. + if (this.skipInitialUserEcho) { + this.skipInitialUserEcho = false + } else { + this.delivery.consumeNext() + } + } + if (message.success && message.data.role === 'assistant' && frame.type === 'message_end') { + this.lastStopReason = message.data.stopReason + this.failure = + message.data.stopReason === 'error' + ? (message.data.errorMessage ?? 'Pi request failed') + : undefined + } + this.deps.lane.apply(this.messages.message(frame, at)) + return + } + case 'tool_execution_start': + case 'tool_execution_update': + case 'tool_execution_end': + this.touch() + this.deps.lane.apply(this.messages.tool(frame)) + return + case 'turn_end': + this.refreshUsage() + break + case 'agent_end': + // This can precede automatic retry or detached compaction. + break + case 'agent_settled': + this.settling = true + this.probe() + return + case 'compaction_start': + case 'auto_compaction_start': + this.revision++ + this.compacting = true + this.deps.lane.apply(this.context.compacting()) + return + case 'compaction_end': + case 'auto_compaction_end': + this.compacting = false + this.context.compacted(frame.result) + if (frame.willRetry === true) { + this.failure = undefined + this.lastStopReason = undefined + } + this.refreshUsage() + if (this.settling) { + this.probe() + } + return + case 'auto_retry_start': + this.touch() + this.deps.lane.apply([piRpcRetryActivity(frame)]) + return + case 'auto_retry_end': + this.deps.lane.apply([piRpcRetryActivity(frame)]) + if (frame.success === true) { + this.failure = undefined + this.lastStopReason = undefined + } else { + this.failure = + typeof frame.finalError === 'string' ? frame.finalError : 'Pi auto-retry failed' + } + break + } + } + + stop(): void { + this.stopped = true + } + + beginCommand(command: StructuredAgentSessionCommandRun): void { + if (this.active) { + throw new Error('Pi is still working') + } + this.deps.lane.beginCommand(command) + this.active = command.turnId + this.failure = undefined + this.lastStopReason = undefined + this.stopped = false + this.revision++ + } + + commandCompleted(): void { + this.settling = true + this.probe() + } + + commandRejected(): void { + if (this.active) { + this.deps.lane.forgetCommand(this.active) + } + this.active = undefined + this.settling = false + this.revision++ + } + + end(): void { + this.ended = true + this.revision++ + this.idleCheck.dispose() + this.delivery.end() + } + + private skipInitialUserEcho = false + private open(at: number): void { + if (this.active) { + return + } + this.active = `run:${this.deps.generation}:${++this.turn}` + this.failure = undefined + this.lastStopReason = undefined + this.stopped = false + this.skipInitialUserEcho = true + this.deps.lane.apply([{ type: 'turn.open', turn: this.active, at }]) + } + + private probe(): void { + this.idleCheck.schedule(this.revision) + } + + private finish(): void { + if (!this.active) { + this.settling = false + return + } + const outcome = this.stopped + ? 'cancellation' + : this.failure || this.lastStopReason === 'error' + ? 'failure' + : 'success' + const events: ProviderTimelineEvent[] = [] + if (outcome === 'failure' && this.failure) { + const fact = agentSessionFailureFact('providerRejected', { + detail: providerDiagnostic(this.failure, 'person') + }) + events.push({ + type: 'item.close', + item: `error:${this.active}`, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(fact, { agentName: 'Pi', surface: 'row' }) + } + }) + } + events.push({ + type: 'turn.end', + at: Date.now(), + state: this.stopped ? 'interrupted' : 'completed', + outcome + }) + this.deps.lane.apply(events) + this.active = undefined + this.settling = false + this.messages.reset() + this.deps.idle() + } + + private refreshUsage(): void { + void this.deps + .request('get_session_stats') + .then((value) => { + if (!this.ended) { + this.deps.lane.apply(this.context.stats(value, Date.now())) + } + }) + .catch((error: unknown) => this.deps.diagnostic?.(error)) + } + private touch(): void { + this.revision++ + this.settling = false + } +} diff --git a/src/main/provider-process/provider-acquisition-starts.test.ts b/src/main/provider-process/provider-acquisition-starts.test.ts new file mode 100644 index 000000000000..b4700448fe00 --- /dev/null +++ b/src/main/provider-process/provider-acquisition-starts.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' +import { ProviderAcquisitionStarts } from './provider-acquisition-starts' +import type { ProviderProcessCloseResult } from './provider-process-close' + +function connection(result: boolean | ProviderProcessCloseResult = false) { + const exits: (() => void)[] = [] + return { + close: vi.fn(async (): Promise => result), + onExit: (listener: () => void) => { + exits.push(listener) + }, + exit: () => { + for (const listener of exits.splice(0)) { + listener() + } + } + } +} + +describe('provider acquisition ownership', () => { + it('stops a connection created after the host already aborted its start', async () => { + const starts = new ProviderAcquisitionStarts>() + const controller = new AbortController() + const attempt = starts.begin(controller.signal) + controller.abort() + const child = connection(true) + starts.track(attempt, child) + expect(child.close).toHaveBeenCalledOnce() + starts.end(attempt) + }) + + it('detaches startup cancellation when ownership transfers to the session', () => { + const starts = new ProviderAcquisitionStarts>() + const controller = new AbortController() + const attempt = starts.begin(controller.signal) + const child = connection(true) + starts.track(attempt, child) + starts.end(attempt) + controller.abort() + expect(child.close).not.toHaveBeenCalled() + }) + + it('retains a failed start after refused cleanup and retries that same child', async () => { + const starts = new ProviderAcquisitionStarts>() + const child = connection() + child.close + .mockResolvedValueOnce(false) + .mockRejectedValueOnce(new Error('unreachable')) + .mockResolvedValueOnce(true) + starts.retainFailed('session', child) + expect(await starts.stopFailed('session')).toBe(false) + expect(await starts.stopFailed('session')).toBe(false) + expect(starts.failedSessionIds()).toEqual(['session']) + expect(await starts.stopFailed('session')).toBe(true) + expect(starts.failedSessionIds()).toEqual([]) + expect(child.close).toHaveBeenCalledTimes(3) + }) + + it('releases ownership on root proof while descendant cleanup remains reported by the connection', async () => { + const starts = new ProviderAcquisitionStarts>() + const child = connection({ root: 'unverifiable', tree: 'unverifiable' }) + child.close + .mockResolvedValueOnce({ root: 'unverifiable', tree: 'exited' }) + .mockResolvedValueOnce({ root: 'exited', tree: 'unverifiable' }) + starts.retainFailed('session', child) + expect(await starts.stopFailed('session')).toBe(false) + expect(await starts.stopFailed('session')).toBe(true) + expect(starts.failedSessionIds()).toEqual([]) + }) + + it('does not let a previous child exit erase a newer failed owner', () => { + const starts = new ProviderAcquisitionStarts>() + const old = connection(), + current = connection() + starts.retainFailed('session', old) + starts.retainFailed('session', current) + old.exit() + expect(starts.failedSessionIds()).toEqual(['session']) + current.exit() + expect(starts.failedSessionIds()).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-structured-starts.ts b/src/main/provider-process/provider-acquisition-starts.ts similarity index 69% rename from src/main/acp/acp-structured-starts.ts rename to src/main/provider-process/provider-acquisition-starts.ts index b87911174ca1..44857e783eec 100644 --- a/src/main/acp/acp-structured-starts.ts +++ b/src/main/provider-process/provider-acquisition-starts.ts @@ -3,22 +3,27 @@ // process's exit is proven, so the next start or quit retries that same connection's close instead // of answering for a process it no longer knows (and never spawns a second one meanwhile). -import type { AcpStructuredConnection } from './acp-structured-connection' +import type { ProviderProcessCloseResult } from './provider-process-close' -export type AcpStartAttempt = { +export type ProviderAcquisitionConnection = { + close(): Promise + onExit(listener: () => void): void +} + +export type ProviderStartAttempt = { /** The host's: the start's one canceller. */ readonly signal: AbortSignal - connection: AcpStructuredConnection | null + connection: Connection | null /** What the signal's abort does while the start runs; detached once it ends. */ readonly stopConnection: () => void } -export class AcpStructuredStarts { - private readonly failed = new Map() +export class ProviderAcquisitionStarts { + private readonly failed = new Map() /** Registered before anything awaits, so an abort from here on stops this start. */ - begin(signal: AbortSignal | undefined): AcpStartAttempt { - const attempt: AcpStartAttempt = { + begin(signal: AbortSignal | undefined): ProviderStartAttempt { + const attempt: ProviderStartAttempt = { signal: signal ?? new AbortController().signal, connection: null, stopConnection: () => void attempt.connection?.close().catch(() => false) @@ -28,7 +33,7 @@ export class AcpStructuredStarts { } /** The start has its connection; one already aborted goes as soon as it exists. */ - track(attempt: AcpStartAttempt, connection: AcpStructuredConnection): void { + track(attempt: ProviderStartAttempt, connection: Connection): void { attempt.connection = connection if (attempt.signal.aborted) { void connection.close().catch(() => false) @@ -37,12 +42,12 @@ export class AcpStructuredStarts { /** A connection the start handed over is the session's: an abort after this goes through its * stop, which knows the close was asked for, not this listener, which would read as a crash. */ - end(attempt: AcpStartAttempt): void { + end(attempt: ProviderStartAttempt): void { attempt.signal.removeEventListener('abort', attempt.stopConnection) } /** A failed start whose process is not proven gone keeps its connection until its exit is. */ - retainFailed(sessionId: string, connection: AcpStructuredConnection): void { + retainFailed(sessionId: string, connection: Connection): void { this.failed.set(sessionId, connection) connection.onExit(() => { if (this.failed.get(sessionId) === connection) { @@ -57,7 +62,8 @@ export class AcpStructuredStarts { if (!connection) { return true } - const proven = await connection.close().catch(() => false) + const result = await connection.close().catch(() => false) + const proven = typeof result === 'boolean' ? result : result.root === 'exited' if (proven && this.failed.get(sessionId) === connection) { this.failed.delete(sessionId) } diff --git a/src/main/provider-process/provider-stdio-error-boundary.ts b/src/main/provider-process/provider-stdio-error-boundary.ts new file mode 100644 index 000000000000..4290a0b1622e --- /dev/null +++ b/src/main/provider-process/provider-stdio-error-boundary.ts @@ -0,0 +1,16 @@ +import type { Readable, Writable } from 'node:stream' + +function ignoreLateError(): void {} + +export function detachProviderStreamErrorHandler( + stream: Readable | Writable, + handler: (error: Error) => void +): void { + stream.removeListener('error', handler) + if (stream.closed) { + return + } + // Node may emit the write error after its callback has already closed the peer. + stream.on('error', ignoreLateError) + stream.once('close', () => stream.removeListener('error', ignoreLateError)) +} diff --git a/src/main/provider-process/provider-stdio-write-queue.test.ts b/src/main/provider-process/provider-stdio-write-queue.test.ts new file mode 100644 index 000000000000..7acf8c0fa82d --- /dev/null +++ b/src/main/provider-process/provider-stdio-write-queue.test.ts @@ -0,0 +1,39 @@ +import { Writable } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import { ProviderStdioWriteQueue } from './provider-stdio-write-queue' + +// An agent that stopped reading its stdin: the first line is handed over and never completes. +function stalledQueue() { + const output = new Writable({ write() {} }) + const onFailure = vi.fn() + const queue = new ProviderStdioWriteQueue(output, 1024, onFailure, { + capacity: () => new Error('capacity'), + closed: () => new Error('closed') + }) + return { queue, onFailure } +} + +describe('ProviderStdioWriteQueue aborts', () => { + it('drops a line still waiting in the queue and rejects only its request', async () => { + const { queue, onFailure } = stalledQueue() + void queue.write('active\n') + const waiting = new AbortController() + const dropped = queue.write('waiting\n', waiting.signal) + waiting.abort(new Error('timed out')) + await expect(dropped).rejects.toThrow('timed out') + // Nothing of it reached the agent, so the connection stays up for the next request. + expect(onFailure).not.toHaveBeenCalled() + void queue.write('next\n') + expect(onFailure).not.toHaveBeenCalled() + }) + + it('closes the connection when the line being written times out, since it cannot be taken back', () => { + const { queue, onFailure } = stalledQueue() + const active = new AbortController() + void queue.write('active\n', active.signal) + active.abort(new Error('timed out')) + // The stream owns the line, possibly partly on the pipe; leaving it would hold every later + // write, a Stop included, behind a reader that stopped. + expect(onFailure).toHaveBeenCalledWith(new Error('timed out')) + }) +}) diff --git a/src/main/provider-process/provider-stdio-write-queue.ts b/src/main/provider-process/provider-stdio-write-queue.ts new file mode 100644 index 000000000000..57a7d92f1910 --- /dev/null +++ b/src/main/provider-process/provider-stdio-write-queue.ts @@ -0,0 +1,129 @@ +import type { Writable } from 'node:stream' + +type Write = { + line: string + resolve: () => void + reject: (error: Error) => void + detachAbort?: () => void +} + +export class ProviderStdioWriteQueue { + private readonly queue: Write[] = [] + private bytes = 0 + private active?: Write + private terminalError?: Error + private detachDrain?: () => void + + constructor( + private readonly output: Writable, + private readonly maxBytes: number, + private readonly onFailure: (error: Error) => void, + private readonly errors: { capacity: () => Error; closed: () => Error } + ) {} + + write(line: string, signal?: AbortSignal): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + if (signal?.aborted) { + return Promise.reject(signal.reason) + } + const bytes = Buffer.byteLength(line) + if (this.bytes + bytes > this.maxBytes) { + return Promise.reject(this.errors.capacity()) + } + this.bytes += bytes + return new Promise((resolve, reject) => { + const write: Write = { line, resolve, reject } + const abort = (): void => { + // Why: a line already handed to the stream can't be taken back, and later writes wait on it. + if (this.active === write) { + this.onFailure( + signal?.reason instanceof Error ? signal.reason : new Error('Agent write aborted') + ) + return + } + const index = this.queue.indexOf(write) + if (index === -1) { + return + } + this.queue.splice(index, 1) + this.bytes -= bytes + write.detachAbort?.() + reject(signal?.reason) + } + signal?.addEventListener('abort', abort, { once: true }) + write.detachAbort = () => signal?.removeEventListener('abort', abort) + this.queue.push(write) + this.flush() + }) + } + + close(error: Error): void { + if (this.terminalError) { + return + } + this.terminalError = error + this.detachDrain?.() + this.detachDrain = undefined + this.active?.detachAbort?.() + this.active?.reject(error) + this.active = undefined + for (const write of this.queue.splice(0)) { + write.detachAbort?.() + write.reject(error) + } + this.bytes = 0 + } + + private flush(): void { + if (this.active || this.terminalError) { + return + } + const write = this.queue.shift() + if (!write) { + return + } + this.active = write + if (this.output.destroyed || !this.output.writable) { + this.onFailure(this.errors.closed()) + return + } + let completed = false + let drained = false + let returned = false + const finish = (): void => { + if (!returned || !completed || !drained || this.terminalError) { + return + } + this.detachDrain?.() + this.detachDrain = undefined + write.detachAbort?.() + this.active = undefined + this.bytes -= Buffer.byteLength(write.line) + write.resolve() + this.flush() + } + const onDrain = (): void => { + drained = true + finish() + } + this.output.once('drain', onDrain) + this.detachDrain = () => this.output.removeListener('drain', onDrain) + try { + const accepted = this.output.write(write.line, (error) => { + if (error) { + this.onFailure(error) + return + } + completed = true + finish() + }) + drained ||= accepted + returned = true + finish() + } catch (error) { + this.onFailure(error instanceof Error ? error : new Error(String(error))) + } + } +} diff --git a/src/main/providers/local-pty-windows-spawn-environment.ts b/src/main/providers/local-pty-windows-spawn-environment.ts index 63ad18883dc9..a1f194e9af0c 100644 --- a/src/main/providers/local-pty-windows-spawn-environment.ts +++ b/src/main/providers/local-pty-windows-spawn-environment.ts @@ -1,4 +1,8 @@ import { win32 as pathWin32 } from 'node:path' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV +} from '../../shared/claude-profile-routing' import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { addWslEnvKeys } from '../wsl-env' @@ -55,8 +59,8 @@ export function finalizeWindowsLocalPtySpawnEnvironment(args: { // Why: managed WSL Claude passes a Linux CLAUDE_CONFIG_DIR through wsl.exe; non-default vars need WSLENV import. addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } - if (env.ORCA_CLAUDE_PROFILE_POINTER) { - addWslEnvKeys(env, ['ORCA_CLAUDE_PROFILE_POINTER', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR']) + if (env[CLAUDE_PROFILE_POINTER_ENV]) { + addWslEnvKeys(env, [CLAUDE_PROFILE_POINTER_ENV, CLAUDE_INJECTED_CONFIG_DIR_ENV]) } if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { // Why: wsl.exe drops custom Windows env vars; the startup wrapper needs this imported inside WSL. diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 38352a1b6d48..e6e573263ab9 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -308,7 +308,7 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() }) - it('keeps a plain Git Bash tab a login shell and wraps one with a startup command', () => { + it('gives a plain Git Bash tab the same rcfile as one with a startup command', () => { const plain = resolveWindowsShellLaunchArgs( 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Users\\alice', @@ -322,14 +322,12 @@ describe('resolveWindowsShellLaunchArgs', () => { "codex 'fix the bug'" ) - expect(plain.shellArgs).toEqual([ - '-c', - 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' - ]) - // Why: without a preflight, only the rcfile carries the codex --no-daemon wrapper. - expect(readFileSync(getGitBashRcfilePath(launched.shellArgs[1]), 'utf8')).toContain( - 'set -- --no-daemon "$@"' - ) + expect(plain.shellArgs).toEqual(launched.shellArgs) + // Why: only the rcfile carries the account-following claude function and codex --no-daemon wrapper. + const rcfile = readFileSync(getGitBashRcfilePath(plain.shellArgs[1]), 'utf8') + expect(rcfile).toContain('function claude {') + expect(rcfile).toContain('set -- --no-daemon "$@"') + expect(rcfile).toContain('source "$HOME/.bash_profile"') expect(launched.startupCommandDeliveredInShellArgs).toBeUndefined() }) diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index c5ceade00a44..b905a3fbb302 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -29,12 +29,9 @@ const CMD_CODEX_LAUNCH_PREFLIGHT = `if defined ORCA_CODEX_LAUNCH_PREFLIGHT call // `&&`) keeps startup working even if chcp.com is missing. const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' -// Why the rcfile for a startup command: it defines the codex wrapper Orca's launches need. -function getGitBashLaunchCommand(useWrapper: boolean): string { - if (!useWrapper) { - return GIT_BASH_UTF8_LOGIN_COMMAND - } - +// Why every pane gets the rcfile: it defines the claude function that follows the selected +// account (and the codex wrapper), as bash panes elsewhere get; it sources the login chain itself. +function getGitBashLaunchCommand(): string { ensureShellReadyWrappersAt() const wrapperArgs = getBashWrapperLaunchArgs() if (!wrapperArgs) { @@ -217,10 +214,7 @@ export function resolveWindowsShellLaunchArgs( if (isWindowsGitBashShellPath(shellPath)) { return { - shellArgs: [ - '-c', - getGitBashLaunchCommand(Boolean(codexLaunchPreflightCommand) || Boolean(startupCommand)) - ], + shellArgs: ['-c', getGitBashLaunchCommand()], effectiveCwd: nativeCwd, validationCwd: nativeCwd } diff --git a/src/main/provisioned-root-ssh-adoption.ts b/src/main/provisioned-root-ssh-adoption.ts index c2e2dfe2c4ba..3f6565382d81 100644 --- a/src/main/provisioned-root-ssh-adoption.ts +++ b/src/main/provisioned-root-ssh-adoption.ts @@ -225,6 +225,7 @@ function buildProvisionedRootMeta( : {}), ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), + ...(args.linkedItems !== undefined ? { linkedItems: args.linkedItems } : {}), ...(args.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: args.linkedTaskSourceContext } : {}) diff --git a/src/main/rate-limits/claude-active-usage-fetch.ts b/src/main/rate-limits/claude-active-usage-fetch.ts index f285ba13ce93..df9867efd3b4 100644 --- a/src/main/rate-limits/claude-active-usage-fetch.ts +++ b/src/main/rate-limits/claude-active-usage-fetch.ts @@ -1,248 +1,86 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { completeClaudeOAuthUsageSuccess, fetchClaudeUsageViaCli } from './claude-cli-usage-fetch' import { readClaudeOAuthCredentials, resolveClaudeOAuthCredentialReadOptions } from './claude-oauth-credentials' -import { - canRetryClaudeOAuthWithLegacyKeychain, - makeClaudeUsageClassificationError, - makeLiveClaudeUsageDeferredResult, - repairClaudeCredentialsThenRetryOAuth, - retryClaudeOAuthWithLegacyKeychain, - shouldDeferClaudeUsageForLiveSession -} from './claude-oauth-recovery' import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' -import { - classifyClaudeCredentialAbsence, - classifyClaudeOAuthUsageError -} from './claude-usage-error-classification' +import { classifyClaudeOAuthUsageError } from './claude-usage-error-classification' +import { OAuthUsageError } from './claude-oauth-usage-error' import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' -import { resolveClaudeUsageRefreshPlan } from './claude-usage-refresh-plan' -import { - abortedClaudeRateLimitResult, - makeClaudeUsageResult, - metadataForClaudeUsageAttempt, - recordClaudeUsageAttempt, - warnClaudeUsageFetchFailure -} from './claude-usage-result' +import { abortedClaudeRateLimitResult, makeClaudeUsageResult } from './claude-usage-result' +import { CLAUDE_PROFILE_MISSING_MESSAGE } from '../../shared/claude-profile-routing' +/** Usage observes the account; only a user-started Claude process may refresh its login. */ export async function fetchActiveClaudeRateLimits( options?: ClaudeRateLimitFetchOptions ): Promise { + const usageError = options?.authPreparation?.usageError + if (usageError) { + return makeClaudeUsageResult('error', usageError, { + failureKind: + usageError === CLAUDE_PROFILE_MISSING_MESSAGE ? 'missing-credentials' : 'usage-unavailable', + attemptedSources: [] + }) + } if (options?.signal?.aborted) { return abortedClaudeRateLimitResult() } - const attempts = { attemptedSources: [] } - const allowCliFallback = options?.allowPtyFallback !== false - const plan = resolveClaudeUsageRefreshPlan({ - authPreparation: options?.authPreparation, - allowCliFallback - }) - - if (options?.authPreparation?.runtime === 'wsl' && !options.authPreparation.wslLinuxConfigDir) { - return makeClaudeUsageResult( - 'error', - `WSL Claude config unavailable for ${options.authPreparation.wslDistro ?? 'default distro'}`, - { - attemptedSources: [], - failureKind: 'cli-unavailable', - authProvenance: options.authPreparation.provenance - } - ) - } - - const oauthCredentials = await readClaudeOAuthCredentials( + const credentials = await readClaudeOAuthCredentials( resolveClaudeOAuthCredentialReadOptions(options?.authPreparation) ) - if (options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - - if (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) { - recordClaudeUsageAttempt(attempts, 'oauth') - try { - const oauthLimits = await fetchClaudeOAuthUsage(oauthCredentials.token, options?.signal) - if (options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - return await completeClaudeOAuthUsageSuccess({ - oauthLimits, - oauthCredentials, - attempts, - options - }) - } catch (error) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) - const classification = classifyClaudeOAuthUsageError(error) - - if ( - canRetryClaudeOAuthWithLegacyKeychain({ - classification, - oauthCredentials, - authPreparation: options?.authPreparation - }) - ) { - const legacyResult = await retryClaudeOAuthWithLegacyKeychain({ - failedToken: oauthCredentials.token, - attempts, - options - }) - if (legacyResult) { - return legacyResult - } - } - - if (shouldDeferClaudeUsageForLiveSession(options?.authPreparation, classification)) { - return makeLiveClaudeUsageDeferredResult({ - attempts, - oauthCredentials, - authPreparation: options?.authPreparation - }) - } - - if (classification.shouldAttemptDelegatedRefresh && allowCliFallback) { - const repaired = await repairClaudeCredentialsThenRetryOAuth({ - options, - attempts, - oauthCredentials - }) - if (repaired) { - return repaired - } - } - - if (classification.shouldAttemptCliFallback && allowCliFallback) { - try { - return await fetchClaudeUsageViaCli({ - authPreparation: options?.authPreparation, - oauthCredentials, - attempts, - networkProxySettings: options?.networkProxySettings, - signal: options?.signal - }) - } catch (ptyError) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, ptyError) - } - } - - return makeClaudeUsageClassificationError({ - error, - classification, - attempts, - oauthCredentials, - authPreparation: options?.authPreparation - }) - } + const metadata = { + credentialSource: credentials.source, + authProvenance: options?.authPreparation?.provenance ?? 'system' } - - const credentialClassification = classifyClaudeCredentialAbsence({ - hasRefreshableCredentials: oauthCredentials.hasRefreshableCredentials, - keychainUnavailable: oauthCredentials.keychainUnavailable, - managedRefreshDeferredByLivePty: options?.authPreparation?.managedRefreshDeferredByLivePty - }) - - if (shouldDeferClaudeUsageForLiveSession(options?.authPreparation, credentialClassification)) { - return makeLiveClaudeUsageDeferredResult({ - attempts, - oauthCredentials, - authPreparation: options?.authPreparation - }) - } - - if ( - oauthCredentials.hasRefreshableCredentials && - credentialClassification.shouldAttemptDelegatedRefresh && - allowCliFallback - ) { - const repaired = await repairClaudeCredentialsThenRetryOAuth({ - options, - attempts, - oauthCredentials - }) - if (repaired) { - return repaired - } - } - + // Why: System Default with no Claude login is an API-key or non-Claude user, not a signed-out account. if ( - (oauthCredentials.token || - oauthCredentials.hasRefreshableCredentials || - oauthCredentials.keychainUnavailable) && - credentialClassification.shouldAttemptCliFallback && - allowCliFallback + !credentials.token && + !credentials.unavailable && + !credentials.hasRefreshableCredentials && + metadata.authProvenance === 'system' ) { - try { - return await fetchClaudeUsageViaCli({ - authPreparation: options?.authPreparation, - oauthCredentials, - attempts, - networkProxySettings: options?.networkProxySettings, - signal: options?.signal - }) - } catch (error) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) - return makeClaudeUsageResult( - 'error', - withMacTailscaleDnsHint(error instanceof Error ? error.message : 'Unknown error'), - { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: - credentialClassification.failureKind === 'keychain-unavailable' - ? 'keychain-unavailable' - : 'cli-unavailable' - }) - } - ) - } - } - - if (oauthCredentials.keychainUnavailable) { - return makeClaudeUsageResult('error', 'Claude Keychain credentials unavailable', { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: 'keychain-unavailable' - }) + return makeClaudeUsageResult('unavailable', 'No subscription plan — API key billing', { + ...metadata, + failureKind: 'missing-credentials', + attemptedSources: [] }) } - - if (oauthCredentials.hasRefreshableCredentials) { - return makeClaudeUsageResult('error', 'Claude OAuth access token unavailable', { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: credentialClassification.failureKind - }) - }) + if (!credentials.token) { + return makeClaudeUsageResult( + 'error', + credentials.unavailable + ? 'Claude usage is unavailable.' + : 'Sign in again to use this account.', + { + ...metadata, + failureKind: credentials.unavailable ? 'keychain-unavailable' : 'missing-credentials', + attemptedSources: [] + } + ) } - - if (allowCliFallback && plan.steps.some((step) => step.source === 'cli')) { - try { - return await fetchClaudeUsageViaCli({ - authPreparation: options?.authPreparation, - oauthCredentials, - attempts, - networkProxySettings: options?.networkProxySettings, - signal: options?.signal - }) - } catch (error) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) - } + try { + return await fetchClaudeOAuthUsage(credentials.token, options?.signal) + } catch (error) { + const { failureKind } = classifyClaudeOAuthUsageError(error) + // Why: the 429 wait gates the poll, and both messages tell the user why usage is missing. + const retryAfterMs = error instanceof OAuthUsageError ? error.retryAfterMs : null + return makeClaudeUsageResult( + 'error', + failureKind === 'stale-token' + ? // Why: Claude refreshes its own login when it next runs; System Default has no "account". + metadata.authProvenance === 'system' + ? 'Claude usage updates the next time Claude runs.' + : 'Claude usage has expired. Start Claude in this account to refresh it.' + : (failureKind === 'rate-limited' || failureKind === 'missing-scope') && + error instanceof OAuthUsageError + ? error.message + : 'Claude usage is unavailable.', + { + ...metadata, + failureKind, + attemptedSources: ['oauth'], + ...(retryAfterMs ? { retryAtMs: Date.now() + retryAfterMs } : {}) + } + ) } - - return makeClaudeUsageResult('unavailable', 'No subscription plan — API key billing', { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: 'missing-credentials' - }) - }) } diff --git a/src/main/rate-limits/claude-cli-usage-fetch.ts b/src/main/rate-limits/claude-cli-usage-fetch.ts deleted file mode 100644 index 70d264a4e68f..000000000000 --- a/src/main/rate-limits/claude-cli-usage-fetch.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { ProviderRateLimits, UsageRateLimitFailureKind } from '../../shared/rate-limit-types' -import type { NetworkProxySettings } from '../../shared/network-proxy' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { fetchViaPty } from './claude-pty' -import type { ClaudeOAuthCredentialReadResult } from './claude-oauth-credentials' -import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' -import { - abortedClaudeRateLimitResult, - canSupplementClaudeOAuthUsage, - isManagedClaudeAuth, - mergeClaudeUsageWindows, - metadataForClaudeUsageAttempt, - recordClaudeUsageAttempt, - type ClaudeUsageAttemptState, - warnClaudeUsageFetchFailure, - withClaudeUsageMetadata -} from './claude-usage-result' - -function classifyClaudeCliUsageFailure( - limits: ProviderRateLimits -): UsageRateLimitFailureKind | undefined { - if (!limits.error) { - return undefined - } - if (/rate limited/i.test(limits.error)) { - return 'rate-limited' - } - if (/plan usage is unavailable|usage is unavailable/i.test(limits.error)) { - return 'usage-unavailable' - } - return 'cli-unavailable' -} - -export async function fetchClaudeUsageViaCli(input: { - authPreparation?: ClaudeRuntimeAuthPreparation - oauthCredentials: ClaudeOAuthCredentialReadResult - attempts: ClaudeUsageAttemptState - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - recordClaudeUsageAttempt(input.attempts, 'cli') - const limits = await fetchViaPty({ - authPreparation: input.authPreparation, - networkProxySettings: input.networkProxySettings, - signal: input.signal - }) - return withClaudeUsageMetadata( - limits, - metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: input.oauthCredentials, - authPreparation: input.authPreparation, - source: 'cli', - failureKind: classifyClaudeCliUsageFailure(limits) - }) - ) -} - -async function supplementOAuthUsageFromCli(input: { - oauthLimits: ProviderRateLimits - authPreparation?: ClaudeRuntimeAuthPreparation - oauthCredentials: ClaudeOAuthCredentialReadResult - attempts: ClaudeUsageAttemptState - allowUsagePanelSupplement: boolean - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - if (input.signal?.aborted || !canSupplementClaudeOAuthUsage(input)) { - return input.oauthLimits - } - try { - return mergeClaudeUsageWindows( - input.oauthLimits, - await fetchClaudeUsageViaCli({ - authPreparation: input.authPreparation, - oauthCredentials: input.oauthCredentials, - attempts: input.attempts, - networkProxySettings: input.networkProxySettings, - signal: input.signal - }) - ) - } catch (error) { - warnClaudeUsageFetchFailure(input.authPreparation, input.oauthCredentials, error) - return input.oauthLimits - } -} - -export async function completeClaudeOAuthUsageSuccess(input: { - oauthLimits: ProviderRateLimits - oauthCredentials: ClaudeOAuthCredentialReadResult - attempts: ClaudeUsageAttemptState - options?: ClaudeRateLimitFetchOptions -}): Promise { - const limits = await supplementOAuthUsageFromCli({ - oauthLimits: input.oauthLimits, - authPreparation: input.options?.authPreparation, - oauthCredentials: input.oauthCredentials, - attempts: input.attempts, - networkProxySettings: input.options?.networkProxySettings, - allowUsagePanelSupplement: - input.options?.allowUsagePanelSupplement ?? - isManagedClaudeAuth(input.options?.authPreparation), - signal: input.options?.signal - }) - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - return withClaudeUsageMetadata( - limits, - metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: input.oauthCredentials, - authPreparation: input.options?.authPreparation, - source: 'oauth' - }) - ) -} diff --git a/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts b/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts deleted file mode 100644 index 428a29106941..000000000000 --- a/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { fetchClaudeRateLimits } from './claude-fetcher' -import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict -} from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() -})) - -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -describe('fetchClaudeRateLimits', () => { - beforeEach(() => { - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() - }) - - it('does not mask OAuth usage rate limits with the PTY fallback', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'expired-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'rate_limit_error', - message: 'Rate limited. Please try again later.' - } - }), - { status: 429, headers: { 'retry-after': '3000' } } - ) - ) - - const before = Date.now() - const result = await fetchClaudeRateLimits({ authPreparation }) - expect(result).toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude usage is rate limited right now.', - usageMetadata: expect.objectContaining({ failureKind: 'rate-limited' }) - }) - expect(result.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3000 * 1000) - expect(result.usageMetadata?.retryAtMs).toBeLessThanOrEqual(Date.now() + 3000 * 1000) - - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer expired-oauth-token' - }) - }) - ) - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('omits retryAtMs when a 429 has no Retry-After header', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'expired-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'rate_limit_error', - message: 'Rate limited. Please try again later.' - } - }), - { status: 429 } - ) - ) - - const result = await fetchClaudeRateLimits({ authPreparation }) - expect(result.status).toBe('error') - expect(result.usageMetadata?.retryAtMs).toBeUndefined() - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('uses CLI fallback for OAuth auth failures when automatic repair is safe', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'authentication_error', - message: 'Invalid OAuth token.' - } - }), - { status: 401 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 56 }, - usageMetadata: { - source: 'cli', - attemptedSources: ['oauth', 'cli'] - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('re-reads credentials and retries OAuth once after CLI repair', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - // Legacy item absent — the stale-scoped legacy fallback must not preempt - // CLI repair in this scenario. - .mockResolvedValueOnce(null) - .mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'repaired-oauth-token', - refreshToken: 'refresh-token-2', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'authentication_error', - message: 'Invalid OAuth token.' - } - }), - { status: 401 } - ) - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 14 }, - seven_day: { utilization: 27 } - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 14 }, - weekly: { usedPercent: 27 }, - usageMetadata: { - source: 'oauth', - attemptedSources: ['oauth', 'cli'], - credentialSource: 'scoped-keychain' - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - expect(netFetchMock).toHaveBeenNthCalledWith( - 2, - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer repaired-oauth-token' - }) - }) - ) - }) - - it('explains auth failures when a live Claude terminal owns managed refresh', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - managedRefreshDeferredByLivePty: true, - provenance: 'managed:account-1' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'authentication_error', - message: 'Invalid OAuth token.' - } - }), - { status: 401 } - ) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: - 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.' - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('does not start CLI fallback when live Claude owns managed refresh and no token is readable', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - managedRefreshDeferredByLivePty: true, - provenance: 'managed:account-1' - } - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: - 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.', - usageMetadata: { - failureKind: 'deferred-by-live-session', - deferredByLiveClaudeSession: true, - attemptedSources: [] - } - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('does not start the PTY fallback when disabled for background fetches', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce(new Response('temporary failure', { status: 500 })) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'OAuth API returned 500' - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('does not start the PTY fallback for refresh-only credentials when disabled', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude OAuth access token unavailable' - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('falls back to CLI when OAuth credentials are missing in automatic mode', async () => { - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir: '/Users/test/.claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 56 }, - usageMetadata: { - source: 'cli', - attemptedSources: ['cli'], - credentialSource: 'none' - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('marks CLI plan usage shell results as usage unavailable', async () => { - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir: '/Users/test/.claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: 1, - error: 'Claude plan usage is unavailable for this Claude CLI session.', - status: 'error' - }) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude plan usage is unavailable for this Claude CLI session.', - usageMetadata: { - source: 'cli', - attemptedSources: ['cli'], - failureKind: 'usage-unavailable' - } - }) - }) - - it('surfaces Keychain read failures as structured usage metadata when CLI fallback is disabled', async () => { - vi.mocked(readActiveClaudeKeychainCredentials).mockRejectedValueOnce( - new Error('security timed out after 3000ms') - ) - - await expect(fetchClaudeRateLimits({ allowPtyFallback: false })).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude Keychain credentials unavailable', - usageMetadata: { - failureKind: 'keychain-unavailable', - attemptedSources: [], - credentialSource: 'none' - } - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('uses CLI fallback when Keychain is unavailable in automatic mode', async () => { - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir: '/Users/test/.claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentials).mockRejectedValueOnce( - new Error('security timed out after 3000ms') - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 56 }, - usageMetadata: { - source: 'cli', - attemptedSources: ['cli'], - credentialSource: 'none' - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) -}) diff --git a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts deleted file mode 100644 index 2a84cc4f2af6..000000000000 --- a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' -import { fetchClaudeRateLimits } from './claude-fetcher' -import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' -import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() -})) - -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -describe('fetchClaudeRateLimits', () => { - beforeEach(() => { - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() - }) - - it('accepts Claude Code statusline-style rate limit window fields', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { used_percentage: 23.5, resets_at: 1770000000 }, - seven_day: { used_percentage: 41.2, resets_at: 1770604800 }, - fable_weekly: { used_percentage: 12.3, resets_at: 1770691200 } - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 23.5, resetsAt: 1770000000000 }, - weekly: { usedPercent: 41.2, resetsAt: 1770604800000 }, - fableWeekly: { usedPercent: 12.3, resetsAt: 1770691200000 } - }) - }) - - it('maps active Fable usage from the scoped OAuth limits array without a PTY read', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'managed:account-1' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 36 }, - seven_day: { utilization: 73 }, - fable_weekly: { utilization: 12 }, - limits: [ - { kind: 'weekly_scoped', percent: 55, scope: null }, - { - kind: 'weekly_scoped', - percent: 100, - resets_at: '2026-07-17T20:00:00.099908+00:00', - is_active: true, - scope: { model: { display_name: 'Fable' } } - } - ] - }), - { status: 200 } - ) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowUsagePanelSupplement: true }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - fableWeekly: { - usedPercent: 100, - resetsAt: Date.parse('2026-07-17T20:00:00.099908+00:00') - }, - usageMetadata: { attemptedSources: ['oauth'] } - }) - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('surfaces inactive scoped Fable usage over the legacy OAuth fallback', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 11 }, - seven_day: { utilization: 22 }, - fable_weekly: { utilization: 33 }, - limits: [ - { - kind: 'weekly_scoped', - percent: 90, - is_active: false, - scope: { model: { display_name: 'fable' } } - } - ] - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - fableWeekly: { usedPercent: 90 } - }) - }) - - it('surfaces an inactive scoped Fable entry when no legacy Fable field exists (#8979)', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 11 }, - seven_day: { utilization: 22 }, - limits: [ - { - kind: 'weekly_scoped', - percent: 64, - resets_at: '2026-07-24T20:00:00+00:00', - is_active: false, - scope: { model: { display_name: 'Fable' } } - } - ] - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - fableWeekly: { - usedPercent: 64, - resetsAt: Date.parse('2026-07-24T20:00:00+00:00') - } - }) - }) - - it('supplements managed-account OAuth usage with Fable from the CLI usage panel', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'managed:account-1' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { used_percentage: 23.5, resets_at: 1770000000 }, - seven_day: { used_percentage: 41.2, resets_at: 1770604800 } - }), - { status: 200 } - ) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { usedPercent: 91, windowMinutes: 300, resetsAt: null, resetDescription: null }, - weekly: null, - fableWeekly: { - usedPercent: 12.3, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d 2h' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 23.5, resetsAt: 1770000000000 }, - weekly: { usedPercent: 41.2, resetsAt: 1770604800000 }, - fableWeekly: { usedPercent: 12.3, resetDescription: '3d 2h' }, - usageMetadata: { - source: 'oauth', - attemptedSources: ['oauth', 'cli'] - } - }) - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('supplements system OAuth usage when the service explicitly allows usage-panel reads', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: null, - weekly: null, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '4d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - await expect( - fetchClaudeRateLimits({ - authPreparation, - allowPtyFallback: false, - allowUsagePanelSupplement: true - }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 }, - fableWeekly: { usedPercent: 58, resetDescription: '4d' }, - usageMetadata: { - source: 'oauth', - attemptedSources: ['oauth', 'cli'] - } - }) - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('ignores bare Fable OAuth usage because the window length is ambiguous', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 11 }, - seven_day: { utilization: 22 }, - fable: { utilization: 33 } - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 11 }, - weekly: { usedPercent: 22 }, - fableWeekly: null - }) - }) -}) diff --git a/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts b/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts deleted file mode 100644 index 9a75d3826e5c..000000000000 --- a/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts +++ /dev/null @@ -1,491 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { join } from 'node:path' -import { fetchClaudeRateLimits } from './claude-fetcher' -import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict -} from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() -})) - -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -describe('fetchClaudeRateLimits', () => { - beforeEach(() => { - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() - }) - - it('does not read host credentials when WSL config resolution fails', async () => { - await expect( - fetchClaudeRateLimits({ - authPreparation: { - configDir: '/Users/test/.claude', - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: null, - envPatch: {}, - stripAuthEnv: true, - provenance: 'wsl:Ubuntu:system' - } - }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'WSL Claude config unavailable for Ubuntu' - }) - - expect(readFileMock).not.toHaveBeenCalled() - expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled() - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('reads scoped Keychain credentials when the Claude config dir is explicit', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - readFileMock.mockResolvedValue( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'file-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 } - }) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir) - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer oauth-token', - 'User-Agent': 'claude-code/2.1.0' - }) - }) - ) - }) - - it('falls back to the legacy keychain token when the scoped token is rejected as stale', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => - dir - ? JSON.stringify({ - claudeAiOauth: { accessToken: 'stale-scoped-token', refreshToken: 'refresh-1' } - }) - : JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } }) - ) - netFetchMock.mockImplementation(async (_url: string, init?: RequestInit) => { - const auth = (init?.headers as Record | undefined)?.Authorization - return auth === 'Bearer fresh-legacy-token' - ? new Response( - JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }), - { status: 200 } - ) - : new Response( - JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), - { status: 401 } - ) - }) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 } - }) - - expect(netFetchMock).toHaveBeenCalledTimes(2) - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('prefers a legacy access token over scoped refresh-only credentials', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => - dir - ? JSON.stringify({ claudeAiOauth: { refreshToken: 'refresh-only' } }) - : JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 } - }) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: 'Bearer fresh-legacy-token' }) - }) - ) - }) - - it('does not retry with the legacy keychain for managed account credentials', async () => { - const configDir = '/Users/test/managed-account' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: true, - provenance: 'managed:account-1' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => - dir ? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-managed-token' } }) : null - ) - netFetchMock.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), { - status: 401 - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error' - }) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalledWith(undefined) - }) - - it('does not retry with the legacy keychain for WSL targets', async () => { - // Why: a WSL target's stale credentials must never be answered with the - // host user's legacy macOS Keychain account. - const configDir = '\\\\wsl$\\Ubuntu\\home\\test\\.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/test/.claude', - envPatch: { CLAUDE_CONFIG_DIR: '/home/test/.claude' }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => - dir - ? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-wsl-token' } }) - : JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } }) - ) - netFetchMock.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), { - status: 401 - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error' - }) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalledWith(undefined) - }) - - it('skips the legacy retry when the legacy item mirrors the failed scoped token', async () => { - // Why: Claude's usage endpoint has a tight request budget; retrying the - // identical token would double the request for a guaranteed second 401. - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue( - JSON.stringify({ claudeAiOauth: { accessToken: 'mirrored-token' } }) - ) - netFetchMock.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), { - status: 401 - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error' - }) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(undefined) - }) - - it('falls back to legacy Keychain credentials for host system default without an explicit config dir', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - runtime: 'host', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'legacy-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 } - }) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined) - expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer legacy-oauth-token' - }) - }) - ) - }) - - it('reads scoped Keychain credentials for host system default without an explicit config dir', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - runtime: 'host', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'scoped-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 } - }) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir) - expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() - expect(fetchViaPty).not.toHaveBeenCalled() - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer scoped-oauth-token' - }) - }) - ) - }) - - it('falls back to the credentials file when Keychain access fails', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockRejectedValue( - new Error('Keychain locked') - ) - readFileMock.mockResolvedValue( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'file-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok' - }) - - expect(readFileMock).toHaveBeenCalledWith( - join('/Users/test/.claude', '.credentials.json'), - 'utf-8' - ) - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer file-oauth-token' - }) - }) - ) - }) - - it('falls back to legacy Keychain when scoped credentials are unusable', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce('{not-json') - .mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'legacy-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok' - }) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined) - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer legacy-oauth-token' - }) - }) - ) - }) - - it('tries OAuth usage even when local credential metadata is expired', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'expired-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 } - }) - - expect(netFetchMock).toHaveBeenNthCalledWith( - 1, - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer expired-oauth-token' - }) - }) - ) - expect(readFileMock).not.toHaveBeenCalled() - expect(fetchViaPty).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts b/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts deleted file mode 100644 index c01bcb00f6eb..000000000000 --- a/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fetchManagedAccountUsage } from './claude-fetcher' -import { - primeClaudeFetcherMocks, - restorePlatform, - setPlatform -} from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from '../claude-accounts/keychain' - -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() -})) - -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -describe('fetchClaudeRateLimits', () => { - let tempDir: string | null = null - - beforeEach(() => { - tempDir = null - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('does not read inactive managed credentials from unowned auth paths', async () => { - setPlatform('linux') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const unownedAuthPath = join(tempDir, 'unowned', 'auth') - mkdirSync(unownedAuthPath, { recursive: true }) - writeFileSync(join(unownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - join(unownedAuthPath, '.credentials.json'), - JSON.stringify({ - claudeAiOauth: { - accessToken: 'unowned-token', - expiresAt: Date.now() + 60_000 - } - }), - 'utf-8' - ) - - await expect( - fetchManagedAccountUsage({ id: 'account-1', managedAuthPath: unownedAuthPath }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'No credentials' - }) - - expect(netFetchMock).not.toHaveBeenCalled() - expect(readFileMock).not.toHaveBeenCalled() - }) - - it('supplements inactive managed account OAuth usage with Fable from its usage panel', async () => { - setPlatform('linux') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const canonicalAuthPath = realpathSync(ownedAuthPath) - writeFileSync( - join(ownedAuthPath, '.credentials.json'), - JSON.stringify({ - claudeAiOauth: { - accessToken: 'inactive-token', - expiresAt: Date.now() + 60_000 - } - }), - 'utf-8' - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: null, - weekly: null, - fableWeekly: { - usedPercent: 42, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '2d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - await expect( - fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 }, - fableWeekly: { usedPercent: 42, resetDescription: '2d' } - }) - expect(fetchViaPty).toHaveBeenCalledWith({ - authPreparation: expect.objectContaining({ - configDir: canonicalAuthPath, - envPatch: { CLAUDE_CONFIG_DIR: canonicalAuthPath }, - provenance: 'managed:account-1:inactive-preview', - stripAuthEnv: true - }) - }) - }) - - it('stages macOS inactive account credentials in a scoped Keychain for Fable preview', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const credentialsJson = JSON.stringify({ - claudeAiOauth: { - accessToken: 'managed-keychain-token', - expiresAt: Date.now() + 60_000 - } - }) - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const canonicalAuthPath = realpathSync(ownedAuthPath) - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(credentialsJson) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { - usedPercent: 12, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - }, - weekly: { - usedPercent: 34, - windowMinutes: 10080, - resetsAt: null, - resetDescription: null - }, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - const result = await fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - - expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) - expect(writeActiveClaudeKeychainCredentials).toHaveBeenCalledWith( - credentialsJson, - canonicalAuthPath - ) - expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath) - }) - - it('cleans up scoped Keychain credentials when the inactive preview fails', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const canonicalAuthPath = realpathSync(ownedAuthPath) - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce( - JSON.stringify({ claudeAiOauth: { accessToken: 'managed-keychain-token' } }) - ) - vi.mocked(fetchViaPty).mockRejectedValueOnce(new Error('preview failed')) - - await expect( - fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 }, - fableWeekly: null - }) - - expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath) - }) - - it('stages refreshed macOS inactive account credentials before Fable preview', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const staleCredentialsJson = JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-access', - refreshToken: 'stale-refresh', - expiresAt: Date.now() - 60_000 - } - }) - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(staleCredentialsJson) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - access_token: 'fresh-access', - expires_in: 3600, - refresh_token: 'fresh-refresh' - }), - { status: 200 } - ) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }), - { - status: 200 - } - ) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { - usedPercent: 12, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - }, - weekly: { - usedPercent: 34, - windowMinutes: 10080, - resetsAt: null, - resetDescription: null - }, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - const result = await fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - - const stagedCredentialsJson = vi.mocked(writeActiveClaudeKeychainCredentials).mock.calls[0]?.[0] - expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) - expect(JSON.parse(stagedCredentialsJson ?? '{}')).toMatchObject({ - claudeAiOauth: { - accessToken: 'fresh-access', - refreshToken: 'fresh-refresh' - } - }) - expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( - 'account-1', - stagedCredentialsJson - ) - }) - - it('does not merge macOS inactive Fable preview when usage windows belong to another account', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'managed-keychain-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { - usedPercent: 91, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - }, - weekly: { - usedPercent: 3, - windowMinutes: 10080, - resetsAt: null, - resetDescription: null - }, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - const result = await fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - - expect(result.fableWeekly).toBeNull() - }) - - it('refreshes and persists an expiring inactive account before fetching usage', async () => { - setPlatform('linux') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const credentialsPath = join(ownedAuthPath, '.credentials.json') - writeFileSync( - credentialsPath, - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-access', - refreshToken: 'stale-refresh', - expiresAt: Date.now() - 60_000 - } - }), - 'utf-8' - ) - - // First net.fetch call is the OAuth refresh (token endpoint); second is the - // usage fetch with the refreshed access token. - netFetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - access_token: 'fresh-access', - expires_in: 3600, - refresh_token: 'fresh-refresh' - }) - }) - netFetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }) - }) - - const result = await fetchManagedAccountUsage({ - id: 'account-1', - managedAuthPath: ownedAuthPath - }) - - expect(result.status).toBe('ok') - // Rotated token persisted back to managed storage. - const persisted = JSON.parse(readFileSync(credentialsPath, 'utf-8')) - expect(persisted.claudeAiOauth.accessToken).toBe('fresh-access') - expect(persisted.claudeAiOauth.refreshToken).toBe('fresh-refresh') - // Usage fetch used the fresh access token. - const usageCall = netFetchMock.mock.calls.find(([url]) => - String(url).includes('/api/oauth/usage') - ) - expect(usageCall?.[1]?.headers?.Authorization).toBe('Bearer fresh-access') - }) -}) diff --git a/src/main/rate-limits/claude-fetcher-test-harness.ts b/src/main/rate-limits/claude-fetcher-test-harness.ts deleted file mode 100644 index f679d8388337..000000000000 --- a/src/main/rate-limits/claude-fetcher-test-harness.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { vi } from 'vitest' -import type { Mock } from 'vitest' -import { fetchViaPty } from './claude-pty' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from '../claude-accounts/keychain' - -/** Electron/fs mocks each claude-fetcher test file declares via its own `vi.mock` factories. */ -export type ClaudeFetcherHoistedMocks = { - netFetchMock: Mock - readFileMock: Mock - resolveProxyMock: Mock - setProxyMock: Mock - appGetPathMock: Mock -} - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - -export function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -export function restorePlatform(): void { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } -} - -/** Default happy-path state: darwin, no keychain credentials, OAuth usage 12/34, PTY session 56. */ -export function primeClaudeFetcherMocks(mocks: ClaudeFetcherHoistedMocks): void { - setPlatform('darwin') - vi.clearAllMocks() - mocks.readFileMock.mockRejectedValue(new Error('missing file')) - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(null) - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue(null) - vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue() - vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockResolvedValue() - vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue() - mocks.appGetPathMock.mockReturnValue('/tmp/orca-claude-fetcher-test') - mocks.resolveProxyMock.mockResolvedValue('DIRECT') - mocks.netFetchMock.mockResolvedValue( - new Response( - JSON.stringify({ - five_hour: { utilization: 12 }, - seven_day: { utilization: 34 } - }), - { status: 200 } - ) - ) - vi.mocked(fetchViaPty).mockResolvedValue({ - provider: 'claude', - session: { usedPercent: 56, windowMinutes: 300, resetsAt: null, resetDescription: null }, - weekly: null, - updatedAt: 1, - error: null, - status: 'ok' - }) -} diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 42427766a26f..9b03a2eb9713 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -1,7 +1,9 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' -import type { InactiveClaudeAccount } from './claude-managed-account-credentials' -import { fetchInactiveClaudeAccountUsage } from './claude-managed-account-usage' +import { + fetchInactiveClaudeAccountUsage, + type InactiveClaudeAccount +} from './claude-managed-account-usage' import type { ClaudeManagedAccountUsageOptions, ClaudeRateLimitFetchOptions diff --git a/src/main/rate-limits/claude-managed-account-credentials.ts b/src/main/rate-limits/claude-managed-account-credentials.ts deleted file mode 100644 index 71d505510cf1..000000000000 --- a/src/main/rate-limits/claude-managed-account-credentials.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { existsSync, lstatSync, readFileSync } from 'node:fs' -import path from 'node:path' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from '../claude-accounts/keychain' -import { - readClaudeManagedAuthFile, - resolveOwnedClaudeManagedAuthPath, - writeClaudeManagedAuthFile -} from '../claude-accounts/managed-auth-path' - -export type InactiveClaudeAccount = { - id: string - managedAuthPath: string - managedAuthRuntime?: 'host' | 'wsl' - wslDistro?: string | null - wslLinuxAuthPath?: string | null -} - -export type ClaudeManagedCredentialsLocation = - | { kind: 'keychain'; accountId: string; managedAuthPath: string } - | { kind: 'file'; managedAuthPath: string } - -export function resolveClaudeManagedCredentialsLocation( - account: InactiveClaudeAccount -): ClaudeManagedCredentialsLocation | null { - if (account.managedAuthRuntime === 'wsl') { - const managedAuthPath = resolveOwnedWslClaudeManagedAuthPath(account) - return managedAuthPath ? { kind: 'file', managedAuthPath } : null - } - const managedAuthPath = resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { - adoptLegacyMarker: true - }) - if (!managedAuthPath) { - return null - } - return process.platform === 'darwin' - ? { kind: 'keychain', accountId: account.id, managedAuthPath } - : { kind: 'file', managedAuthPath } -} - -export async function readClaudeManagedCredentialsJson( - location: ClaudeManagedCredentialsLocation -): Promise { - try { - return location.kind === 'keychain' - ? await readManagedClaudeKeychainCredentials(location.accountId) - : readClaudeManagedAuthFile(location.managedAuthPath, '.credentials.json') - } catch { - return null - } -} - -export async function writeClaudeManagedCredentialsJson( - location: ClaudeManagedCredentialsLocation, - credentialsJson: string -): Promise { - if (location.kind === 'keychain') { - await writeManagedClaudeKeychainCredentials(location.accountId, credentialsJson) - } else { - writeClaudeManagedAuthFile(location.managedAuthPath, '.credentials.json', credentialsJson) - } -} - -function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccount): string | null { - if (process.platform !== 'win32') { - return null - } - const wslInfo = parseWslUncPath(account.managedAuthPath) - if (!wslInfo || (account.wslDistro && wslInfo.distro !== account.wslDistro)) { - return null - } - const linuxPath = account.wslLinuxAuthPath ?? wslInfo.linuxPath - if ( - !linuxPath.includes('/.local/share/orca/claude-accounts/') || - !linuxPath.endsWith(`/${account.id}/auth`) - ) { - return null - } - try { - const markerPath = path.join(account.managedAuthPath, '.orca-managed-claude-auth') - if ( - !existsSync(markerPath) || - lstatSync(markerPath).isSymbolicLink() || - readFileSync(markerPath, 'utf-8').trim() !== account.id - ) { - return null - } - return account.managedAuthPath - } catch { - return null - } -} - -export async function withClaudeManagedPreviewKeychainCredentials( - location: ClaudeManagedCredentialsLocation, - credentialsJson: string, - operation: () => Promise -): Promise { - if (location.kind !== 'keychain') { - return operation() - } - await writeActiveClaudeKeychainCredentials(credentialsJson, location.managedAuthPath) - try { - return await operation() - } finally { - await deleteActiveClaudeKeychainCredentialsStrict(location.managedAuthPath).catch(() => {}) - } -} - -export async function readStagedClaudeManagedPreviewCredentials( - location: ClaudeManagedCredentialsLocation -): Promise { - if (location.kind !== 'keychain') { - return null - } - try { - return await readActiveClaudeKeychainCredentialsStrict(location.managedAuthPath) - } catch { - return null - } -} diff --git a/src/main/rate-limits/claude-managed-account-usage.ts b/src/main/rate-limits/claude-managed-account-usage.ts index ddf7b3df7508..b2e419f8bb0e 100644 --- a/src/main/rate-limits/claude-managed-account-usage.ts +++ b/src/main/rate-limits/claude-managed-account-usage.ts @@ -1,104 +1,66 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { - isOauthTokenExpiring, - refreshClaudeOauthCredentials -} from '../claude-accounts/oauth-refresh' -import { - readClaudeManagedCredentialsJson, - resolveClaudeManagedCredentialsLocation, - writeClaudeManagedCredentialsJson, - type InactiveClaudeAccount -} from './claude-managed-account-credentials' -import { fetchClaudeManagedUsagePanelSupplement } from './claude-managed-usage-panel' -import { parseClaudeOAuthCredentialsJson } from './claude-oauth-credentials' -import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' +import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' +import { savedWslClaudeAccountHome } from '../claude-accounts/claude-profile-wsl-paths' +import { toWindowsWslPath } from '../../shared/wsl-paths' import type { ClaudeManagedAccountUsageOptions } from './claude-usage-fetch-options' -import { - abortedClaudeRateLimitResult, - canSupplementClaudeOAuthUsage, - mergeClaudeUsageWindows, - warnClaudeUsageFetchFailure -} from './claude-usage-result' +import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' +import { claudeUsageUnavailable, makeClaudeUsageResult } from './claude-usage-result' +import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' -function noClaudeManagedCredentialsResult(): ProviderRateLimits { - return { - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'No credentials', - status: 'error' - } +/** Usage reads the account's own folder; a host account's comes from its id, never a stored path. */ +export type InactiveClaudeAccount = { + id: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxAuthPath?: string | null } export async function fetchInactiveClaudeAccountUsage( account: InactiveClaudeAccount, options: ClaudeManagedAccountUsageOptions = {} ): Promise { - if (options.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - const location = resolveClaudeManagedCredentialsLocation(account) - let credentialsJson = location ? await readClaudeManagedCredentialsJson(location) : null - if (options.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - if (!location || !credentialsJson) { - return noClaudeManagedCredentialsResult() - } - - let token = parseClaudeOAuthCredentialsJson(credentialsJson, 'credentials-file').token - if (isOauthTokenExpiring(credentialsJson)) { - const refreshed = await refreshClaudeOauthCredentials(credentialsJson) - if (options.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - if (refreshed) { - try { - await writeClaudeManagedCredentialsJson(location, refreshed) - } catch { - // Keep the refreshed token for this fetch; a later poll can persist it. - } - credentialsJson = refreshed - token = parseClaudeOAuthCredentialsJson(refreshed, 'credentials-file').token - } - } - - if (!token) { - return noClaudeManagedCredentialsResult() - } - const oauthLimits = await fetchClaudeOAuthUsage(token, options.signal) - if (options.signal?.aborted) { - return abortedClaudeRateLimitResult() + const configDir = + account.managedAuthRuntime === 'wsl' + ? savedWslClaudeAccountHome(account) + : (getClaudeProfileRouter()?.accountHome(account.id) ?? null) + if (!configDir || (account.managedAuthRuntime === 'wsl' && !account.wslDistro)) { + return makeClaudeUsageResult('error', 'Sign in again to use this account.', { + failureKind: 'missing-credentials', + attemptedSources: [] + }) } + const home = + account.managedAuthRuntime === 'wsl' && account.wslDistro + ? toWindowsWslPath(configDir, account.wslDistro) + : configDir + // Why: a stopped distro parks a UNC read for minutes, and the inactive loop is sequential. if ( - !canSupplementClaudeOAuthUsage({ - oauthLimits, - authPreparation: undefined, - allowUsagePanelSupplement: options.allowUsagePanelSupplement === true - }) + account.managedAuthRuntime === 'wsl' && + (await filterPathsToRunningWslDistrosAsync([home], { requireConfirmed: true })).length === 0 ) { - return oauthLimits + return claudeUsageUnavailable() } - - try { - return mergeClaudeUsageWindows( - oauthLimits, - await fetchClaudeManagedUsagePanelSupplement({ - account, - location, - credentialsJson, - oauthLimits, - networkProxySettings: options.networkProxySettings, - signal: options.signal - }) - ) - } catch (error) { - warnClaudeUsageFetchFailure( - undefined, - parseClaudeOAuthCredentialsJson(credentialsJson, 'credentials-file'), - error - ) - return oauthLimits + const read = fetchActiveClaudeRateLimits({ + signal: options.signal, + authPreparation: { + configDir: home, + runtime: account.managedAuthRuntime, + wslDistro: account.wslDistro, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: true, + provenance: `profile:${account.id}` + } + }) + if (account.managedAuthRuntime !== 'wsl') { + return read } + const timeout = AbortSignal.timeout(INACTIVE_WSL_READ_TIMEOUT_MS) + return Promise.race([ + read, + new Promise((resolve) => + timeout.addEventListener('abort', () => resolve(claudeUsageUnavailable()), { once: true }) + ) + ]) } + +const INACTIVE_WSL_READ_TIMEOUT_MS = 10_000 diff --git a/src/main/rate-limits/claude-managed-usage-panel.ts b/src/main/rate-limits/claude-managed-usage-panel.ts deleted file mode 100644 index 15c1e90169c7..000000000000 --- a/src/main/rate-limits/claude-managed-usage-panel.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' -import type { NetworkProxySettings } from '../../shared/network-proxy' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { fetchViaPty } from './claude-pty' -import { - readStagedClaudeManagedPreviewCredentials, - withClaudeManagedPreviewKeychainCredentials, - writeClaudeManagedCredentialsJson, - type ClaudeManagedCredentialsLocation, - type InactiveClaudeAccount -} from './claude-managed-account-credentials' - -function getManagedUsagePanelAuthPreparation( - account: InactiveClaudeAccount, - location: ClaudeManagedCredentialsLocation -): ClaudeRuntimeAuthPreparation | null { - if (process.platform === 'win32') { - return null - } - if (account.managedAuthRuntime === 'wsl') { - if (!account.wslLinuxAuthPath || !account.wslDistro) { - return null - } - return { - configDir: location.managedAuthPath, - runtime: 'wsl', - wslDistro: account.wslDistro, - wslLinuxConfigDir: account.wslLinuxAuthPath, - envPatch: { CLAUDE_CONFIG_DIR: account.wslLinuxAuthPath }, - stripAuthEnv: true, - provenance: `managed:${account.id}:inactive-preview` - } - } - return { - configDir: location.managedAuthPath, - runtime: 'host', - wslDistro: null, - wslLinuxConfigDir: null, - envPatch: { CLAUDE_CONFIG_DIR: location.managedAuthPath }, - stripAuthEnv: true, - provenance: `managed:${account.id}:inactive-preview` - } -} - -function windowsAgree(left: RateLimitWindow | null, right: RateLimitWindow | null): boolean { - return Boolean(left && right && Math.abs(left.usedPercent - right.usedPercent) <= 1) -} - -function canTrustManagedUsagePanelSupplement( - oauthLimits: ProviderRateLimits, - cliLimits: ProviderRateLimits, - requireMatchingOAuthWindow: boolean -): boolean { - if (!requireMatchingOAuthWindow) { - return true - } - const sharedWindowMatches = [ - oauthLimits.session && cliLimits.session - ? windowsAgree(oauthLimits.session, cliLimits.session) - : null, - oauthLimits.weekly && cliLimits.weekly - ? windowsAgree(oauthLimits.weekly, cliLimits.weekly) - : null - ].filter((match): match is boolean => match !== null) - // Why: older Claude builds may ignore the scoped Keychain and expose the active account. - return sharedWindowMatches.length > 0 && sharedWindowMatches.every(Boolean) -} - -export async function fetchClaudeManagedUsagePanelSupplement(input: { - account: InactiveClaudeAccount - location: ClaudeManagedCredentialsLocation - credentialsJson: string - oauthLimits: ProviderRateLimits - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - if (input.signal?.aborted) { - return null - } - const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location) - if (!authPreparation) { - return null - } - - return withClaudeManagedPreviewKeychainCredentials( - input.location, - input.credentialsJson, - async () => { - const cliLimits = await fetchViaPty({ - authPreparation, - networkProxySettings: input.networkProxySettings, - signal: input.signal - }) - if (input.signal?.aborted) { - return null - } - if ( - !canTrustManagedUsagePanelSupplement( - input.oauthLimits, - cliLimits, - input.location.kind === 'keychain' - ) - ) { - return null - } - const refreshed = await readStagedClaudeManagedPreviewCredentials(input.location) - if (refreshed && refreshed !== input.credentialsJson) { - await writeClaudeManagedCredentialsJson(input.location, refreshed) - } - return cliLimits - } - ) -} diff --git a/src/main/rate-limits/claude-oauth-credentials.ts b/src/main/rate-limits/claude-oauth-credentials.ts index 3e14fb2909e7..7fa4d04fbb92 100644 --- a/src/main/rate-limits/claude-oauth-credentials.ts +++ b/src/main/rate-limits/claude-oauth-credentials.ts @@ -1,160 +1,137 @@ import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict -} from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -type ClaudeCredentials = { - claudeAiOauth?: { - accessToken?: string - refreshToken?: string - expiresAt?: number - } -} +import { z } from 'zod' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth/runtime-auth-types' +import { readUserClaudeConfigDir } from '../claude-accounts/claude-profile-paths' +import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' export type ClaudeOAuthCredentialSource = | 'scoped-keychain' | 'legacy-keychain' | 'credentials-file' | 'none' - export type ClaudeOAuthCredentialReadResult = { token: string | null hasRefreshableCredentials: boolean source: ClaudeOAuthCredentialSource keychainUnavailable?: boolean + unavailable?: boolean } - type ClaudeOAuthCredentialReadOptions = { credentialsFileConfigDir?: string keychainConfigDir?: string + /** System Default only: a managed profile never reads another login's item. */ + unsuffixedKeychainFallback?: boolean } - +const schema = z.object({ + claudeAiOauth: z + .object({ + accessToken: z.string().optional(), + refreshToken: z.string().optional() + }) + .optional() +}) export function parseClaudeOAuthCredentialsJson( raw: string, source: ClaudeOAuthCredentialSource ): ClaudeOAuthCredentialReadResult { try { - const oauth = (JSON.parse(raw) as ClaudeCredentials)?.claudeAiOauth - const hasRefreshableCredentials = - typeof oauth?.refreshToken === 'string' && oauth.refreshToken.trim() !== '' - if (!oauth?.accessToken || typeof oauth.accessToken !== 'string') { - return { token: null, hasRefreshableCredentials, source } - } + const oauth = schema.parse(JSON.parse(raw)).claudeAiOauth // Why: expiresAt is not authoritative for the usage endpoint; let the server decide. - return { token: oauth.accessToken, hasRefreshableCredentials, source } + return { + token: oauth?.accessToken || null, + hasRefreshableCredentials: Boolean(oauth?.refreshToken), + source + } } catch { - return emptyClaudeOAuthCredentialReadResult() + return { ...emptyClaudeOAuthCredentialReadResult(), unavailable: true } } } - export function emptyClaudeOAuthCredentialReadResult(): ClaudeOAuthCredentialReadResult { return { token: null, hasRefreshableCredentials: false, source: 'none' } } - -function unavailableKeychainResult(): ClaudeOAuthCredentialReadResult { - return { - token: null, - hasRefreshableCredentials: false, - source: 'none', - keychainUnavailable: true - } -} - -async function readFromKeychain(configDir?: string): Promise { - if (process.platform !== 'darwin') { - return emptyClaudeOAuthCredentialReadResult() - } - - if (configDir) { - const scoped = await readClaudeCredentialsFromStrictKeychain(configDir, 'scoped-keychain') - if (scoped.token) { - return scoped - } - - const legacy = await readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') - // Why: a real access token must not be shadowed by scoped refresh-only credentials. - if (legacy.token) { - return legacy - } - if (scoped.hasRefreshableCredentials) { - return scoped - } - if (legacy.hasRefreshableCredentials) { - return legacy - } - return scoped.keychainUnavailable || legacy.keychainUnavailable - ? unavailableKeychainResult() - : legacy - } - - try { - const credentials = await readActiveClaudeKeychainCredentials(configDir) - return credentials - ? parseClaudeOAuthCredentialsJson(credentials, 'legacy-keychain') - : emptyClaudeOAuthCredentialReadResult() - } catch { - return unavailableKeychainResult() - } -} - export async function readClaudeCredentialsFromStrictKeychain( configDir: string | undefined, source: ClaudeOAuthCredentialSource ): Promise { try { - const credentials = await readActiveClaudeKeychainCredentialsStrict(configDir) - return credentials - ? parseClaudeOAuthCredentialsJson(credentials, source) + const contents = await readActiveClaudeKeychainCredentialsStrict(configDir) + return contents + ? parseClaudeOAuthCredentialsJson(contents, source) : emptyClaudeOAuthCredentialReadResult() } catch { - return unavailableKeychainResult() + return { + ...emptyClaudeOAuthCredentialReadResult(), + unavailable: true, + keychainUnavailable: true + } } } - -async function readFromCredentialsFile( - configDir?: string +async function readClaudeKeychainCredentials( + options?: ClaudeOAuthCredentialReadOptions ): Promise { - const credentialPath = path.join( - configDir ?? path.join(homedir(), '.claude'), - '.credentials.json' + if (!options?.keychainConfigDir) { + return readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') + } + const scoped = await readClaudeCredentialsFromStrictKeychain( + options.keychainConfigDir, + 'scoped-keychain' ) - try { - return parseClaudeOAuthCredentialsJson( - await readFile(credentialPath, 'utf-8'), - 'credentials-file' - ) - } catch { - return emptyClaudeOAuthCredentialReadResult() + if (scoped.token || !options.unsuffixedKeychainFallback) { + return scoped } + const legacy = await readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') + // Why: as before profiles, a token wins over an unreadable item. + const candidates = [scoped, legacy] + return ( + candidates.find((candidate) => candidate.token) ?? + candidates.find((candidate) => candidate.unavailable) ?? + legacy + ) } - export async function readClaudeOAuthCredentials( options?: ClaudeOAuthCredentialReadOptions ): Promise { - const keychain = await readFromKeychain(options?.keychainConfigDir) - if (keychain.token || keychain.hasRefreshableCredentials) { + const keychain = + process.platform === 'darwin' + ? await readClaudeKeychainCredentials(options) + : emptyClaudeOAuthCredentialReadResult() + if (keychain.token) { return keychain } - - const file = await readFromCredentialsFile(options?.credentialsFileConfigDir) - if (file.token || file.hasRefreshableCredentials) { - return file + try { + return parseClaudeOAuthCredentialsJson( + await readFile( + path.join( + options?.credentialsFileConfigDir ?? path.join(homedir(), '.claude'), + '.credentials.json' + ), + 'utf8' + ), + 'credentials-file' + ) + } catch (error) { + return isDefinitiveAbsence(error) ? keychain : { ...keychain, unavailable: true } } - return keychain.keychainUnavailable ? keychain : emptyClaudeOAuthCredentialReadResult() } - export function resolveClaudeOAuthCredentialReadOptions( authPreparation?: ClaudeRuntimeAuthPreparation ): ClaudeOAuthCredentialReadOptions | undefined { if (!authPreparation) { return undefined } + const accountDir = authPreparation.envPatch.CLAUDE_CONFIG_DIR return { credentialsFileConfigDir: authPreparation.configDir, - keychainConfigDir: authPreparation.configDir + // Why the user's own folder, login shell first: Claude names System default's item from it. + keychainConfigDir: + accountDir ?? + getClaudeProfileRouter()?.userConfigDir() ?? + readUserClaudeConfigDir(process.env), + // An unsuffixed lookup is exclusively System default, never a fallback from an account. + unsuffixedKeychainFallback: !accountDir } } diff --git a/src/main/rate-limits/claude-oauth-recovery.ts b/src/main/rate-limits/claude-oauth-recovery.ts deleted file mode 100644 index 58c6bf91cb37..000000000000 --- a/src/main/rate-limits/claude-oauth-recovery.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { completeClaudeOAuthUsageSuccess, fetchClaudeUsageViaCli } from './claude-cli-usage-fetch' -import { - readClaudeCredentialsFromStrictKeychain, - readClaudeOAuthCredentials, - resolveClaudeOAuthCredentialReadOptions, - type ClaudeOAuthCredentialReadResult -} from './claude-oauth-credentials' -import { OAuthUsageError } from './claude-oauth-usage-error' -import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' -import type { ClaudeUsageErrorClassification } from './claude-usage-error-classification' -import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' -import { - abortedClaudeRateLimitResult, - isManagedClaudeAuth, - makeClaudeUsageResult, - mergeClaudeUsageWindows, - metadataForClaudeUsageAttempt, - recordClaudeUsageAttempt, - type ClaudeUsageAttemptState, - warnClaudeUsageFetchFailure, - withClaudeUsageMetadata -} from './claude-usage-result' - -const LIVE_REFRESH_DEFERRED_MESSAGE = - 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.' - -export function canRetryClaudeOAuthWithLegacyKeychain(input: { - classification: ClaudeUsageErrorClassification - oauthCredentials: ClaudeOAuthCredentialReadResult - authPreparation?: ClaudeRuntimeAuthPreparation -}): boolean { - return ( - input.classification.failureKind === 'stale-token' && - input.oauthCredentials.source === 'scoped-keychain' && - (input.authPreparation?.runtime ?? 'host') === 'host' && - !isManagedClaudeAuth(input.authPreparation) - ) -} - -export async function retryClaudeOAuthWithLegacyKeychain(input: { - failedToken: string | null - attempts: ClaudeUsageAttemptState - options?: ClaudeRateLimitFetchOptions -}): Promise { - const legacy = await readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') - if (!legacy.token || legacy.token === input.failedToken) { - return null - } - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - try { - const limits = await fetchClaudeOAuthUsage(legacy.token, input.options?.signal) - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - return await completeClaudeOAuthUsageSuccess({ - oauthLimits: limits, - oauthCredentials: legacy, - attempts: input.attempts, - options: input.options - }) - } catch (error) { - warnClaudeUsageFetchFailure(input.options?.authPreparation, legacy, error) - return null - } -} - -export function shouldDeferClaudeUsageForLiveSession( - authPreparation: ClaudeRuntimeAuthPreparation | undefined, - classification: ClaudeUsageErrorClassification -): boolean { - return Boolean( - authPreparation?.managedRefreshDeferredByLivePty && - (classification.failureKind === 'stale-token' || - classification.failureKind === 'refreshable-credentials-without-token' || - classification.failureKind === 'deferred-by-live-session') - ) -} - -export function makeLiveClaudeUsageDeferredResult(input: { - attempts: ClaudeUsageAttemptState - oauthCredentials: ClaudeOAuthCredentialReadResult - authPreparation?: ClaudeRuntimeAuthPreparation -}): ProviderRateLimits { - return makeClaudeUsageResult('error', LIVE_REFRESH_DEFERRED_MESSAGE, { - ...metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: input.oauthCredentials, - authPreparation: input.authPreparation, - failureKind: 'deferred-by-live-session', - deferredByLiveClaudeSession: true - }) - }) -} - -export function makeClaudeUsageClassificationError(input: { - error: unknown - classification: ClaudeUsageErrorClassification - attempts: ClaudeUsageAttemptState - oauthCredentials: ClaudeOAuthCredentialReadResult - authPreparation?: ClaudeRuntimeAuthPreparation -}): ProviderRateLimits { - const message = - input.error instanceof Error ? input.error.message : String(input.error || 'Unknown error') - const retryAfterMs = input.error instanceof OAuthUsageError ? input.error.retryAfterMs : null - return makeClaudeUsageResult('error', withMacTailscaleDnsHint(message), { - ...metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: input.oauthCredentials, - authPreparation: input.authPreparation, - failureKind: input.classification.failureKind, - retryAtMs: retryAfterMs ? Date.now() + retryAfterMs : undefined - }) - }) -} - -export async function repairClaudeCredentialsThenRetryOAuth(input: { - options?: ClaudeRateLimitFetchOptions - attempts: ClaudeUsageAttemptState - oauthCredentials: ClaudeOAuthCredentialReadResult -}): Promise { - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - let cliResult: ProviderRateLimits | null = null - try { - cliResult = await fetchClaudeUsageViaCli({ - authPreparation: input.options?.authPreparation, - oauthCredentials: input.oauthCredentials, - attempts: input.attempts, - networkProxySettings: input.options?.networkProxySettings, - signal: input.options?.signal - }) - } catch (error) { - warnClaudeUsageFetchFailure(input.options?.authPreparation, input.oauthCredentials, error) - } - - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - const refreshed = await readClaudeOAuthCredentials( - resolveClaudeOAuthCredentialReadOptions(input.options?.authPreparation) - ) - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - if (refreshed.token) { - recordClaudeUsageAttempt(input.attempts, 'oauth') - try { - const retry = await fetchClaudeOAuthUsage(refreshed.token, input.options?.signal) - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - return withClaudeUsageMetadata( - mergeClaudeUsageWindows(retry, cliResult), - metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: refreshed, - authPreparation: input.options?.authPreparation, - source: 'oauth' - }) - ) - } catch (error) { - warnClaudeUsageFetchFailure(input.options?.authPreparation, refreshed, error) - } - } - return cliResult -} diff --git a/src/main/rate-limits/claude-fetcher-extra-usage.test.ts b/src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts similarity index 66% rename from src/main/rate-limits/claude-fetcher-extra-usage.test.ts rename to src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts index fb56512bd8c6..9911343080c4 100644 --- a/src/main/rate-limits/claude-fetcher-extra-usage.test.ts +++ b/src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts @@ -1,94 +1,28 @@ -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' -import { fetchClaudeRateLimits } from './claude-fetcher' -import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) +const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() })) vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() + net: { fetch: netFetchMock }, + session: { defaultSession: {} } })) -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() +vi.mock('../network/proxy-settings', () => ({ + ensureElectronProxyFromEnvironment: vi.fn(async () => {}) })) -function oauthPrep(): ClaudeRuntimeAuthPreparation { - return { - configDir: '/Users/test/.claude', - envPatch: { CLAUDE_CONFIG_DIR: '/Users/test/.claude' }, - stripAuthEnv: false, - provenance: 'system' - } -} - async function fetchUsageResponse(body: string) { - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { accessToken: 'oauth-token', expiresAt: Date.now() + 60_000 } - }) - ) netFetchMock.mockResolvedValueOnce(new Response(body, { status: 200 })) - return fetchClaudeRateLimits({ authPreparation: oauthPrep() }) + return fetchClaudeOAuthUsage('oauth-token') } -describe('fetchClaudeRateLimits extra usage', () => { +describe('Claude OAuth usage extra usage', () => { beforeEach(() => { - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() + vi.clearAllMocks() }) it('maps the usage-credits spend object into a capped balance in major units', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) netFetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -105,7 +39,7 @@ describe('fetchClaudeRateLimits extra usage', () => { ) ) - await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { @@ -122,14 +56,6 @@ describe('fetchClaudeRateLimits extra usage', () => { }) it('keeps the usage-credits cap visible without inventing an out-of-credits balance', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) netFetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -147,7 +73,7 @@ describe('fetchClaudeRateLimits extra usage', () => { ) ) - await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { @@ -163,14 +89,6 @@ describe('fetchClaudeRateLimits extra usage', () => { }) it('keeps legacy extra_usage spend data without inventing a missing balance', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) netFetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -188,7 +106,7 @@ describe('fetchClaudeRateLimits extra usage', () => { ) ) - await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { diff --git a/src/main/rate-limits/claude-profile-usage.test.ts b/src/main/rate-limits/claude-profile-usage.test.ts new file mode 100644 index 000000000000..36816ea655ae --- /dev/null +++ b/src/main/rate-limits/claude-profile-usage.test.ts @@ -0,0 +1,271 @@ +import type * as Keychain from '../claude-accounts/keychain' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +const calls = vi.hoisted(() => ({ keychain: vi.fn(), usage: vi.fn(), keychainService: vi.fn() })) +vi.mock('../macos-keychain/generic-password', () => ({ + readKeychainPassword: calls.keychainService +})) +vi.mock('../claude-accounts/keychain', () => ({ + readActiveClaudeKeychainCredentialsStrict: calls.keychain +})) +const profileRouter = vi.hoisted((): { userConfigDir?: string } => ({})) +vi.mock('../claude-accounts/claude-profile-installed-router', () => ({ + getClaudeProfileRouter: () => + profileRouter.userConfigDir ? { userConfigDir: () => profileRouter.userConfigDir } : undefined +})) +vi.mock('./claude-oauth-usage-request', () => ({ fetchClaudeOAuthUsage: calls.usage })) +const runningDistros = vi.hoisted(() => ({ + filter: vi.fn(async (paths: readonly string[]) => [...paths]) +})) +vi.mock('../wsl-running-path-filter', () => ({ + filterPathsToRunningWslDistrosAsync: runningDistros.filter +})) +vi.mock('../../shared/child-process/run-process', () => ({ + spawnProcess: () => { + throw new Error('Usage must not launch a process') + }, + runProcess: () => { + throw new Error('Usage must not launch a process') + } +})) +import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' +import { + readClaudeOAuthCredentials, + resolveClaudeOAuthCredentialReadOptions +} from './claude-oauth-credentials' +import { OAuthUsageError } from './claude-oauth-usage-error' +import { CLAUDE_PROFILE_MISSING_MESSAGE } from '../../shared/claude-profile-routing' +const roots: string[] = [] +afterEach(() => { + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) + vi.unstubAllGlobals() + vi.clearAllMocks() +}) +function profile() { + const home = mkdtempSync(join(tmpdir(), 'claude-usage-')) + roots.push(home) + calls.keychain.mockResolvedValue(null) + const options = { + authPreparation: { + configDir: home, + envPatch: { CLAUDE_CONFIG_DIR: home }, + stripAuthEnv: true, + provenance: 'profile:fake' + } + } + return { home, options } +} +function systemDefault() { + const home = mkdtempSync(join(tmpdir(), 'claude-usage-default-')) + roots.push(home) + calls.keychain.mockResolvedValue(null) + return { + home, + options: { + authPreparation: { configDir: home, envPatch: {}, stripAuthEnv: false, provenance: 'system' } + } + } +} +it('lets the server decide a locally expired token and never refreshes it', async () => { + const f = profile() + const file = join(f.home, '.credentials.json') + writeFileSync( + file, + JSON.stringify({ + claudeAiOauth: { accessToken: 'expired-fake', refreshToken: 'must-not-be-used', expiresAt: 1 } + }) + ) + vi.stubGlobal('fetch', () => { + throw new Error('No refresh endpoint') + }) + calls.usage.mockRejectedValue(new OAuthUsageError('Invalid OAuth token.', 401, true, null)) + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + status: 'error', + error: 'Claude usage has expired. Start Claude in this account to refresh it.', + usageMetadata: { failureKind: 'stale-token' } + }) + expect(calls.usage.mock.calls.map(([token]) => token)).toEqual(['expired-fake']) + const system = systemDefault() + writeFileSync( + join(system.home, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'expired-default', expiresAt: 1 } }) + ) + calls.usage.mockResolvedValueOnce({ provider: 'claude', status: 'ok' }) + expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({ status: 'ok' }) + expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({ + error: 'Claude usage updates the next time Claude runs.', + usageMetadata: { failureKind: 'stale-token' } + }) +}) +it('reads only the requested scoped Keychain, with no unsuffixed fallback', async () => { + const f = profile() + await readClaudeOAuthCredentials({ credentialsFileConfigDir: f.home, keychainConfigDir: f.home }) + if (process.platform === 'darwin') { + expect(calls.keychain.mock.calls).toEqual([[f.home]]) + } else { + expect(calls.keychain).not.toHaveBeenCalled() + } +}) +it('distinguishes missing, malformed and inaccessible credential observations', async () => { + const f = profile() + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + usageMetadata: { failureKind: 'missing-credentials' } + }) + writeFileSync(join(f.home, '.credentials.json'), '{') + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + usageMetadata: { failureKind: 'keychain-unavailable' } + }) +}) +it('reports an unreadable selected account before any credential read and requests no hidden recovery', async () => { + const f = profile() + expect( + await fetchActiveClaudeRateLimits({ + authPreparation: { ...f.options.authPreparation, usageError: CLAUDE_PROFILE_MISSING_MESSAGE } + }) + ).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'missing-credentials' } }) + expect(calls.keychain).not.toHaveBeenCalled() + writeFileSync( + join(f.home, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'fake' } }) + ) + calls.usage.mockRejectedValue(new Error('network unavailable')) + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ status: 'error' }) + expect(calls.usage).toHaveBeenCalledTimes(1) +}) + +it('strict Keychain lookup never falls back to the unsuffixed service', async () => { + const f = profile() + calls.keychainService.mockResolvedValue(null) + const actual = await vi.importActual('../claude-accounts/keychain') + await actual.readActiveClaudeKeychainCredentialsStrict(f.home) + expect(calls.keychainService).toHaveBeenCalled() + for (const [service] of calls.keychainService.mock.calls) { + expect(service).toMatch(/^Claude Code-credentials-[0-9a-f]{8}$/) + } + calls.keychainService.mockClear() + calls.keychainService.mockRejectedValue(new Error('locked')) + await expect(actual.readActiveClaudeKeychainCredentialsStrict(f.home)).rejects.toThrow('locked') + expect(calls.keychainService).toHaveBeenCalledTimes(1) +}) +it('treats an unreadable credential file as unavailable, not a missing login', async () => { + const f = profile() + mkdirSync(join(f.home, '.credentials.json')) + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + usageMetadata: { failureKind: 'keychain-unavailable' } + }) +}) +it('hides Claude usage for System Default with no Claude login, instead of asking to sign in again', async () => { + const f = systemDefault() + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + status: 'unavailable', + usageMetadata: { failureKind: 'missing-credentials' } + }) + expect(await fetchActiveClaudeRateLimits(profile().options)).toMatchObject({ + status: 'error', + error: 'Sign in again to use this account.' + }) +}) +it("keeps a 429's Retry-After and rate-limit message so polling waits it out", async () => { + const f = profile() + writeFileSync( + join(f.home, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'fake' } }) + ) + const message = 'Claude usage is rate limited right now.' + calls.usage.mockRejectedValueOnce(new OAuthUsageError(message, 429, true, 3_000_000)) + const before = Date.now() + const limited = await fetchActiveClaudeRateLimits(f.options) + expect(limited).toMatchObject({ + status: 'error', + error: message, + usageMetadata: { failureKind: 'rate-limited' } + }) + expect(limited.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3_000_000) + expect(limited.usageMetadata?.retryAtMs).toBeLessThanOrEqual(Date.now() + 3_000_000) + calls.usage.mockRejectedValueOnce(new OAuthUsageError(message, 429, true, null)) + expect((await fetchActiveClaudeRateLimits(f.options)).usageMetadata?.retryAtMs).toBeUndefined() +}) +it("reads System default's own CLAUDE_CONFIG_DIR Keychain item before the unsuffixed one", async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-usage-inherited-')) + roots.push(root) + const inherited = join(root, 'own-claude-config') + vi.stubEnv('CLAUDE_CONFIG_DIR', inherited) + try { + calls.keychain.mockResolvedValue(null) + await readClaudeOAuthCredentials( + resolveClaudeOAuthCredentialReadOptions({ + configDir: inherited, + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + }) + ) + expect(calls.keychain.mock.calls).toEqual( + process.platform === 'darwin' ? [[inherited], [undefined]] : [] + ) + } finally { + vi.unstubAllEnvs() + } + const managed = profile() + calls.keychain.mockClear() + await fetchActiveClaudeRateLimits(managed.options) + expect(calls.keychain.mock.calls).toEqual(process.platform === 'darwin' ? [[managed.home]] : []) +}) +it("names System default's Keychain item from the login shell's CLAUDE_CONFIG_DIR a Dock launch lacks", () => { + vi.stubEnv('CLAUDE_CONFIG_DIR', '') + profileRouter.userConfigDir = '/shell/claude-config' + try { + expect( + resolveClaudeOAuthCredentialReadOptions({ + configDir: '/shell/claude-config', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + })?.keychainConfigDir + ).toBe('/shell/claude-config') + } finally { + profileRouter.userConfigDir = undefined + vi.unstubAllEnvs() + } +}) +it('reports a host problem as unavailable usage, never as a signed-out account', async () => { + const f = profile() + expect( + await fetchActiveClaudeRateLimits({ + authPreparation: { + ...f.options.authPreparation, + usageError: 'Could not read the home folder of WSL distro Ubuntu.' + } + }) + ).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'usage-unavailable' } }) +}) +it('asks an inactive account from an older Orca to sign in again, reading nothing', async () => { + const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') + expect( + await fetchInactiveClaudeAccountUsage({ + id: 'acct', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/acct/auth' + }) + ).toMatchObject({ usageMetadata: { failureKind: 'missing-credentials' } }) + expect(calls.keychain).not.toHaveBeenCalled() +}) +it('does not read an inactive WSL account through a stopped distro', async () => { + const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') + runningDistros.filter.mockResolvedValueOnce([]) + const result = await fetchInactiveClaudeAccountUsage({ + id: 'acct', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/acct/home' + }) + expect(runningDistros.filter).toHaveBeenCalledWith( + ['\\\\wsl.localhost\\Ubuntu\\home\\u\\.local\\share\\orca\\claude-profiles\\acct\\home'], + { requireConfirmed: true } + ) + expect(result).toMatchObject({ usageMetadata: { failureKind: 'usage-unavailable' } }) + expect(calls.keychain).not.toHaveBeenCalled() +}) diff --git a/src/main/rate-limits/claude-pty-reset-parser.ts b/src/main/rate-limits/claude-pty-reset-parser.ts deleted file mode 100644 index 43137a8cea54..000000000000 --- a/src/main/rate-limits/claude-pty-reset-parser.ts +++ /dev/null @@ -1,307 +0,0 @@ -import type { RateLimitWindow } from '../../shared/rate-limit-types' -import { buildWallClockTimestamp } from './time-zone-wall-clock' - -const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i -const MONTH_PATTERN = - 'jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?' -const MONTH_DAY_COMPACT_RE = new RegExp( - `\\b(${MONTH_PATTERN})(\\d{1,2})(?=\\s*at\\s*\\d|\\D|$)`, - 'i' -) -const MONTH_DAY_TIME_RE = new RegExp( - `\\b(${MONTH_PATTERN})\\.?\\s+(\\d{1,2})(?:,?\\s*(?:at\\s+)?)?(\\d{1,2})(?::(\\d{2}))?\\s*(am|pm)\\b`, - 'i' -) -const WEEKDAY_TIME_RE = - /\b(sun(?:day)?|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?)\.?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i -const TIME_ONLY_RE = /\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i -// Why: newer Codex CLIs print 24-hour reset times ("10:21 on 28 Jul") with no am/pm. -const TIME_24H_RE = /\b(\d{1,2}):(\d{2})\b/ -const DAY_MONTH_RE = new RegExp(`\\b(?:on\\s+)?(\\d{1,2})\\s+(${MONTH_PATTERN})\\b`, 'i') -const RELATIVE_RESET_RE = /^(?:\s*\d+\s*(?:d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)\s*)+$/i -const RELATIVE_RESET_TOKEN_RE = /(\d+)\s*(d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)/gi -const IANA_TIME_ZONE_RE = /\(([^()]*)\)?\s*$/ - -export type ClaudePtyResetMetadata = Pick - -const MONTH_INDEX_BY_NAME: Record = { - jan: 0, - january: 0, - feb: 1, - february: 1, - mar: 2, - march: 2, - apr: 3, - april: 3, - may: 4, - jun: 5, - june: 5, - jul: 6, - july: 6, - aug: 7, - august: 7, - sep: 8, - sept: 8, - september: 8, - oct: 9, - october: 9, - nov: 10, - november: 10, - dec: 11, - december: 11 -} - -const WEEKDAY_INDEX_BY_NAME: Record = { - sun: 0, - sunday: 0, - mon: 1, - monday: 1, - tue: 2, - tuesday: 2, - wed: 3, - wednesday: 3, - thu: 4, - thursday: 4, - fri: 5, - friday: 5, - sat: 6, - saturday: 6 -} - -export function extractClaudePtyResetMetadata( - lines: string[], - matchesLabel: (line: string) => boolean, - isSectionLabel: (line: string) => boolean -): ClaudePtyResetMetadata { - for (let i = 0; i < lines.length; i++) { - if (!matchesLabel(lines[i])) { - continue - } - for (let j = i; j < Math.min(i + 14, lines.length); j++) { - if (j > i && isSectionLabel(lines[j])) { - break - } - const m = RESET_LINE_RE.exec(lines[j]) - if (m) { - const resetDescription = normalizeResetDescription(m[1]) - return { - resetsAt: parseResetTimestamp(resetDescription), - resetDescription - } - } - } - } - return { resetsAt: null, resetDescription: null } -} - -function normalizeResetDescription(raw: string): string { - // Why: Claude's TUI occasionally drops spaces around the Fable reset date - // when copied from the PTY buffer, but the value still encodes a real reset. - return ( - raw - .trim() - // Why: PTY captures keep trailing box-border glyphs from framed status panels. - .replace(/[)\s│]+$/, '') - .replace(/\s+/g, ' ') - .replace(MONTH_DAY_COMPACT_RE, '$1 $2') - .replace(/(\d{1,2})\s*at\s*(\d{1,2}(?::\d{2})?\s*(?:am|pm))/i, '$1 at $2') - .replace(/(\d)(am|pm)\(/gi, '$1$2 (') - ) -} - -function parseResetTimestamp(resetDescription: string | null): number | null { - if (!resetDescription) { - return null - } - - return ( - parseRelativeResetTimestamp(resetDescription) ?? - parseMonthDayResetTimestamp(resetDescription) ?? - parseWeekdayResetTimestamp(resetDescription) ?? - parseTimeOnlyResetTimestamp(resetDescription) ?? - parseTwentyFourHourResetTimestamp(resetDescription) - ) -} - -function parseTwentyFourHourResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const timeMatch = TIME_24H_RE.exec(resetText) - if (!timeMatch) { - return null - } - const hour = Number(timeMatch[1]) - const minute = Number(timeMatch[2]) - if (!isValidClockTime(hour, minute)) { - return null - } - - const dayMonthMatch = DAY_MONTH_RE.exec(resetText) - if (dayMonthMatch) { - const day = Number(dayMonthMatch[1]) - const monthIndex = MONTH_INDEX_BY_NAME[dayMonthMatch[2].toLowerCase()] - if (monthIndex === undefined || day < 1 || day > 31) { - return null - } - const now = new Date() - const timeZone = extractResetTimeZone(resetDescription) - let timestamp = buildWallClockTimestamp( - { year: now.getFullYear(), monthIndex, day, hour, minute }, - timeZone - ) - if (timestamp !== null && timestamp <= Date.now()) { - timestamp = buildWallClockTimestamp( - { year: now.getFullYear() + 1, monthIndex, day, hour, minute }, - timeZone - ) - } - return timestamp - } - - const candidate = new Date() - candidate.setHours(hour, minute, 0, 0) - if (candidate.getTime() <= Date.now()) { - candidate.setDate(candidate.getDate() + 1) - } - return candidate.getTime() -} - -function parseRelativeResetTimestamp(resetDescription: string): number | null { - if (!RELATIVE_RESET_RE.test(resetDescription)) { - return null - } - - let durationMs = 0 - for (const match of resetDescription.matchAll(RELATIVE_RESET_TOKEN_RE)) { - const amount = Number(match[1]) - const unit = match[2].toLowerCase()[0] - if (!Number.isFinite(amount)) { - continue - } - if (unit === 'd') { - durationMs += amount * 24 * 60 * 60_000 - } else if (unit === 'h') { - durationMs += amount * 60 * 60_000 - } else if (unit === 'm') { - durationMs += amount * 60_000 - } - } - return durationMs > 0 ? Date.now() + durationMs : null -} - -function parseMonthDayResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const match = MONTH_DAY_TIME_RE.exec(resetText) - if (!match) { - return null - } - - const monthIndex = MONTH_INDEX_BY_NAME[match[1].toLowerCase()] - if (monthIndex === undefined) { - return null - } - - const day = Number(match[2]) - const hour = parseHour(match[3], match[5]) - const minute = Number(match[4] ?? 0) - if (!isValidClockTime(hour, minute)) { - return null - } - - const now = new Date() - const timeZone = extractResetTimeZone(resetDescription) - let timestamp = buildWallClockTimestamp( - { year: now.getFullYear(), monthIndex, day, hour, minute }, - timeZone - ) - if (timestamp !== null && timestamp <= Date.now()) { - timestamp = buildWallClockTimestamp( - { year: now.getFullYear() + 1, monthIndex, day, hour, minute }, - timeZone - ) - } - return timestamp -} - -function parseWeekdayResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const match = WEEKDAY_TIME_RE.exec(resetText) - if (!match) { - return null - } - - const weekdayIndex = WEEKDAY_INDEX_BY_NAME[match[1].toLowerCase()] - if (weekdayIndex === undefined) { - return null - } - - const now = new Date() - const hour = parseHour(match[2], match[4]) - const minute = Number(match[3] ?? 0) - if (!isValidClockTime(hour, minute)) { - return null - } - - const candidate = new Date(now) - const daysUntil = (weekdayIndex - now.getDay() + 7) % 7 - candidate.setDate(now.getDate() + daysUntil) - candidate.setHours(hour, minute, 0, 0) - if (candidate.getTime() <= Date.now()) { - candidate.setDate(candidate.getDate() + 7) - } - return candidate.getTime() -} - -function parseTimeOnlyResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const match = TIME_ONLY_RE.exec(resetText) - if (!match) { - return null - } - - const hour = parseHour(match[1], match[3]) - const minute = Number(match[2] ?? 0) - if (!isValidClockTime(hour, minute)) { - return null - } - - const candidate = new Date() - candidate.setHours(hour, minute, 0, 0) - if (candidate.getTime() <= Date.now()) { - candidate.setDate(candidate.getDate() + 1) - } - return candidate.getTime() -} - -function parseHour(hourText: string, periodText: string): number { - const hour = Number(hourText) - const period = periodText.toLowerCase() - if (hour < 1 || hour > 12) { - return Number.NaN - } - if (period === 'am') { - return hour === 12 ? 0 : hour - } - return hour === 12 ? 12 : hour + 12 -} - -function isValidClockTime(hour: number, minute: number): boolean { - return Number.isInteger(hour) && hour >= 0 && hour < 24 && minute >= 0 && minute < 60 -} - -function stripResetTimeZone(resetDescription: string): string { - return resetDescription.replace(/\s*\([^)]*\)?\s*$/, '').trim() -} - -function extractResetTimeZone(resetDescription: string): string | null { - const match = IANA_TIME_ZONE_RE.exec(resetDescription) - if (!match?.[1]) { - return null - } - const timeZone = match[1].trim() - try { - new Intl.DateTimeFormat('en-US', { timeZone }) - return timeZone - } catch { - return null - } -} diff --git a/src/main/rate-limits/claude-pty-stop-markers.ts b/src/main/rate-limits/claude-pty-stop-markers.ts deleted file mode 100644 index 2bba56c69112..000000000000 --- a/src/main/rate-limits/claude-pty-stop-markers.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Why: these substrings indicate the /usage TUI panel has finished rendering. -export const CLAUDE_USAGE_STOP_SUBSTRINGS = [ - 'Current week (all models)', - 'Current week (Opus)', - 'Current week (Sonnet only)', - 'Current week (Sonnet)', - 'Weekly limits', - 'Weekly limit', - 'Weekly usage', - '7-day', - 'Current session', - 'Failed to load usage data', - 'failed to load usage data' -] diff --git a/src/main/rate-limits/claude-pty-usage-parser.ts b/src/main/rate-limits/claude-pty-usage-parser.ts deleted file mode 100644 index 3328d9471600..000000000000 --- a/src/main/rate-limits/claude-pty-usage-parser.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' -import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser' - -// Why: these patterns match the Claude CLI's /usage TUI panel output. -// "Current session" shows a percent like "62% used" or "62% left". -// Weekly labels have varied between "Current week" and "Weekly limits". -const SESSION_RE = /current\s*session/i -const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i -const FABLE_WORD_RE = /\bfable\b/i -const FABLE_LABEL_RE = /^\s*fable\s*$/i -// Why: derive from WEEKLY_RE so a future weekly-wording change stays in one place -// instead of silently reopening the Fable-weekly parsing gap this fix closed. -const FABLE_WEEKLY_LABEL_RE = new RegExp( - `${WEEKLY_RE.source}\\s*(?:\\([^)]*\\bfable\\b[^)]*\\)|[-:]?\\s*\\bfable\\b)`, - 'i' -) -const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i -const ESC = String.fromCharCode(27) -const BEL = String.fromCharCode(7) -const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g') -const CSI_SEQUENCE_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g') - -export function stripTerminalControlSequences(output: string): string { - return output.replace(OSC_SEQUENCE_RE, '').replace(CSI_SEQUENCE_RE, '') -} - -/** - * Extract percent-left from lines following a label match. - * Scans up to 12 lines after the label to find the associated percent. - */ -function matchesWeeklyLabel(line: string): boolean { - return WEEKLY_RE.test(line) && !FABLE_WORD_RE.test(line) -} - -function matchesFableBoundary(line: string): boolean { - return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) -} - -function matchesFableUsageLabel(line: string): boolean { - // Why: broad Fable-weekly copy should stop nearby scans, but only a real - // Fable usage heading should produce the distinct Fable meter. - return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) -} - -function isSectionLabel(line: string): boolean { - return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) -} - -function extractPercentAfterLabel( - lines: string[], - matchesLabel: (line: string) => boolean -): number | null { - for (let i = 0; i < lines.length; i++) { - if (!matchesLabel(lines[i])) { - continue - } - // Scan next 12 lines for a percent - for (let j = i; j < Math.min(i + 12, lines.length); j++) { - if (j > i && isSectionLabel(lines[j])) { - break - } - const m = PERCENT_RE.exec(lines[j]) - if (m) { - const pct = Number.parseFloat(m[1]) - const word = m[2].toLowerCase() - const isUsed = word === 'used' || word === 'consumed' - return isUsed ? pct : 100 - pct - } - } - } - return null -} - -export function parseClaudePtyUsage(output: string): { - session: RateLimitWindow | null - weekly: RateLimitWindow | null - fableWeekly: RateLimitWindow | null -} { - const lines = output.split(/\r\n|\n|\r/) - - const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) - const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) - const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) - const sessionReset = extractClaudePtyResetMetadata( - lines, - (line) => SESSION_RE.test(line), - isSectionLabel - ) - const weeklyReset = extractClaudePtyResetMetadata(lines, matchesWeeklyLabel, isSectionLabel) - const fableWeeklyReset = extractClaudePtyResetMetadata( - lines, - matchesFableUsageLabel, - isSectionLabel - ) - - const session: RateLimitWindow | null = - sessionPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, sessionPct)), - windowMinutes: 300, - resetsAt: sessionReset.resetsAt, - resetDescription: sessionReset.resetDescription - } - : null - - const weekly: RateLimitWindow | null = - weeklyPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, weeklyPct)), - windowMinutes: 10080, - resetsAt: weeklyReset.resetsAt, - resetDescription: weeklyReset.resetDescription - } - : null - - const fableWeekly: RateLimitWindow | null = - fableWeeklyPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), - windowMinutes: 10080, - resetsAt: fableWeeklyReset.resetsAt, - resetDescription: fableWeeklyReset.resetDescription - } - : null - - return { session, weekly, fableWeekly } -} - -const RATE_LIMITED_RE = /rate limited\.?\s+please try again later/i -const LOAD_FAILED_RE = /failed to load usage data/i -const CLAUDE_21_USAGE_TABS_RE = /settings?\s+status?\s+config\s+usage\s+stats/i -const CLAUDE_21_SESSION_STATS_RE = /total\s*cost|total\s*duration|usage:\s*\d+\s*input/i - -export function isClaude21UsagePanel(output: string): boolean { - return CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output) -} - -export function describeClaudeUsageFailure(output: string): string { - if (RATE_LIMITED_RE.test(output)) { - return 'Claude usage is rate limited right now.' - } - - if (LOAD_FAILED_RE.test(output)) { - return 'Claude usage is unavailable right now.' - } - - if (CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)) { - return 'Claude plan usage is unavailable for this Claude CLI session.' - } - - // Why: parser failures are an implementation detail of Orca's PTY fallback. - // The UI should explain the user-visible outcome, not leak internal parsing - // mechanics that the user cannot act on. - return 'Claude usage is unavailable right now.' -} - -export function abortedClaudeUsageResult(): ProviderRateLimits { - return { - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Rate-limit fetch aborted', - status: 'error' - } -} diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts deleted file mode 100644 index f2fd976821b6..000000000000 --- a/src/main/rate-limits/claude-pty.test.ts +++ /dev/null @@ -1,554 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { resolveClaudeCommandMock, spawnMock } = vi.hoisted(() => ({ - resolveClaudeCommandMock: vi.fn(), - spawnMock: vi.fn() -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: resolveClaudeCommandMock -})) - -vi.mock('node-pty', () => ({ - spawn: spawnMock -})) - -import { fetchViaPty } from './claude-pty' -import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' - -function makeDisposable() { - return { dispose: vi.fn() } -} - -type MockTerm = { - onData: ReturnType - onExit: ReturnType - write: ReturnType - kill: ReturnType -} - -function makeMockTerm(): MockTerm & { - emitData: (data: string) => void - emitExit: () => void -} { - let dataHandler: ((data: string) => void) | null = null - let exitHandler: (() => void) | null = null - return { - onData: vi.fn((handler: (data: string) => void) => { - dataHandler = handler - return makeDisposable() - }), - onExit: vi.fn((handler: () => void) => { - exitHandler = handler - return makeDisposable() - }), - write: vi.fn(), - kill: vi.fn(), - emitData: (data: string) => dataHandler?.(data), - emitExit: () => exitHandler?.() - } -} - -describe('fetchViaPty', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - resolveClaudeCommandMock.mockReturnValue('claude') - }) - - it('disposes node-pty listeners before killing the hidden PTY on timeout', async () => { - const onDataDisposable = makeDisposable() - const onExitDisposable = makeDisposable() - const killMock = vi.fn() - - spawnMock.mockReturnValue({ - onData: vi.fn(() => onDataDisposable), - onExit: vi.fn(() => onExitDisposable), - write: vi.fn(), - kill: killMock - }) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(25_000) - await resultPromise - - expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - }) - - it('injects the configured proxy into the hidden PTY spawn env', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty({ - networkProxySettings: { httpProxyUrl: 'http://127.0.0.1:7890' } - }) - await vi.advanceTimersByTimeAsync(0) - - expect(spawnMock).toHaveBeenCalled() - const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as Record - expect(spawnEnv.HTTPS_PROXY).toBe('http://127.0.0.1:7890') - expect(spawnEnv.HTTP_PROXY).toBe('http://127.0.0.1:7890') - - term.emitExit() - await resultPromise - }) - - it('spawns the hidden usage PTY in a bounded non-root cwd', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(0) - - const spawnCwd = spawnMock.mock.calls[0]?.[2]?.cwd as string - expect(spawnCwd).toContain('rate-limit-pty-cwd') - expect(spawnCwd).not.toBe('/') - expect(spawnCwd).not.toMatch(/^[A-Za-z]:(?:[\\/])?$/) - - term.emitExit() - await resultPromise - }) - - it('leaves the spawn env proxy untouched when no proxy is configured', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - const inheritedHttpsProxy = process.env.HTTPS_PROXY - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(0) - - const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as Record - expect(spawnEnv.HTTPS_PROXY).toBe(inheritedHttpsProxy) - - term.emitExit() - await resultPromise - }) - - it('exports the configured proxy inside the WSL launch command', async () => { - // Why: Windows-side spawn env does not cross into the distro, so the proxy - // must be exported in the bash command for the inner claude to see it. - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty({ - networkProxySettings: { httpProxyUrl: 'http://127.0.0.1:7890' }, - authPreparation: { - configDir: '/home/u/.claude', - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/u/.claude', - envPatch: { CLAUDE_CONFIG_DIR: '/home/u/.claude' }, - stripAuthEnv: false, - provenance: 'system' - } - }) - await vi.advanceTimersByTimeAsync(0) - - const [spawnFile, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]] - expect(spawnFile).toBe('wsl.exe') - const bashCommand = spawnArgs.at(-1) as string - expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') - expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') - expect(bashCommand).toContain("export HTTPS_PROXY='http://127.0.0.1:7890'") - expect(bashCommand).toContain('exec claude') - - term.emitExit() - await resultPromise - }) - - it('kills and unregisters the hidden PTY when the fetch signal aborts', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - const controller = new AbortController() - const killMock = term.kill - - const resultPromise = fetchViaPty({ signal: controller.signal }) - await vi.advanceTimersByTimeAsync(0) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(1) - - controller.abort() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Rate-limit fetch aborted' - }) - expect(killMock).toHaveBeenCalledTimes(1) - expect(getActiveHiddenRateLimitPtyCount()).toBe(0) - }) - - it('clears the startup delay timer when the hidden PTY exits early', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(0) - expect(spawnMock).toHaveBeenCalled() - expect(vi.getTimerCount()).toBeGreaterThan(0) - - term.emitExit() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'error' - }) - expect(vi.getTimerCount()).toBe(0) - }) - - it('clears pending settle timers when the hidden PTY exits after usage output starts', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(2_000) - term.emitData('Current session\r12% used\r') - expect(vi.getTimerCount()).toBeGreaterThan(0) - - term.emitExit() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - } - }) - expect(vi.getTimerCount()).toBe(0) - }) - - it('treats Claude 2.1 tabbed /usage session stats as rendered but unavailable', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - expect(term.write).toHaveBeenCalledWith('/usage\r') - - term.emitData(` - Settings Status Config Usage Stats - - Session - Total cost: $0.0000 - Usage: 0 input, 0 output, 0 cache read, 0 cache write - `) - - await vi.advanceTimersByTimeAsync(8_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - session: null, - weekly: null, - error: 'Claude plan usage is unavailable for this Claude CLI session.' - }) - expect(term.write).not.toHaveBeenCalledWith('\x1b[D\x1b[D') - }) - - it('keeps waiting for plan windows after the Claude 2.1 usage shell renders', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Settings Status Config Usage Stats - Session - Total cost: $0.0000 - `) - - await vi.advanceTimersByTimeAsync(1_000) - term.emitData('Current session\r12% used\rResets 4:00pm\rCurrent week (all models)\r34% used\r') - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12, - resetDescription: '4:00pm' - }, - weekly: { - usedPercent: 34 - }, - error: null - }) - }) - - it('parses the newer Claude weekly limits wording for Fable usage', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - vi.setSystemTime(new Date(2026, 6, 3, 20, 0)) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - 18% remaining - Resets in 2h 10m - - Weekly limits - Fable - 42% consumed - Resets in 3d 2h - `) - await vi.advanceTimersByTimeAsync(2_000) - - const result = await resultPromise - - expect(result).toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 82, - resetsAt: Date.now() + 2 * 60 * 60_000 + 10 * 60_000, - resetDescription: '2h 10m' - }, - weekly: null, - fableWeekly: { - usedPercent: 42, - resetsAt: Date.now() + 3 * 24 * 60 * 60_000 + 2 * 60 * 60_000, - resetDescription: '3d 2h' - }, - error: null - }) - }) - - it('parses generic weekly and Fable weekly limits as separate windows', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - 18% remaining - Resets in 2h 10m - - Current week (all models) - 84% left - Resets in 5d 4h - - Fable - 42% consumed - Resets in 3d 2h - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 82, - resetDescription: '2h 10m' - }, - weekly: { - usedPercent: 16, - resetDescription: '5d 4h' - }, - fableWeekly: { - usedPercent: 42, - resetDescription: '3d 2h' - }, - error: null - }) - }) - - it('parses Claude current-week Fable usage as a distinct weekly window', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - vi.setSystemTime(new Date(2026, 6, 3, 20, 0)) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - 8% used - Resets 3:39am - - Current week (all models) - 33% used - Resets Jul 10 at 12:59pm - - Current week (Fable) - 62% used - Resets Jul10at12:59pm(America/Los_Angeles) - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 8, - resetDescription: '3:39am' - }, - weekly: { - usedPercent: 33, - resetsAt: new Date(2026, 6, 10, 12, 59).getTime(), - resetDescription: 'Jul 10 at 12:59pm' - }, - fableWeekly: { - usedPercent: 62, - resetsAt: Date.parse('2026-07-10T19:59:00.000Z'), - resetDescription: 'Jul 10 at 12:59pm (America/Los_Angeles' - }, - error: null - }) - }) - - it('does not let an incomplete Fable section consume later usage sections', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Fable - Usage unavailable - - Current session - 12% used - - Current week (all models) - 84% left - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - }, - weekly: { - usedPercent: 16 - }, - fableWeekly: null, - error: null - }) - }) - - it('does not treat inline Fable weekly text as a parsed usage label', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - Fable weekly usage - 42% consumed - - Current week (all models) - 84% left - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: null, - weekly: { - usedPercent: 16 - }, - fableWeekly: null, - error: null - }) - }) - - it('keeps waiting after a bare Fable heading until another usage section renders', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - let settled = false - - const resultPromise = fetchViaPty().finally(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Fable - `) - await vi.advanceTimersByTimeAsync(2_000) - - expect(settled).toBe(false) - - term.emitData(` - 42% consumed - - Current session - 12% used - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - }, - fableWeekly: { - usedPercent: 42 - }, - error: null - }) - }) - - it('parses 7-day weekly labels without the old Current week heading', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Usage - - Current session - 12% used - - 7-day - 84% left - Resets Wed at 9:05 PM - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - }, - weekly: { - usedPercent: 16, - resetDescription: 'Wed at 9:05 PM' - }, - error: null - }) - }) -}) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts deleted file mode 100644 index cbaedbd17f9d..000000000000 --- a/src/main/rate-limits/claude-pty.ts +++ /dev/null @@ -1,354 +0,0 @@ -import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' -import { resolveClaudeCommand } from '../codex-cli/command' -// Why: import from the shared module, not the codex-cli re-export, so a test that -// mocks '../codex-cli/command' does not have to restate this pure helper. -import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { applyClaudeEnvPatch } from '../claude-accounts/environment' -import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' -import { - getHiddenRateLimitWslCwdSetupCommands, - resolveHiddenRateLimitPtyCwd -} from './hidden-rate-limit-pty-cwd' -import { - abortedClaudeUsageResult, - describeClaudeUsageFailure, - isClaude21UsagePanel, - parseClaudePtyUsage, - stripTerminalControlSequences -} from './claude-pty-usage-parser' -import { quoteHiddenRateLimitShellValue } from './hidden-rate-limit-shell' -import { CLAUDE_USAGE_STOP_SUBSTRINGS } from './claude-pty-stop-markers' - -const PTY_TIMEOUT_MS = 25_000 -const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit - -// --------------------------------------------------------------------------- -// PTY fallback — spawn interactive `claude`, send `/usage`, parse the TUI -// --------------------------------------------------------------------------- - -// Why: prompt detection is unreliable because the Claude CLI v2.x renders -// a status bar and TUI elements that push the `❯` prompt out of any -// reasonable detection window. Instead we wait a fixed 2s after spawning -// for the CLI to initialize, then send `/usage\r` directly. Command -// palette prompts ("Show plan usage limits") are auto-confirmed with Enter. -const COMMAND_PALETTE_RE = /show plan|usage limits/i -const TRUST_PROMPT_RE = /do you trust|trust the files|safety check/i -const STARTUP_DELAY_MS = 2_000 -const SETTLE_AFTER_STOP_MS = 2_000 -const SETTLE_AFTER_CLAUDE_21_USAGE_MS = 8_000 - -export async function fetchViaPty(options?: { - authPreparation?: ClaudeRuntimeAuthPreparation - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - if (options?.signal?.aborted) { - return abortedClaudeUsageResult() - } - const pty = await import('node-pty') - if (options?.signal?.aborted) { - return abortedClaudeUsageResult() - } - - return new Promise((resolve) => { - let output = '' - let resolved = false - let sentUsage = false - let stopDetected = false - let claude21UsageDetected = false - let startupDelayTimer: ReturnType | null = null - let stopSettleTimer: ReturnType | null = null - let claude21UsageSettleTimer: ReturnType | null = null - - const claudeCommand = resolveClaudeCommand() - - // Why: node-pty cannot spawn .cmd/.bat batch scripts directly on Windows — - // those need cmd.exe as an interpreter. Always route through cmd.exe on win32 - // and ensure the command path is properly quoted if it contains spaces. - const isWin32 = process.platform === 'win32' - const spawnEnv = applyClaudeEnvPatch( - { ...process.env, TERM: 'xterm-256color' } as Record, - options?.authPreparation?.envPatch ?? {}, - { stripAuthEnv: options?.authPreparation?.stripAuthEnv ?? false } - ) - // Why: this hidden usage PTY spawns `claude` directly, not the user's shell - // wrapper, so without the configured proxy it would reach api.anthropic.com - // from the app's own IP — bypassing the proxy the user set for Claude and - // risking rate-limit/geo signals on the account. Falls back to {} when unset. - const proxyEnv = buildConfiguredProxyEnv(options?.networkProxySettings) - Object.assign(spawnEnv, proxyEnv) - const authPreparation = options?.authPreparation - const wslConfig = - authPreparation?.runtime === 'wsl' && - authPreparation.wslDistro && - authPreparation.wslLinuxConfigDir - ? { - distro: authPreparation.wslDistro, - linuxConfigDir: authPreparation.wslLinuxConfigDir - } - : null - const spawnFile = wslConfig ? 'wsl.exe' : isWin32 ? 'cmd.exe' : claudeCommand - const spawnArgs = wslConfig - ? [ - '-d', - wslConfig.distro, - '--exec', - 'bash', - '-lc', - // Why: Windows-side env does not cross into the distro without WSLENV, - // so export the configured proxy inside the command for the inner claude. - [ - // Why: hidden usage probes must not inherit a root-like WSL cwd; - // keep Claude discovery bounded to a tiny temp directory. - ...getHiddenRateLimitWslCwdSetupCommands(), - `export CLAUDE_CONFIG_DIR=${quoteHiddenRateLimitShellValue(wslConfig.linuxConfigDir)}`, - ...Object.entries(proxyEnv).map( - ([key, value]) => `export ${key}=${quoteHiddenRateLimitShellValue(value)}` - ), - 'exec claude' - ].join(' && ') - ] - : isWin32 - ? ['/c', `"${claudeCommand}"`] - : [] - - const term = pty.spawn(spawnFile, spawnArgs, { - name: 'xterm-256color', - cols: 120, - rows: 40, - // Why: hidden usage PTYs must not inherit the process cwd (e.g. / or a - // drive root), which can trigger unbounded file discovery. - cwd: resolveHiddenRateLimitPtyCwd(), - env: withCliRuntimeOnPath(claudeCommand, spawnEnv) - }) - const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)] - let enterInterval: ReturnType | null = null - let timeout: ReturnType | null = null - - function clearFollowupTimers(): void { - if (startupDelayTimer) { - clearTimeout(startupDelayTimer) - startupDelayTimer = null - } - if (stopSettleTimer) { - clearTimeout(stopSettleTimer) - stopSettleTimer = null - } - if (claude21UsageSettleTimer) { - clearTimeout(claude21UsageSettleTimer) - claude21UsageSettleTimer = null - } - if (enterInterval) { - clearInterval(enterInterval) - enterInterval = null - } - } - - function settleAborted(): void { - if (resolved) { - return - } - resolved = true - if (timeout) { - clearTimeout(timeout) - timeout = null - } - clearFollowupTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - resolve(abortedClaudeUsageResult()) - } - - if (options?.signal) { - if (options.signal.aborted) { - settleAborted() - return - } - options.signal.addEventListener('abort', settleAborted, { once: true }) - termDisposables.push({ - dispose: () => options.signal?.removeEventListener('abort', settleAborted) - }) - } - - timeout = setTimeout(() => { - if (!resolved) { - resolved = true - clearFollowupTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - // Even on timeout, try to parse whatever we collected - const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) - if (session || weekly || fableWeekly) { - resolve({ - provider: 'claude', - session, - weekly, - fableWeekly, - updatedAt: Date.now(), - error: null, - status: 'ok' - }) - } else { - resolve({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: withMacTailscaleDnsHint( - isClaude21UsagePanel(clean) - ? describeClaudeUsageFailure(clean) - : 'PTY timeout — /usage panel did not render', - clean - ), - status: 'error' - }) - } - } - }, PTY_TIMEOUT_MS) - - // Why: the Claude TUI may have scrollable panels or prompts. - // Sending Enter every 0.8s advances through them. - function startEnterPresses(): void { - if (enterInterval) { - return - } - enterInterval = setInterval(() => { - if (!resolved && !stopDetected) { - term.write('\r') - } - }, 800) - } - - function finalize(): void { - if (resolved) { - return - } - resolved = true - if (timeout) { - clearTimeout(timeout) - timeout = null - } - clearFollowupTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - - const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) - - if (!session && !weekly && !fableWeekly) { - resolve({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: withMacTailscaleDnsHint(describeClaudeUsageFailure(clean), clean), - status: 'error' - }) - } else { - resolve({ - provider: 'claude', - session, - weekly, - fableWeekly, - updatedAt: Date.now(), - error: null, - status: 'ok' - }) - } - } - - // Why: wait 2s for the CLI to initialize, then send `/usage\r` - // directly without detecting the prompt character (see comment above). - startupDelayTimer = setTimeout(() => { - startupDelayTimer = null - if (resolved) { - return - } - sentUsage = true - term.write('/usage\r') - startEnterPresses() - }, STARTUP_DELAY_MS) - - const onDataDisposable = term.onData((data) => { - output += data - // Why: prevent memory exhaustion if the CLI process floods output - if (output.length > MAX_OUTPUT_LENGTH) { - output = output.slice(-MAX_OUTPUT_LENGTH) - } - - const cleanChunk = stripTerminalControlSequences(data) - - // Why: the Claude CLI may prompt for first-run setup (trust files, - // workspace directory). Auto-accept so we can reach /usage. - if (TRUST_PROMPT_RE.test(cleanChunk)) { - term.write('y\r') - return - } - - // Why: Claude CLI v2.x may show a command palette when `/usage` is - // entered, listing options like "Show plan usage limits". Auto-confirm - // by sending Enter when these prompts appear. - if (sentUsage && COMMAND_PALETTE_RE.test(cleanChunk)) { - term.write('\r') - } - - // Check if we've hit a stop substring indicating the panel rendered - if (sentUsage && !stopDetected) { - const clean = stripTerminalControlSequences(output) - if (!claude21UsageDetected && isClaude21UsagePanel(clean)) { - claude21UsageDetected = true - if (enterInterval) { - clearInterval(enterInterval) - enterInterval = null - } - // Why: Claude 2.1 may render session stats without subscription - // plan windows. Give async usage loading a grace period, then finish - // with a user-facing unavailable state instead of a false PTY timeout. - claude21UsageSettleTimer = setTimeout(finalize, SETTLE_AFTER_CLAUDE_21_USAGE_MS) - } - for (const sub of CLAUDE_USAGE_STOP_SUBSTRINGS) { - if (clean.includes(sub)) { - stopDetected = true - // Why: 2.0s settle time after detecting the stop substring - // allows the full panel to finish rendering. - stopSettleTimer = setTimeout(finalize, SETTLE_AFTER_STOP_MS) - break - } - } - } - }) - if (onDataDisposable) { - termDisposables.push(onDataDisposable) - } - - const onExitDisposable = term.onExit(() => { - cleanupHiddenRateLimitPty(term, termDisposables, { kill: false }) - clearFollowupTimers() - if (!resolved) { - resolved = true - if (timeout) { - clearTimeout(timeout) - timeout = null - } - const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) - resolve({ - provider: 'claude', - session, - weekly, - fableWeekly, - updatedAt: Date.now(), - error: - session || weekly || fableWeekly - ? null - : withMacTailscaleDnsHint('CLI exited before /usage rendered', clean), - status: session || weekly || fableWeekly ? 'ok' : 'error' - }) - } - }) - if (onExitDisposable) { - termDisposables.push(onExitDisposable) - } - }) -} diff --git a/src/main/rate-limits/claude-usage-error-classification.test.ts b/src/main/rate-limits/claude-usage-error-classification.test.ts index 9371479cfba5..5e60c281674f 100644 --- a/src/main/rate-limits/claude-usage-error-classification.test.ts +++ b/src/main/rate-limits/claude-usage-error-classification.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - classifyClaudeCredentialAbsence, - classifyClaudeOAuthUsageError -} from './claude-usage-error-classification' +import { classifyClaudeOAuthUsageError } from './claude-usage-error-classification' import { OAuthUsageError } from './claude-oauth-usage-error' describe('classifyClaudeOAuthUsageError', () => { @@ -47,38 +44,3 @@ describe('classifyClaudeOAuthUsageError', () => { }) }) }) - -describe('classifyClaudeCredentialAbsence', () => { - it('classifies refresh-only credentials as repairable', () => { - expect(classifyClaudeCredentialAbsence({ hasRefreshableCredentials: true })).toMatchObject({ - failureKind: 'refreshable-credentials-without-token', - shouldAttemptDelegatedRefresh: true, - shouldAttemptCliFallback: true - }) - }) - - it('classifies Keychain read failures separately from missing credentials', () => { - expect( - classifyClaudeCredentialAbsence({ - hasRefreshableCredentials: false, - keychainUnavailable: true - }) - ).toMatchObject({ - failureKind: 'keychain-unavailable', - shouldAttemptCliFallback: true, - shouldAttemptDelegatedRefresh: false - }) - }) - - it('classifies live Claude ownership as a deferred state', () => { - expect( - classifyClaudeCredentialAbsence({ - hasRefreshableCredentials: true, - managedRefreshDeferredByLivePty: true - }) - ).toMatchObject({ - failureKind: 'deferred-by-live-session', - terminal: true - }) - }) -}) diff --git a/src/main/rate-limits/claude-usage-error-classification.ts b/src/main/rate-limits/claude-usage-error-classification.ts index 71c88c242bd8..440d1155d243 100644 --- a/src/main/rate-limits/claude-usage-error-classification.ts +++ b/src/main/rate-limits/claude-usage-error-classification.ts @@ -39,23 +39,6 @@ export function classifyClaudeOAuthUsageError(error: unknown): ClaudeUsageErrorC return fallbackOnly('unknown') } -export function classifyClaudeCredentialAbsence(input: { - hasRefreshableCredentials: boolean - keychainUnavailable?: boolean - managedRefreshDeferredByLivePty?: boolean -}): ClaudeUsageErrorClassification { - if (input.managedRefreshDeferredByLivePty) { - return terminal('deferred-by-live-session') - } - if (input.keychainUnavailable) { - return fallbackOnly('keychain-unavailable') - } - if (input.hasRefreshableCredentials) { - return recoverableAuth('refreshable-credentials-without-token') - } - return terminal('missing-credentials') -} - function recoverableAuth(failureKind: UsageRateLimitFailureKind): ClaudeUsageErrorClassification { return { failureKind, diff --git a/src/main/rate-limits/claude-usage-refresh-plan.test.ts b/src/main/rate-limits/claude-usage-refresh-plan.test.ts deleted file mode 100644 index dced9e7815c7..000000000000 --- a/src/main/rate-limits/claude-usage-refresh-plan.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveClaudeUsageRefreshPlan } from './claude-usage-refresh-plan' - -describe('resolveClaudeUsageRefreshPlan', () => { - it('uses OAuth then CLI app auto ordering when CLI fallback is available', () => { - expect(resolveClaudeUsageRefreshPlan({ allowCliFallback: true }).steps).toEqual([ - { source: 'oauth', reason: 'app-auto-preferred-oauth' }, - { source: 'cli', reason: 'app-auto-fallback-cli' } - ]) - }) - - it('keeps web deferred and internal-only for the first parity pass', () => { - expect(resolveClaudeUsageRefreshPlan({ allowCliFallback: true }).webDeferred).toBe(true) - }) - - it('omits CLI fallback for unresolved WSL Claude config', () => { - expect( - resolveClaudeUsageRefreshPlan({ - allowCliFallback: true, - authPreparation: { - configDir: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude', - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: null, - envPatch: {}, - stripAuthEnv: true, - provenance: 'wsl:Ubuntu:system' - } - }).steps - ).toEqual([{ source: 'oauth', reason: 'app-auto-preferred-oauth' }]) - }) -}) diff --git a/src/main/rate-limits/claude-usage-refresh-plan.ts b/src/main/rate-limits/claude-usage-refresh-plan.ts deleted file mode 100644 index 06144a15715b..000000000000 --- a/src/main/rate-limits/claude-usage-refresh-plan.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { UsageRateLimitSource } from '../../shared/rate-limit-types' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -export type ClaudeUsageRefreshStep = { - source: UsageRateLimitSource - reason: 'app-auto-preferred-oauth' | 'app-auto-fallback-cli' | 'future-web-fallback-deferred' -} - -export type ClaudeUsageRefreshPlanInput = { - authPreparation?: ClaudeRuntimeAuthPreparation - allowCliFallback: boolean -} - -export type ClaudeUsageRefreshPlan = { - steps: ClaudeUsageRefreshStep[] - webDeferred: boolean -} - -export function resolveClaudeUsageRefreshPlan( - input: ClaudeUsageRefreshPlanInput -): ClaudeUsageRefreshPlan { - const steps: ClaudeUsageRefreshStep[] = [ - { - source: 'oauth', - reason: 'app-auto-preferred-oauth' - } - ] - - if (input.allowCliFallback && isCliPlausiblyAvailable(input.authPreparation)) { - steps.push({ - source: 'cli', - reason: 'app-auto-fallback-cli' - }) - } - - return { - steps, - webDeferred: true - } -} - -function isCliPlausiblyAvailable(authPreparation?: ClaudeRuntimeAuthPreparation): boolean { - if (authPreparation?.runtime === 'wsl') { - return Boolean(authPreparation.wslDistro && authPreparation.wslLinuxConfigDir) - } - return true -} diff --git a/src/main/rate-limits/claude-usage-result.ts b/src/main/rate-limits/claude-usage-result.ts index a29cb38d4aad..e1863be724d3 100644 --- a/src/main/rate-limits/claude-usage-result.ts +++ b/src/main/rate-limits/claude-usage-result.ts @@ -1,16 +1,4 @@ -import type { - ProviderRateLimits, - UsageRateLimitFailureKind, - UsageRateLimitMetadata, - UsageRateLimitSource -} from '../../shared/rate-limit-types' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import type { ClaudeOAuthCredentialReadResult } from './claude-oauth-credentials' -import { OAuthUsageError } from './claude-oauth-usage-error' - -export type ClaudeUsageAttemptState = { - attemptedSources: UsageRateLimitSource[] -} +import type { ProviderRateLimits, UsageRateLimitMetadata } from '../../shared/rate-limit-types' export function abortedClaudeRateLimitResult(): ProviderRateLimits { return { @@ -23,30 +11,6 @@ export function abortedClaudeRateLimitResult(): ProviderRateLimits { } } -export function recordClaudeUsageAttempt( - state: ClaudeUsageAttemptState, - source: UsageRateLimitSource -): UsageRateLimitSource[] { - if (!state.attemptedSources.includes(source)) { - state.attemptedSources.push(source) - } - return state.attemptedSources -} - -export function withClaudeUsageMetadata( - limits: ProviderRateLimits, - metadata: UsageRateLimitMetadata -): ProviderRateLimits { - return { - ...limits, - usageMetadata: { - ...limits.usageMetadata, - ...metadata, - attemptedSources: metadata.attemptedSources ?? limits.usageMetadata?.attemptedSources - } - } -} - export function makeClaudeUsageResult( status: ProviderRateLimits['status'], error: string | null, @@ -63,76 +27,10 @@ export function makeClaudeUsageResult( } } -export function metadataForClaudeUsageAttempt(input: { - attemptedSources: UsageRateLimitSource[] - oauthCredentials: ClaudeOAuthCredentialReadResult - authPreparation?: ClaudeRuntimeAuthPreparation - source?: UsageRateLimitSource - failureKind?: UsageRateLimitFailureKind - deferredByLiveClaudeSession?: boolean - retryAtMs?: number -}): UsageRateLimitMetadata { - return { - source: input.source, - attemptedSources: [...input.attemptedSources], - failureKind: input.failureKind, - credentialSource: input.oauthCredentials.source, - authProvenance: input.authPreparation?.provenance ?? 'system', - deferredByLiveClaudeSession: input.deferredByLiveClaudeSession, - retryAtMs: input.retryAtMs - } -} - -export function warnClaudeUsageFetchFailure( - authPreparation: ClaudeRuntimeAuthPreparation | undefined, - credentials: ClaudeOAuthCredentialReadResult, - error: unknown -): void { - console.warn('[claude-rate-limits] Claude usage refresh failed', { - provenance: authPreparation?.provenance ?? 'system', - runtime: authPreparation?.runtime ?? 'host', - wslDistro: authPreparation?.wslDistro ?? null, - hasExplicitClaudeConfigDir: Boolean(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), - credentialSource: credentials.source, - keychainUnavailable: credentials.keychainUnavailable, - hasRefreshableCredentials: credentials.hasRefreshableCredentials, - status: error instanceof OAuthUsageError ? error.status : null, - message: error instanceof Error ? error.message : String(error) +// Why plain: the profile/host reason is shown on the account row, not as raw text in usage. +export function claudeUsageUnavailable(): ProviderRateLimits { + return makeClaudeUsageResult('error', 'Claude usage is unavailable right now.', { + failureKind: 'usage-unavailable', + attemptedSources: [] }) } - -export function mergeClaudeUsageWindows( - primary: ProviderRateLimits, - supplement: ProviderRateLimits | null -): ProviderRateLimits { - if (!supplement) { - return primary - } - return { - ...primary, - session: primary.session ?? supplement.session, - weekly: primary.weekly ?? supplement.weekly, - fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null, - // Only OAuth reports the extra-usage cap; keep it when the CLI supplements windows. - extraUsage: primary.extraUsage ?? supplement.extraUsage ?? null - } -} - -export function canSupplementClaudeOAuthUsage(input: { - oauthLimits: ProviderRateLimits - authPreparation?: ClaudeRuntimeAuthPreparation - allowUsagePanelSupplement: boolean -}): boolean { - return Boolean( - input.allowUsagePanelSupplement && - !input.authPreparation?.managedRefreshDeferredByLivePty && - !input.oauthLimits.fableWeekly && - (input.oauthLimits.session || input.oauthLimits.weekly) - ) -} - -export function isManagedClaudeAuth( - authPreparation: ClaudeRuntimeAuthPreparation | undefined -): boolean { - return authPreparation?.provenance.startsWith('managed:') === true -} diff --git a/src/main/rate-limits/hidden-pty-cleanup.test.ts b/src/main/rate-limits/hidden-pty-cleanup.test.ts deleted file mode 100644 index 43e8453fa0fd..000000000000 --- a/src/main/rate-limits/hidden-pty-cleanup.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - cleanupHiddenRateLimitPty, - getActiveHiddenRateLimitPtyCount, - registerHiddenRateLimitPty -} from './hidden-pty-cleanup' - -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -describe('cleanupHiddenRateLimitPty', () => { - const originalPlatform = process.platform - - afterEach(() => { - setPlatform(originalPlatform) - vi.clearAllMocks() - }) - - it('disposes listeners, kills the child, then destroys the PTY fd on POSIX', () => { - setPlatform('darwin') - const dataDisposable = { dispose: vi.fn() } - const exitDisposable = { dispose: vi.fn() } - const killMock = vi.fn() - const term = { - kill: killMock, - destroy: vi.fn() - } - - cleanupHiddenRateLimitPty(term, [dataDisposable, exitDisposable], { kill: true }) - - expect(dataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - expect(exitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - expect(killMock.mock.invocationCallOrder[0]).toBeLessThan( - term.destroy.mock.invocationCallOrder[0] - ) - }) - - it('releases the PTY fd without killing again after natural exit', () => { - setPlatform('darwin') - const killMock = vi.fn() - const term = { - kill: killMock, - destroy: vi.fn() - } - - cleanupHiddenRateLimitPty(term, [], { kill: false }) - - expect(killMock).not.toHaveBeenCalled() - expect(term.destroy).toHaveBeenCalledTimes(1) - }) - - it('neutralizes POSIX destroy-time SIGHUP after the intentional kill', () => { - setPlatform('linux') - const killMock = vi.fn() - const term = { - kill: killMock, - destroy: vi.fn(() => { - term.kill('SIGHUP') - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: true }) - - expect(killMock).toHaveBeenCalledTimes(1) - expect(killMock).toHaveBeenCalledWith() - }) - - it('does not destroy after an intentional Windows kill because destroy kills again', () => { - setPlatform('win32') - const term = { - kill: vi.fn(), - destroy: vi.fn(() => { - term.kill() - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: true }) - - expect(term.kill).toHaveBeenCalledTimes(1) - expect(term.destroy).not.toHaveBeenCalled() - }) - - it('destroys a Windows PTY after natural exit so ConPTY cleanup still runs', () => { - setPlatform('win32') - const term = { - kill: vi.fn(), - destroy: vi.fn(() => { - term.kill() - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: false }) - - expect(term.kill).toHaveBeenCalledTimes(1) - expect(term.destroy).toHaveBeenCalledTimes(1) - }) - - it('removes registered hidden PTYs when cleanup kills them', () => { - setPlatform('darwin') - const term = { - kill: vi.fn(), - destroy: vi.fn() - } - const registration = registerHiddenRateLimitPty(term) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(1) - - cleanupHiddenRateLimitPty(term, [registration], { kill: true }) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(0) - }) - - it('removes registered hidden PTYs after natural exit cleanup', () => { - setPlatform('darwin') - const term = { - kill: vi.fn(), - destroy: vi.fn() - } - const registration = registerHiddenRateLimitPty(term) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(1) - - cleanupHiddenRateLimitPty(term, [registration], { kill: false }) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(0) - }) -}) diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts deleted file mode 100644 index 068b937ee844..000000000000 --- a/src/main/rate-limits/hidden-pty-cleanup.ts +++ /dev/null @@ -1,58 +0,0 @@ -type HiddenPty = { - kill: (signal?: string) => void - destroy?: () => void -} - -type Disposable = { - dispose: () => void -} - -const activeHiddenRateLimitPtys = new Set() - -export function registerHiddenRateLimitPty(term: HiddenPty): Disposable { - activeHiddenRateLimitPtys.add(term) - return { - dispose: () => { - activeHiddenRateLimitPtys.delete(term) - } - } -} - -export function getActiveHiddenRateLimitPtyCount(): number { - return activeHiddenRateLimitPtys.size -} - -export function cleanupHiddenRateLimitPty( - term: HiddenPty, - disposables: Disposable[], - options: { kill: boolean } -): void { - for (const disposable of disposables.splice(0)) { - disposable.dispose() - } - - if (options.kill) { - try { - term.kill() - } catch { - /* already exited */ - } - - // Why: node-pty WindowsTerminal.destroy() calls kill() again, which can - // close the same ConPTY handle twice after an intentional termination. - if (process.platform === 'win32') { - return - } - } - - // Why: node-pty destroy releases the master PTY fd; on POSIX, neutralize - // the post-close SIGHUP hook after exit/kill to avoid pid reuse. - if (process.platform !== 'win32') { - term.kill = () => {} - } - try { - term.destroy?.() - } catch { - /* already torn down */ - } -} diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts index 0c65c2fb7882..f91af85eb068 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts @@ -425,9 +425,16 @@ describe('fetchOpenCodeGoRateLimits', () => { ])( 'keeps valid quota when optional billing has an %s', async (_label, billingResponse, disabledReason) => { + const billingSignals: AbortSignal[] = [] netFetchMock .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) - .mockResolvedValueOnce(billingResponse) + .mockImplementationOnce((_url, init: RequestInit) => { + if (init.signal) { + billingSignals.push(init.signal) + } + return Promise.resolve(billingResponse) + }) + const readBody = vi.spyOn(billingResponse, 'text') const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') @@ -436,6 +443,10 @@ describe('fetchOpenCodeGoRateLimits', () => { expect(result.extraUsage).toEqual( expect.objectContaining({ balance: null, enabled: false, disabledReason }) ) + expect(billingSignals).toHaveLength(1) + expect(billingSignals[0]?.aborted).toBe(!billingResponse.ok) + expect(readBody).toHaveBeenCalledTimes(billingResponse.ok ? 1 : 0) + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) } ) diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts index d8ebf61d17d7..74a96fb372eb 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -146,6 +146,7 @@ function startOptionalBillingFetch(openCodeSession: Session, workspaceId: string signal: abortController.signal }) if (!response.ok) { + abortController.abort() return makeOpenCodeGoZenBalance(null, 'refresh-failed') } const balance = parseOpenCodeGoBillingStatusPayload(await response.text()) diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 27e93208c1a1..4366417dc3e7 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -551,9 +551,7 @@ describe('RateLimitService', () => { it('does not cache host Claude usage under an outgoing WSL account', async () => { const service = new RateLimitService() - service.setInactiveClaudeAccountsResolver(() => [ - { id: 'wsl-account-1', managedAuthPath: '/tmp/account-1/auth' } - ]) + service.setInactiveClaudeAccountsResolver(() => [{ id: 'wsl-account-1' }]) service.setClaudeAuthPreparationResolver(async (target) => ({ configDir: target?.runtime === 'wsl' diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index cae71c569e71..469b51e4cf1c 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -78,7 +78,7 @@ describe('RateLimitService', () => { it('aborts inactive Claude preview fetches on stop', async () => { const service = new RateLimitService() - const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + const account = { id: 'account-1' } const capturedSignals: { claude?: AbortSignal } = {} service.setInactiveClaudeAccountsResolver(() => [account]) vi.mocked(fetchManagedAccountUsage).mockImplementation( @@ -212,7 +212,7 @@ describe('RateLimitService', () => { it('allows usage-panel Fable supplements for inactive Claude account previews', async () => { const service = new RateLimitService() - const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + const account = { id: 'account-1' } service.setInactiveClaudeAccountsResolver(() => [account]) vi.mocked(fetchManagedAccountUsage).mockResolvedValueOnce(okProvider('claude', 33, Date.now())) @@ -230,9 +230,7 @@ describe('RateLimitService', () => { it('does not start overlapping inactive Claude preview fetches', async () => { const service = new RateLimitService() const accountFetch = deferred() - service.setInactiveClaudeAccountsResolver(() => [ - { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } - ]) + service.setInactiveClaudeAccountsResolver(() => [{ id: 'account-1' }]) vi.mocked(fetchManagedAccountUsage).mockReturnValueOnce(accountFetch.promise) const firstFetch = service.fetchInactiveClaudeAccountsOnOpen() @@ -401,7 +399,7 @@ describe('RateLimitService', () => { it('does not recache an inactive Claude account removed during fetch-on-open', async () => { const service = new RateLimitService() const accountFetch = deferred() - let inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }] + let inactiveAccounts = [{ id: 'account-1' }] service.setInactiveClaudeAccountsResolver(() => inactiveAccounts) service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/tmp/.claude', @@ -420,7 +418,7 @@ describe('RateLimitService', () => { ]) service.evictInactiveClaudeCache('account-1') - inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }] + inactiveAccounts = [{ id: 'account-1' }] await service.refreshForClaudeAccountChange('account-1') expect(service.getState().inactiveClaudeAccounts[0]?.accountId).toBe('account-1') @@ -435,9 +433,7 @@ describe('RateLimitService', () => { it('does not overwrite inactive Claude cache from a stale same-id fetch', async () => { const service = new RateLimitService() const accountFetch = deferred() - service.setInactiveClaudeAccountsResolver(() => [ - { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } - ]) + service.setInactiveClaudeAccountsResolver(() => [{ id: 'account-1' }]) service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/tmp/.claude', envPatch: {}, diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 8c35ab77acc7..f6cbc7006200 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -14,6 +14,12 @@ import { resetRateLimitProviderMocks } from './rate-limit-service-test-harness' +const profileRouter = vi.hoisted((): { userConfigDir?: string } => ({})) +vi.mock('../claude-accounts/claude-profile-installed-router', () => ({ + getClaudeProfileRouter: () => + profileRouter.userConfigDir ? { userConfigDir: () => profileRouter.userConfigDir } : undefined +})) + vi.mock('./claude-fetcher', () => ({ fetchClaudeRateLimits: vi.fn(), fetchManagedAccountUsage: vi.fn() @@ -280,6 +286,60 @@ describe('RateLimitService', () => { } }) + it('attributes statusline posts to System Default when it inherits CLAUDE_CONFIG_DIR', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + vi.stubEnv('CLAUDE_CONFIG_DIR', '/own/claude-config') + const service = new RateLimitService() + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '/own/claude-config', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + })) + await service.refresh() + service.ingestLiveClaudeRateLimits({ + configDir: '/own/claude-config', + fiveHour: { used_percentage: 44 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(44) + } finally { + vi.unstubAllEnvs() + vi.useRealTimers() + } + }) + + it("attributes System Default posts to the login shell's CLAUDE_CONFIG_DIR a Dock launch lacks", async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + vi.stubEnv('CLAUDE_CONFIG_DIR', '') + profileRouter.userConfigDir = '/shell/claude-config' + const service = new RateLimitService() + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '/shell/claude-config', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + })) + await service.refresh() + service.ingestLiveClaudeRateLimits({ + configDir: '/shell/claude-config', + fiveHour: { used_percentage: 44 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(44) + } finally { + profileRouter.userConfigDir = undefined + vi.unstubAllEnvs() + vi.useRealTimers() + } + }) + it('keeps the other window when a statusline post carries only one', async () => { vi.useFakeTimers() try { @@ -477,51 +537,4 @@ describe('RateLimitService', () => { vi.useRealTimers() } }) - - describe('refreshAfterClaudeLivePtysDrained', () => { - function deferredClaudeResult(): ProviderRateLimits { - return { - ...errorProvider('claude', 'Waiting for Claude session'), - usageMetadata: { - failureKind: 'deferred-by-live-session', - deferredByLiveClaudeSession: true - } - } - } - - it('refetches Claude usage when the current result was deferred by a live session', async () => { - const service = new RateLimitService() - vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(deferredClaudeResult()) - await service.refresh() - expect(service.getState().claude?.usageMetadata?.deferredByLiveClaudeSession).toBe(true) - vi.mocked(fetchClaudeRateLimits).mockClear() - vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) - - await service.refreshAfterClaudeLivePtysDrained() - - expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) - expect(service.getState().claude?.status).toBe('ok') - }) - - it('does not refetch when the current Claude result was not deferred', async () => { - const service = new RateLimitService() - vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce( - errorProvider('claude', 'Token expired') - ) - await service.refresh() - vi.mocked(fetchClaudeRateLimits).mockClear() - - await service.refreshAfterClaudeLivePtysDrained() - - expect(fetchClaudeRateLimits).not.toHaveBeenCalled() - }) - - it('does not refetch when there is no Claude state yet', async () => { - const service = new RateLimitService() - - await service.refreshAfterClaudeLivePtysDrained() - - expect(fetchClaudeRateLimits).not.toHaveBeenCalled() - }) - }) }) diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts index 6aeacd99583b..944c09be2479 100644 --- a/src/main/rate-limits/service/service-account-refresh.ts +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -191,15 +191,4 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna await this.fetchClaudeOnly({ force: true }) return this.getState() } - - async refreshAfterClaudeLivePtysDrained(): Promise { - // Why: "Waiting for Claude session" can only recover once no live claude - // owns the credentials. Refetch on the last PTY exit instead of leaving - // the stale terminal error up until the failure backoff elapses. - if (!this.state.claude?.usageMetadata?.deferredByLiveClaudeSession) { - return - } - this.activeFailureStreakByProvider.claude = 0 - await this.fetchClaudeOnly({ force: true }) - } } diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts index cfd4a6e9d58e..907f0512f007 100644 --- a/src/main/rate-limits/service/service-fetch-policy.ts +++ b/src/main/rate-limits/service/service-fetch-policy.ts @@ -10,6 +10,8 @@ import { type ProviderRateLimits } from './service-types' import { mapClaudeUsageWindow } from '../claude-usage-window' +import { readUserClaudeConfigDir } from '../../claude-accounts/claude-profile-paths' +import { getClaudeProfileRouter } from '../../claude-accounts/claude-profile-installed-router' export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchTargets { protected getMiniMaxCredentialError(message: string): ProviderRateLimits { @@ -91,7 +93,12 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT return } this.lastClaudeAuthSnapshot = { - configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), + // Why the user's own folder: System default passes it through rather than injecting it. + configDir: normalizeClaudeConfigDir( + authPreparation?.envPatch.CLAUDE_CONFIG_DIR ?? + getClaudeProfileRouter()?.userConfigDir() ?? + readUserClaudeConfigDir(process.env) + ), provenance: authPreparation?.provenance ?? 'system' } } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index e4f73dd32119..c78a433415dc 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -65,7 +65,7 @@ import { type AgentSessionReserveRequest, type AgentSessionReserveResult } from './agent-session-reservation-admission' -import type { AgentSessionStoreState } from './agent-session-store-state' +import { heldAgentSessionIds, type AgentSessionStoreState } from './agent-session-store-state' import { agentSessionVisibleTabIndex, listVisibleAgentSessionIds, @@ -120,6 +120,9 @@ export class AgentSessionRecordStore { listRecords = (): AgentSessionRecord[] => [...this.state.records.values()] + /** Every chat this host holds a row for, readable or not. */ + listHeldSessionIds = (): string[] => heldAgentSessionIds(this.state) + /** Whether this host has recorded a chat, readable or not. Nothing removes a record row. */ holdsRecords = (): boolean => this.state.records.size > 0 || this.state.unreadableRecords.size > 0 diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 5fbf939d1afc..ff840c755b35 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -47,6 +47,7 @@ import { } from './agent-session-lease-transitions' import type { AgentSessionStoreState } from './agent-session-store-state' import { agentSessionRecordIdentityFields } from './agent-session-record-founding' +import { agentSessionAccountHomesEqual } from '../../shared/agent-session-account-home' export type AgentSessionReserveRequest = { /** Host-resolved floating directory committed with the first owner reservation. */ @@ -186,8 +187,7 @@ export function applyAgentSessionReservation( if ( !agentSessionExecutionLocationsEqual(existing.location, request.location) || existing.provider !== request.provider || - existing.accountHome.variable !== request.accountHome.variable || - existing.accountHome.path !== request.accountHome.path + !agentSessionAccountHomesEqual(existing.accountHome, request.accountHome) ) { // Why: location, provider, and account are the session identity; changing one is a fork. throw agentSessionRefusalError('agent_session_conflict', { reason: 'identityMismatch' }) diff --git a/src/main/runtime/agent-session-runtime-attribution.test.ts b/src/main/runtime/agent-session-runtime-attribution.test.ts new file mode 100644 index 000000000000..c17d69589b4e --- /dev/null +++ b/src/main/runtime/agent-session-runtime-attribution.test.ts @@ -0,0 +1,150 @@ +// Whether a chat's agent died with the Orca runtime that held it, and how that runtime ended. Only +// an owner tagged with an earlier runtime of this build can be attributed; anything unknown keeps +// the death unattributed, so the chat keeps its generic words. + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionLease } from '../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' +import { + agentSessionRuntimeIncarnation, + beginAgentSessionRuntimeIncarnationForTest +} from './agent-session-runtime-attribution' +import { + beginAgentSessionRuntimeRecord, + recordAgentSessionRuntimeEnd +} from './agent-session-runtime-end-record' + +const SESSION = 'session-alpha-1' +const OWNER = { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-owner' +} + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-runtime-attribution-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +/** A lease the runtime before this one held when it went away, as that runtime left it. */ +async function seedHeldBy(runtime: string | null, lease: Partial = {}) { + await seedTestAgentSessionRecordStore(directory, { + records: [ + agentSessionRecordFixture( + agentSessionLeaseFixture({ + ownerProcess: runtime ? { ...OWNER, runtime } : OWNER, + unreconciled: true, + ...lease + }) + ) + ] + }) +} + +/** What that runtime recorded about itself: its start, and its end when it ended gracefully. */ +function recorded(runtime: string, end?: 'quit' | 'update') { + beginAgentSessionRuntimeRecord(directory, runtime, 1_000) + if (end) { + recordAgentSessionRuntimeEnd(end, 50_000) + } +} + +async function restartEvidence() { + const store = await openTestAgentSessionRecordStore(directory) + await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: 90_000 }) + return store.getRecord(SESSION)?.lease.deathEvidence ?? null +} + +describe("an agent that died with Orca's previous runtime", () => { + it('died in a crash when that runtime started and never ended', async () => { + await seedHeldBy('runtime-a') + recorded('runtime-a') + expect(await restartEvidence()).toMatchObject({ kind: 'pid-absent', runtimeEnd: 'crash' }) + }) + + it.each(['update', 'quit'] as const)( + 'died with the %s that runtime began, when the quit did not finish', + async (trigger) => { + await seedHeldBy('runtime-a') + recorded('runtime-a', trigger) + expect(await restartEvidence()).toMatchObject({ runtimeEnd: trigger }) + } + ) + + it("is never told by another runtime's end", async () => { + await seedHeldBy('runtime-b') + recorded('runtime-b') + recorded('runtime-a', 'update') + expect(await restartEvidence()).toMatchObject({ runtimeEnd: 'crash' }) + }) + + it('names no cause when that runtime left no record', async () => { + await seedHeldBy('runtime-a') + recorded('runtime-b', 'quit') + expect(await restartEvidence()).not.toHaveProperty('runtimeEnd') + }) + + it("names no cause when that runtime's record cannot be read", async () => { + await seedHeldBy('runtime-a') + await mkdir(join(directory, 'agent-session-runtimes'), { recursive: true }) + await writeFile(join(directory, 'agent-session-runtimes', 'runtime-a.json'), '{not json') + expect(await restartEvidence()).not.toHaveProperty('runtimeEnd') + }) + + it('names no cause for an owner an older build recorded', async () => { + await seedHeldBy(null) + expect(await restartEvidence()).not.toHaveProperty('runtimeEnd') + }) + + it("names no cause for a terminal's agent", async () => { + await seedHeldBy('runtime-a', { claimStatus: 'conflicted' }) + expect(await restartEvidence()).not.toHaveProperty('runtimeEnd') + }) + + it('is told once a survivor that outlived the runtime is stopped and proven gone', async () => { + await seedHeldBy('runtime-a') + recorded('runtime-a') + const store = await openTestAgentSessionRecordStore(directory) + // The owner was still alive at restart, so it waits in recovery for its stop. + await store.reconcileOnRestart({ + probe: async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }), + now: 90_000 + }) + expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') + + await store.evictProvenDeadOwner({ + sessionId: SESSION, + expectedFence: 7, + probe: { outcome: 'pid-absent' }, + now: 95_000 + }) + + expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ runtimeEnd: 'crash' }) + }) +}) + +describe('an agent this runtime ran', () => { + it("becomes an earlier runtime's after a relaunch", async () => { + await seedHeldBy(agentSessionRuntimeIncarnation()) + // This runtime's store opened, so it recorded its start; it then died without a quit. + await openTestAgentSessionRecordStore(directory) + beginAgentSessionRuntimeIncarnationForTest() + expect(await restartEvidence()).toMatchObject({ runtimeEnd: 'crash' }) + }) +}) diff --git a/src/main/runtime/agent-session-runtime-attribution.ts b/src/main/runtime/agent-session-runtime-attribution.ts new file mode 100644 index 000000000000..a3d255169a2f --- /dev/null +++ b/src/main/runtime/agent-session-runtime-attribution.ts @@ -0,0 +1,71 @@ +// Which Orca runtime held each chat's agent, and, when that agent is later proven dead, whether it +// died with that runtime and how the runtime ended. Applied to every store transaction, so a death +// any transition proves (a restart's adjudication, recovery's stop of a survivor) is told the same +// way. When anything is unknown (an owner an older build recorded, a terminal's claim, a runtime +// with no readable record) the death names no cause and the chat keeps its generic words. + +import { randomUUID } from 'node:crypto' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionStoreState } from './agent-session-store-state' + +/** This Orca runtime: one per process, so a relaunch is a new one. */ +let incarnation = randomUUID() + +export function agentSessionRuntimeIncarnation(): string { + return incarnation +} + +/** For tests that relaunch Orca inside one process. */ +export function beginAgentSessionRuntimeIncarnationForTest(): void { + incarnation = randomUUID() +} + +function attributed( + before: AgentSessionRecord | undefined, + after: AgentSessionRecord, + runtimeEnds: AgentSessionStoreState['runtimeEnds'] +): AgentSessionRecord { + const { ownerProcess, deathEvidence } = after.lease + // An owner this transaction recorded is this runtime's. + if (ownerProcess && !ownerProcess.runtime && ownerProcess !== before?.lease.ownerProcess) { + return { + ...after, + lease: { ...after.lease, ownerProcess: { ...ownerProcess, runtime: incarnation } } + } + } + const heldBy = before?.lease.ownerProcess?.runtime + if ( + !deathEvidence || + deathEvidence === before?.lease.deathEvidence || + deathEvidence.runtimeEnd !== undefined || + !heldBy || + heldBy === incarnation || + // A terminal's agent, which an older build recorded: its transport was never Orca's. + before?.lease.claimStatus === 'conflicted' || + !runtimeEnds + ) { + return after + } + // Unrecorded, pruned or unreadable: no cause. A crash is only a runtime that started and never ended. + const runtimeEnd = runtimeEnds.get(heldBy) + if (!runtimeEnd) { + return after + } + return { ...after, lease: { ...after.lease, deathEvidence: { ...deathEvidence, runtimeEnd } } } +} + +/** Stamps what this transaction learned about runtimes onto the records it changed. */ +export function attributeAgentSessionRuntime( + published: AgentSessionStoreState, + draft: AgentSessionStoreState +): void { + for (const [sessionId, record] of draft.records) { + const before = published.records.get(sessionId) + if (record !== before) { + const next = attributed(before, record, draft.runtimeEnds) + if (next !== record) { + draft.records.set(sessionId, next) + } + } + } +} diff --git a/src/main/runtime/agent-session-runtime-end-record.test.ts b/src/main/runtime/agent-session-runtime-end-record.test.ts new file mode 100644 index 000000000000..7121f5098b6f --- /dev/null +++ b/src/main/runtime/agent-session-runtime-end-record.test.ts @@ -0,0 +1,173 @@ +// What a runtime records about itself: a start when its store opens, and its end on a graceful exit. +// A crash is only a runtime known to have started and never ended; anything else names no cause. + +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DurableFileWrite from '../durable-file-write' + +const writes = vi.hoisted(() => ({ fail: false })) +vi.mock('../durable-file-write', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileDurableSync: (...args: Parameters) => { + if (writes.fail) { + throw new Error('ENOSPC: no space left on device') + } + actual.writeFileDurableSync(...args) + } + } +}) + +import { + beginAgentSessionRuntimeRecord, + readAgentSessionRuntimeEnds, + recordAgentSessionRuntimeEnd, + recordAgentSessionRuntimeEndOnExit +} from './agent-session-runtime-end-record' +import { tearDownRuntime, type InstalledRuntime } from './structured-agent-session-runtime-teardown' + +const RUNTIME_A = '0000000a-0000-4000-8000-000000000000' +const RUNTIME_B = '0000000b-0000-4000-8000-000000000000' + +let directory: string + +beforeEach(async () => { + writes.fail = false + directory = await mkdtemp(join(tmpdir(), 'orca-runtime-end-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +function runtimesDirectory(): string { + return join(directory, 'agent-session-runtimes') +} + +describe('what a runtime records about itself', () => { + it('reads as a crash once it started and never ended', () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('crash') + }) + + it.each(['quit', 'update'] as const)('reads as the %s it recorded as it ended', (trigger) => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + recordAgentSessionRuntimeEnd(trigger, 2) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe(trigger) + }) + + it('keeps the first end it recorded', () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + recordAgentSessionRuntimeEnd('update', 2) + recordAgentSessionRuntimeEnd('quit', 3) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('update') + }) + + it('names no cause for a runtime it has no record of', () => { + expect(readAgentSessionRuntimeEnds(directory)).toEqual(new Map()) + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + expect(readAgentSessionRuntimeEnds(directory)?.has(RUNTIME_B)).toBe(false) + }) + + it('names no cause for a record it cannot read, and the next start writes its own', async () => { + await mkdir(runtimesDirectory(), { recursive: true }) + await writeFile(join(runtimesDirectory(), `${RUNTIME_A}.json`), '{not json') + expect(readAgentSessionRuntimeEnds(directory)?.has(RUNTIME_A)).toBe(false) + + beginAgentSessionRuntimeRecord(directory, RUNTIME_B, 1) + recordAgentSessionRuntimeEnd('quit', 2) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_B)).toBe('quit') + }) + + it('names no cause when the records cannot be listed', async () => { + await writeFile(runtimesDirectory(), 'not a directory') + expect(readAgentSessionRuntimeEnds(directory)).toBeNull() + }) + + it('keeps only the newest records, so a pruned runtime names no cause', async () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + // The oldest record by far. + await utimes(join(runtimesDirectory(), `${RUNTIME_A}.json`), 1, 1) + for (let index = 0; index < 16; index += 1) { + beginAgentSessionRuntimeRecord( + directory, + `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + index + ) + } + const ends = readAgentSessionRuntimeEnds(directory) + expect(ends?.size).toBe(16) + expect(ends?.has(RUNTIME_A)).toBe(false) + }) + + it("never loses another runtime's record to its own writes", () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + beginAgentSessionRuntimeRecord(directory, RUNTIME_B, 2) + recordAgentSessionRuntimeEnd('quit', 3) + expect(readAgentSessionRuntimeEnds(directory)).toEqual( + new Map([ + [RUNTIME_A, 'crash'], + [RUNTIME_B, 'quit'] + ]) + ) + }) + + it('never fails the exit when it cannot be written', async () => { + await writeFile(runtimesDirectory(), 'not a directory') + expect(() => beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1)).not.toThrow() + expect(() => recordAgentSessionRuntimeEnd('quit', 2)).not.toThrow() + }) + + it('keeps its end when the same runtime records its start again during the quit', () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + recordAgentSessionRuntimeEnd('update', 2) + // A host reinstalled by a request that landed during the quit opens its store again. + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 3) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('update') + }) + + it('names no cause, rather than a crash, when its end could not be written', () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + writes.fail = true + recordAgentSessionRuntimeEnd('quit', 2) + expect(readAgentSessionRuntimeEnds(directory)?.has(RUNTIME_A)).toBe(false) + }) + + it('reads a clean process exit nothing else recorded as a quit, and any other exit as nothing', () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + expect(process.listeners('exit')).toContain(recordAgentSessionRuntimeEndOnExit) + recordAgentSessionRuntimeEndOnExit(1) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('crash') + recordAgentSessionRuntimeEndOnExit(0) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('quit') + }) + + it('leaves no temp files behind', async () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + recordAgentSessionRuntimeEnd('quit', 2) + expect(await readdir(runtimesDirectory())).toEqual([`${RUNTIME_A}.json`]) + }) +}) + +describe('a quit', () => { + it('records its end before it waits on anything, so a quit that never finishes is still a quit', () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + const installed = { + host: { stopDelivery: vi.fn(), flushAllStreamedEvents: vi.fn() }, + adapter: { closeAll: vi.fn() }, + journalDatabase: { stateDirectory: directory, close: vi.fn() }, + // A recovery that never drains: the quit's deadline ends the process here. + waitForRecovery: () => new Promise(() => {}) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown reads only the members stubbed above before it parks on the recovery wait. + void tearDownRuntime(installed as unknown as InstalledRuntime, 'update') + + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('update') + expect(installed.host.flushAllStreamedEvents).not.toHaveBeenCalled() + expect(existsSync(join(runtimesDirectory(), `${RUNTIME_A}.json`))).toBe(true) + }) +}) diff --git a/src/main/runtime/agent-session-runtime-end-record.ts b/src/main/runtime/agent-session-runtime-end-record.ts new file mode 100644 index 000000000000..c57c593b4dc1 --- /dev/null +++ b/src/main/runtime/agent-session-runtime-end-record.ts @@ -0,0 +1,174 @@ +// What each Orca runtime that ran chats here says about itself: that it started, and, when it ends +// gracefully, how (a quit or an update). A later start reads them to tell an owner that died with a +// quit from one that died in a crash. A crash is only ever concluded from a runtime known to have +// started and never ended; a runtime with no readable record names no cause. +// +// One file per runtime, written only by that runtime, so two processes sharing the directory never +// lose each other's word to a read-modify-write. Pruned to the newest few, and losing a record only +// costs the distinction. + +import { mkdirSync, readdirSync, rmSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write' +import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' +import type { AgentSessionOrcaStopCause } from '../../shared/agent-session-orca-stop' +import { + AGENT_SESSION_RESUME_TRIGGERS, + type AgentSessionResumeTrigger +} from '../../shared/agent-session-resume-marker' + +const RUNTIMES_DIRECTORY = 'agent-session-runtimes' +/** Enough runtimes that an owner a few restarts old still finds its runtime's record. */ +const MAX_RUNTIME_RECORDS = 16 +const MAX_RUNTIME_RECORD_BYTES = 4 * 1024 +/** A temp file older than this belongs to a writer that died between write and rename. */ +const STALE_TEMP_FILE_MS = 60_000 +const RECORD_FILE = /^([A-Za-z0-9-]{1,64})\.json$/ + +type RuntimeRecord = { + runtime: string + startedAt: number + end?: { trigger: AgentSessionResumeTrigger; at: number } +} + +/** This process's runtime, once its store opened: the one record a graceful end rewrites. */ +let current: { path: string; record: RuntimeRecord } | null = null + +function writeRecord(path: string, record: RuntimeRecord): void { + writeFileDurableSync(durableWriteTempPath(path), path, JSON.stringify(record)) +} + +function prune(directory: string, keep: string): void { + const records: { name: string; modifiedAt: number }[] = [] + for (const name of readdirSync(directory)) { + const modifiedAt = statSync(join(directory, name), { throwIfNoEntry: false })?.mtimeMs ?? 0 + if (name.endsWith('.tmp')) { + if (Date.now() - modifiedAt > STALE_TEMP_FILE_MS) { + rmSync(join(directory, name), { force: true }) + } + } else if (name !== keep) { + records.push({ name, modifiedAt }) + } + } + records.sort((a, b) => b.modifiedAt - a.modifiedAt) + for (const { name } of records.slice(MAX_RUNTIME_RECORDS - 1)) { + rmSync(join(directory, name), { force: true }) + } +} + +/** A graceful exit no call site recorded, such as an `app.exit(0)` or `process.exit(0)`, is a quit: + * Electron emits the process's 'exit' on every quit or exit once its loop runs, and Node on every + * `process.exit`. A non-zero exit records nothing, so it stays a crash. */ +export function recordAgentSessionRuntimeEndOnExit(code: number): void { + if (code === 0) { + recordAgentSessionRuntimeEnd('quit') + } +} + +let exitHookInstalled = false + +/** Records that this runtime started. Never throws: a runtime with no record reads as unknown. A + * second start of the same runtime (a host reinstalled during its quit) keeps the end it recorded. */ +export function beginAgentSessionRuntimeRecord( + stateDirectory: string, + runtime: string, + now: number +): void { + const directory = join(stateDirectory, RUNTIMES_DIRECTORY) + const name = `${runtime}.json` + const path = join(directory, name) + const ended = current?.path === path && current.record.end ? current.record : null + current = { path, record: ended ?? { runtime, startedAt: now } } + if (!exitHookInstalled) { + exitHookInstalled = true + process.once('exit', recordAgentSessionRuntimeEndOnExit) + } + try { + mkdirSync(directory, { recursive: true }) + writeRecord(current.path, current.record) + prune(directory, name) + } catch { + // Unwritten, this runtime's owners name no cause when they die. + } +} + +/** + * The one "this Orca runtime is ending" entry point, for every graceful exit. Synchronous, so an + * exit that cannot await still records it; the first call wins. Never throws. + */ +export function recordAgentSessionRuntimeEnd( + trigger: AgentSessionResumeTrigger, + now = Date.now() +): void { + if (!current || current.record.end) { + return + } + current.record = { ...current.record, end: { trigger, at: now } } + try { + writeRecord(current.path, current.record) + } catch { + // The start alone would read as a crash; with no record its owners name no cause. + try { + rmSync(current.path, { force: true }) + } catch { + // Nothing left to try: the start stays, and reads as a crash. + } + } +} + +function readTrigger(value: unknown): AgentSessionResumeTrigger | undefined { + return AGENT_SESSION_RESUME_TRIGGERS.find((trigger) => trigger === value) +} + +/** How a recorded runtime ended: its quit or update, else a crash. Undefined when unreadable. */ +function readRecordedEnd(path: string, runtime: string): AgentSessionOrcaStopCause | undefined { + try { + const parsed: unknown = JSON.parse( + readNodeFileSyncWithinLimit(path, MAX_RUNTIME_RECORD_BYTES).buffer.toString('utf8') + ) + if ( + typeof parsed !== 'object' || + parsed === null || + !('runtime' in parsed) || + parsed.runtime !== runtime || + !('startedAt' in parsed) || + typeof parsed.startedAt !== 'number' + ) { + return undefined + } + if (!('end' in parsed) || parsed.end === undefined) { + return 'crash' + } + const end = parsed.end + return readTrigger( + typeof end === 'object' && end !== null && 'trigger' in end ? end.trigger : undefined + ) + } catch { + return undefined + } +} + +/** + * How each recorded runtime ended. A runtime missing from the map (never recorded, pruned, or + * unreadable) names no cause. Null when the records cannot be listed at all. + */ +export function readAgentSessionRuntimeEnds( + stateDirectory: string +): ReadonlyMap | null { + const directory = join(stateDirectory, RUNTIMES_DIRECTORY) + let names: string[] + try { + names = readdirSync(directory) + } catch (error) { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' ? new Map() : null + } + const ends = new Map() + for (const name of names) { + const runtime = RECORD_FILE.exec(name)?.[1] + const end = runtime ? readRecordedEnd(join(directory, name), runtime) : undefined + if (runtime && end) { + ends.set(runtime, end) + } + } + return ends +} diff --git a/src/main/runtime/agent-session-store-state.ts b/src/main/runtime/agent-session-store-state.ts index 34d46a6bf1fd..cee15a96ac49 100644 --- a/src/main/runtime/agent-session-store-state.ts +++ b/src/main/runtime/agent-session-store-state.ts @@ -3,6 +3,7 @@ import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionOrcaStopCause } from '../../shared/agent-session-orca-stop' import type { AgentSessionTabTable } from './agent-session-tab-table' export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number } @@ -15,4 +16,12 @@ export type AgentSessionStoreState = { unreadableRecords: Map /** Chat tab id → the conversation it shows; null until this store first records a tab. */ sessionTabs: AgentSessionTabTable | null + /** How each earlier runtime recorded here ended: its quit or update, or a crash when it started + * and never ended; read once at load. A runtime it lacks, or null, attributes no death to Orca. */ + runtimeEnds?: ReadonlyMap | null +} + +/** Every session id the state holds a row for, readable or not. */ +export function heldAgentSessionIds(state: AgentSessionStoreState): string[] { + return [...state.records.keys(), ...state.unreadableRecords.keys()] } diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 778956762d2c..3877da33af42 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -13,6 +13,14 @@ import { journalOpenRefusalError } from '../native-chat/agent-session-journal/jo import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' import type { AgentSessionStoreState } from './agent-session-store-state' import { writeAgentSessionStoreRows } from './agent-session-record-rows' +import { + agentSessionRuntimeIncarnation, + attributeAgentSessionRuntime +} from './agent-session-runtime-attribution' +import { + beginAgentSessionRuntimeRecord, + readAgentSessionRuntimeEnds +} from './agent-session-runtime-end-record' import { agentSessionStoreDraftRowWrites, draftAgentSessionStoreState, @@ -79,7 +87,16 @@ export class AgentSessionStoreTransactions { loaded: AgentSessionStoreState ) { freezeRows(loaded, null) - this.published = loaded + this.published = { + ...loaded, + runtimeEnds: readAgentSessionRuntimeEnds(journalDatabase.stateDirectory) + } + // Before any owner this runtime records: a crash is concluded only from a recorded start. + beginAgentSessionRuntimeRecord( + journalDatabase.stateDirectory, + agentSessionRuntimeIncarnation(), + Date.now() + ) } /** The committed state. A transaction in flight never shows here until its rows have landed. */ @@ -148,6 +165,7 @@ export class AgentSessionStoreTransactions { const published = this.published const draft = draftAgentSessionStoreState(published) const result = apply(draft) + attributeAgentSessionRuntime(published, draft) const writes = agentSessionStoreDraftRowWrites(published, draft) return { result, diff --git a/src/main/runtime/antigravity-readiness-transcripts.test.ts b/src/main/runtime/antigravity-readiness-transcripts.test.ts index 6ef52c56e21f..845ebdfc8a2b 100644 --- a/src/main/runtime/antigravity-readiness-transcripts.test.ts +++ b/src/main/runtime/antigravity-readiness-transcripts.test.ts @@ -8,9 +8,6 @@ * * One case is pinned as a KNOWN DEFECT: the shipped detector refuses a ready screen whose retained * tail ends on the error block. That asserts what it does, not what it should. - * - * Capture protocol: docs/reference/agent-pty-transcript-capture.md - * What each transcript decides: docs/reference/antigravity-readiness-evidence.md */ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' @@ -26,15 +23,6 @@ vi.mock('electron', () => ({ })) const FIXTURE_DIR = join(__dirname, '__fixtures__') -const EVIDENCE_DOC = join( - __dirname, - '..', - '..', - '..', - 'docs', - 'reference', - 'antigravity-readiness-evidence.md' -) // Why asymmetric: a ready verdict has to survive the settle window, while a refusal only has to // hold for one poll. Keeping the refusal short keeps seven transcripts off the suite's clock. const READY_TIMEOUT_MS = 2_000 @@ -48,7 +36,7 @@ const ESC = String.fromCharCode(27) type TranscriptCase = { /** Fixture basename; `.txt` under `__fixtures__/`. */ name: string - /** Capture in docs/reference/antigravity-readiness-evidence.md. */ + /** Capture group identifier. */ capture: string what: string /** What a correct detector must answer. Not what the shipped one answers. */ @@ -114,8 +102,7 @@ const TRANSCRIPTS: readonly TranscriptCase[] = [ expectReady: true }, // Not captured: this machine's agy has no OAuth session and offers only Gemini models, and - // reaching the rest would mean signing the operator out or deleting their config. See - // docs/reference/antigravity-readiness-evidence.md § What could not be captured. + // reaching the rest would mean signing the operator out or deleting their config. { name: 'antigravity-ready-business-non-gemini', capture: 'A', @@ -225,13 +212,4 @@ describe('Antigravity readiness, decided by captured transcripts', () => { expect(text).toContain(ESC) }) } - - it('documents every transcript the detector is allowed to depend on', () => { - // Why a test: the doc is the operator's checklist. A name that drifts out of it is a - // transcript nobody will capture, and a case that silently skips forever. - const doc = readFileSync(EVIDENCE_DOC, 'utf8') - for (const transcript of TRANSCRIPTS) { - expect(doc).toContain(`${transcript.name}.txt`) - } - }) }) diff --git a/src/main/runtime/browser-screencast-renderer-throttle-lease.test.ts b/src/main/runtime/browser-screencast-renderer-throttle-lease.test.ts new file mode 100644 index 000000000000..c46ee63acd1f --- /dev/null +++ b/src/main/runtime/browser-screencast-renderer-throttle-lease.test.ts @@ -0,0 +1,179 @@ +/** + * A remote browser stream keeps the desktop window drawing for exactly as long as it lives: guest + * frames come from the embedder's compositor, which a throttled hidden window stops running. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserScreencastOptions } from '../browser/browser-screencast-stream-types' +import { createScreencastHarness } from './browser-screencast-subscriber-test-harness' +import { RuntimeBrowserCommands } from './orca-runtime-browser' +import { createSinglePageBrowserCommandsHost } from './single-page-browser-commands-host-test-double' + +const { webContentsFromId, startBrowserScreencast } = vi.hoisted(() => ({ + webContentsFromId: vi.fn(), + startBrowserScreencast: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { on: vi.fn(), removeListener: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() }, + webContents: { fromId: webContentsFromId } +})) +vi.mock('../browser/browser-screencast-stream', () => ({ startBrowserScreencast })) + +type PageStream = { options: BrowserScreencastOptions; close: () => void } + +function createRig() { + const { runtime } = createScreencastHarness() + const setBackgroundThrottling = vi.fn() + const window = { webContents: { isDestroyed: () => false, setBackgroundThrottling } } + Object.assign(runtime, { + browserCommands: new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost(window)) + }) + const pageStreams: PageStream[] = [] + // Models Chromium's asynchronous teardown: a held stop leaves the stream open until `close()`. + const stopControl = { hold: false } + startBrowserScreencast.mockImplementation( + async (_guest: unknown, options: BrowserScreencastOptions) => { + let close!: () => void + const done = new Promise((resolve) => { + close = resolve + }) + pageStreams.push({ options, close }) + return { + stop: () => { + if (!stopControl.hold) { + close() + } + }, + done, + updateViewport: vi.fn(async () => {}), + updateFrameBudget: vi.fn(async () => {}) + } + } + ) + + const subscribe = (connectionId: string, signal?: AbortSignal) => { + const emit = vi.fn() + const done = runtime.browserScreencast( + { worktree: 'id:wt-1', page: 'page-1', format: 'jpeg' }, + { connectionId, clientKind: 'mobile', sendBinary: vi.fn(() => true), signal, emit } + ) + const ready = async (): Promise => { + await vi.waitFor(() => + expect(emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'ready' })) + ) + return emit.mock.calls.find(([event]) => event.type === 'ready')?.[0].subscriptionId + } + const eventTypes = (): string[] => emit.mock.calls.map(([event]) => event.type) + return { done, ready, eventTypes } + } + + return { + runtime, + subscribe, + pageStreams, + stopControl, + throttleCalls: () => setBackgroundThrottling.mock.calls.map(([allowed]) => allowed) + } +} + +describe('remote browser screencast renderer throttle lease', () => { + beforeEach(() => { + webContentsFromId.mockReset() + webContentsFromId.mockReturnValue({ isDestroyed: () => false }) + startBrowserScreencast.mockReset() + }) + + it('lifts the window throttle before the stream starts capturing', async () => { + const rig = createRig() + let callsAtStart: boolean[] = [] + const started = startBrowserScreencast.getMockImplementation() + startBrowserScreencast.mockImplementation(async (...args: unknown[]) => { + callsAtStart = rig.throttleCalls() + return started?.(...args) + }) + + const phone = rig.subscribe('conn-phone') + await phone.ready() + + expect(callsAtStart).toEqual([false]) + }) + + // Unsubscribe, connection close, ghost eviction and a desktop page close all end through the + // page stream's `done`, so one of them stands for all. + it('restores it when the stream ends', async () => { + const rig = createRig() + const phone = rig.subscribe('conn-phone') + const subscriptionId = await phone.ready() + + rig.runtime.cleanupSubscription(subscriptionId) + await phone.done + + await vi.waitFor(() => expect(rig.throttleCalls()).toEqual([false, true])) + }) + + it('restores it when the stream fails to start', async () => { + const rig = createRig() + startBrowserScreencast.mockRejectedValue(new Error('Could not attach debugger.')) + const phone = rig.subscribe('conn-phone') + + await expect(phone.done).rejects.toThrow('Could not attach debugger.') + + await vi.waitFor(() => expect(rig.throttleCalls()).toEqual([false, true])) + }) + + it('ends the stream on an error, then restores it', async () => { + const rig = createRig() + const phone = rig.subscribe('conn-phone') + await phone.ready() + + rig.pageStreams[0].options.onError?.('Browser stream timed out.') + await phone.done + + expect(phone.eventTypes()).toEqual(['ready', 'error', 'end']) + await vi.waitFor(() => expect(rig.throttleCalls()).toEqual([false, true])) + }) + + it('starts a fresh stream for a viewer that joins while the errored one tears down', async () => { + const rig = createRig() + const phone = rig.subscribe('conn-phone') + await phone.ready() + rig.stopControl.hold = true + + rig.pageStreams[0].options.onError?.('Browser stream timed out.') + const guestLookups = webContentsFromId.mock.calls.length + const tablet = rig.subscribe('conn-tablet') + // Past its guest lookup, the joiner reaches the page record with no further await. + await vi.waitFor(() => + expect(webContentsFromId.mock.calls.length).toBeGreaterThan(guestLookups) + ) + await new Promise((resolve) => setImmediate(resolve)) + rig.pageStreams[0].close() + await tablet.ready() + + expect(startBrowserScreencast).toHaveBeenCalledTimes(2) + expect(tablet.eventTypes()).toEqual(['ready']) + await phone.done + }) + + it('restores it when the subscription is aborted before ready', async () => { + const rig = createRig() + let releaseStart!: () => void + const started = startBrowserScreencast.getMockImplementation() + startBrowserScreencast.mockImplementation(async (...args: unknown[]) => { + await new Promise((resolve) => { + releaseStart = resolve + }) + return started?.(...args) + }) + const abort = new AbortController() + const phone = rig.subscribe('conn-phone', abort.signal) + await vi.waitFor(() => expect(releaseStart).toBeTypeOf('function')) + + abort.abort() + releaseStart() + await phone.done + + expect(phone.eventTypes()).not.toContain('ready') + await vi.waitFor(() => expect(rig.throttleCalls()).toEqual([false, true])) + }) +}) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index ad2818c2e9bb..628f3f4f6917 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -252,8 +252,9 @@ beforeEach(async () => { resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => claudeAuthPolicy, openClaudeConnection: claude.openConnection, - claudeThinkingDisplay: { - argsFor: async () => ({ 'thinking-display': 'summarized' }), + claudeCliFlags: { + supports: async (flag) => flag.option === '--thinking-display', + prewarm: () => {}, observeExit: () => {} }, // Production's sink wiring onto a real hook server, whose records a Stop reaches. diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index db0671101a93..08ac7df81b85 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -171,6 +171,7 @@ describe('mobile RPC allowlist', () => { 'agentSession.subscribe', 'agentSession.unsubscribe', 'agentSession.subscribeStatus', + 'agentSession.readVisual', 'agentSession.hold', 'agentSession.release' ]) diff --git a/src/main/runtime/mobile-session-layout-projection.ts b/src/main/runtime/mobile-session-layout-projection.ts index 3dd92091c77b..60ab2aec3d1d 100644 --- a/src/main/runtime/mobile-session-layout-projection.ts +++ b/src/main/runtime/mobile-session-layout-projection.ts @@ -249,6 +249,24 @@ export function distributeHeadlessTabsAcrossGroups( .filter((group) => group.tabOrder.length > 0) } +// Why: a restored split must name one of its own groups as active, never a generated headless id. +export function pickRestoredActiveGroupId( + groups: readonly RuntimeMobileSessionTabGroup[], + preferredGroupIds: readonly (string | null | undefined)[], + activeTopLevelId: string | null +): string | null { + const groupIds = new Set(groups.map((group) => group.id)) + const preferred = preferredGroupIds.find((id): id is string => id != null && groupIds.has(id)) + return ( + preferred ?? + (activeTopLevelId + ? groups.find((group) => group.tabOrder.includes(activeTopLevelId))?.id + : undefined) ?? + groups[0]?.id ?? + null + ) +} + export function buildMaterializedHeadlessParentLayout( leafId: string, ptyId: string, diff --git a/src/main/runtime/native-chat-visuals-workspace-verdict.test.ts b/src/main/runtime/native-chat-visuals-workspace-verdict.test.ts new file mode 100644 index 000000000000..07ad7372d751 --- /dev/null +++ b/src/main/runtime/native-chat-visuals-workspace-verdict.test.ts @@ -0,0 +1,271 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { + createNativeChatVisualsWorkspaceVerdicts, + readNativeChatVisualsWorkspaceCatalogs, + type NativeChatVisualsFilesystem, + type NativeChatVisualsWorkspaceCatalogs +} from './native-chat-visuals-workspace-verdict' + +const REPO_PATH = '/work/repo-1' +const WORKTREE_PATH = '/work/repo-1-feature' +const WORKTREE_ID = `repo-1::${WORKTREE_PATH}` + +function catalogs( + options: { + repos?: string[] + meta?: string[] + folders?: string[] + others?: Partial + } = {} +): NativeChatVisualsWorkspaceCatalogs { + return { + active: { + getRepo: (id: string) => + (options.repos ?? ['repo-1']).includes(id) ? { path: REPO_PATH } : undefined, + getAllWorktreeMeta: () => Object.fromEntries((options.meta ?? []).map((id) => [id, {}])), + getFolderWorkspaces: () => (options.folders ?? []).map((id) => ({ id })) + }, + others: { ids: new Set(), repoIds: new Set(), unreadableProfiles: 0, ...options.others } + } +} + +const at = ( + workspaceId: string, + overrides: Partial = {} +): AgentSessionExecutionLocation => ({ + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId, + workspaceKind: 'git-worktree', + ...overrides +}) + +type Presence = 'present' | 'absent' | 'unknown' + +function verdictWith( + known: NativeChatVisualsWorkspaceCatalogs | null, + paths: Record = {}, + gitRecords: boolean | null = false +) { + const fs: NativeChatVisualsFilesystem = { + presence: vi.fn(async (path: string): Promise => paths[path] ?? 'present'), + gitRecordsWorktree: vi.fn(async () => gitRecords) + } + return { verdict: createNativeChatVisualsWorkspaceVerdicts(() => known, fs)(), fs } +} + +describe('whether a chat workspace is provably removed', () => { + it('is removed when its project was removed from Orca in every profile', async () => { + const { verdict, fs } = verdictWith(catalogs({ repos: [] })) + await expect(verdict(at(WORKTREE_ID))).resolves.toBe('removed') + expect(fs.presence).not.toHaveBeenCalled() + }) + + it("keeps a chat whose project or folder only another profile holds (chats are shared, catalogs aren't)", async () => { + const { verdict } = verdictWith( + catalogs({ + repos: [], + others: { repoIds: new Set(['repo-1']), ids: new Set(['folder:f-2']) } + }) + ) + await expect(verdict(at(WORKTREE_ID))).resolves.toBe('unverifiable') + await expect(verdict(at('folder:f-2', { workspaceKind: 'folder' }))).resolves.toBe('present') + }) + + it('decides nothing while any profile cannot be read', async () => { + const { verdict } = verdictWith(catalogs({ repos: [], others: { unreadableProfiles: 1 } })) + await expect(verdict(at(WORKTREE_ID))).resolves.toBe('unverifiable') + await expect(verdict(at('folder:gone', { workspaceKind: 'folder' }))).resolves.toBe( + 'unverifiable' + ) + }) + + it('is present while any profile still tracks the worktree', async () => { + for (const known of [ + catalogs({ meta: [WORKTREE_ID] }), + catalogs({ others: { ids: new Set([WORKTREE_ID]) } }) + ]) { + const { verdict, fs } = verdictWith(known, { [WORKTREE_PATH]: 'absent' }) + await expect(verdict(at(WORKTREE_ID))).resolves.toBe('present') + expect(fs.presence).not.toHaveBeenCalled() + } + }) + + it('is removed when the worktree folder is gone and git no longer records it', async () => { + const { verdict, fs } = verdictWith(catalogs(), { [WORKTREE_PATH]: 'absent' }, false) + await expect(verdict(at(WORKTREE_ID))).resolves.toBe('removed') + expect(fs.gitRecordsWorktree).toHaveBeenCalledWith(REPO_PATH, WORKTREE_PATH) + }) + + it('keeps a worktree on an unmounted drive: git still records it, or the project is unreachable', async () => { + await expect( + verdictWith(catalogs(), { [WORKTREE_PATH]: 'absent' }, true).verdict(at(WORKTREE_ID)) + ).resolves.toBe('unverifiable') + await expect( + verdictWith(catalogs(), { [WORKTREE_PATH]: 'absent' }, null).verdict(at(WORKTREE_ID)) + ).resolves.toBe('unverifiable') + await expect( + verdictWith(catalogs(), { [WORKTREE_PATH]: 'absent', [REPO_PATH]: 'absent' }).verdict( + at(WORKTREE_ID) + ) + ).resolves.toBe('unverifiable') + }) + + it("never removes the project's own checkout while the project is known", async () => { + const { verdict } = verdictWith(catalogs(), { [REPO_PATH]: 'absent' }) + await expect(verdict(at(`repo-1::${REPO_PATH}`))).resolves.toBe('unverifiable') + }) + + it('keeps a worktree whose folder is there or unreadable', async () => { + await expect(verdictWith(catalogs()).verdict(at(WORKTREE_ID))).resolves.toBe('present') + await expect( + verdictWith(catalogs(), { [WORKTREE_PATH]: 'unknown' }).verdict(at(WORKTREE_ID)) + ).resolves.toBe('unverifiable') + }) + + it('reads a folder workspace from every catalog', async () => { + const folder = (id: string) => at(`folder:${id}`, { workspaceKind: 'folder' }) + const { verdict } = verdictWith(catalogs({ folders: ['f-1'] })) + await expect(verdict(folder('f-1'))).resolves.toBe('present') + await expect(verdict(folder('f-2'))).resolves.toBe('removed') + }) + + it('never decides for another host, a WSL distro, the floating workspace, or without catalogs', async () => { + const { verdict, fs } = verdictWith(catalogs({ repos: [] })) + await expect(verdict(at(WORKTREE_ID, { executionHostId: 'ssh:box' }))).resolves.toBe( + 'unverifiable' + ) + await expect(verdict(at(WORKTREE_ID, { wslDistro: 'Ubuntu' }))).resolves.toBe('unverifiable') + await expect( + verdict(at(FLOATING_TERMINAL_WORKTREE_ID, { workspaceKind: 'folder' })) + ).resolves.toBe('unverifiable') + await expect(verdictWith(null).verdict(at(WORKTREE_ID))).resolves.toBe('unverifiable') + expect(fs.presence).not.toHaveBeenCalled() + }) + + it('reads the catalogs once per run, however many chats it judges', async () => { + const read = vi.fn(() => catalogs({ repos: [] })) + const verdict = createNativeChatVisualsWorkspaceVerdicts(read)() + await verdict(at(WORKTREE_ID)) + await verdict(at('repo-1::/work/other')) + expect(read).toHaveBeenCalledOnce() + }) +}) + +describe("git's own record of a linked worktree", () => { + const scratch: string[] = [] + afterEach(() => { + for (const dir of scratch.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + function repoWithWorktreeRecord(recordedWorktree: string | null): string { + const repo = realpathSync(mkdtempSync(join(tmpdir(), 'orca-visuals-git-'))) + scratch.push(repo) + mkdirSync(join(repo, '.git', 'worktrees', 'feature'), { recursive: true }) + if (recordedWorktree) { + writeFileSync( + join(repo, '.git', 'worktrees', 'feature', 'gitdir'), + `${recordedWorktree}/.git\n` + ) + } + return repo + } + + it('removes only a worktree git no longer records', async () => { + const gone = join(tmpdir(), 'orca-visuals-missing-worktree-a') + const verdicts = (repo: string) => + createNativeChatVisualsWorkspaceVerdicts(() => ({ + ...catalogs(), + active: { + ...catalogs().active, + getRepo: () => ({ path: repo }) + } + }))() + const recorded = repoWithWorktreeRecord(gone) + await expect(verdicts(recorded)(at(`repo-1::${gone}`))).resolves.toBe('unverifiable') + const pruned = repoWithWorktreeRecord(null) + await expect(verdicts(pruned)(at(`repo-1::${gone}`))).resolves.toBe('removed') + }) + + it('reads a record git wrote relative to its own folder', async () => { + const repo = repoWithWorktreeRecord(null) + const worktree = join(repo, '..', 'orca-visuals-relative-worktree-b') + writeFileSync( + join(repo, '.git', 'worktrees', 'feature', 'gitdir'), + '../../../../orca-visuals-relative-worktree-b/.git\n' + ) + const verdict = createNativeChatVisualsWorkspaceVerdicts(() => ({ + ...catalogs(), + active: { ...catalogs().active, getRepo: () => ({ path: repo }) } + }))() + await expect(verdict(at(`repo-1::${worktree}`))).resolves.toBe('unverifiable') + }) + + it('resolves a relative record against the real path when the project was added through a link', async () => { + const real = realpathSync(mkdtempSync(join(tmpdir(), 'orca-visuals-real-'))) + scratch.push(real) + const nested = join(real, 'a', 'b', 'c') + mkdirSync(join(nested, 'repo', '.git', 'worktrees', 'wt'), { recursive: true }) + const linkedParent = join(real, 'deep') + symlinkSync(nested, linkedParent) + const worktree = join(real, 'wts', 'wt') + // What git writes: relative from the record folder's real path. + writeFileSync( + join(nested, 'repo', '.git', 'worktrees', 'wt', 'gitdir'), + '../../../../../../../wts/wt/.git\n' + ) + const verdict = createNativeChatVisualsWorkspaceVerdicts(() => ({ + ...catalogs(), + active: { ...catalogs().active, getRepo: () => ({ path: join(linkedParent, 'repo') }) } + }))() + await expect(verdict(at(`repo-1::${worktree}`))).resolves.toBe('unverifiable') + }) +}) + +describe('reading the catalogs for a sweep run', () => { + it('skips the running profile named by its own storage folder', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-visuals-profiles-')) + try { + mkdirSync(join(root, 'profiles', 'running'), { recursive: true }) + mkdirSync(join(root, 'profiles', 'next'), { recursive: true }) + const profile = (id: string) => ({ + id, + name: id, + kind: 'local', + createdAt: 0, + updatedAt: 0, + lastOpenedAt: 0, + avatar: { kind: 'initials', initials: id.slice(0, 2), color: 'neutral' } + }) + // A switch already named the next profile active; this process still runs the old one. + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: 'next', profiles: [profile('running'), profile('next')] }) + ) + for (const id of ['running', 'next']) { + writeFileSync( + join(root, 'profiles', id, 'orca-data.json'), + JSON.stringify({ repos: [{ id: `repo-${id}` }] }) + ) + } + const read = readNativeChatVisualsWorkspaceCatalogs( + { + ...catalogs().active, + getProfileStorageDirectory: () => join(root, 'profiles', 'running') + }, + root + ) + expect([...read.others.repoIds]).toEqual(['repo-next']) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/runtime/native-chat-visuals-workspace-verdict.ts b/src/main/runtime/native-chat-visuals-workspace-verdict.ts new file mode 100644 index 000000000000..838283805e88 --- /dev/null +++ b/src/main/runtime/native-chat-visuals-workspace-verdict.ts @@ -0,0 +1,204 @@ +// Whether the local workspace a chat ran in is provably gone, for the visuals folder sweep. +// Chat records and visuals live at the user-data root, shared by every Orca profile, while each +// profile keeps its own workspace catalog: so only absence from EVERY profile's catalog counts. A +// worktree still in a known project is gone only when git itself no longer records it. Never runs +// git or lists a repo's worktrees: that walk can touch protected folders. + +import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join, resolve } from 'node:path' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { isFloatingWorkspaceId } from '../../shared/floating-workspace-worktree' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope' +import type { Store } from '../persistence' +import { readOtherProfileWorkspaceCatalog } from '../orca-profiles/other-profile-workspace-catalog' +import { + getOrcaProfilesDirectory, + getProfileUserDataPath +} from '../orca-profiles/profile-storage-paths' +import type { NativeChatVisualsWorkspaceVerdict } from '../native-chat/native-chat-visuals-sweep' + +/** This profile's live catalog and every other profile's persisted one. The running profile is + * named by its own storage folder, not the profile index, which a switch rewrites first. */ +export function readNativeChatVisualsWorkspaceCatalogs( + store: NativeChatVisualsWorkspaceCatalogs['active'] & Pick, + userDataPath = getProfileUserDataPath() +): NativeChatVisualsWorkspaceCatalogs { + const storage = store.getProfileStorageDirectory() + const runningProfileId = samePath(dirname(storage), getOrcaProfilesDirectory(userDataPath)) + ? basename(storage) + : undefined + return { + active: store, + others: readOtherProfileWorkspaceCatalog(userDataPath, { + ...(runningProfileId ? { runningProfileId } : {}), + // Chat records are never deleted, so this rule is what cleans up; a profile created but never + // opened must not switch it off. + neverWrittenIsEmpty: true + }) + } +} + +export type NativeChatVisualsWorkspaceCatalogs = { + /** The running profile's live catalog: the Store, narrowed to what a verdict reads. */ + active: { + getRepo: (id: string) => { path: string } | undefined + getAllWorktreeMeta: () => Readonly> + getFolderWorkspaces: () => readonly { id: string }[] + } + /** Every other profile's worktree ids, folder workspace keys and project ids. */ + others: { ids: ReadonlySet; repoIds: ReadonlySet; unreadableProfiles: number } +} + +export type NativeChatVisualsFilesystem = { + /** `present`, `absent` (definitively), or `unknown` for any other answer. */ + presence: (path: string) => Promise<'present' | 'absent' | 'unknown'> + /** Whether git in the repo at `repoPath` records a linked worktree at `worktreePath`; null when + * its records can't be read. */ + gitRecordsWorktree: (repoPath: string, worktreePath: string) => Promise +} + +async function presence(path: string): Promise<'present' | 'absent' | 'unknown'> { + try { + await stat(path) + return 'present' + } catch (error) { + return isDefinitiveAbsence(error) ? 'absent' : 'unknown' + } +} + +const samePath = (left: string, right: string): boolean => + normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) + +async function gitRecordsWorktree(repoPath: string, worktreePath: string): Promise { + const gitDir = join(repoPath, '.git') + try { + // A `.git` file (the repo is itself a linked worktree or a submodule) is not read here. + if (!(await lstat(gitDir)).isDirectory()) { + return null + } + let names: string[] + try { + names = await readdir(join(gitDir, 'worktrees')) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return false + } + throw error + } + for (const name of names) { + let recorded: string + try { + recorded = await readFile(join(gitDir, 'worktrees', name, 'gitdir'), 'utf8') + } catch (error) { + if (isDefinitiveAbsence(error)) { + continue + } + throw error + } + // The record names the worktree's own `.git` file; git 2.48+ may write it relative to the + // record folder's real path, which differs from the stored one when that runs through a link. + const gitFile = recorded.trim() + const absolute = isAbsolute(gitFile) + ? gitFile + : resolve(await realpath(join(gitDir, 'worktrees', name)), gitFile) + if (samePath(dirname(absolute), worktreePath)) { + return true + } + } + return false + } catch { + return null + } +} + +const NODE_FILESYSTEM: NativeChatVisualsFilesystem = { presence, gitRecordsWorktree } + +async function worktreeVerdict( + catalogs: NativeChatVisualsWorkspaceCatalogs, + workspaceId: string, + fs: NativeChatVisualsFilesystem +): Promise { + const worktree = splitWorktreeIdForFilesystem(workspaceId) + if (!worktree?.repoId || !worktree.worktreePath) { + return 'unverifiable' + } + const repo = catalogs.active.getRepo(worktree.repoId) + // The project was removed from Orca in every profile. + if (!repo && !catalogs.others.repoIds.has(worktree.repoId)) { + return 'removed' + } + if ( + Object.hasOwn(catalogs.active.getAllWorktreeMeta(), workspaceId) || + catalogs.others.ids.has(workspaceId) + ) { + return 'present' + } + // The project's own checkout, or one only another profile can say anything about. + if (!repo || samePath(repo.path, worktree.worktreePath)) { + return 'unverifiable' + } + const own = await fs.presence(worktree.worktreePath) + if (own !== 'absent') { + return own === 'present' ? 'present' : 'unverifiable' + } + // A worktree on an unmounted drive is still recorded by git, so absence alone proves nothing. + if ((await fs.presence(repo.path)) !== 'present') { + return 'unverifiable' + } + return (await fs.gitRecordsWorktree(repo.path, worktree.worktreePath)) === false + ? 'removed' + : 'unverifiable' +} + +/** + * A verdict function over one snapshot of every profile's catalog; call once per sweep run. + * Null catalogs, or any profile whose state can't be read, decide nothing. + */ +export function createNativeChatVisualsWorkspaceVerdicts( + readCatalogs: () => NativeChatVisualsWorkspaceCatalogs | null, + fs: NativeChatVisualsFilesystem = NODE_FILESYSTEM +): () => (location: AgentSessionExecutionLocation) => Promise { + return () => { + let snapshot: NativeChatVisualsWorkspaceCatalogs | null | undefined + const catalogs = (): NativeChatVisualsWorkspaceCatalogs | null => { + if (snapshot === undefined) { + try { + snapshot = readCatalogs() + } catch { + snapshot = null + } + } + return snapshot && snapshot.others.unreadableProfiles === 0 ? snapshot : null + } + return async (location) => { + // Another host or a WSL distro owns its own answer; loss of contact is never removal. + if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro !== null) { + return 'unverifiable' + } + // The floating workspace names a setting, not a place the user removes. + if (isFloatingWorkspaceId(location.workspaceId)) { + return 'unverifiable' + } + const known = catalogs() + if (!known) { + return 'unverifiable' + } + if (location.workspaceKind !== 'folder') { + return worktreeVerdict(known, location.workspaceId, fs) + } + if (parseWorkspaceKey(location.workspaceId)?.type !== 'folder') { + return 'unverifiable' + } + const listed = + known.others.ids.has(location.workspaceId) || + known.active + .getFolderWorkspaces() + .some((workspace) => folderWorkspaceKey(workspace.id) === location.workspaceId) + return listed ? 'present' : 'removed' + } + } +} diff --git a/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts b/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts index 23ff4c882328..38a6f5ce489d 100644 --- a/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts +++ b/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts @@ -1,9 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import { REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES } from '../../shared/remote-runtime-memory-limits' import { BROWSER_SCREENCAST_GHOST_SUBSCRIBER_REFUSAL_LIMIT } from './browser-screencast-ghost-subscriber-eviction' -import type { RuntimeBrowserCommandHost, RuntimeBrowserCommands } from './orca-runtime-browser' -import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry' +import type { RuntimeBrowserCommands } from './orca-runtime-browser' +import { createSinglePageBrowserCommandsHost } from './single-page-browser-commands-host-test-double' const { webContentsFromId, startBrowserScreencast } = vi.hoisted(() => ({ webContentsFromId: vi.fn(), @@ -24,32 +23,6 @@ function deferred() { return { promise, resolve } } -function createCommandsHost(): RuntimeBrowserCommandHost { - const runtimeBrowserPages = new RuntimeBrowserPageRegistry() - const bridge = { - getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]])), - getActivePageId: vi.fn(() => 'page-1'), - tabList: vi.fn(() => ({ - tabs: [ - { - browserPageId: 'page-1', - index: 0, - url: 'about:blank', - title: 'Browser', - active: true - } - ] - })) - } as unknown as AgentBrowserBridge - return { - resolveWorktreeSelector: async () => ({ id: 'wt-1' }), - getAgentBrowserBridge: () => bridge, - getRuntimeBrowserPageRegistry: () => runtimeBrowserPages, - getAvailableAuthoritativeWindow: vi.fn(() => null), - getOffscreenBrowserBackend: vi.fn(() => null) - } as unknown as RuntimeBrowserCommandHost -} - describe('RuntimeBrowserCommands screencast fanout', () => { beforeEach(() => { webContentsFromId.mockReset() @@ -63,7 +36,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => { const stop = vi.fn(() => done.resolve()) const updateViewport = vi.fn(async () => {}) startBrowserScreencast.mockResolvedValue({ stop, done: done.promise, updateViewport }) - const commands = new RuntimeBrowserCommands(createCommandsHost()) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) const firstSend = vi.fn(() => false) const secondSend = vi.fn(() => true) const first = await commands.browserScreencast( @@ -112,7 +85,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => { const stop = vi.fn(() => done.resolve()) const updateViewport = vi.fn(async () => {}) startBrowserScreencast.mockResolvedValue({ stop, done: done.promise, updateViewport }) - const commands = new RuntimeBrowserCommands(createCommandsHost()) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) const sized = await commands.browserScreencast( { worktree: 'id:wt-1', @@ -160,7 +133,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => { updateFrameBudget: vi.fn(async () => {}) } }) - const commands = new RuntimeBrowserCommands(createCommandsHost()) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) const creatorSend = vi.fn(() => true) await commands.browserScreencast( { @@ -210,7 +183,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => { updateViewport: vi.fn(async () => {}), updateFrameBudget }) - const commands = new RuntimeBrowserCommands(createCommandsHost()) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) const desktop = await commands.browserScreencast( { worktree: 'id:wt-1', @@ -271,7 +244,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => { updateViewport: vi.fn(async () => {}) }) const sendBinary = vi.fn(() => true) - const commands = new RuntimeBrowserCommands(createCommandsHost()) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) const started = await commands.browserScreencast( { worktree: 'id:wt-1', page: 'page-1', format: 'jpeg' }, { sendBinary } @@ -316,7 +289,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => { const stop = vi.fn(() => done.resolve()) const updateViewport = vi.fn(async () => {}) startBrowserScreencast.mockResolvedValue({ stop, done: done.promise, updateViewport }) - const commands = new RuntimeBrowserCommands(createCommandsHost()) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) const survivorSend = vi.fn(() => true) const survivor: Subscription = await commands.browserScreencast( { @@ -374,7 +347,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => { done: done.promise, updateViewport: vi.fn(async () => {}) }) - const commands = new RuntimeBrowserCommands(createCommandsHost()) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) const only: Subscription = await commands.browserScreencast( { worktree: 'id:wt-1', page: 'page-1', format: 'jpeg' }, { sendBinary: sendUntilQuit(1) } @@ -394,7 +367,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => { done: done.promise, updateViewport: vi.fn(async () => {}) }) - const commands = new RuntimeBrowserCommands(createCommandsHost()) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) let sends = 0 // Why: this is the backpressure shape — a link that drains one frame per window must never // be mistaken for a socket that is gone. @@ -422,7 +395,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => { done: done.promise, updateViewport: vi.fn(async () => {}) }) - const commands = new RuntimeBrowserCommands(createCommandsHost()) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) let gateOpen = false const joinerSend = vi.fn(() => gateOpen) const joiner: Subscription = await commands.browserScreencast( @@ -451,7 +424,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => { const stop = vi.fn(() => done.resolve()) const updateViewport = vi.fn(async () => {}) startBrowserScreencast.mockResolvedValue({ stop, done: done.promise, updateViewport }) - const commands = new RuntimeBrowserCommands(createCommandsHost()) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) const ghostSend = vi.fn(() => true) const ghost: Subscription = await commands.browserScreencast( { @@ -504,7 +477,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => { done: done.promise, updateViewport: vi.fn(async () => {}) }) - const commands = new RuntimeBrowserCommands(createCommandsHost()) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) const anonymousFirst = vi.fn(() => true) const anonymousSecond = vi.fn(() => true) const identified = vi.fn(() => true) diff --git a/src/main/runtime/orca-runtime-default-tab-agent-status.test.ts b/src/main/runtime/orca-runtime-default-tab-agent-status.test.ts new file mode 100644 index 000000000000..6af614603557 --- /dev/null +++ b/src/main/runtime/orca-runtime-default-tab-agent-status.test.ts @@ -0,0 +1,240 @@ +// A host-side create dresses its agent's tab as the first orca.yaml default tab. That title +// belongs to the tab, as the window's setTabCustomTitle makes it: on a headless host the +// phone must still read the agent's own titles for its status. +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { createMinimalPersistedTerminalTab } from '../persistence/restoring-sessions/session-owner-fields' +import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection' +import type { RuntimeNotifier } from './runtime-notifier-contract' +import type { RuntimeStore } from './runtime-store-contract' +import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' +import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisioning' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const WORKTREE_ID = 'wt-1' +const AGENT_TAB_ID = 'agent-tab' +const AGENT_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const AGENT_PTY_ID = 'pty-agent' + +class DefaultTabRuntime extends OrcaRuntimeService { + constructor( + store: RuntimeStore | null, + private readonly connectionId: string | null + ) { + super(store) + } + + protected override async resolveTerminalWorkspaceLaunchScope(): Promise { + return { + id: WORKTREE_ID, + path: '/repo/app', + connectionId: this.connectionId, + repo: null, + folderWorkspace: null + } + } + + provisioningHost() { + return this.getWorktreeTerminalProvisioningHost() + } + + ptyTitle(ptyId: string): string | null | undefined { + return this.ptysById.get(ptyId)?.title + } +} + +function sessionStore(initial: WorkspaceSessionState): { + store: RuntimeStore + getSession: () => WorkspaceSessionState +} { + let session = initial + const store: RuntimeStore = { + getRepos: () => [], + getRepo: () => undefined, + addRepo: vi.fn(), + updateRepo: vi.fn(), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getGitHubCache: vi.fn(), + getWorkspaceSession: () => session, + setWorkspaceSession: (next) => { + session = next + }, + getSettings: () => ({ + workspaceDir: '/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) + } + return { store, getSession: () => session } +} + +function notifierWithRename(renameTerminal: RuntimeNotifier['renameTerminal']): RuntimeNotifier { + return { + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal, + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + } +} + +async function createHeadlessRuntimeWithAgent( + connectionId: string | null = null, + store: RuntimeStore | null = null +): Promise<{ runtime: DefaultTabRuntime; handle: string }> { + const runtime = new DefaultTabRuntime(store, connectionId) + let spawned = 0 + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: spawned++ === 0 ? AGENT_PTY_ID : `pty-${spawned}` })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const agent = await runtime.createTerminal(`id:${WORKTREE_ID}`, { + tabId: AGENT_TAB_ID, + leafId: AGENT_LEAF_ID, + launchAgent: 'claude' + }) + return { runtime, handle: agent.handle } +} + +async function dressAgentTab(runtime: DefaultTabRuntime, handle: string): Promise { + // Only the handle crosses: the host finds the tab, as it must for a create over SSH too. + await provisionWorktreeTerminals(runtime.provisioningHost(), { + worktreeSelector: `id:${WORKTREE_ID}`, + worktreeId: WORKTREE_ID, + worktreePath: '/repo/app', + defaultTabs: { + runCommands: true, + tabs: [{ title: 'Dev', command: 'pnpm dev', color: '#ff0000' }] + }, + primaryTerminalHandle: handle, + hasStartupTerminal: true, + setupCommandPlatform: 'posix' + }) +} + +async function agentTab(runtime: OrcaRuntimeService) { + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs.find( + (candidate) => candidate.type === 'terminal' && candidate.parentTabId === AGENT_TAB_ID + ) + if (tab?.type !== 'terminal') { + throw new Error('expected the agent terminal tab') + } + return tab +} + +describe('the agent tab a host-side create dresses as the first default tab', () => { + it.each([ + ['a local', null], + ['an SSH', 'ssh-1'] + ])( + 'gives %s agent tab the template title and color before the agent titles itself', + async (_, connectionId) => { + const { runtime, handle } = await createHeadlessRuntimeWithAgent(connectionId) + await dressAgentTab(runtime, handle) + + expect(await agentTab(runtime)).toEqual( + expect.objectContaining({ title: 'Dev', color: '#ff0000' }) + ) + } + ) + + it("keeps the phone's status on the agent's own titles", async () => { + const { runtime, handle } = await createHeadlessRuntimeWithAgent() + await dressAgentTab(runtime, handle) + + runtime.onPtyData(AGENT_PTY_ID, '\x1b]0;⠋ Claude\x07', Date.now()) + + expect((await agentTab(runtime)).agentStatus).toEqual( + expect.objectContaining({ state: 'working' }) + ) + }) + + it('titles the tab, never the pane, and keeps the title across a restart', async () => { + // The spawn's pty binding has already saved the agent's tab when provisioning runs. + const { store, getSession } = sessionStore({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + createMinimalPersistedTerminalTab({ + worktreeId: WORKTREE_ID, + tabId: AGENT_TAB_ID, + ptyId: AGENT_PTY_ID, + existingTabCount: 0 + }) + ] + } + }) + const { runtime, handle } = await createHeadlessRuntimeWithAgent(null, store) + const renameTerminal = vi.fn() + runtime.setNotifier(notifierWithRename(renameTerminal)) + + await dressAgentTab(runtime, handle) + + expect(renameTerminal).toHaveBeenCalledWith(AGENT_TAB_ID, 'Dev', { recordInteraction: false }) + expect(runtime.ptyTitle(AGENT_PTY_ID)).toBeNull() + // A restarted headless host rebuilds the phone's tabs from the saved session. + const session = getSession() + expect( + buildHeadlessMobileSessionTerminalTabs( + WORKTREE_ID, + session.tabsByWorktree[WORKTREE_ID] ?? [], + session + ) + ).toEqual([ + expect.objectContaining({ parentTabId: AGENT_TAB_ID, title: 'Dev', color: '#ff0000' }) + ]) + }) + + it("still titles the tab once the window's graph has taken over the handle", async () => { + const { runtime, handle } = await createHeadlessRuntimeWithAgent() + const renameTerminal = vi.fn() + runtime.setNotifier(notifierWithRename(renameTerminal)) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: AGENT_TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: AGENT_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: AGENT_TAB_ID, + worktreeId: WORKTREE_ID, + leafId: AGENT_LEAF_ID, + paneRuntimeId: 1, + ptyId: AGENT_PTY_ID + } + ] + }) + + await runtime.provisioningHost().setTabTitle(handle, 'Dev') + + expect(renameTerminal).toHaveBeenCalledWith(AGENT_TAB_ID, 'Dev', { recordInteraction: false }) + }) +}) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 0fcdaae95323..c7e3c1d79a12 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -2,7 +2,6 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { OrcaRuntimeWithGetWorktreePs } from './orca-runtime-get-worktree-ps' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { resolveStructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support' import { resolveCommittedStructuredAgentSessionAdoptionIntent, resolveStructuredAgentSessionAdoptionForCreate @@ -11,6 +10,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { resolveHostStructuredAgentCreateSupport } from './structured-agent-launch-support' import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' @@ -23,7 +23,7 @@ import { } from '../../shared/floating-workspace-worktree' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { - agentSessionAccountHome, + isLegacyAgentSessionAccountHome, type AgentSessionAccountHome } from '../../shared/agent-session-account-home' import { @@ -37,24 +37,17 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR worktreeSelector: string, agent: StructuredAgentId ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { - const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) - return resolveStructuredAgentSessionCreateSupport({ + return resolveHostStructuredAgentCreateSupport({ agent, - location, - adapterSupportsCreate: await this.structuredAgentSupportsLocation(agent, location), - getSettings: () => this.requireStore().getSettings() + worktreeSelector, + location: await this.resolveStructuredAgentSessionLocation(worktreeSelector), + runtime: this }) } - /** The agent's own location rule, from its registration: answered without installing the host, - * and false for an agent this runtime does not register. */ - protected async structuredAgentSupportsLocation(agent: StructuredAgentId, location) { - return structuredAgentRuntimeRegistration(agent)?.supportsLocation(location) ?? false - } - /** Where a launch of `agent` finds its account, resolved on this host by the agent's own * registration; null for an agent this runtime does not register, whose create is refused. */ - protected structuredAgentAccountHomePathResolver( + protected structuredAgentAccountHomeResolver( agent: StructuredAgentId, worktree: string, purpose: 'launch' | 'read', @@ -71,7 +64,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR workspaceTrustSettings: () => this.requireStore().getSettings() } return async ({ launchEnv, location }) => - registration.resolveAccountHomePath( + registration.resolveAccountHome( { launchEnv, location: location ?? null, @@ -160,18 +153,18 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR const hostLaunchDirectory = isFloatingWorkspaceSelector(input.worktree) ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path : undefined - const resolveAccountHomePath = this.structuredAgentAccountHomePathResolver( + const resolveAccountHome = this.structuredAgentAccountHomeResolver( input.agent, input.worktree, 'launch', hostLaunchDirectory ) - if (!resolveAccountHomePath) { + if (!resolveAccountHome) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) } - const resolved = await this.resolveStructuredAgentSessionIntent(input, resolveAccountHomePath) + const resolved = await this.resolveStructuredAgentSessionIntent(input, resolveAccountHome) return hostLaunchDirectory ? { ...resolved, hostLaunchDirectory } : resolved } @@ -183,18 +176,17 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR async resolveStructuredAgentAccountHome( agent: StructuredAgentId ): Promise { - const resolvePath = this.structuredAgentAccountHomePathResolver(agent, '', 'read') - if (!resolvePath) { + const resolveAccountHome = this.structuredAgentAccountHomeResolver(agent, '', 'read') + if (!resolveAccountHome) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) } - const definition = this.requireRegisteredStructuredAgent(agent) const launchEnv = resolveTuiAgentLaunchEnv( agent, this.requireStore().getSettings().agentDefaultEnv ) - return agentSessionAccountHome(definition, await resolvePath({ launchEnv, location: null })) + return resolveAccountHome({ launchEnv, location: null }) } protected async resolveStructuredAgentSessionIntent( @@ -205,7 +197,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR callerKey?: string resumeFrom?: { providerSessionId: string } }, - resolveAccountHomePath: (context: { + resolveAccountHome: (context: { launchEnv: NodeJS.ProcessEnv location: { executionHostId: string @@ -213,7 +205,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR workspaceId: string workspaceKind: 'folder' | 'git-worktree' } - }) => string | Promise + }) => AgentSessionAccountHome | Promise ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) // Adopting a conversation reads the agent's own transcript, which only Claude and Codex have @@ -238,21 +230,27 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR if (committedReplay) { return committedReplay } - const selectedAccountHomePath = await resolveAccountHomePath({ launchEnv, location }) + const selectedAccountHome = await resolveAccountHome({ launchEnv, location }) + if (input.resumeFrom && !isLegacyAgentSessionAccountHome(selectedAccountHome)) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } // Adopting pins the account home to wherever the conversation actually lives, which is not // necessarily the one a fresh create would pick: Codex resolves its rollout under // `accountHome.path`, and Claude reads its transcript under `/projects`. Resuming under // the wrong home finds nothing and lands the user in a blank chat wearing the old chat's name. - const adoption = input.resumeFrom - ? await resolveStructuredAgentSessionAdoptionForCreate({ - host, - settings, - agent: input.agent, - providerSessionId: input.resumeFrom.providerSessionId, - selfSessionId: input.envelope.sessionId, - selectedAccountHomePath - }) - : null + const adoption = + input.resumeFrom && isLegacyAgentSessionAccountHome(selectedAccountHome) + ? await resolveStructuredAgentSessionAdoptionForCreate({ + host, + settings, + agent: input.agent, + providerSessionId: input.resumeFrom.providerSessionId, + selfSessionId: input.envelope.sessionId, + selectedAccountHomePath: selectedAccountHome.path + }) + : null return { envelope: { sessionId: input.envelope.sessionId, @@ -263,10 +261,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR location, provider: input.agent, agent: input.agent, - accountHome: agentSessionAccountHome( - definition, - adoption ? adoption.accountHomePath : selectedAccountHomePath - ), + accountHome: + adoption && isLegacyAgentSessionAccountHome(selectedAccountHome) + ? { variable: selectedAccountHome.variable, path: adoption.accountHomePath } + : selectedAccountHome, ...(options ? { options } : {}), ...(input.resumeFrom && adoption ? { diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 174fcae70857..8ac017498e68 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -44,7 +44,11 @@ import { codexStructuredPermissionPolicyForSettings } from '../codex/codex-struc import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' import { structuredAgentConfiguredArgs } from '../native-chat/structured-agent-configured-args' -import { claudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support' +import { claudeCliFlagSupport } from '../claude/claude-cli-flag-support' +import { + createNativeChatVisualsWorkspaceVerdicts, + readNativeChatVisualsWorkspaceCatalogs +} from './native-chat-visuals-workspace-verdict' export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVisibleReadProbe { async getWorktreePs( @@ -223,7 +227,14 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis resolveClaudeLaunchEnv: () => resolveTuiAgentLaunchEnv('claude', this.requireStore().getSettings().agentDefaultEnv), // Wired only here, so a test runtime never runs a real `claude --version`. - claudeThinkingDisplay: claudeThinkingDisplaySupport, + claudeCliFlags: claudeCliFlagSupport, + nativeChatVisuals: { + isEnabled: () => this.requireStore().getSettings().nativeChatInlineVisuals !== false, + // Chats and their visuals are shared by every profile; each profile keeps its own catalog. + workspaceVerdicts: createNativeChatVisualsWorkspaceVerdicts(() => + this.store ? readNativeChatVisualsWorkspaceCatalogs(this.store) : null + ) + }, resolveShellEnvironmentPolicy: () => nativeChatShellEnvironmentPolicy(this.requireStore().getSettings()), resolveClaudeAuthPolicy: () => @@ -245,8 +256,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, this.requireStore().getSettings().agentDefaultEnv) : {}, - // Same gate and same settings as agentSession.createSupport, re-read on every acquisition. - getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(), + resolveAgentCommandSettings: () => this.requireStore().getSettings(), resolveAgentAccountHome: (agent) => this.resolveStructuredAgentAccountHome(agent), // Structured chat has no agent CLI hooks, so this projection is what the first-work // workspace rename listens to instead of `agentStatus:set`. diff --git a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts index 3b465f643b36..0add054a67c5 100644 --- a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts +++ b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts @@ -27,7 +27,18 @@ export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRunt createTerminal: (selector, options) => this.createTerminal(selector, options, createdWorktree), splitTerminal: (handle, options) => this.splitTerminal(handle, options, createdWorktree), - setTabColor: async (worktreeId, tabId, color) => { + setTabTitle: async (handle, title) => { + const { worktreeId, tabId } = this.getProvisionedTerminalTab(handle) + // Why not renameTerminal: it writes the pane's title, stamped to outrank every later + // agent title, which would hide the agent's own titles and status for the pane's life. + this.notifier?.renameTerminal(tabId, title, { recordInteraction: false }) + if (!this.getAvailableAuthoritativeWindow()) { + this.persistHeadlessTerminalTitle(worktreeId, tabId, title) + this.applyHeadlessSessionTabPropsToSnapshot(worktreeId, tabId, { title }) + } + }, + setTabColor: async (handle, color) => { + const { worktreeId, tabId } = this.getProvisionedTerminalTab(handle) await this.setMobileSessionTabProps(`id:${worktreeId}`, { tabId, color }) }, getSettings: () => this.requireStore().getSettings(), @@ -37,6 +48,17 @@ export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRunt } } + protected getProvisionedTerminalTab(handle: string): { worktreeId: string; tabId: string } { + const pty = this.getLivePtyForHandle(handle)?.pty + if (pty?.tabId) { + return { worktreeId: pty.worktreeId, tabId: pty.tabId } + } + // Why: once the window's graph sync adopts the handle it names the window's leaf, not the + // runtime's pty, as renameTerminal also resolves it. + const { leaf } = this.getLiveLeafForHandle(handle) + return { worktreeId: leaf.worktreeId, tabId: leaf.tabId } + } + protected getWorktreeStartupReadinessHost(): WorktreeStartupReadinessHost { return { getPtyId: (handle) => this.getLivePtyForHandle(handle)?.pty.ptyId ?? null, diff --git a/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts b/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts index 49fdc91573a5..4c25c12bfdb4 100644 --- a/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts +++ b/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts @@ -15,7 +15,8 @@ import { collectHeadlessParentTabOrder, distributeHeadlessTabsAcrossGroups, getHeadlessMobileSessionGroupId, - pickHeadlessActiveTerminalTab + pickHeadlessActiveTerminalTab, + pickRestoredActiveGroupId } from './mobile-session-layout-projection' import { mergeMobileSessionSnapshotTabs, @@ -236,7 +237,13 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession ? this.getMergedMobileSessionPublicationEpoch(existing, tabs) : `headless-hydrated:${Date.now().toString(36)}`, snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, - activeGroupId: existing?.activeGroupId ?? groupId, + activeGroupId: hasPersistedSplit + ? pickRestoredActiveGroupId( + nextTabGroups, + [existing?.activeGroupId, session.activeGroupIdByWorktree?.[entryWorktreeId]], + activeTopLevelId + ) + : (existing?.activeGroupId ?? groupId), activeTabId: mergedActiveTab?.id ?? null, activeTabType: mergedActiveTab?.type ?? null, tabGroups: nextTabGroups, diff --git a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts index 7c21c0256545..78c31b578346 100644 --- a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts +++ b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts @@ -67,7 +67,12 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi protected applyHeadlessSessionTabPropsToSnapshot( worktreeId: string, tabId: string, - props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' } + props: { + title?: string + color?: string | null + isPinned?: boolean + viewMode?: 'terminal' | 'chat' + } ): void { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { @@ -81,6 +86,7 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi changed = true return { ...tab, + ...(props.title !== undefined ? { title: props.title } : {}), ...(props.color !== undefined ? { color: props.color } : {}), ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}), ...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {}) diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 21f0d73cf7a4..daf6037db7af 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -100,6 +100,11 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin return this.getLivePtyForHandle(handle)?.pty.ptyId ?? null } + /** Every provider-session row this host holds, resume-identity-only rows included. */ + getAgentProviderSessionRows(): AgentStatusIpcPayload[] { + return this.getAgentProviderSessionSnapshotFn?.() ?? [] + } + /** Agent status rows this host holds for a pane, from hooks, OSC and titles alike. */ getAgentStatusRowsForPane(paneKey: string): AgentStatusIpcPayload[] { return this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? [] diff --git a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts index 6ce5f8a36502..146f87e095a1 100644 --- a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts @@ -127,8 +127,8 @@ export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends Or * binds one leaf in two tabs and orphans the PTY under the new one) and refuses the correct ones. * Same resolution `restoreReattachedPtyRuntime` already does for its own reattach fence. * - * Both workspace partitions are read because SSH spawns bind panes into `ssh:` while - * reattach binds into `local`; consulting one would report "nowhere" for a pane the other holds. + * Both workspace partitions are read because older builds' relay reattach left SSH panes in + * `local`; consulting one would report "nowhere" for a pane the other holds. */ protected findCurrentTerminalTabIdForLeaf(targetId: string, leafId: string): string | undefined { for (const leaf of this.leaves.values()) { diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index f66240f57390..ed4aa8981b48 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -24,7 +24,7 @@ import { terminalShellOverrideRefusal } from './terminal-shell-override-host-sup import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../shared/execution-host' -import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' +import { invalidateAuthorizedRootsCacheForRepo } from '../ipc/filesystem-auth' import { resumeInterruptedWorktreeRemovals, retryFailedWorktreeRemoval, @@ -124,7 +124,7 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith this.removeWorktreeMetadataAndHistory(store, worktreeId, removalHostId) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(repoId) - invalidateAuthorizedRootsCache() + invalidateAuthorizedRootsCacheForRepo(store, repoId) } protected removeWorktreeMetadataAndHistory( diff --git a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts index 6e313e111af6..00f602c9bea9 100644 --- a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts @@ -63,14 +63,17 @@ describe('the structured agent registration list', () => { it("resolves an account home through the agent's registration, with the read purpose", async () => { const claude = structuredAgentRuntimeRegistration('claude')! - vi.spyOn(claude, 'resolveAccountHomePath').mockResolvedValue('/accounts/claude') + vi.spyOn(claude, 'resolveAccountHome').mockResolvedValue({ + variable: 'CLAUDE_CONFIG_DIR', + path: '/accounts/claude' + }) const { runtime, installHost } = runtimeAt() expect(await runtime.resolveStructuredAgentAccountHome('claude')).toEqual({ variable: 'CLAUDE_CONFIG_DIR', path: '/accounts/claude' }) - expect(claude.resolveAccountHomePath).toHaveBeenCalledWith( + expect(claude.resolveAccountHome).toHaveBeenCalledWith( expect.objectContaining({ purpose: 'read', location: null, workspacePath: null }), expect.anything() ) diff --git a/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts b/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts deleted file mode 100644 index 64b451e9ea90..000000000000 --- a/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' -import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { - return { - id, - email: `${id}@example.com`, - managedAuthPath: `/managed/${id}`, - managedAuthRuntime, - authMethod: 'subscription-oauth' as const, - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0 - } -} - -const WSL_ONLY: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } -} - -/** Registered Claude accounts with none selected: ambient auth, and the UI names no host identity, - * so this must reach structured rather than silently falling back to a terminal session. */ -const ACCOUNTS_PRESENT_NONE_ACTIVE: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('host-1', 'host'), managedAccount('host-2', 'host')], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } -} - -const HOST_SELECTED: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('host-1', 'host')], - activeClaudeManagedAccountId: 'host-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } -} - -function runtimeWithAccounts(claude: ClaudeManagedAccountGateSettings | null): OrcaRuntimeService { - // No store at all is the unreadable-settings case the gate must fail closed on. - const runtime = claude - ? new OrcaRuntimeService({ getSettings: () => claude } as never) - : new OrcaRuntimeService() - const internal = runtime as unknown as { - resolveStructuredAgentSessionLocation: (selector: string) => Promise - ensureStructuredAgentSessionHost: () => Promise - } - internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' as const - })) - // The adapter's own location answer is irrelevant here; pin it supported so only the account - // gate can refuse. - internal.ensureStructuredAgentSessionHost = vi.fn(async () => {}) - setStructuredAgentSessionHost({ - supportsCreate: () => true - } as unknown as StructuredAgentSessionHost) - return runtime -} - -afterEach(() => { - setStructuredAgentSessionHost(null) -}) - -describe('structured Claude managed-account gate', () => { - it('refuses Claude under a WSL-only managed account', async () => { - const runtime = runtimeWithAccounts(WSL_ONLY) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') - ).resolves.toMatchObject({ supported: false }) - }) - - it('supports Claude when accounts are registered but none is selected', async () => { - const runtime = runtimeWithAccounts(ACCOUNTS_PRESENT_NONE_ACTIVE) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') - ).resolves.toMatchObject({ supported: true }) - }) - - it('still supports Claude under a selected host managed account', async () => { - const runtime = runtimeWithAccounts(HOST_SELECTED) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') - ).resolves.toMatchObject({ supported: true }) - }) - - it('fails closed for Claude when the account runtime cannot be determined', async () => { - const runtime = runtimeWithAccounts(null) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') - ).resolves.toMatchObject({ supported: false }) - }) - - /** The gate is Claude's alone: Codex resolves its account separately and this lane must not - * change any Codex answer. */ - it('leaves Codex supported under the same WSL-only Claude account', async () => { - const runtime = runtimeWithAccounts(WSL_ONLY) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'codex') - ).resolves.toMatchObject({ supported: true }) - }) -}) diff --git a/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts b/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts deleted file mode 100644 index 0d9b12f7c528..000000000000 --- a/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -const installed = vi.hoisted(() => ({ deps: null as Record | null })) - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -vi.mock('./structured-agent-session-runtime', () => ({ - ensureStructuredAgentSessionHost: vi.fn(async (deps: Record) => { - installed.deps = deps - }) -})) - -import { OrcaRuntimeService } from './orca-runtime' -import { - readClaudeManagedAccountGateSettings, - type ClaudeManagedAccountGateSettings -} from '../native-chat/claude-structured-managed-account-support' - -const SETTINGS = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - agentDefaultEnv: {}, - agentDefaultArgs: {} -} as unknown as ClaudeManagedAccountGateSettings - -function gateSettingsGetter(): (() => ClaudeManagedAccountGateSettings) | undefined { - const deps: Record = installed.deps ?? {} - const get = deps['getClaudeManagedAccountGateSettings'] - return typeof get === 'function' ? (get as () => ClaudeManagedAccountGateSettings) : undefined -} - -/** The runtime class this wiring lives on does not typecheck its own `this` calls, so a broken or - * missing gate hookup compiles clean. Pin it behaviourally instead. */ -describe('structured Claude managed-account gate wiring', () => { - it('hands the host a gate reader that resolves the live settings', async () => { - installed.deps = null - const runtime = new OrcaRuntimeService({ getSettings: () => SETTINGS } as never) - - await runtime.ensureStructuredAgentSessionHost() - - const get = gateSettingsGetter() - expect(typeof get).toBe('function') - expect(get?.()).toBe(SETTINGS) - }) - - /** The installer composes this getter with the fail-closed reader, which is the shape the - * resolver consumes; pin that composition end to end. */ - it('composes into a null answer instead of throwing when settings cannot be read', async () => { - installed.deps = null - const runtime = new OrcaRuntimeService() - - await runtime.ensureStructuredAgentSessionHost() - - const get = gateSettingsGetter() - expect(typeof get).toBe('function') - expect(() => get?.()).toThrow() - expect(readClaudeManagedAccountGateSettings(get!)).toBeNull() - }) -}) diff --git a/src/main/runtime/orca-runtime-test-lifecycle.spec.ts b/src/main/runtime/orca-runtime-test-lifecycle.spec.ts index 413035af5094..522a1ca2ac5d 100644 --- a/src/main/runtime/orca-runtime-test-lifecycle.spec.ts +++ b/src/main/runtime/orca-runtime-test-lifecycle.spec.ts @@ -1,6 +1,7 @@ import * as mocks from './orca-runtime-test-mocks.spec' import { awaitBackgroundRemovalsInRuntimeTests } from './orca-runtime-background-removal-test-support' import { _resetPendingWorktreeRemovalsForTests } from '../worktree-background-removal' +import { _resetCanonicalRepoKeyCacheForTests } from '../git/canonical-repo-key' const { MOCK_GIT_WORKTREES, RuntimeBrowserCommands, _resetTerminalViewAttributesForTest } = mocks const { addGitHubIssueCommentMock, addGitHubPRReviewCommentMock } = mocks @@ -63,6 +64,7 @@ const { updateGitLabIssueMock, updateGitLabMRMock, updateGitLabMRReviewersMock, awaitBackgroundRemovalsInRuntimeTests(mocks.OrcaRuntimeService.prototype as never) function resetRuntimeTestMocks(): void { + _resetCanonicalRepoKeyCacheForTests() _resetPendingWorktreeRemovalsForTests() // Why: constructing the browser commands is what pulls the Chromium cluster in, so // production installs this at the Electron entry. A Node host installs none and the diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts index 81cc968e1848..612efbb77b82 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts @@ -57,6 +57,28 @@ function makeRestartedServeRuntime(overrides: Partial = { return runtime } +const SPLIT_GROUPS = [ + { + id: 'group-left', + worktreeId: TEST_WORKTREE_ID, + activeTabId: 'serve-tab', + tabOrder: ['serve-tab'] + }, + { + id: 'group-right', + worktreeId: TEST_WORKTREE_ID, + activeTabId: 'daemon-tab', + tabOrder: ['daemon-tab'] + } +] + +// Why: the per-client list projection repairs activeGroupId, so read the host's stored snapshot. +function storedActiveGroupId(runtime: OrcaRuntimeService): string | null | undefined { + const snapshots: Map = + runtime['mobileSessionTabsByWorktree'] + return snapshots.get(TEST_WORKTREE_ID)?.activeGroupId +} + function terminalPtyIds(tabs: readonly { type: string; ptyId?: string | null }[]): string[] { return tabs.flatMap((tab) => (tab.type === 'terminal' && tab.ptyId ? [tab.ptyId] : [])).sort() } @@ -104,22 +126,8 @@ describe('OrcaRuntimeService', () => { it('restores a persisted split group layout on the cold rebuild', async () => { const runtime = makeRestartedServeRuntime({ - tabGroups: { - [TEST_WORKTREE_ID]: [ - { - id: 'group-left', - worktreeId: TEST_WORKTREE_ID, - activeTabId: 'serve-tab', - tabOrder: ['serve-tab'] - }, - { - id: 'group-right', - worktreeId: TEST_WORKTREE_ID, - activeTabId: 'daemon-tab', - tabOrder: ['daemon-tab'] - } - ] - } + activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: 'group-left' }, + tabGroups: { [TEST_WORKTREE_ID]: SPLIT_GROUPS } }) const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) @@ -128,5 +136,44 @@ describe('OrcaRuntimeService', () => { ['serve-tab'], ['daemon-tab'] ]) + expect(storedActiveGroupId(runtime)).toBe('group-left') + }) + + it('restores the saved active group of a split even when it is not the first group', async () => { + const runtime = makeRestartedServeRuntime({ + activeTabId: 'daemon-tab', + activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'daemon-tab' }, + activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: 'group-right' }, + tabGroups: { [TEST_WORKTREE_ID]: SPLIT_GROUPS } + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(storedActiveGroupId(runtime)).toBe('group-right') + expect(listed.activeGroupId).toBe('group-right') + }) + + it('falls back to the active tab group when the saved active group was not restored', async () => { + const runtime = makeRestartedServeRuntime({ + activeTabId: 'daemon-tab', + activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'daemon-tab' }, + activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: 'group-gone' }, + tabGroups: { [TEST_WORKTREE_ID]: SPLIT_GROUPS } + }) + + await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(storedActiveGroupId(runtime)).toBe('group-right') + }) + + it('keeps the headless group active when there is no persisted split', async () => { + const runtime = makeRestartedServeRuntime({ + activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: 'group-gone' } + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabGroups).toHaveLength(1) + expect(storedActiveGroupId(runtime)).toBe(listed.tabGroups?.[0]?.id) }) }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts index 96abf6a97cef..1017a8e99085 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts @@ -350,7 +350,7 @@ describe('OrcaRuntimeService', () => { // an unqualified purge would evict a same-id row owned by another host. expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) - expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() + expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalledWith(runtimeStore, TEST_REPO_ID) expect(notifier.worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) } finally { await rm(parentDir, { recursive: true, force: true }) @@ -376,7 +376,7 @@ describe('OrcaRuntimeService', () => { // an unqualified purge would evict a same-id row owned by another host. expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) - expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() + expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalledWith(runtimeStore, TEST_REPO_ID) expect(notifier.worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) } finally { await rm(parentDir, { recursive: true, force: true }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index d371ac3f96f0..89f516dfac2d 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -489,6 +489,7 @@ describe('OrcaRuntimeService', () => { }) ) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(TEST_WORKTREE_ID) + expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalledWith(store, TEST_REPO_ID) expect(result.warning).toBe( `orca.yaml archive hook skipped for ${TEST_WORKTREE_PATH}; pass --run-hooks to run it.` ) diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index 8c3724365884..f40f25da7e48 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -411,6 +411,92 @@ describe('OrcaRuntimeService', () => { ) }) + it('puts the agent a create starts in the first default tab, as the window does', async () => { + const metaById: Record = {} + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const spawn = vi + .fn<(options: { command?: string }) => Promise<{ id: string }>>() + .mockResolvedValueOnce({ id: 'pty-default-agent' }) + .mockResolvedValueOnce({ id: 'pty-default-test' }) + const revealTerminalSession = vi + .fn<(worktreeId: string, request: { tabId?: string }) => Promise<{ tabId: string }>>() + .mockResolvedValueOnce({ tabId: 'tab-default-agent' }) + .mockResolvedValueOnce({ tabId: 'tab-default-test' }) + const renameTerminal = vi.fn() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal, + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-default-agent') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-default-agent') + vi.mocked(getDefaultTabsLaunch).mockReturnValue({ + runCommands: true, + tabs: [ + { title: 'Dev', command: 'pnpm dev' }, + { title: 'Test', command: 'pnpm test' } + ] + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-default-agent', + head: 'def', + branch: 'runtime-default-agent', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-default-agent', + setupDecision: 'run', + createdWithAgent: 'claude', + startup: { command: 'claude' } + }) + + await vi.waitFor(() => expect(revealTerminalSession).toHaveBeenCalledTimes(2)) + const agentTabId = revealTerminalSession.mock.calls[0]?.[1].tabId + expect(agentTabId).toEqual(expect.any(String)) + expect(renameTerminal).toHaveBeenCalledWith(agentTabId, 'Dev', { recordInteraction: false }) + // The first template's command never runs beside the agent; only the rest are created. + expect(spawn).toHaveBeenCalledTimes(2) + expect(spawn.mock.calls.map(([options]) => options.command)).toEqual([ + expect.stringContaining('claude'), + 'pnpm test' + ]) + expect(revealTerminalSession).toHaveBeenNthCalledWith( + 2, + result.worktree.id, + expect.objectContaining({ title: 'Test', activate: false }) + ) + }) + it('uses desktop task agent selection and bracketed-pastes startup drafts for local worktrees', async () => { vi.useFakeTimers() onTestFinished(() => { diff --git a/src/main/runtime/rpc/methods/accounts.test.ts b/src/main/runtime/rpc/methods/accounts.test.ts index b8473d00e014..c10d70b7ad99 100644 --- a/src/main/runtime/rpc/methods/accounts.test.ts +++ b/src/main/runtime/rpc/methods/accounts.test.ts @@ -15,20 +15,6 @@ function method(name: string) { describe('account RPC methods', () => { it.each([ - { - methodName: 'accounts.addClaudeFromConfigDir', - params: { - configDir: join(tmpdir(), 'claude-login'), - previousLegacyCredentialsSha256: 'a'.repeat(64) - }, - runtimeMethod: 'addClaudeAccountFromConfigDir', - expectedSource: join(tmpdir(), 'claude-login'), - expectedOptions: { - runtime: undefined, - wslDistro: null, - previousLegacyCredentialsSha256: 'a'.repeat(64) - } - }, { methodName: 'accounts.addCodexFromHome', params: { sourceHome: join(tmpdir(), 'codex-login') }, @@ -50,29 +36,37 @@ describe('account RPC methods', () => { }) it.each([ - ['accounts.addClaudeFromConfigDir', { configDir: join(tmpdir(), 'claude-login') }], - ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }], + ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }, /only available/], [ 'accounts.addDataFromHome', - { provider: 'opencode', sourceDataHome: join(tmpdir(), 'login'), label: 'Work' } - ] - ])('rejects paired-device calls to %s', async (methodName, params) => { + { provider: 'opencode', sourceDataHome: join(tmpdir(), 'login'), label: 'Work' }, + /only available/ + ], + ['accounts.beginClaudeSignIn', {}, /Sign in on the Orca execution host/], + ['accounts.finishClaudeSignIn', { accountId: 'a' }, /Sign in on the Orca execution host/] + ] as const)('rejects paired-device calls to %s', async (methodName, params, refusal) => { const runtime = { - addClaudeAccountFromConfigDir: vi.fn(), - addCodexAccountFromHome: vi.fn() - } as unknown as OrcaRuntimeService + addCodexAccountFromHome: vi.fn(), + addDataAccountFromHome: vi.fn(), + beginClaudeSignIn: vi.fn(), + finishClaudeSignIn: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: refused calls never reach the runtime; only these stubs are asserted. + const runtimeService = runtime as unknown as OrcaRuntimeService const addMethod = method(methodName) if (isStreamingMethod(addMethod)) { throw new Error(`${methodName} must be a request method`) } for (const clientKind of ['mobile', 'runtime'] as const) { - await expect(addMethod.handler(params, { runtime, clientKind })).rejects.toThrow( - /only available on the Orca host runtime/ - ) + await expect( + addMethod.handler(params, { runtime: runtimeService, clientKind }) + ).rejects.toThrow(refusal) } - expect(runtime.addClaudeAccountFromConfigDir).not.toHaveBeenCalled() expect(runtime.addCodexAccountFromHome).not.toHaveBeenCalled() + expect(runtime.addDataAccountFromHome).not.toHaveBeenCalled() + expect(runtime.beginClaudeSignIn).not.toHaveBeenCalled() + expect(runtime.finishClaudeSignIn).not.toHaveBeenCalled() }) it('keeps explicit account-list refreshes on the forced refresh lane', async () => { @@ -227,3 +221,14 @@ describe('account RPC methods', () => { await running }) }) + +it('rejects the retired Claude import even for a local socket, before touching its supplied path', async () => { + const old = method('accounts.addClaudeFromConfigDir') + if (isStreamingMethod(old)) { + throw new Error('Unexpected stream') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Rejection happens before the handler accesses its context. + await expect(old.handler({ configDir: '/never-read' }, {} as never)).rejects.toThrow( + 'Update the Orca CLI to add accounts' + ) +}) diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index 9186e378da24..786bc73985df 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -4,6 +4,8 @@ import { SelectDataAccountParams, RemoveDataAccountParams, AccountsUnsubscribeParams, + BeginClaudeSignInParams, + FinishClaudeSignInParams, AddClaudeFromConfigDirParams, AddCodexFromHomeParams, ConsumeCodexResetCreditParams, @@ -19,15 +21,39 @@ import { // registerSubscriptionCleanup's existing-key eviction path. let accountsSubscriptionSeq = 0 -// Why: bridges the desktop ClaudeAccountService / CodexAccountService / -// RateLimitService into the WebSocket / local-socket RPC. Read + switch + -// remove for all clients; interactive add/re-auth flows spawn `claude login` -// / `codex login` PTYs that need a desktop browser, so they intentionally -// remain desktop-only. `accounts.addClaudeFromConfigDir` is the exception: it -// captures an already-authenticated CLAUDE_CONFIG_DIR (no PTY) so the local -// `orca account add` CLI can register accounts on a headless host; it is gated -// to the local runtime connection, never a mobile device token. See #1438. export const ACCOUNT_METHODS = [ + // Why local only: sign-in runs `claude auth login` in a terminal on the execution host. + defineMethod({ + name: 'accounts.beginClaudeSignIn', + params: BeginClaudeSignInParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Sign in on the Orca execution host.') + } + return runtime.beginClaudeSignIn(params) + } + }), + defineMethod({ + name: 'accounts.finishClaudeSignIn', + params: FinishClaudeSignInParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Sign in on the Orca execution host.') + } + return runtime.finishClaudeSignIn(params) + } + }), + defineMethod({ + name: 'accounts.cancelClaudeSignIn', + params: FinishClaudeSignInParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Sign in on the Orca execution host.') + } + await runtime.cancelClaudeSignIn(params) + return {} + } + }), defineMethod({ name: 'accounts.listData', params: null, @@ -106,17 +132,10 @@ export const ACCOUNT_METHODS = [ defineMethod({ name: 'accounts.addClaudeFromConfigDir', params: AddClaudeFromConfigDirParams, - handler: async (params, { runtime, clientKind }) => { - // Why: capturing a host filesystem path is local-socket-only; paired - // mobile and remote-runtime tokens must never read host credential paths. - if (clientKind !== undefined) { - throw new Error('Adding Claude accounts is only available on the Orca host runtime.') - } - return runtime.addClaudeAccountFromConfigDir(params.configDir, { - runtime: params.runtime, - wslDistro: params.wslDistro ?? null, - previousLegacyCredentialsSha256: params.previousLegacyCredentialsSha256 - }) + handler: async () => { + throw new Error( + 'Update the Orca CLI to add accounts. Importing Claude logins is no longer supported.' + ) } }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts index 8b0a81df6102..9b7c121a6373 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts @@ -9,7 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import { commitStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt' import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' -import { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-send-mutation' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { recordingStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support' diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts index 05f1f8a7b3a2..d66326ef2115 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -8,18 +8,18 @@ * * Nothing here queues. The durable record that the text is owed already exists and is the journal's * own submission row: `performSend` appends it before dispatching and the attach path settles it, so - * a second host-side copy could only disagree with it. What IS reused is the outbox's entry and + * a second host-side copy could only disagree with it. What IS reused is the clients' body and * envelope builders, so this send is shaped exactly like the renderer's and mobile's — same body, * same operation id as client message id, same payload fingerprint. * * The renderer still delivers its own launch prompts, because its launcher does not call - * `agent.launch` yet; when it does, its launch-sourced outbox entries become this call. + * `agent.launch` yet; when it does, its staged launch prompts become this call. */ import { - createStructuredAgentSessionOutboxEntry, - structuredAgentSessionSendMutation -} from '../../../../shared/structured-agent-session-outbox' + structuredAgentSessionMessageSendMutation, + structuredAgentSessionSendBody +} from '../../../../shared/structured-agent-session-send-mutation' import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' import { randomUUID } from 'node:crypto' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' @@ -46,16 +46,14 @@ export async function commitStructuredAgentSessionLaunchPrompt(args: { return null } const clientMessageId = createStructuredAgentSessionOperationId(randomUUID) - const entry = createStructuredAgentSessionOutboxEntry({ - clientMessageId, - sessionId: args.sessionId, - text: args.text, - attachments: [], - queuedAt: Date.now() - }) try { const result = await args.host.send(args.caller, { - ...structuredAgentSessionSendMutation(entry, args.fence), + ...structuredAgentSessionMessageSendMutation({ + sessionId: args.sessionId, + clientOperationId: clientMessageId, + expectedRuntimeFence: args.fence, + body: structuredAgentSessionSendBody(args.text, []) + }), // A person's first prompt, sent for them: kept as a card if a restart or a close comes first. personsMessage: true }) diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index caed71853054..7658c5e66b6e 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -49,6 +49,8 @@ import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS } from './structured-agent-session-attachments' +import { STRUCTURED_AGENT_SESSION_VISUAL_METHODS } from './structured-agent-session-visual' import { STRUCTURED_AGENT_SESSION_AGENTS_METHODS } from './structured-agent-session-agents' import { ARTIFACT_METHODS } from './artifacts' import { PET_SPEAK_METHODS } from './pet-speak' @@ -70,6 +72,8 @@ export const ALL_RPC_METHODS = [ ...WORKTREE_METHODS, ...AGENT_SESSION_METHODS, ...STRUCTURED_AGENT_SESSION_METHODS, + ...STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS, + ...STRUCTURED_AGENT_SESSION_VISUAL_METHODS, ...STRUCTURED_AGENT_SESSION_AGENTS_METHODS, ...AGENT_LAUNCH_METHODS, ...TERMINAL_METHODS, diff --git a/src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts b/src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts index 358b723e1ac3..09c40be523a1 100644 --- a/src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts +++ b/src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts @@ -70,8 +70,12 @@ function fixture() { } function context(): RpcContext { - const runtime = { registerSubscriptionCleanup: vi.fn(), cleanupSubscription: vi.fn() } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These handlers only register or invoke subscription cleanup on the runtime. + const runtime = { + getAgentProviderSessionRows: () => [], + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These handlers only read hook rows and register or invoke subscription cleanup on the runtime. return { runtime: runtime as unknown as RpcContext['runtime'], clientKind: 'mobile' } } diff --git a/src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts b/src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts index ec9d872d5d34..d68f2ef76a81 100644 --- a/src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts +++ b/src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts @@ -109,8 +109,12 @@ function denseFixture(version: 'v1' | 'v2'): string { } function context(clientKind: RpcContext['clientKind'] = 'mobile'): RpcContext { - const runtime = { registerSubscriptionCleanup: vi.fn(), cleanupSubscription: vi.fn() } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Native-chat only uses these subscription cleanup methods; its feature emitter records no interaction. + const runtime = { + getAgentProviderSessionRows: () => [], + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Native-chat only uses the hook rows and these subscription cleanup methods; its feature emitter records no interaction. return { runtime: runtime as unknown as RpcContext['runtime'], clientKind } } 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 new file mode 100644 index 000000000000..af41d481692f --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-ssh-transcript.test.ts @@ -0,0 +1,218 @@ +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 type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +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' + +const CONNECTION_ID = 'ssh-target-26057' +const SESSION_ID = 'claude-session-26057' + +let hookRows: Partial[] = [] + +function claudeLines(...texts: string[]): string { + return texts + .map((text, index) => + JSON.stringify({ + sessionId: SESSION_ID, + uuid: `${SESSION_ID}-${index}`, + timestamp: '2026-10-07T03:00:00.000Z', + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text }] } + }) + ) + .map((line) => `${line}\n`) + .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', + connectionId: CONNECTION_ID, + agentType: 'claude', + providerSession: { key: 'session_id', id: SESSION_ID, transcriptPath } + } +} + +const METHODS = eraseRpcMethods(NATIVE_CHAT_METHODS) + +type SubscriptionRuntime = Pick< + RpcContext['runtime'], + 'registerSubscriptionCleanup' | 'cleanupSubscription' +> + +function phoneContext(runtime: Partial): RpcContext { + const members = { ...runtime, getAgentProviderSessionRows: () => hookRows } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: native chat handlers use only the hook rows and the subscription-cleanup members. + const handlerRuntime = members as unknown as RpcContext['runtime'] + return { runtime: handlerRuntime, connectionId: 'phone-1', clientKind: 'mobile' } +} + +function method(name: string): (typeof METHODS)[number] { + const found = METHODS.find((candidate) => candidate.name === name) + if (!found) { + throw new Error(`${name} is not registered`) + } + return found +} + +function texts(value: unknown): string[] { + if (!value || typeof value !== 'object' || !('messages' in value)) { + return [] + } + const messages: readonly NativeChatMessage[] = Array.isArray(value.messages) ? value.messages : [] + return messages.flatMap((message) => + message.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + ) +} + +async function readSession( + transcriptPath: string +): Promise<{ texts: string[]; error: unknown; notFound: unknown }> { + 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({}) + ) + return { + texts: texts(page), + error: page && typeof page === 'object' && 'error' in page ? page.error : undefined, + notFound: page && typeof page === 'object' && 'notFound' in page ? page.notFound : undefined + } +} + +describe('native chat for an agent whose transcript lives on an SSH host (#26057)', () => { + let localDir: string + // The path the remote hook reported. A same-named file exists on this machine too, so a read + // that answers locally shows the wrong conversation instead of failing quietly. + let transcriptPath: string + + beforeEach(() => { + localDir = mkdtempSync(join(tmpdir(), 'orca-ssh-transcript-')) + transcriptPath = join(localDir, 'projects', 'p', `${SESSION_ID}.jsonl`) + mkdirSync(dirname(transcriptPath), { recursive: true }) + writeFileSync(transcriptPath, claudeLines('LOCAL MACHINE FILE')) + hookRows = [sshHookRow(transcriptPath)] + }) + + 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('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 () => { + const page = await readSession(transcriptPath) + + expect(page.texts).toEqual([]) + expect(page.error).toBeDefined() + }) + + it('keeps reading a local session from this machine', async () => { + hookRows = [{ ...sshHookRow(transcriptPath), connectionId: null }] + + const page = await readSession(transcriptPath) + + expect(page.texts).toEqual(['LOCAL MACHINE FILE']) + }) +}) diff --git a/src/main/runtime/rpc/methods/native-chat-subscription-token.test.ts b/src/main/runtime/rpc/methods/native-chat-subscription-token.test.ts index e6b3aae471ed..b232bd692616 100644 --- a/src/main/runtime/rpc/methods/native-chat-subscription-token.test.ts +++ b/src/main/runtime/rpc/methods/native-chat-subscription-token.test.ts @@ -31,8 +31,9 @@ function phoneConnection(): { } { const registry = new RuntimeSubscriptionRegistry() const context: RpcContext = { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test only touch these three registry-backed members. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test only touch these registry-backed members and the hook rows. runtime: { + getAgentProviderSessionRows: () => [], registerSubscriptionCleanup: registry.register.bind(registry), cleanupSubscription: registry.cleanup.bind(registry), cleanupSubscriptionsByPrefix: registry.cleanupByPrefix.bind(registry) diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 47a6d9e855e9..68f6f821dd40 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -133,19 +133,22 @@ function subscribeHandler(): ( } function streamingContext(clientKind: RpcContext['clientKind']): RpcContext { - return { - runtime: { - registerSubscriptionCleanup: vi.fn(), - cleanupSubscription: vi.fn(), - cleanupSubscriptionsByPrefix: vi.fn() - } as unknown as RpcContext['runtime'], - connectionId: 'connection-1', - clientKind + const runtime = { + getAgentProviderSessionRows: () => [], + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn(), + cleanupSubscriptionsByPrefix: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: native chat handlers use only the hook rows and these subscription members. + const handlerRuntime = runtime as unknown as RpcContext['runtime'] + return { runtime: handlerRuntime, connectionId: 'connection-1', clientKind } } function ctxWith(clientKind: RpcContext['clientKind']): RpcContext { - return { runtime: {} as RpcContext['runtime'], clientKind } + const runtime = { getAgentProviderSessionRows: () => [] } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: readSession uses only the hook rows. + const handlerRuntime = runtime as unknown as RpcContext['runtime'] + return { runtime: handlerRuntime, clientKind } } function firstOutput(result: unknown): string { diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 99c0178ea0cf..296d55e3b6ee 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -6,6 +6,7 @@ import { type NativeChatTranscriptSubscription, type SubscribeNativeChatTranscriptArgs } from '../../../native-chat/transcript-watch' +import { nativeChatTranscriptPathOnExecutionHost } from '../../../native-chat/ssh-transcript-path' import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' import { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize' import { @@ -77,13 +78,17 @@ export const NATIVE_CHAT_METHODS = [ defineMethod({ name: 'nativeChat.readSession', params: NativeChatSession, - handler: async (params, { clientKind, signal }) => { + handler: async (params, { runtime, clientKind, signal }) => { const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW const result = await readNativeChatTranscriptTail( { agent: params.agent, sessionId: params.sessionId, - transcriptPath: params.transcriptPath, + transcriptPath: nativeChatTranscriptPathOnExecutionHost( + runtime.getAgentProviderSessionRows(), + params.sessionId, + params.transcriptPath + ), limit, beforeOffset: params.beforeOffset }, @@ -149,7 +154,11 @@ export const NATIVE_CHAT_METHODS = [ const subscribeArgs: SubscribeNativeChatTranscriptArgs = { agent: params.agent, sessionId: params.sessionId, - transcriptPath: params.transcriptPath, + transcriptPath: nativeChatTranscriptPathOnExecutionHost( + runtime.getAgentProviderSessionRows(), + params.sessionId, + params.transcriptPath + ), initialLimit: limit, onInitialSnapshot: (messages, hasMore, beforeOffset, error, lifecycle) => { if (closed) { diff --git a/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts index bc446197ca0f..e607e46aa86f 100644 --- a/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' @@ -70,6 +71,24 @@ describe('a tab of an agent beyond Claude and Codex', () => { expect(projectSessionTabAgentStatus(payload, 'runtime', REGISTERED_AGENTS_CLIENT)).toBe(payload) }) + it('holds a Pi tab for clients that understand registered agents but cannot show Pi dialogs', () => { + const payload = { + ...snapshot(), + tabs: [...snapshot().tabs, chatTab('pi', false)] + } + const old = projectSessionTabAgentStatus(payload, 'runtime', REGISTERED_AGENTS_CLIENT) + expect(old.tabs.map((tab) => tab.id)).not.toContain('agent-session:pi-session') + expect( + projectSessionTabAgentStatus(payload, 'runtime', [ + ...REGISTERED_AGENTS_CLIENT, + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY + ]) + ).toBe(payload) + expect( + projectSessionTabAgentStatus(payload, 'mobile', REGISTERED_AGENTS_CLIENT).tabs.at(-1) + ).toMatchObject({ title: STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE }) + }) + it('stays listed on a phone under the title that names the fix', () => { const projected = projectSessionTabAgentStatus(snapshot(), 'mobile', TODAYS_CLIENT) expect(projected.tabs.map((tab) => tab.title)).toEqual([ diff --git a/src/main/runtime/rpc/methods/structured-agent-session-agents.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-agents.test.ts new file mode 100644 index 000000000000..20db04b9aeed --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-agents.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../../structured-agent-runtime-registrations' +import type { RpcContext } from '../core' +import { STRUCTURED_AGENT_SESSION_AGENTS_METHODS } from './structured-agent-session-agents' + +const [method] = STRUCTURED_AGENT_SESSION_AGENTS_METHODS + +const ensureStructuredAgentSessionHost = vi.fn(async () => { + throw new Error('the journal would not open') +}) + +function context(overrides: Partial): RpcContext { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only the caller's capabilities; the runtime stub proves it never installs the host. + return { runtime: { ensureStructuredAgentSessionHost }, ...overrides } as unknown as RpcContext +} + +describe('agentSession.agents', () => { + it("lists this build's registered agents without installing the host", async () => { + const ctx = context({}) + + const result = await method.handler({}, ctx) + + expect(result.agents.map(({ agent }) => agent)).toEqual( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) + ) + expect(result.agents.map(({ agent }) => agent)).toContain('opencode') + expect(ensureStructuredAgentSessionHost).not.toHaveBeenCalled() + }) + + it('still refuses a remote client that cannot read structured sessions', async () => { + await expect( + method.handler({}, context({ clientKind: 'mobile', clientCapabilities: [] })) + ).rejects.toMatchObject({ refusal: { code: 'structured_agent_session_unsupported' } }) + await expect( + method.handler( + {}, + context({ + clientKind: 'mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }) + ) + ).resolves.toMatchObject({ agents: expect.any(Array) }) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts index 6d03767d79f0..8faf017f493d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts @@ -6,18 +6,28 @@ import type { AgentSessionAgentsResult } from '../../../../shared/agent-session-registered-agents' import { AGENT_SESSION_AGENTS_METHOD } from '../../../../shared/agent-session-registered-agents' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../../structured-agent-runtime-registrations' import { defineMethod } from '../core' -import { requireInstalledStructuredHost } from './structured-agent-session-gate' +import { requireStructuredCapability } from './structured-agent-session-gate' import { AgentsParams } from './structured-agent-session-schemas' +import { clientReadsStructuredSessionAgent } from './structured-agent-session-policy' export const STRUCTURED_AGENT_SESSION_AGENTS_METHODS = [ defineMethod({ name: AGENT_SESSION_AGENTS_METHOD, params: AgentsParams, - handler: async (_params, ctx): Promise => ({ - agents: (await requireInstalledStructuredHost(ctx)) - .agentDefinitions() - .map(({ agent, capabilities }) => ({ agent, capabilities: { ...capabilities } })) - }) + // Read from the registrations the host is built from, as createSupport is: the answer is + // fixed for this build, so it never waits on, or fails with, installing the host. + handler: async (_params, ctx): Promise => { + requireStructuredCapability(ctx) + return { + agents: STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.filter(({ definition }) => + clientReadsStructuredSessionAgent(ctx, definition.agent) + ).map(({ definition }) => ({ + agent: definition.agent, + capabilities: { ...definition.capabilities } + })) + } + } }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts new file mode 100644 index 000000000000..e2b33bc9bb6a --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts @@ -0,0 +1,216 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { + AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, + RUNTIME_CAPABILITIES +} from '../../../../shared/protocol-version' +import { AgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store' +import { setAgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store-registry' +import { STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS } from './structured-agent-session-attachments' +import { isMobileE2EETextPayloadWithinLimit } from '../mobile-e2ee-outbound-admission' +import { + clearStructuredHostStub, + installStructuredHostStub, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +let root: string +let store: AgentSessionAttachmentStore + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-attachment-rpc-')) + store = new AgentSessionAttachmentStore(join(root, 'agent-session-attachments'), { + hasSession: (sessionId) => sessionId === 'session-alpha' + }) + setAgentSessionAttachmentStore(store) + installStructuredHostStub() +}) + +afterEach(async () => { + store.clearInFlightForTests() + setAgentSessionAttachmentStore(null) + clearStructuredHostStub() + await rm(root, { recursive: true, force: true }) +}) + +async function call( + method: string, + params: unknown, + client: { clientId?: string; clientKind?: 'runtime' | 'mobile'; clientCapabilities?: string[] } +): Promise { + const dispatcher = new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attachment methods read only the host-install hook and runtime id. + runtime: { + getRuntimeId: () => 'runtime-1', + ensureStructuredAgentSessionHost: async () => {} + } as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS + }) + const replies: RpcResponse[] = [] + await dispatcher.dispatchStreaming( + { id: 'request-1', authToken: 'token', method, params }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher writes serialized RpcResponse frames. + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + client + ) + if (!replies[0]) { + throw new Error(`no reply for ${method}`) + } + return replies[0] +} + +function result(response: RpcResponse): T { + if (!response.ok) { + throw new Error(`refused: ${JSON.stringify(response)}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each caller names the result shape its method returns. + return response.result as T +} + +const CLIENT_A = { ...STRUCTURED_CLIENT, clientId: 'client-a' } + +async function uploadBytes(name: string, bytes: Buffer): Promise { + const { uploadId } = result<{ uploadId: string }>( + await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name, byteLength: bytes.byteLength }, + CLIENT_A + ) + ) + const chunk = 384 * 1024 + for (let offset = 0; offset < bytes.byteLength; offset += chunk) { + result( + await call( + 'agentSessionAttachment.uploadAppend', + { + uploadId, + offset, + contentBase64: bytes.subarray(offset, offset + chunk).toString('base64') + }, + CLIENT_A + ) + ) + } + return result<{ path: string }>( + await call('agentSessionAttachment.uploadCommit', { uploadId }, CLIENT_A) + ).path +} + +describe('agentSessionAttachment.*', () => { + it('is advertised, so a client can tell a host with the store from an older one', () => { + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY) + }) + + it('stores an upload for the chat and hands back the server path', async () => { + const { uploadId } = result<{ uploadId: string }>( + await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name: 'shot.png', byteLength: 3 }, + CLIENT_A + ) + ) + result( + await call( + 'agentSessionAttachment.uploadAppend', + { uploadId, offset: 0, contentBase64: Buffer.from('png').toString('base64') }, + CLIENT_A + ) + ) + const stored = result<{ path: string; name: string }>( + await call('agentSessionAttachment.uploadCommit', { uploadId }, CLIENT_A) + ) + expect(stored.name).toBe('shot.png') + expect(stored.path.startsWith(store.rootDir)).toBe(true) + expect(await readFile(stored.path, 'utf8')).toBe('png') + + const preview = await call('agentSessionAttachment.read', { path: stored.path }, CLIENT_A) + expect(preview).toMatchObject({ ok: true, result: { isBinary: true, mimeType: 'image/png' } }) + }) + + it('refuses a client that cannot read structured sessions', async () => { + const response = await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name: 'shot.png', byteLength: 3 }, + { clientKind: 'runtime', clientCapabilities: [] } + ) + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + }) + + it('refuses a declared size over the attachment limit before storing anything', async () => { + const response = await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name: 'big.bin', byteLength: 51 * 1024 * 1024 }, + CLIENT_A + ) + expect(response.ok).toBe(false) + }) + + it("keeps one client's upload out of another's reach", async () => { + const { uploadId } = result<{ uploadId: string }>( + await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name: 'a.txt', byteLength: 1 }, + CLIENT_A + ) + ) + const response = await call( + 'agentSessionAttachment.uploadCommit', + { uploadId }, + { + ...STRUCTURED_CLIENT, + clientId: 'client-b' + } + ) + expect(response).toMatchObject({ ok: false }) + expect(store.isUploadInFlight(uploadId)).toBe(true) + }) + + it('refuses an upload for a chat this host does not hold', async () => { + const response = await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-elsewhere', name: 'a.txt', byteLength: 1 }, + CLIENT_A + ) + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('not on this host') } + }) + }) + + it('answers a remote preview of an image over 3 MiB with a small refusal, not an oversized reply', async () => { + const path = await uploadBytes('big.png', Buffer.alloc(3.5 * 1024 * 1024, 7)) + const replies: string[] = [] + const dispatcher = new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attachment methods read only the host-install hook and runtime id. + runtime: { + getRuntimeId: () => 'runtime-1', + ensureStructuredAgentSessionHost: async () => {} + } as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS + }) + await dispatcher.dispatchStreaming( + { + id: 'request-big', + authToken: 'token', + method: 'agentSessionAttachment.read', + params: { path } + }, + (raw) => replies.push(raw), + { ...CLIENT_A, clientKind: 'runtime' } + ) + expect(replies).toHaveLength(1) + expect(isMobileE2EETextPayloadWithinLimit(replies[0] ?? '')).toBe(true) + expect(JSON.parse(replies[0] ?? '{}')).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('file_too_large') } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts new file mode 100644 index 000000000000..2c45d75c662e --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts @@ -0,0 +1,76 @@ +// `agentSessionAttachment.*` — files a client attaches to a structured chat, stored on this host. +// +// Behind the same gate as `agentSession.*`: only a client that reads structured sessions may write +// into their store. A namespace of its own, because these reach the attachment store rather than +// the session host. Clients call them only where the host advertises +// `agent-session.attachments.v1`; an older host answers `method_not_found`. + +import { defineMethod, type RpcContext } from '../core' +import { + requireInstalledStructuredHost, + structuredCallerFor +} from './structured-agent-session-gate' +import { + AttachmentReadParams, + AttachmentUploadAppendParams, + AttachmentUploadIdParams, + AttachmentUploadStartParams +} from '../../../../shared/rpc-contract/agent-session-attachment-params' +import { remoteFileContentBudget } from './files-remote-content-budget' +import type { AgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store' +import { getAgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store-registry' + +async function requireStore( + ctx: RpcContext +): Promise<{ store: AgentSessionAttachmentStore; callerKey: string }> { + await requireInstalledStructuredHost(ctx) + const store = getAgentSessionAttachmentStore() + if (!store) { + throw new Error('Chat attachments are not available on this host') + } + return { store, callerKey: structuredCallerFor(ctx).callerKey } +} + +export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ + defineMethod({ + name: 'agentSessionAttachment.uploadStart', + params: AttachmentUploadStartParams, + handler: async (params, ctx) => { + const { store, callerKey } = await requireStore(ctx) + return store.startUpload({ callerKey, ...params }) + } + }), + defineMethod({ + name: 'agentSessionAttachment.uploadAppend', + params: AttachmentUploadAppendParams, + handler: async (params, ctx) => { + const { store, callerKey } = await requireStore(ctx) + return store.appendChunk({ callerKey, ...params }) + } + }), + defineMethod({ + name: 'agentSessionAttachment.uploadCommit', + params: AttachmentUploadIdParams, + handler: async (params, ctx) => { + const { store, callerKey } = await requireStore(ctx) + return store.commitUpload({ callerKey, uploadId: params.uploadId }) + } + }), + defineMethod({ + name: 'agentSessionAttachment.uploadAbort', + params: AttachmentUploadIdParams, + handler: async (params, ctx) => { + const { store, callerKey } = await requireStore(ctx) + return store.abortUpload({ callerKey, uploadId: params.uploadId }) + } + }), + defineMethod({ + name: 'agentSessionAttachment.read', + params: AttachmentReadParams, + handler: async (params, ctx) => { + const { store } = await requireStore(ctx) + // A remote reply must fit the connection's outbound budget, or the server closes the socket. + return store.readPreview(params.path, remoteFileContentBudget(ctx.clientKind, ctx.requestId)) + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts b/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts index 08ecc212ec1b..a68a41ec1db8 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts @@ -16,7 +16,9 @@ export const STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS = [ params: OptionsParams, handler: async (params, ctx) => readAgentSessionConversationOutline( - await (await requireInstalledStructuredHost(ctx)).journalSnapshot(params.sessionId) + await ( + await requireInstalledStructuredHost(ctx, params.sessionId) + ).journalSnapshot(params.sessionId) ) }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index 754412ffea9d..0b9b25c8005f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -16,6 +16,7 @@ import type { RpcContext } from '../core' import { structuredAgentSessionHostRefusal } from '../../structured-agent-session-host-refusal' import { createSupportFollowsHostSetting, + clientReadsStructuredSessionAgent, isStructuredNativeChatEnabled, supportsStructuredAgentSessions } from './structured-agent-session-policy' @@ -36,12 +37,21 @@ export function requireStructuredCapability(ctx: RpcContext): void { } } +export function requireStructuredAgentAudience(ctx: RpcContext, agent: string): void { + requireStructuredCapability(ctx) + if (!clientReadsStructuredSessionAgent(ctx, agent)) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) + } +} + /** * `agentSession.createSupport` alone also reads the host setting, for a client that leaves the * launch mode to the host; it gets the refusal it got before, which it reads as "open a terminal". */ -export function requireStructuredCreateSupportAdmission(ctx: RpcContext): void { - requireStructuredCapability(ctx) +export function requireStructuredCreateSupportAdmission(ctx: RpcContext, agent?: string): void { + requireStructuredAgentAudience(ctx, agent ?? '') if (createSupportFollowsHostSetting(ctx) && !isStructuredNativeChatEnabled(ctx.runtime)) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'clientCapabilityMissing' @@ -49,11 +59,20 @@ export function requireStructuredCreateSupportAdmission(ctx: RpcContext): void { } } -export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHost { - requireStructuredCapability(ctx) +export function requireStructuredHost(ctx: RpcContext, agent?: string): StructuredAgentSessionHost { + requireStructuredAgentAudience(ctx, agent ?? '') return requireHostOrRefusal() } +export function requireStructuredSessionHost( + ctx: RpcContext, + sessionId: string +): StructuredAgentSessionHost { + const host = requireStructuredHost(ctx) + requireStructuredAgentAudience(ctx, host.sessionAgent(sessionId) ?? '') + return host +} + /** * The gate for methods that stop or retire work the caller already owns: close, cancel, * unsubscribe and release. It asks only what no caller can do without (the wire capability and a @@ -84,7 +103,13 @@ function requireHostOrRefusal(): StructuredAgentSessionHost { /** Builds the host for a call that may be the first this process sees. Every session is addressed * by its durable record — a read opens a conversation at rest — so each call that reaches for one * may meet a host nothing has built yet. */ -export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { +export async function ensureStructuredHostInstalled( + ctx: RpcContext, + agent?: string +): Promise { + if (agent) { + requireStructuredAgentAudience(ctx, agent) + } // Gated first: a client that cannot read structured sessions must not be able // to make the host exist, which is an observable side effect of the surface. if (!supportsStructuredSessions(ctx)) { @@ -98,10 +123,11 @@ export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { await ensureStructuredHostInstalled(ctx) - return requireStructuredHost(ctx) + return sessionId ? requireStructuredSessionHost(ctx, sessionId) : requireStructuredHost(ctx) } /** Mirrors the existing agent-session host-authority derivation so one client diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts index e1975f1c7255..ef6e89f8ee81 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts @@ -16,8 +16,8 @@ export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [ defineMethod({ name: 'agentSession.hold', params: HoldParams, - handler: async (_params, ctx) => { - await requireInstalledStructuredHost(ctx) + handler: async (params, ctx) => { + await requireInstalledStructuredHost(ctx, params.sessionId) return { held: true as const } } }), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 98d94439b1f8..97833114c2e0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -11,17 +11,34 @@ import { defineMethod } from '../core' import { requireInstalledStructuredHost, + requireStructuredAgentAudience, requireStructuredHost } from './structured-agent-session-gate' -import { ModelCatalogParams, OptionsParams } from './structured-agent-session-schemas' +import { + HandoffStatusParams, + ModelCatalogParams, + OptionsParams +} from './structured-agent-session-schemas' import { agentSessionPinnedLaunchDirectory } from '../../agent-session-record-launch-directory' export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ + defineMethod({ + name: 'agentSession.handoffStatus', + params: HandoffStatusParams, + handler: async (params, ctx) => + (await requireInstalledStructuredHost(ctx, params.sessionId)).handoffStatus(params.sessionId) + }), + defineMethod({ + name: 'agentSession.commands', + params: OptionsParams, + handler: async (params, ctx) => + (await requireInstalledStructuredHost(ctx, params.sessionId)).readCommands(params.sessionId) + }), defineMethod({ name: 'agentSession.options', params: OptionsParams, handler: async (params, ctx) => - (await requireInstalledStructuredHost(ctx)).readOptions(params.sessionId) + (await requireInstalledStructuredHost(ctx, params.sessionId)).readOptions(params.sessionId) }), defineMethod({ name: 'agentSession.modelCatalog', @@ -29,10 +46,11 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ // A structured chat's read names its session and builds the host, since it may come first; // terminal-backed chat's session-less read must not open the journal where none runs. handler: async ({ worktree, ...params }, ctx) => { + requireStructuredAgentAudience(ctx, params.agent) const host = params.sessionId === undefined ? requireStructuredHost(ctx) - : await requireInstalledStructuredHost(ctx) + : await requireInstalledStructuredHost(ctx, params.sessionId) const catalog = host.deps.modelCatalog if (!catalog) { return { origin: 'unknown' as const } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts new file mode 100644 index 000000000000..9b290a449b20 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts @@ -0,0 +1,386 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' +import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities' +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../../structured-agent-runtime-registrations' +import { STRUCTURED_AGENT_SESSION_AGENTS_METHODS } from './structured-agent-session-agents' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import type { + AgentSessionStatusSummary, + AgentSessionTurnCompletion +} from '../../../../shared/agent-session-wire' +import type { AgentSessionPromptAttention } from '../../../../shared/agent-session-turn-completion-wire' +import type { StructuredAgentSessionStatusSubscriber } from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed' +import type { StructuredAgentSessionTurnCompletionSubscriber } from '../../../native-chat/agent-session-wire/structured-agent-session-turn-completion-feed' +import { + call, + clearStructuredHostStub, + dispatcher, + envelope, + hostCalls, + installStructuredHostStub, + runtimeCalls, + SESSION, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' +import { + CLEANUP_METHODS, + WORK_METHODS +} from './structured-agent-session-gate-classification.test-fixture' + +const OLD_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: [ + ...STRUCTURED_CLIENT.clientCapabilities, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ] +} +const PI_CLIENT = { + ...OLD_CLIENT, + clientCapabilities: [...OLD_CLIENT.clientCapabilities, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY] +} +const MOBILE_CLIENT = { + clientKind: 'mobile' as const, + // Mirrors the mobile transport list without importing its Expo project into this Node suite. + clientCapabilities: remoteRuntimeClientCapabilities([ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_LAUNCH_RUNTIME_CAPABILITY + ]) +} +const DESKTOP_CLIENT = { + clientKind: 'runtime' as const, + clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] +} +const UNSUPPORTED = { message: expect.stringContaining('structured_agent_session_unsupported') } + +beforeEach(installStructuredHostStub) +afterEach(clearStructuredHostStub) + +describe('Pi dialog-shape client capability', () => { + it('filters only Pi from the registered agent list of an older client', async () => { + const registered = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map( + ({ definition }) => definition.agent + ) + const listing = (agents: string[]) => ({ + ok: true, + result: { agents: agents.map((agent) => ({ agent })) } + }) + expect(registered).toContain('pi') + + expect( + await call('agentSession.agents', {}, OLD_CLIENT, {}, STRUCTURED_AGENT_SESSION_AGENTS_METHODS) + ).toMatchObject(listing(registered.filter((agent) => agent !== 'pi'))) + expect( + await call('agentSession.agents', {}, PI_CLIENT, {}, STRUCTURED_AGENT_SESSION_AGENTS_METHODS) + ).toMatchObject(listing(registered)) + }) + + it('refuses Pi create support before asking the runtime, while other agents keep their path', async () => { + expect( + await call( + 'agentSession.createSupport', + { worktree: 'id:workspace-1', agent: 'pi' }, + OLD_CLIENT + ) + ).toMatchObject({ ok: false, error: UNSUPPORTED }) + expect(runtimeCalls.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled() + + expect( + await call( + 'agentSession.createSupport', + { worktree: 'id:workspace-1', agent: 'grok' }, + OLD_CLIENT + ) + ).toMatchObject({ ok: true }) + expect( + await call( + 'agentSession.createSupport', + { worktree: 'id:workspace-1', agent: 'pi' }, + PI_CLIENT + ) + ).toMatchObject({ ok: true }) + }) + + it('refuses Pi create before resolution or attachment', async () => { + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree: 'id:workspace-1', agent: 'pi' } + }) + }), + worktree: 'id:workspace-1', + agent: 'pi' + } + expect(await call('agentSession.create', params, OLD_CLIENT)).toMatchObject({ + ok: false, + error: UNSUPPORTED + }) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).not.toHaveBeenCalled() + expect(hostCalls.attach).not.toHaveBeenCalled() + expect(await call('agentSession.create', params, PI_CLIENT)).toMatchObject({ ok: true }) + }) + + it('withholds Pi journal history from a client that cannot show its dialogs', async () => { + hostCalls.sessionAgent.mockReturnValue('pi') + expect( + await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, OLD_CLIENT) + ).toMatchObject({ ok: false, error: UNSUPPORTED }) + expect(hostCalls.history).not.toHaveBeenCalled() + expect( + await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, PI_CLIENT) + ).toMatchObject({ ok: true }) + }) + + it.each([ + ['old D3 desktop', OLD_CLIENT], + ['current mobile', MOBILE_CLIENT] + ] as const)( + 'refuses Pi session reads and mutations by ID for %s before host work', + async (_label, client) => { + hostCalls.sessionAgent.mockReturnValue('pi') + for (const { method, params } of WORK_METHODS) { + if ( + method === 'agentSession.createSupport' || + method === 'agentSession.create' || + method === 'agentSession.ensure' || + method === 'agentSession.subscribeStatus' || + method === 'agentSession.reveal' + ) { + continue + } + expect(await call(method, params, client), method).toMatchObject({ + ok: false, + error: UNSUPPORTED + }) + } + expect(hostCalls.send).not.toHaveBeenCalled() + expect(hostCalls.readOptions).not.toHaveBeenCalled() + expect(hostCalls.rewind).not.toHaveBeenCalled() + expect(hostCalls.respondToPrompt).not.toHaveBeenCalled() + expect(hostCalls.setOption).not.toHaveBeenCalled() + expect(hostCalls.history).not.toHaveBeenCalled() + expect(hostCalls.revealSession).not.toHaveBeenCalled() + expect(await call('agentSession.modelCatalog', { agent: 'pi' }, client)).toMatchObject({ + ok: false, + error: UNSUPPORTED + }) + hostCalls.sessionAgent.mockReturnValue('codex') + expect( + await call('agentSession.modelCatalog', { agent: 'pi', sessionId: SESSION }, client) + ).toMatchObject({ ok: false, error: UNSUPPORTED }) + hostCalls.sessionAgent.mockReturnValue('pi') + expect(await call('agentSession.reveal', { sessionId: SESSION }, client)).toMatchObject({ + ok: true, + result: { ok: false, refusal: { code: 'structured_agent_session_unsupported' } } + }) + for (const [method, params] of [ + ['agentSession.commands', { sessionId: SESSION }], + ['agentSession.conversationCommand', { envelope: envelope(), command: 'compact' }], + ['agentSession.modelCatalog', { agent: 'codex', sessionId: SESSION }] + ] as const) { + expect(await call(method, params, client), method).toMatchObject({ + ok: false, + error: UNSUPPORTED + }) + } + expect(hostCalls.revealSession).not.toHaveBeenCalled() + } + ) + + it.each([ + ['old D3 desktop', OLD_CLIENT], + ['current mobile', MOBILE_CLIENT] + ] as const)('keeps Pi cleanup callable for %s', async (_label, client) => { + hostCalls.sessionAgent.mockReturnValue('pi') + for (const { method, params } of CLEANUP_METHODS) { + expect(await call(method, params, client), method).toMatchObject({ ok: true }) + } + expect(hostCalls.cancel).toHaveBeenCalledOnce() + expect(hostCalls.close).toHaveBeenCalledOnce() + }) + + it('admits current desktop Pi send, options and commands, and preserves other agents', async () => { + hostCalls.sessionAgent.mockReturnValue('pi') + hostCalls.readCommands = vi.fn(async () => []) + for (const method of [ + 'agentSession.send', + 'agentSession.options', + 'agentSession.commands' + ] as const) { + const params = + method === 'agentSession.send' + ? WORK_METHODS.find((entry) => entry.method === method)?.params + : { sessionId: SESSION } + expect(await call(method, params, DESKTOP_CLIENT), method).toMatchObject({ ok: true }) + } + expect(hostCalls.send).toHaveBeenCalledOnce() + expect(hostCalls.readOptions).toHaveBeenCalledOnce() + expect(hostCalls.readCommands).toHaveBeenCalledOnce() + + hostCalls.sessionAgent.mockReturnValue('codex') + expect( + await call( + 'agentSession.send', + WORK_METHODS.find((entry) => entry.method === 'agentSession.send')?.params, + OLD_CLIENT + ) + ).toMatchObject({ ok: true }) + expect(await call('agentSession.options', { sessionId: SESSION }, MOBILE_CLIENT)).toMatchObject( + { ok: true } + ) + }) + + it('filters Pi status from the opening snapshot and live updates', async () => { + const codex: AgentSessionStatusSummary = { + sessionId: 'codex-session', + workspaceId: 'workspace-1', + agent: 'codex', + status: 'idle', + latestPrompt: 'hello', + updatedAt: 1 + } + const pi: AgentSessionStatusSummary = { ...codex, sessionId: SESSION, agent: 'pi' } + hostCalls.subscribeStatus.mockImplementation( + (subscriber: StructuredAgentSessionStatusSubscriber) => { + subscriber.emit({ type: 'snapshot', sessions: [codex, pi] }) + subscriber.emit({ type: 'status', session: pi }) + subscriber.emit({ type: 'status', session: codex }) + subscriber.emit({ type: 'end' }) + return () => undefined + } + ) + for (const [client, seesPi] of [ + [OLD_CLIENT, false], + [MOBILE_CLIENT, false], + [DESKTOP_CLIENT, true] + ] as const) { + const replies: unknown[] = [] + await dispatcher().dispatchStreaming( + { + id: 'status-test', + authToken: 'token', + method: 'agentSession.subscribeStatus', + params: null + }, + (raw) => replies.push(JSON.parse(raw)), + client + ) + const serialized = JSON.stringify(replies) + expect(serialized).toContain('codex-session') + expect(serialized.includes(SESSION)).toBe(seesPi) + expect(replies).toHaveLength(seesPi ? 4 : 3) + } + }) + + it('filters Pi turn completions from the global live stream', async () => { + hostCalls.sessionAgent.mockImplementation((sessionId: string) => + sessionId === SESSION ? 'pi' : 'codex' + ) + hostCalls.subscribeTurnCompletions = vi.fn( + (subscriber: StructuredAgentSessionTurnCompletionSubscriber) => { + const completion: AgentSessionTurnCompletion = { + scope: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + }, + sessionId: SESSION, + turnId: 'turn-1', + outcome: 'success' as const, + completedAt: 1 + } + subscriber.emit({ type: 'completion', completion }) + subscriber.emit({ + type: 'completion', + completion: { ...completion, sessionId: 'codex-session' } + }) + subscriber.emit({ type: 'end' }) + return () => undefined + } + ) + for (const [client, seesPi] of [ + [OLD_CLIENT, false], + [DESKTOP_CLIENT, true] + ] as const) { + const replies: unknown[] = [] + await dispatcher().dispatchStreaming( + { + id: 'completion-test', + authToken: 'token', + method: 'agentSession.subscribeTurnCompletions', + params: null + }, + (raw) => replies.push(JSON.parse(raw)), + client + ) + const serialized = JSON.stringify(replies) + expect(serialized).toContain('codex-session') + expect(serialized.includes(SESSION)).toBe(seesPi) + expect(replies).toHaveLength(seesPi ? 3 : 2) + } + }) + + it('filters Pi prompt alerts from a client that asked for prompts', async () => { + hostCalls.sessionAgent.mockImplementation((sessionId: string) => + sessionId === SESSION ? 'pi' : 'codex' + ) + hostCalls.subscribeTurnCompletions = vi.fn( + (subscriber: StructuredAgentSessionTurnCompletionSubscriber) => { + const prompt: AgentSessionPromptAttention = { + scope: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + }, + sessionId: SESSION, + promptId: 'prompt-1', + raisedAt: 1 + } + subscriber.emit({ type: 'prompt', prompt }) + subscriber.emit({ type: 'prompt', prompt: { ...prompt, sessionId: 'codex-session' } }) + subscriber.emit({ type: 'end' }) + return () => undefined + } + ) + for (const [client, seesPi] of [ + [OLD_CLIENT, false], + [DESKTOP_CLIENT, true] + ] as const) { + const replies: unknown[] = [] + await dispatcher().dispatchStreaming( + { + id: 'prompt-test', + authToken: 'token', + method: 'agentSession.subscribeTurnCompletions', + params: { includePrompts: true } + }, + (raw) => replies.push(JSON.parse(raw)), + client + ) + const serialized = JSON.stringify(replies) + expect(serialized).toContain('codex-session') + expect(serialized.includes(SESSION)).toBe(seesPi) + expect(replies).toHaveLength(seesPi ? 3 : 2) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts index 662cc948b7dc..a000ced431a7 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts @@ -1,15 +1,43 @@ import { describe, expect, it } from 'vitest' import { + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { createSupportFollowsHostSetting, + structuredAgentsReadBy, + clientRendersStructuredAgent, supportsStructuredAgentSessions } from './structured-agent-session-policy' const CAPABLE = [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +describe('Pi audience in a registered-agents client', () => { + const registered = [...CAPABLE, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY] + + it('keeps Pi out of tab and restart audiences until the dialog shape is advertised', () => { + expect(clientRendersStructuredAgent(registered, 'pi')).toBe(false) + expect(clientRendersStructuredAgent(registered, 'grok')).toBe(true) + const audience = structuredAgentsReadBy( + { clientKind: 'runtime', clientCapabilities: registered }, + ['codex', 'grok', 'pi'] + ) + expect(audience?.('pi')).toBe(false) + expect(audience?.('grok')).toBe(true) + expect( + structuredAgentsReadBy( + { + clientKind: 'runtime', + clientCapabilities: [...registered, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY] + }, + ['codex', 'grok', 'pi'] + ) + ).toBeUndefined() + }) +}) + describe('supportsStructuredAgentSessions', () => { it.each(['runtime', 'mobile'] as const)( 'admits a %s client that advertises the capability', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index 0892f35d7976..4295dee842f0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -1,5 +1,6 @@ import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -24,9 +25,7 @@ export function supportsStructuredAgentSessions( ) } -/** Whether a remote client renders `agent`'s chat: the one rule for every surface that withholds - * an agent's rows (tabs, restart offers). Codex needs structured support; Claude also its own - * capability; any other agent a client that renders the host's registered agents. */ +/** One audience rule for tabs and restart offers. Pi also needs dialog-shape support. */ export function clientRendersStructuredAgent( clientCapabilities: readonly RuntimeCapability[] | undefined, agent: string @@ -37,6 +36,9 @@ export function clientRendersStructuredAgent( if (agent === 'codex') { return true } + if (agent === 'pi' && !clientCapabilities.includes(PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY)) { + return false + } return clientCapabilities.includes( agent === 'claude' ? CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY @@ -44,6 +46,18 @@ export function clientRendersStructuredAgent( ) } +/** Preserve the existing session audience for every agent except Pi. */ +export function clientReadsStructuredSessionAgent( + context: Pick, + agent: string +): boolean { + return ( + agent !== 'pi' || + context.clientKind === undefined || + clientRendersStructuredAgent(context.clientCapabilities, agent) + ) +} + /** The agents this client reads rows of, among those registered or saved here; undefined when it * reads every one, so an action for it is exactly the unscoped one (one fence for every offer). */ export function structuredAgentsReadBy( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts index 259ee2dbe148..c98dfa4c787c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts @@ -4,7 +4,7 @@ import { defineMethod } from '../core' import { - requireStructuredHost as requireHost, + requireStructuredSessionHost as requireSessionHost, structuredCallerFor as callerFor } from './structured-agent-session-gate' import { @@ -16,16 +16,22 @@ export const STRUCTURED_AGENT_SESSION_QUEUED_METHODS = [ defineMethod({ name: 'agentSession.queuedMessageSend', params: QueuedMessageActionParams, - handler: async (params, ctx) => requireHost(ctx).queuedMessageSend(callerFor(ctx), params) + handler: async (params, ctx) => + requireSessionHost(ctx, params.envelope.sessionId).queuedMessageSend(callerFor(ctx), params) }), defineMethod({ name: 'agentSession.queuedMessageDelete', params: QueuedMessageActionParams, - handler: async (params, ctx) => requireHost(ctx).queuedMessageDelete(callerFor(ctx), params) + handler: async (params, ctx) => + requireSessionHost(ctx, params.envelope.sessionId).queuedMessageDelete(callerFor(ctx), params) }), defineMethod({ name: 'agentSession.queuedMessagesResume', params: QueuedMessagesResumeParams, - handler: async (params, ctx) => requireHost(ctx).queuedMessagesResume(callerFor(ctx), params) + handler: async (params, ctx) => + requireSessionHost(ctx, params.envelope.sessionId).queuedMessagesResume( + callerFor(ctx), + params + ) }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts index 1d1ddfcc5983..c28fe9856f69 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts @@ -11,7 +11,11 @@ import { requireStructuredHost, structuredCallerFor } from './structured-agent-session-gate' -import { RestartResumableParams, RestartResumeParams } from './structured-agent-session-schemas' +import { + ContinueInterruptedParams, + RestartResumableParams, + RestartResumeParams +} from './structured-agent-session-schemas' import { structuredAgentsReadBy } from './structured-agent-session-policy' export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ @@ -68,6 +72,18 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ ) } }), + defineMethod({ + // Continue on a reply an Orca stop cut off: the same continuation, bound to the cut turn + // instead of an offer. Clients gate it on AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY. + // It names one chat the client shows, as a send does, so no agent audience applies. + name: 'agentSession.continueInterrupted', + params: ContinueInterruptedParams, + handler: async (params, ctx) => { + await ensureStructuredHostInstalled(ctx) + const host = requireStructuredHost(ctx) + return host.restartResume.continueInterrupted(params.sessionId, params.turnItemId) + } + }), defineMethod({ // Reattach only, no send. Reattaching is nothing now — an agent starts only for work — so this // answers that nothing was resumed. No Orca surface calls it, but it is a PUBLISHED wire diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 10b5610e3531..118cfb418f59 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -20,6 +20,7 @@ import { defineMethod } from '../core' import { ensureStructuredHostInstalled, requireStructuredCapability, + requireStructuredAgentAudience, requireStructuredHost } from './structured-agent-session-gate' import { OptionsParams } from './structured-agent-session-schemas' @@ -33,7 +34,9 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ await ensureStructuredHostInstalled(ctx) let revealed: StructuredAgentSessionReveal try { - revealed = await requireStructuredHost(ctx).revealSession(params.sessionId) + const host = requireStructuredHost(ctx) + requireStructuredAgentAudience(ctx, host.sessionAgent(params.sessionId) ?? '') + revealed = await host.revealSession(params.sessionId) } catch (error) { // The host raises its refusal as the code itself; anything else is a genuine fault and // must not be laundered into a tidy "no such chat". diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 43f813b35ee6..c4b3f62700b6 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -16,7 +16,7 @@ import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { RpcRequest, RpcResponse } from '../core' +import type { RpcAnyMethodDeclaration, RpcRequest, RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' @@ -216,7 +216,10 @@ export function hostStub(): StructuredAgentSessionHost { return hostCalls as unknown as StructuredAgentSessionHost } -export function dispatcher(runtimeOverrides: Record = {}): RpcDispatcher { +export function dispatcher( + runtimeOverrides: Record = {}, + methods: readonly RpcAnyMethodDeclaration[] = STRUCTURED_AGENT_SESSION_METHODS +): RpcDispatcher { reset(runtimeCalls) Object.assign(runtimeCalls, { getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })), @@ -254,7 +257,7 @@ export function dispatcher(runtimeOverrides: Record = {}): RpcD } return new RpcDispatcher({ runtime: runtime as unknown as OrcaRuntimeService, - methods: STRUCTURED_AGENT_SESSION_METHODS + methods }) } @@ -269,10 +272,11 @@ export async function call( clientCapabilities?: string[] signal?: AbortSignal }, - runtimeOverrides: Record = {} + runtimeOverrides: Record = {}, + methods?: readonly RpcAnyMethodDeclaration[] ): Promise { const replies: RpcResponse[] = [] - await dispatcher(runtimeOverrides).dispatchStreaming( + await dispatcher(runtimeOverrides, methods).dispatchStreaming( request(method, params), (raw) => replies.push(JSON.parse(raw) as RpcResponse), client diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index f55619350ef1..1f749d50490d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -33,3 +33,4 @@ export { ThreadGoalParams, UnsubscribeParams } from '../../../../shared/rpc-contract/structured-agent-session-params' +export { ContinueInterruptedParams } from '../../../../shared/rpc-contract/structured-agent-session-continue-params' diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts index 019c0d63453f..7f6460bdc556 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts @@ -6,7 +6,7 @@ import { agentSessionSendSubmission } from '../../../../shared/agent-session-wir import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' import type { RpcContext } from '../core' -import { requireStructuredHost, structuredCallerFor } from './structured-agent-session-gate' +import { requireStructuredSessionHost, structuredCallerFor } from './structured-agent-session-gate' /** * A send answers once the host accepts it. A client that predates that answer cannot show a @@ -19,7 +19,7 @@ export async function sendStructuredAgentSessionForClient( params: Parameters[1], context: RpcContext ) { - const host = requireStructuredHost(context) + const host = requireStructuredSessionHost(context, params.envelope.sessionId) // A client's own send: a restart or a close keeps it as a card if it never reached the agent. const result = await host.send(structuredCallerFor(context), { ...params, userSend: true }) const capabilities = context.clientCapabilities ?? [] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts index c93401e0e22c..a4a2de579599 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts @@ -6,6 +6,7 @@ import { defineStreamingMethod, type RpcContext } from '../core' import { requireStructuredHost as requireHost } from './structured-agent-session-gate' import { structuredAgentSessionStatusSubscriptionId } from './structured-agent-session-subscription-id' +import { clientReadsStructuredSessionAgent } from './structured-agent-session-policy' /** Ties a stream to both ends that can close it — the runtime's subscription registry and the * transport abort — so either one runs `onClose` exactly once. */ @@ -53,7 +54,24 @@ export const STRUCTURED_AGENT_SESSION_STATUS_METHODS = [ if (stream.isClosed()) { return } - dispose = host.subscribeStatus({ id: subscriptionId, emit }) + dispose = host.subscribeStatus({ + id: subscriptionId, + emit: (event) => { + if (event.type === 'snapshot') { + emit({ + ...event, + sessions: event.sessions.filter((session) => + clientReadsStructuredSessionAgent(ctx, session.agent) + ) + }) + } else if ( + event.type !== 'status' || + clientReadsStructuredSessionAgent(ctx, event.session.agent) + ) { + emit(event) + } + } + }) if (stream.isClosed()) { dispose() } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts b/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts index b28a51500d21..87ba26fe37b9 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts @@ -5,7 +5,7 @@ import { defineMethod } from '../core' import { - requireStructuredHost as requireHost, + requireStructuredSessionHost as requireSessionHost, structuredCallerFor as callerFor } from './structured-agent-session-gate' import { ThreadGoalParams } from './structured-agent-session-schemas' @@ -14,6 +14,7 @@ export const STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS = [ defineMethod({ name: 'agentSession.threadGoal', params: ThreadGoalParams, - handler: async (params, ctx) => requireHost(ctx).changeThreadGoal(callerFor(ctx), params) + handler: async (params, ctx) => + requireSessionHost(ctx, params.envelope.sessionId).changeThreadGoal(callerFor(ctx), params) }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts index 24f03ac07eee..a5442097f464 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts @@ -17,6 +17,7 @@ import { } from './structured-agent-session-schemas' import { structuredAgentSessionTurnCompletionSubscriptionId } from './structured-agent-session-subscription-id' import { bindStructuredAgentSessionStream } from './structured-agent-session-status-stream' +import { clientReadsStructuredSessionAgent } from './structured-agent-session-policy' export const STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS = [ // Retire only deliveries whose journal cause the client's accepted read covered: the session and @@ -46,7 +47,21 @@ export const STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS = [ } dispose = host.subscribeTurnCompletions({ id: subscriptionId, - emit, + // A client that cannot read an agent's rows is not told that agent's edges either. + emit: (event) => { + const sessionId = + event.type === 'completion' + ? event.completion.sessionId + : event.type === 'prompt' + ? event.prompt.sessionId + : undefined + if ( + sessionId === undefined || + clientReadsStructuredSessionAgent(ctx, host.sessionAgent(sessionId) ?? '') + ) { + emit(event) + } + }, includePrompts: params.includePrompts === true }) if (stream.isClosed()) { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts new file mode 100644 index 000000000000..8d73e3b374fd --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts @@ -0,0 +1,149 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { nativeChatVisualsFolderFor } from '../../../native-chat/native-chat-visuals-folder' +import { ALL_RPC_METHODS } from '.' +import { STRUCTURED_AGENT_SESSION_VISUAL_METHODS } from './structured-agent-session-visual' +import { + call as callStructured, + clearStructuredHostStub, + hostStub, + SESSION, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +type CallClient = Parameters[2] + +function call(method: string, params: unknown, client: CallClient) { + return callStructured(method, params, client, {}, STRUCTURED_AGENT_SESSION_VISUAL_METHODS) +} + +let stateDirectory: string +vi.mock('../../../orca-profiles/profile-storage-paths', () => ({ + getProfileUserDataPath: () => stateDirectory +})) +let record: AgentSessionRecord | null + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-visual-rpc-')) + record = { ...agentSessionRecordFixture(), sessionId: SESSION } + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + deps: { + store: { getRecord: (id: string) => (record?.sessionId === id ? record : null) } + } + }) + ) +}) + +afterEach(async () => { + clearStructuredHostStub() + await rm(stateDirectory, { recursive: true, force: true }) +}) + +async function writeVisual(name: string, html: string): Promise { + const folder = nativeChatVisualsFolderFor(stateDirectory, SESSION) + await mkdir(folder, { recursive: true }) + await writeFile(join(folder, name), html) +} + +describe('agentSession.readVisual', () => { + it('is registered on the runtime manifest', () => { + expect(ALL_RPC_METHODS.map((method) => method.name)).toContain('agentSession.readVisual') + }) + + it("reads a file from the chat's own folder under the host's state directory", async () => { + await writeVisual('chart.html', '

chart

') + const reply = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + expect(reply).toMatchObject({ + ok: true, + result: { ok: true, html: '

chart

', sizeBytes: 12 } + }) + }) + + it('answers unchanged for the revision the client holds', async () => { + await writeVisual('chart.html', '

chart

') + const first = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + const result: unknown = first.ok ? first.result : null + const revision = + typeof result === 'object' && result !== null && 'revision' in result + ? String(result.revision) + : '' + const again = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html', knownRevision: revision }, + STRUCTURED_CLIENT + ) + expect(again).toMatchObject({ ok: true, result: { ok: true, unchanged: true, revision } }) + const againResult: unknown = again.ok ? again.result : null + expect(typeof againResult === 'object' && againResult !== null && 'html' in againResult).toBe( + false + ) + }) + + it('reports a session this host has no record of', async () => { + record = null + const reply = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + expect(reply).toMatchObject({ ok: true, result: { ok: false, error: 'session_not_found' } }) + }) + + it('refuses a chat recorded on another execution host or a WSL distro', async () => { + const base = agentSessionRecordFixture() + record = { + ...base, + sessionId: SESSION, + location: { ...base.location, executionHostId: 'ssh:box' } + } + const ssh = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + expect(ssh).toMatchObject({ ok: true, result: { ok: false, error: 'unsupported_location' } }) + + record = { ...base, sessionId: SESSION, location: { ...base.location, wslDistro: 'Ubuntu' } } + const wsl = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + expect(wsl).toMatchObject({ ok: true, result: { ok: false, error: 'unsupported_location' } }) + }) + + it.each([ + ['a path', { sessionId: SESSION, file: '../chart.html' }], + ['a non-html file', { sessionId: SESSION, file: 'chart.txt' }], + ['a bad session id', { sessionId: '../x', file: 'chart.html' }], + ['an unknown field', { sessionId: SESSION, file: 'chart.html', path: '/etc' }], + ['a malformed revision', { sessionId: SESSION, file: 'chart.html', knownRevision: 'zz' }] + ])('rejects %s as invalid params', async (_name, params) => { + const reply = await call('agentSession.readVisual', params, STRUCTURED_CLIENT) + expect(reply).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + }) + + it('is refused to a client that cannot read structured sessions', async () => { + await writeVisual('chart.html', '

chart

') + const reply = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + { clientKind: 'runtime', clientCapabilities: [] } + ) + expect(reply.ok).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-visual.ts b/src/main/runtime/rpc/methods/structured-agent-session-visual.ts new file mode 100644 index 000000000000..ed486013a65d --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-visual.ts @@ -0,0 +1,42 @@ +// `agentSession.readVisual` — one HTML visual from a chat's own visuals folder. +// +// Additive: an older host answers `method_not_found` (a phone gets `forbidden` from the mobile +// allowlist gate on a host without the entry), and the client shows the visual as unavailable. +// The host resolves the folder from its own state directory and record; a client supplies only the +// session id and a bare file name. + +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { + ReadVisualParams, + type AgentSessionReadVisualResult +} from '../../../../shared/rpc-contract/agent-session-visual-params' +import { nativeChatVisualsFolderFor } from '../../../native-chat/native-chat-visuals-folder' +import { getProfileUserDataPath } from '../../../orca-profiles/profile-storage-paths' +import { readNativeChatVisualFile } from '../../../native-chat/native-chat-visual-file-read' +import { defineMethod } from '../core' +import { requireInstalledStructuredHost } from './structured-agent-session-gate' + +export const STRUCTURED_AGENT_SESSION_VISUAL_METHODS = [ + defineMethod({ + name: 'agentSession.readVisual', + params: ReadVisualParams, + handler: async (params, ctx): Promise => { + const host = await requireInstalledStructuredHost(ctx) + const record = host.deps.store.getRecord(params.sessionId) + if (!record) { + return { ok: false, error: 'session_not_found' } + } + // Structured chats run on their owning runtime's own filesystem; any other location has no + // visuals folder this process can read. + if ( + record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID || + record.location.wslDistro + ) { + return { ok: false, error: 'unsupported_location' } + } + // The same state directory the chat host and its journal are opened in on this process. + const folder = nativeChatVisualsFolderFor(getProfileUserDataPath(), params.sessionId) + return readNativeChatVisualFile(folder, params.file, params.knownRevision) + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 370c0bb3f55e..4bbd415cd025 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -175,7 +175,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(33) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(34) }) it('hides the surface from a declared client that did not advertise it', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index d2da7b0f92c2..8536cf27ec0a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -21,9 +21,11 @@ import { ensureStructuredHostInstalled as ensureHostInstalled, requireInstalledStructuredHost as requireInstalledHost, requireStructuredCapability, + requireStructuredAgentAudience, requireStructuredCleanupHost, requireStructuredCreateSupportAdmission, requireStructuredHost as requireHost, + requireStructuredSessionHost as requireSessionHost, structuredCallerFor as callerFor } from './structured-agent-session-gate' import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' @@ -56,7 +58,6 @@ import { CreateParams, CreateSupportParams, HistoryParams, - HandoffStatusParams, OptionsParams, RespondParams, RespondToQuestionParams, @@ -75,8 +76,8 @@ import { sendStructuredAgentSessionForClient } from './structured-agent-session- * cannot forge that answer. */ async function resolveClientSuppliedAttach(params: z.infer, ctx: RpcContext) { - await ensureHostInstalled(ctx) - const host = requireHost(ctx) + await ensureHostInstalled(ctx, params.agent) + const host = requireHost(ctx, params.agent) if (!host.supportsCreate(params.location, params.agent)) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' @@ -106,7 +107,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ handler: async (params, ctx) => { requireStructuredCapability(ctx) await ensureHostInstalled(ctx) - return requireHost(ctx).rewind(callerFor(ctx), params) + return requireSessionHost(ctx, params.envelope.sessionId).rewind(callerFor(ctx), params) } }), defineMethod({ @@ -115,7 +116,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ handler: async (params, ctx) => { requireStructuredCapability(ctx) await ensureHostInstalled(ctx) - const host = requireHost(ctx) + const host = requireSessionHost(ctx, params.envelope.sessionId) await host.revealSession(params.envelope.sessionId) const result = await host.conversationCommand(callerFor(ctx), params) if (result.ok && result.value.command === 'clear' && result.value.replacementSessionId) { @@ -134,7 +135,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.createSupport', params: CreateSupportParams, handler: async (params, ctx) => { - requireStructuredCreateSupportAdmission(ctx) + requireStructuredCreateSupportAdmission(ctx, params.agent) const support = await ctx.runtime.getStructuredAgentSessionCreateSupport( params.worktree, params.agent @@ -150,7 +151,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.create', params: CreateParams, handler: async (params, ctx) => { - requireStructuredCapability(ctx) + requireStructuredAgentAudience(ctx, params.agent) if (params.envelope.expectedRuntimeFence !== null) { throw agentSessionRefusalError('agent_session_operation_invalid', { reason: 'requestMalformed' @@ -231,35 +232,31 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.respondToApproval', params: RespondParams, handler: async (params, ctx) => - requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'approval' }) + requireSessionHost(ctx, params.envelope.sessionId).respondToPrompt(callerFor(ctx), { + ...params, + kind: 'approval' + }) }), defineMethod({ name: 'agentSession.respondToQuestion', params: RespondToQuestionParams, handler: async (params, ctx) => - requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'question' }) + requireSessionHost(ctx, params.envelope.sessionId).respondToPrompt(callerFor(ctx), { + ...params, + kind: 'question' + }) }), defineMethod({ name: 'agentSession.setOption', params: SetOptionParams, - handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params) - }), - defineMethod({ - name: 'agentSession.handoffStatus', - params: HandoffStatusParams, handler: async (params, ctx) => - (await requireInstalledHost(ctx)).handoffStatus(params.sessionId) - }), - defineMethod({ - name: 'agentSession.commands', - params: OptionsParams, - handler: async (params, ctx) => (await requireInstalledHost(ctx)).readCommands(params.sessionId) + requireSessionHost(ctx, params.envelope.sessionId).setOption(callerFor(ctx), params) }), defineMethod({ name: 'agentSession.history', params: HistoryParams, handler: async (params, ctx) => { - const host = await requireInstalledHost(ctx) + const host = await requireInstalledHost(ctx, params.sessionId) return projectTurnItemHistory( projectBackgroundTaskHistory(await host.history(params), ctx), ctx, @@ -271,7 +268,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.subscribe', params: SubscribeParams, handler: async (params, ctx, emit) => { - const host = await requireInstalledHost(ctx) + const host = await requireInstalledHost(ctx, params.sessionId) const subscriptionId = subscriptionIdFor(ctx, params.sessionId) // A stream reads; it never keeps an agent alive or starts one. let dispose = (): void => {} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-originating-pane-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-originating-pane-methods.ts index a4897ea3ca6c..2619d9df000f 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-originating-pane-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-originating-pane-methods.ts @@ -4,7 +4,7 @@ import { navigationTargetsHost, resolveRuntimeNavigationTarget } from '../../../../../shared/runtime-navigation' -import { TERMINAL_ORIGINATING_PANE_NAVIGATION_RUNTIME_CAPABILITY } from '../../../../../shared/protocol-version' +import { TERMINAL_ORIGINATING_PANE_NAVIGATION_RUNTIME_CAPABILITY } from '../../../../../shared/terminal-navigation-runtime-capabilities' export const TERMINAL_ORIGINATING_PANE_METHODS = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index 623d553d3d61..54336dcbd68f 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -36,6 +36,7 @@ export function buildManagedWorktreeCreateArgs( linkedAzureDevOpsPR: params.linkedAzureDevOpsPR, linkedGiteaPR: params.linkedGiteaPR, linkedWorkItem: params.linkedWorkItem, + linkedItems: params.linkedItems, linkedTaskSourceContext: params.linkedTaskSourceContext, comment: params.comment, displayName: params.displayName, diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 11cdba088902..39bc3d0618d4 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -157,6 +157,13 @@ export const WORKTREE_METHODS = [ linkedAzureDevOpsPR: params.linkedAzureDevOpsPR, linkedGiteaPR: params.linkedGiteaPR, linkedWorkItem: params.linkedWorkItem, + linkedItems: params.linkedItems, + ...(params.linkedItemsBase !== undefined + ? { linkedItemsBase: params.linkedItemsBase } + : {}), + ...(params.linkedItemsSelectionChanged !== undefined + ? { linkedItemsSelectionChanged: params.linkedItemsSelectionChanged } + : {}), linkedTaskSourceContext: params.linkedTaskSourceContext, comment: params.comment, isArchived: params.isArchived, @@ -181,7 +188,7 @@ export const WORKTREE_METHODS = [ noParent: params.noParent === true } : undefined - } as Parameters[1]) + }) }) }), defineMethod({ diff --git a/src/main/runtime/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index c824301e5a5f..248adf2c18f1 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -2,7 +2,9 @@ import type { ClaudeAccountService } from '../claude-accounts/service' import { hasAppEnvironment } from '../../shared/app-environment' import { getManagedDataAccountService } from '../managed-data-accounts/service' import type { + ClaudeAccountSignIn, ClaudeRateLimitAccountsState, + ClaudeSignInRequest, CodexRateLimitAccountsState, ManagedDataAccountProvider, ManagedDataAccountsState @@ -164,15 +166,18 @@ export class RuntimeAccountController { return this.requireServices().claudeAccounts.removeAccount(accountId) } - addClaudeFromConfigDir( - configDir: string, - options?: { - runtime?: 'host' | 'wsl' - wslDistro?: string | null - previousLegacyCredentialsSha256?: string | null - } + beginClaudeSignIn(request: ClaudeSignInRequest): Promise { + return this.requireServices().claudeAccounts.beginSignIn(request) + } + + finishClaudeSignIn( + signIn: Omit ): Promise { - return this.requireServices().claudeAccounts.addAccountFromConfigDir(configDir, options) + return this.requireServices().claudeAccounts.finishSignIn(signIn) + } + + cancelClaudeSignIn(signIn: Omit): Promise { + return this.requireServices().claudeAccounts.cancelSignIn(signIn) } removeCodex(accountId: string): Promise { diff --git a/src/main/runtime/runtime-browser-commands-browser-screencast.ts b/src/main/runtime/runtime-browser-commands-browser-screencast.ts index d77f2c134109..e57e59100928 100644 --- a/src/main/runtime/runtime-browser-commands-browser-screencast.ts +++ b/src/main/runtime/runtime-browser-commands-browser-screencast.ts @@ -17,6 +17,7 @@ import type { BrowserEvalResult, BrowserScreencastResult } from '../../shared/ru import { BrowserError } from '../browser/browser-error' import { randomUUID } from 'node:crypto' import { startBrowserScreencast } from '../browser/browser-screencast-stream' +import { rendererPublicationThrottle } from '../window/renderer-publication-throttle' import { sendRemoteBrowserScreencastFrame } from './remote-browser-screencast-frame-admission' import { INITIAL_SCREENCAST_SUBSCRIBER_DELIVERY, @@ -69,6 +70,9 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC viewportOwnerSubscriptionId: null, appliedBudget: budget } as ActiveBrowserScreencastPage + // Why: guest frames come from the window's compositor, which a throttled hidden window stops. + const win = this.host.getAvailableAuthoritativeWindow() + const releaseThrottle = win ? rendererPublicationThrottle.acquire(win.webContents) : () => {} record.started = startBrowserScreencast(guest, { format: params.format, ...budget, @@ -103,6 +107,12 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC for (const subscriber of record.subscribers.values()) { subscriber.emit?.({ type: 'error', message }) } + // Why: every stream error is terminal; marking it stopping makes a newcomer start a fresh + // stream instead of joining this one and waiting on frames that will never come. + if (record.session) { + record.stopping = true + record.session.stop() + } } }) active = record @@ -113,6 +123,7 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC return session.done }) .finally(() => { + releaseThrottle() if (this.activeScreencastsByPageId.get(browserPageId) === record) { this.activeScreencastsByPageId.delete(browserPageId) } diff --git a/src/main/runtime/runtime-folder-workspace.ts b/src/main/runtime/runtime-folder-workspace.ts index 4ecdbc80a8bc..cce0d0f73ac7 100644 --- a/src/main/runtime/runtime-folder-workspace.ts +++ b/src/main/runtime/runtime-folder-workspace.ts @@ -55,6 +55,7 @@ export function mergeRuntimeFolderWorkspace( linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null, linkedGiteaPR: meta.linkedGiteaPR ?? null, linkedWorkItem: meta.linkedWorkItem ?? null, + ...(meta.linkedItems !== undefined ? { linkedItems: meta.linkedItems } : {}), linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, isArchived: meta.isArchived ?? false, isUnread: meta.isUnread ?? false, diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index 34c8d91432d1..6534f84e1990 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -108,6 +108,7 @@ export async function createRuntimeFolderWorktree(args: { : {}), ...(request.linkedGiteaPR !== undefined ? { linkedGiteaPR: request.linkedGiteaPR } : {}), ...(request.linkedWorkItem !== undefined ? { linkedWorkItem: request.linkedWorkItem } : {}), + ...(request.linkedItems !== undefined ? { linkedItems: request.linkedItems } : {}), ...(request.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: request.linkedTaskSourceContext } : {}), diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index 979b05b90907..834962fc6b14 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -90,9 +90,21 @@ import { createWorktreeCreateTimingRecorder, type WorktreeCreateTimingRecorder } from '../worktree-create-timing' +import type { SparsePreset } from '../../shared/worktree/create-types' const worktreePath = resolve('/worktrees', 'app') +const writtenMeta: Partial[] = [] +const WEB_PRESET: SparsePreset = { + id: 'preset-web', + repoId: 'repo-1', + name: 'web', + directories: ['apps/web', 'packages/ui'], + createdAt: 0, + updatedAt: 0 +} +const readSparsePresets = vi.fn<(repoId: string) => SparsePreset[]>() + function createWorktree( request: Partial = {}, rearm: PreparationRearmHolder = { fire: () => {} }, @@ -106,7 +118,11 @@ function createWorktree( refreshLocalBaseRefOnWorktreeCreate: false, branchPrefix: '' }), - setWorktreeMeta: (_id: string, updates: Partial) => updates + setWorktreeMeta: (_id: string, updates: Partial) => { + writtenMeta.push(updates) + return updates + }, + getSparsePresets: (repoId: string): SparsePreset[] => readSparsePresets(repoId) } return createRuntimeLocalManagedWorktree({ request: { repoSelector: 'repo-1', name: 'app', baseBranch: 'main', ...request }, @@ -162,6 +178,7 @@ beforeEach(() => { mocks.resolveInclude.mockResolvedValue(['.env']) mocks.copyPaths.mockResolvedValue([]) mocks.effectiveHooks.mockReturnValue(null) + readSparsePresets.mockReturnValue([WEB_PRESET]) }) describe('runtime prepared-worktree replenishment', () => { @@ -336,6 +353,30 @@ describe('runtime create Git priority', () => { } }) + it.each([ + ['exactly its directories', ['packages/ui', 'apps/web'], 'preset-web'], + ['an edited selection', ['apps/web'], undefined] + ])('records the sparse preset only for %s', async (_case, directories, recorded) => { + writtenMeta.length = 0 + await createWorktree({ sparseCheckout: { directories, presetId: 'preset-web' } }) + + expect(writtenMeta.find((meta) => meta.sparseDirectories)?.sparsePresetId).toBe(recorded) + }) + + it('still creates the worktree, with no sparse preset, when presets cannot be read', async () => { + writtenMeta.length = 0 + readSparsePresets.mockImplementation(() => { + throw new Error('corrupt presets') + }) + await createWorktree({ + sparseCheckout: { directories: ['apps/web', 'packages/ui'], presetId: 'preset-web' } + }) + + const sparseMeta = writtenMeta.find((meta) => meta.sparseDirectories) + expect(sparseMeta?.sparseDirectories).toEqual(['apps/web', 'packages/ui']) + expect(sparseMeta?.sparsePresetId).toBeUndefined() + }) + it('preserves priority for sparse creates and remote base refreshes', async () => { const base = { remote: 'origin', diff --git a/src/main/runtime/runtime-local-worktree-materialization.ts b/src/main/runtime/runtime-local-worktree-materialization.ts index 9347a7e7ffce..04a064589aab 100644 --- a/src/main/runtime/runtime-local-worktree-materialization.ts +++ b/src/main/runtime/runtime-local-worktree-materialization.ts @@ -22,6 +22,7 @@ import { resolveWorktreeSharedDirectories } from '../git/worktree-shared-directo import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' import type { RemoteTrackingBase } from './runtime-remote-fetch-controller' import type { RuntimeStore } from './runtime-store-contract' +import { attributedSparsePresetId } from '../ipc/sparse-preset-attribution' import type { WorktreeCreateTimingRecorder } from '../worktree-create-timing' export async function materializeRuntimeLocalWorktree(args: { @@ -91,7 +92,12 @@ export async function materializeRuntimeLocalWorktree(args: { ? { sparseDirectories, sparseBaseRef: metadataBaseRef, - sparsePresetId: request.sparseCheckout?.presetId + sparsePresetId: attributedSparsePresetId( + () => store.getSparsePresets?.(repo.id) ?? [], + repo.id, + request.sparseCheckout?.presetId, + sparseDirectories + ) } : {}), ...(request.linkedIssue !== undefined ? { linkedIssue: request.linkedIssue } : {}), @@ -117,6 +123,7 @@ export async function materializeRuntimeLocalWorktree(args: { : {}), ...(request.linkedGiteaPR !== undefined ? { linkedGiteaPR: request.linkedGiteaPR } : {}), ...(request.linkedWorkItem !== undefined ? { linkedWorkItem: request.linkedWorkItem } : {}), + ...(request.linkedItems !== undefined ? { linkedItems: request.linkedItems } : {}), ...(request.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: request.linkedTaskSourceContext } : {}), diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index 08a151bd88ee..6b9c2bc8d037 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -5,6 +5,7 @@ import type { CliWorkspaceProvenance, GitPushTarget, WorkspaceLinkedItem, + WorkspaceAttachment, Worktree } from '../../shared/worktree/types' import type { TuiAgent } from '../../shared/tui-agent' @@ -33,6 +34,7 @@ export type RuntimeManagedWorktreeCreateArgs = { linkedAzureDevOpsPR?: number | null linkedGiteaPR?: number | null linkedWorkItem?: WorkspaceLinkedItem | null + linkedItems?: WorkspaceAttachment[] linkedTaskSourceContext?: TaskSourceContext | null comment?: string displayName?: string diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index 0777e7e1e7da..973da646e87c 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -119,7 +119,8 @@ export type RuntimeNotifier = { newLeafId?: string } ): void - renameTerminal(tabId: string, title: string | null): void + /** `recordInteraction: false` marks a title the host applied, not one the user typed. */ + renameTerminal(tabId: string, title: string | null, options?: { recordInteraction: false }): void focusTerminal(tabId: string, worktreeId: string, leafId?: string | null): void focusEditorTab?(tabId: string, worktreeId: string): void closeSessionTab?(tabId: string, worktreeId: string): void | Promise diff --git a/src/main/runtime/runtime-project-group-controller.ts b/src/main/runtime/runtime-project-group-controller.ts index 2530ef9ad158..f866ccfde866 100644 --- a/src/main/runtime/runtime-project-group-controller.ts +++ b/src/main/runtime/runtime-project-group-controller.ts @@ -1,3 +1,4 @@ +import type { WorkspaceAttachmentMutation } from '../../shared/workspace-attachment-mutation' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' @@ -29,6 +30,7 @@ type FolderWorkspaceUpdates = Partial< | 'name' | 'folderPath' | 'linkedTask' + | 'linkedItems' | 'linkedTaskSourceContext' | 'comment' | 'isArchived' @@ -43,7 +45,8 @@ type FolderWorkspaceUpdates = Partial< | 'lastActivityAt' | 'diffComments' > -> +> & + WorkspaceAttachmentMutation export class RuntimeProjectGroupController { constructor(private readonly deps: RuntimeProjectGroupDependencies) {} @@ -126,6 +129,7 @@ export class RuntimeProjectGroupController { connectionId?: string | null creatorProvenance?: FolderWorkspace['creatorProvenance'] linkedTask?: FolderWorkspace['linkedTask'] + linkedItems?: FolderWorkspace['linkedItems'] linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] createdWithAgent?: FolderWorkspace['createdWithAgent'] pendingFirstAgentMessageRename?: boolean diff --git a/src/main/runtime/runtime-remote-worktree-create-request.ts b/src/main/runtime/runtime-remote-worktree-create-request.ts index 0f12caff28c8..a40bc4b0513e 100644 --- a/src/main/runtime/runtime-remote-worktree-create-request.ts +++ b/src/main/runtime/runtime-remote-worktree-create-request.ts @@ -55,6 +55,7 @@ export async function requestRuntimeRemoteWorktree( : {}), ...(args.linkedGiteaPR != null ? { linkedGiteaPR: args.linkedGiteaPR } : {}), ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), + ...(args.linkedItems !== undefined ? { linkedItems: args.linkedItems } : {}), ...(args.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: args.linkedTaskSourceContext } : {}), diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts index 53c0a8717031..56b30d837a5d 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts @@ -23,6 +23,8 @@ export const MOBILE_AGENT_SESSION_RPC_METHODS = [ 'agentSession.unsubscribe', // Every session's status on one stream: the phone's chat reads the host's "Stopping…" from it. 'agentSession.subscribeStatus', + // One visual from the chat's own visuals folder, for the transcript's `::orca-visual` lines. + 'agentSession.readVisual', // No-ops on a current host; kept until MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION passes the // mobile builds that still call them. 'agentSession.hold', diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index c62fc799dd40..966976e1a946 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -54,7 +54,9 @@ export type RuntimeServiceCommandSurface = { selectCodexAccountForTarget: RuntimeAccountController['selectCodexForTarget'] consumeCodexRateLimitResetCredit: RuntimeAccountController['consumeCodexResetCredit'] removeClaudeAccount: RuntimeAccountController['removeClaude'] - addClaudeAccountFromConfigDir: RuntimeAccountController['addClaudeFromConfigDir'] + beginClaudeSignIn: RuntimeAccountController['beginClaudeSignIn'] + finishClaudeSignIn: RuntimeAccountController['finishClaudeSignIn'] + cancelClaudeSignIn: RuntimeAccountController['cancelClaudeSignIn'] removeCodexAccount: RuntimeAccountController['removeCodex'] addCodexAccountFromHome: RuntimeAccountController['addCodexFromHome'] addDataAccountFromHome: RuntimeAccountController['addDataFromHome'] @@ -154,7 +156,9 @@ export function installRuntimeServiceCommandSurface( selectCodexAccountForTarget: accounts.selectCodexForTarget.bind(accounts), consumeCodexRateLimitResetCredit: accounts.consumeCodexResetCredit.bind(accounts), removeClaudeAccount: accounts.removeClaude.bind(accounts), - addClaudeAccountFromConfigDir: accounts.addClaudeFromConfigDir.bind(accounts), + beginClaudeSignIn: accounts.beginClaudeSignIn.bind(accounts), + finishClaudeSignIn: accounts.finishClaudeSignIn.bind(accounts), + cancelClaudeSignIn: accounts.cancelClaudeSignIn.bind(accounts), removeCodexAccount: accounts.removeCodex.bind(accounts), addCodexAccountFromHome: accounts.addCodexFromHome.bind(accounts), addDataAccountFromHome: accounts.addDataFromHome.bind(accounts), diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index a3787e7f3020..24940d7f2dbb 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -130,6 +130,7 @@ export type RuntimeStore = { hostSettingOverrides?: GlobalSettings['hostSettingOverrides'] agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled'] nativeChatSessionOptions?: GlobalSettings['nativeChatSessionOptions'] + nativeChatInlineVisuals?: GlobalSettings['nativeChatInlineVisuals'] nativeChatInheritShellEnvironment?: GlobalSettings['nativeChatInheritShellEnvironment'] nativeChatShellEnvironmentVariables?: GlobalSettings['nativeChatShellEnvironmentVariables'] aiVaultSearch?: GlobalSettings['aiVaultSearch'] diff --git a/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts b/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts new file mode 100644 index 000000000000..3ae0cd8e1b95 --- /dev/null +++ b/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { + provisionWorktreeTerminals, + type WorktreeTerminalProvisioningHost +} from './runtime-worktree-terminal-provisioning' + +const DEFAULT_TABS = { + runCommands: true, + tabs: [ + { title: 'Dev', command: 'pnpm dev', color: '#ff0000' }, + { title: 'Tests', command: 'pnpm test', color: '#00ff00' } + ] +} + +function fakeHost() { + let created = 0 + return { + canSpawn: () => true, + createTerminal: vi.fn(async () => { + created += 1 + return { handle: `term_${created}` } + }), + splitTerminal: vi.fn(), + setTabTitle: vi.fn(async (_handle: string, _title: string) => {}), + setTabColor: vi.fn(async (_handle: string, _color: string) => {}), + getSettings: () => ({}), + getPtyId: () => undefined, + recordSetupCompletionToken: vi.fn() + } satisfies Partial> +} + +function provision(host: ReturnType, startup: boolean) { + return provisionWorktreeTerminals( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every host member provisioning reaches for default tabs with no setup. + host as unknown as WorktreeTerminalProvisioningHost, + { + worktreeSelector: 'id:wt-1', + worktreeId: 'wt-1', + worktreePath: '/worktrees/wt-1', + defaultTabs: DEFAULT_TABS, + primaryTerminalHandle: startup ? 'term_agent' : null, + hasStartupTerminal: startup, + setupCommandPlatform: 'posix' + } + ) +} + +describe('default tabs beside the agent a create started', () => { + it('makes the agent the first default tab, as the window lays it out', async () => { + const host = fakeHost() + await provision(host, true) + + expect(host.setTabTitle).toHaveBeenCalledWith('term_agent', 'Dev') + expect(host.setTabColor).toHaveBeenCalledWith('term_agent', '#ff0000') + // The first template's command never runs beside the agent; only the rest are created. + expect(host.createTerminal).toHaveBeenCalledTimes(1) + expect(host.createTerminal).toHaveBeenCalledWith('id:wt-1', { + title: 'Tests', + command: 'pnpm test' + }) + }) + + it('dresses every created default tab the same way', async () => { + const host = fakeHost() + await provision(host, false) + + expect(host.createTerminal).toHaveBeenCalledTimes(2) + expect(host.setTabTitle.mock.calls).toEqual([ + ['term_1', 'Dev'], + ['term_2', 'Tests'] + ]) + expect(host.setTabColor.mock.calls).toEqual([ + ['term_1', '#ff0000'], + ['term_2', '#00ff00'] + ]) + }) + + it('still colors the tab and creates the rest when titling fails', async () => { + const host = fakeHost() + host.setTabTitle.mockRejectedValue(new Error('terminal_tab_unresolved')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect(provision(host, true)).resolves.toEqual({ + setupSpawned: false, + setupTerminalHandle: null + }) + + expect(host.setTabColor).toHaveBeenCalledWith('term_agent', '#ff0000') + expect(host.createTerminal).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + '[worktree-create] Failed to title a default tab for wt-1:', + expect.any(Error) + ) + warn.mockRestore() + }) +}) diff --git a/src/main/runtime/runtime-worktree-terminal-provisioning.ts b/src/main/runtime/runtime-worktree-terminal-provisioning.ts index 248757f863a8..3110c5228c65 100644 --- a/src/main/runtime/runtime-worktree-terminal-provisioning.ts +++ b/src/main/runtime/runtime-worktree-terminal-provisioning.ts @@ -5,7 +5,7 @@ import { buildObservedSetupCommand } from './orchestration/setup-completion-sign import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' import type { RuntimeStore } from './runtime-store-contract' -type TerminalResult = { handle: string; tabId?: string | null } +type TerminalResult = { handle: string } export type WorktreeProvisionTerminalOptions = { title?: string command?: string @@ -25,7 +25,10 @@ export type WorktreeTerminalProvisioningHost = { handle: string, options: WorktreeProvisionTerminalOptions ) => Promise - setTabColor: (worktreeId: string, tabId: string, color: string) => Promise + // Why: keyed by the terminal; the host resolves its tab, so no create path can forget to pass it. + // The title is the tab's own, as the window's setTabCustomTitle sets it, never the pane's title. + setTabTitle: (handle: string, title: string) => Promise + setTabColor: (handle: string, color: string) => Promise getSettings: () => ReturnType getPtyId: (handle: string) => string | undefined recordSetupCompletionToken: (ptyId: string, token: string) => void @@ -51,13 +54,20 @@ export async function createWorktreeDefaultTabTerminals( selector: string, worktreeId: string, defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined, - surfacing: { surfaceOwner?: false } = {} + surfacing: { surfaceOwner?: false } = {}, + startupHandle?: string ): Promise { if (!defaultTabs || defaultTabs.tabs.length === 0 || !host.canSpawn()) { return [] } + const [first, ...rest] = defaultTabs.tabs + // The agent the create started is the workspace's first tab, as the window lays it out: it takes + // the first template's title and color, and that template's command does not run beside it. + if (startupHandle && first) { + await dressAsDefaultTab(host, worktreeId, startupHandle, first) + } const handles: string[] = [] - for (const template of defaultTabs.tabs) { + for (const template of startupHandle ? rest : defaultTabs.tabs) { try { const command = template.command?.trim() const terminal = await host.createTerminal(selector, { @@ -66,9 +76,7 @@ export async function createWorktreeDefaultTabTerminals( ...surfacing }) handles.push(terminal.handle) - if (template.color && terminal.tabId) { - await host.setTabColor(worktreeId, terminal.tabId, template.color) - } + await dressAsDefaultTab(host, worktreeId, terminal.handle, template) } catch (error) { console.warn(`[worktree-create] Failed to create default tab for ${worktreeId}:`, error) } @@ -76,6 +84,30 @@ export async function createWorktreeDefaultTabTerminals( return handles } +// Why: a tab's look is bookkeeping; a failure is logged and never costs the tab, the other field, +// or the create. +async function dressAsDefaultTab( + host: WorktreeTerminalProvisioningHost, + worktreeId: string, + handle: string, + template: NonNullable['tabs'][number] +): Promise { + if (template.title) { + try { + await host.setTabTitle(handle, template.title) + } catch (error) { + console.warn(`[worktree-create] Failed to title a default tab for ${worktreeId}:`, error) + } + } + if (template.color) { + try { + await host.setTabColor(handle, template.color) + } catch (error) { + console.warn(`[worktree-create] Failed to color a default tab for ${worktreeId}:`, error) + } + } +} + export async function provisionWorktreeTerminals( host: WorktreeTerminalProvisioningHost, args: WorktreeTerminalProvisioningArgs @@ -92,7 +124,8 @@ export async function provisionWorktreeTerminals( args.worktreeSelector, args.worktreeId, args.defaultTabs, - surfacing + surfacing, + args.hasStartupTerminal ? (args.primaryTerminalHandle ?? undefined) : undefined ) let primaryHandle = args.primaryTerminalHandle ?? defaultHandles[0] ?? null const setupLaunchMode = diff --git a/src/main/runtime/single-page-browser-commands-host-test-double.ts b/src/main/runtime/single-page-browser-commands-host-test-double.ts new file mode 100644 index 000000000000..dae6f73e02e1 --- /dev/null +++ b/src/main/runtime/single-page-browser-commands-host-test-double.ts @@ -0,0 +1,34 @@ +import { vi } from 'vitest' +import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import type { RuntimeBrowserCommandHost } from './orca-runtime-browser' +import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry' + +// The part of the authoritative window a browser stream touches: its renderer's throttle. +export type AuthoritativeWindowDouble = { + webContents: { isDestroyed: () => boolean; setBackgroundThrottling: (allowed: boolean) => void } +} + +/** A browser command host whose worktree `wt-1` has one registered page, `page-1`. */ +export function createSinglePageBrowserCommandsHost( + window: AuthoritativeWindowDouble | null = null +): RuntimeBrowserCommandHost { + const runtimeBrowserPages = new RuntimeBrowserPageRegistry() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: page commands read only these bridge members. + const bridge = { + getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]])), + getActivePageId: vi.fn(() => 'page-1'), + tabList: vi.fn(() => ({ + tabs: [ + { browserPageId: 'page-1', index: 0, url: 'about:blank', title: 'Browser', active: true } + ] + })) + } as unknown as AgentBrowserBridge + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: page commands read only these host members. + return { + resolveWorktreeSelector: async () => ({ id: 'wt-1' }), + getAgentBrowserBridge: () => bridge, + getRuntimeBrowserPageRegistry: () => runtimeBrowserPages, + getAvailableAuthoritativeWindow: vi.fn(() => window), + getOffscreenBrowserBackend: vi.fn(() => null) + } as unknown as RuntimeBrowserCommandHost +} diff --git a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts new file mode 100644 index 000000000000..97f258120726 --- /dev/null +++ b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts @@ -0,0 +1,137 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' +import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' +import { resolveHostStructuredAgentCreateSupport } from './structured-agent-launch-support' + +// Live QA run 5 on #25845: the app launched with HOME and every XDG directory pointed into the rig, +// and OpenCode's Command and per-agent PATH both naming a private 1.18.31. The host's whole create +// check, with a real `--version` against stand-in scripts, must admit that OpenCode. + +const { loginShell } = vi.hoisted(() => { + const loginShell: { env: Record } = { env: {} } + return { loginShell } +}) +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: async () => loginShell.env +})) + +let root: string +let privateOpencode: string + +/** A stand-in `opencode` that answers `--version` with `version`; never a real agent CLI. */ +async function fakeOpencode(dir: string, version: string): Promise { + await mkdir(dir, { recursive: true }) + const file = join(dir, 'opencode') + await writeFile(file, `#!/bin/sh\necho ${version}\n`) + await chmod(file, 0o755) + return file +} + +function rigSettings(command: string, extraEnv: Record = {}) { + return { + nativeChatInheritShellEnvironment: true, + nativeChatShellEnvironmentVariables: [], + agentCmdOverrides: { opencode: command }, + agentDefaultEnv: { + opencode: { PATH: `${join(root, 'oc-prefix', 'bin')}:/usr/bin:/bin`, ...extraEnv } + } + } +} + +const managedAccounts = { + list: () => ({ accounts: [], activeAccountId: null }), + restoreOriginalEnvironment: restoreManagedDataAccountEnvironment, + environmentForAccount: () => ({}) +} + +/** OpenCode's account binding, as create resolves it: the account a new chat would pin. */ +function pinnedAccount(launchEnv: Record) { + return openCodeAcpAccountBinding(() => managedAccounts).resolve({ launchEnv }) +} + +/** `agentSession.createSupport` on this host for a local git worktree under the rig's settings. */ +function createSupport(settings: ReturnType) { + return resolveHostStructuredAgentCreateSupport({ + agent: 'opencode', + worktreeSelector: 'id:workspace-1', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + runtime: { + requireStore: () => ({ getSettings: () => settings }), + resolveRuntimeFileTarget: async () => ({ worktree: { path: join(root, 'proj') } }) + } + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-opencode-rig-')) + await mkdir(join(root, 'proj')) + const homebrew = await fakeOpencode(join(root, 'homebrew'), '2.0.21') + privateOpencode = await fakeOpencode(join(root, 'oc-prefix', 'bin'), '1.18.31') + const home = join(root, 'home-r7') + loginShell.env = { + PATH: `${join(homebrew, '..')}:/usr/bin:/bin`, + HOME: home, + XDG_CONFIG_HOME: join(home, '.config'), + XDG_DATA_HOME: join(home, '.local', 'share'), + XDG_STATE_HOME: join(home, '.local', 'state'), + XDG_CACHE_HOME: join(home, '.cache') + } +}) +afterEach(async () => { + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +describe.skipIf(process.platform === 'win32')('OpenCode create support under the QA rig', () => { + it('admits the private 1.18.31 the Command setting names, with every XDG directory set', async () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}) + + expect(await createSupport(rigSettings(privateOpencode))).toEqual({ supported: true }) + expect(info).toHaveBeenCalledWith( + `[agent-cli-version] ${privateOpencode} --version: 1.18.31 is supported` + ) + }) + + it('admits the 2.x first on the shell PATH when the Command names it', async () => { + vi.spyOn(console, 'info').mockImplementation(() => {}) + const homebrew = join(root, 'homebrew', 'opencode') + + expect(await createSupport(rigSettings(homebrew))).toEqual({ supported: true }) + }) + + it('names the check that refused an older Command in the main log', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const older = await fakeOpencode(join(root, 'older'), '1.18.30') + + expect(await createSupport(rigSettings(older))).toEqual({ + supported: false, + reason: 'agent' + }) + expect(warn).toHaveBeenCalledWith( + '[structured-create-support] opencode unsupported: installed-agent check refused (reason agent)' + ) + }) + + it('admits inline credentials and relative folders, recording no folders for the chat', async () => { + vi.spyOn(console, 'info').mockImplementation(() => {}) + const settings = rigSettings(privateOpencode, { + OPENCODE_AUTH_CONTENT: '{}', + XDG_DATA_HOME: 'relative/data', + XDG_STATE_HOME: 'relative/state' + }) + + expect(await createSupport(settings)).toEqual({ supported: true }) + await expect(pinnedAccount(settings.agentDefaultEnv.opencode)).resolves.toEqual({ + kind: 'opencode', + locator: { kind: 'unmanaged' } + }) + }) +}) diff --git a/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts b/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts new file mode 100644 index 000000000000..393f0158594e --- /dev/null +++ b/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts @@ -0,0 +1,123 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { acpLaunchSpecFor } from '../acp/acp-launch-specs' +import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' +import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' + +const { loginShell } = vi.hoisted(() => { + const loginShell: { env: Record } = { env: {} } + return { loginShell } +}) +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: async () => loginShell.env +})) + +type Settings = Pick< + GlobalSettings, + 'agentDefaultEnv' | 'nativeChatInheritShellEnvironment' | 'nativeChatShellEnvironmentVariables' +> & + Partial> + +let root: string +let shellBin: string +let privateBin: string + +/** A stand-in `omp` that answers `--version` as OMP does; never the real agent CLI. */ +async function fakeOmp(dir: string, version: string): Promise { + await mkdir(dir, { recursive: true }) + const file = join(dir, 'omp') + await writeFile(file, `#!/bin/sh\necho omp/${version}\n`) + await chmod(file, 0o755) + return dir +} + +function runtimeWith(settings: Settings) { + return { + requireStore: () => ({ getSettings: () => settings }), + resolveRuntimeFileTarget: async () => ({ worktree: { path: root } }) + } +} + +function launchWith(settings: Settings) { + return createAcpStructuredLaunchResolver(acpLaunchSpecFor('omp')!, { + store: { + getRecord: () => ({ + ...agentSessionRecordFixture(), + provider: 'omp', + providerHandleChain: [], + accountHome: { variable: 'PI_CODING_AGENT_DIR', path: join(root, '.omp', 'agent') } + }) + }, + readJournal: () => null, + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => loginShell.env, + resolveLaunchEnv: () => settings.agentDefaultEnv?.omp ?? {}, + resolveCommandSettings: () => settings, + inheritedEnv: {} + })({ + identity: { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'omp', + providerHandle: null + } + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-omp-probe-')) + shellBin = await fakeOmp(join(root, 'shell-bin'), '16.2.0') + privateBin = await fakeOmp(join(root, 'omp-prefix', 'bin'), '17.0.5') + // Orca's resolved login-shell PATH, with an OMP older than ACP support first. + loginShell.env = { PATH: `${shellBin}:/usr/bin:/bin`, HOME: root } +}) +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +const base = { nativeChatInheritShellEnvironment: true, nativeChatShellEnvironmentVariables: [] } + +// Real `--version` runs against stand-in scripts: the whole create check, not a mocked probe. +describe.skipIf(process.platform === 'win32')('OMP create check and launch, real probe', () => { + it('checks and launches the binary the Command setting names', async () => { + const settings = { + ...base, + agentDefaultEnv: {}, + agentCmdOverrides: { omp: join(privateBin, 'omp') } + } + expect(await structuredAgentSupportsLaunch('omp', 'id:w', runtimeWith(settings))).toBe(true) + const launch = await launchWith(settings) + expect(launch.command).toBe(join(privateBin, 'omp')) + expect(launch.args).toEqual(['acp']) + }) + + it('asks the OMP the per-agent PATH names, not the one first on the shell PATH', async () => { + const settings = { ...base, agentDefaultEnv: { omp: { PATH: `${privateBin}:/usr/bin:/bin` } } } + expect(await structuredAgentSupportsLaunch('omp', 'id:w', runtimeWith(settings))).toBe(true) + expect((await launchWith(settings)).command).toBe(join(privateBin, 'omp')) + }) + + it('keeps an OMP older than 17.0.5 on its terminal-backed chat', async () => { + const settings = { ...base, agentDefaultEnv: {} } + expect(await structuredAgentSupportsLaunch('omp', 'id:w', runtimeWith(settings))).toBe(false) + }) + + it('refuses the launch with the setting named when the Command is not runnable', async () => { + const settings = { + ...base, + agentDefaultEnv: {}, + agentCmdOverrides: { omp: join(root, 'missing', 'omp') } + } + // Admitted, so the chat states why rather than silently opening a terminal. + expect(await structuredAgentSupportsLaunch('omp', 'id:w', runtimeWith(settings))).toBe(true) + await expect(launchWith(settings)).rejects.toMatchObject({ + name: 'AgentSessionPreSpawnError', + reason: 'agentCommandNotRunnable' + }) + }) +}) diff --git a/src/main/runtime/structured-agent-launch-support-probe.test.ts b/src/main/runtime/structured-agent-launch-support-probe.test.ts new file mode 100644 index 000000000000..ba62cfac4228 --- /dev/null +++ b/src/main/runtime/structured-agent-launch-support-probe.test.ts @@ -0,0 +1,144 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { acpLaunchSpecFor } from '../acp/acp-launch-specs' +import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' +import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' + +const { loginShell } = vi.hoisted(() => { + const loginShell: { env: Record } = { env: {} } + return { loginShell } +}) +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: async () => loginShell.env +})) + +type Settings = Pick< + GlobalSettings, + 'agentDefaultEnv' | 'nativeChatInheritShellEnvironment' | 'nativeChatShellEnvironmentVariables' +> & + Partial> + +let root: string +let shellBin: string +let privateBin: string + +/** A stand-in `opencode` that answers `--version` with `version`; never a real agent CLI. */ +async function fakeOpencode(dir: string, version: string): Promise { + await mkdir(dir, { recursive: true }) + const file = join(dir, 'opencode') + await writeFile(file, `#!/bin/sh\necho ${version}\n`) + await chmod(file, 0o755) + return dir +} + +function runtimeWith(settings: Settings) { + return { + requireStore: () => ({ getSettings: () => settings }), + resolveRuntimeFileTarget: async () => ({ worktree: { path: root } }) + } +} + +function launchWith(settings: Settings) { + return createAcpStructuredLaunchResolver(acpLaunchSpecFor('opencode')!, { + store: { + getRecord: () => ({ + ...agentSessionRecordFixture(), + provider: 'opencode', + providerHandleChain: [], + accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } } + }) + }, + readJournal: () => null, + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => loginShell.env, + resolveLaunchEnv: () => settings.agentDefaultEnv?.opencode ?? {}, + resolveCommandSettings: () => settings, + inheritedEnv: {} + })({ + identity: { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'opencode', + providerHandle: null + } + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-opencode-probe-')) + shellBin = await fakeOpencode(join(root, 'homebrew'), '1.18.30') + privateBin = await fakeOpencode(join(root, 'oc-prefix', 'bin'), '1.18.31') + // Orca's resolved login-shell PATH, with an `opencode` older than the structured chat's first. + loginShell.env = { PATH: `${shellBin}:/usr/bin:/bin`, HOME: root } +}) +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +const base = { nativeChatInheritShellEnvironment: true, nativeChatShellEnvironmentVariables: [] } + +// Real `--version` runs against stand-in scripts: the whole create check, not a mocked probe. +describe.skipIf(process.platform === 'win32')( + 'OpenCode create check and launch, real probe', + () => { + it('asks the OpenCode the per-agent PATH names, not the one first on the shell PATH', async () => { + const settings = { + ...base, + agentDefaultEnv: { opencode: { PATH: `${privateBin}:/usr/bin:/bin` } } + } + expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( + true + ) + expect((await launchWith(settings)).command).toBe(join(privateBin, 'opencode')) + }) + + it('keeps a refused release first on PATH on the terminal chat when nothing points elsewhere', async () => { + const settings = { ...base, agentDefaultEnv: {} } + expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( + false + ) + }) + + it('runs a stable 2.x first on PATH in the structured chat', async () => { + await fakeOpencode(shellBin, '2.0.21') + const settings = { ...base, agentDefaultEnv: {} } + expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( + true + ) + expect((await launchWith(settings)).command).toBe(join(shellBin, 'opencode')) + }) + + it('checks and launches the binary the Command setting names', async () => { + const settings = { + ...base, + agentDefaultEnv: {}, + agentCmdOverrides: { opencode: join(privateBin, 'opencode') } + } + expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( + true + ) + expect((await launchWith(settings)).command).toBe(join(privateBin, 'opencode')) + }) + + it('refuses the launch with the setting named when the Command is not runnable', async () => { + const settings = { + ...base, + agentDefaultEnv: {}, + agentCmdOverrides: { opencode: join(root, 'missing', 'opencode') } + } + // Admitted, so the chat states why rather than silently opening a terminal. + expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( + true + ) + await expect(launchWith(settings)).rejects.toMatchObject({ + name: 'AgentSessionPreSpawnError', + reason: 'agentCommandNotRunnable' + }) + }) + } +) diff --git a/src/main/runtime/structured-agent-launch-support.test.ts b/src/main/runtime/structured-agent-launch-support.test.ts new file mode 100644 index 000000000000..7335d79e9320 --- /dev/null +++ b/src/main/runtime/structured-agent-launch-support.test.ts @@ -0,0 +1,120 @@ +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { acpLaunchSpecFor } from '../acp/acp-launch-specs' +import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' +import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' + +const { probeAgentCliVersion, loginShell } = vi.hoisted(() => { + const loginShell: { env: Record } = { env: {} } + return { probeAgentCliVersion: vi.fn(), loginShell } +}) +vi.mock('../agent-cli-version-probe', async (importOriginal) => ({ + ...(await importOriginal()), + probeAgentCliVersion +})) +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: async () => loginShell.env +})) + +let bin: string +const asked: { program: string; cwd: string; env: Record }[] = [] +function installedVersion(version: string): void { + probeAgentCliVersion.mockImplementation(async (input, supports) => { + asked.push(input) + return supports(version) + }) +} + +const settings = { + agentDefaultEnv: { opencode: { OPENCODE_EXTRA: '1' } }, + nativeChatInheritShellEnvironment: true, + nativeChatShellEnvironmentVariables: [] +} +const runtime = { + requireStore: () => ({ getSettings: () => settings }), + resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: { path: '/repo/worktree' } })) +} + +beforeEach(async () => { + asked.length = 0 + bin = await mkdtemp(join(tmpdir(), 'orca-opencode-bin-')) + const opencode = join(bin, 'opencode') + await writeFile(opencode, '#!/bin/sh\n') + await chmod(opencode, 0o755) + loginShell.env = { PATH: bin, HOME: '/home/user' } +}) +afterEach(async () => { + probeAgentCliVersion.mockReset() + runtime.resolveRuntimeFileTarget.mockClear() + await rm(bin, { recursive: true, force: true }) +}) + +describe('structuredAgentSupportsLaunch', () => { + it('asks nothing for an agent whose location alone decides', async () => { + expect(await structuredAgentSupportsLaunch('grok', 'id:workspace-1', runtime)).toBe(true) + expect(probeAgentCliVersion).not.toHaveBeenCalled() + expect(runtime.resolveRuntimeFileTarget).not.toHaveBeenCalled() + }) + + it('allows stable OpenCode 1.x and 2.x, asking in the workspace with the launch env', async () => { + for (const [version, supported] of [ + ['1.18.31', true], + ['2.0.14', true], + ['2.0.21', true], + ['1.18.30', false], + ['2.0.13', false], + ['2.1.0-beta.1', false], + ['3.0.0', false] + ] as const) { + installedVersion(version) + expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe( + supported + ) + } + expect(asked[0]).toMatchObject({ + cwd: '/repo/worktree', + env: { PATH: bin, OPENCODE_EXTRA: '1', OPENCODE_CLIENT: 'acp' } + }) + }) + + it('refuses when the version cannot be read', async () => { + probeAgentCliVersion.mockResolvedValue(false) + expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(false) + }) + + it.skipIf(process.platform === 'win32')( + 'asks about the same binary the launch then spawns', + async () => { + installedVersion('1.18.31') + await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime) + const launch = await createAcpStructuredLaunchResolver(acpLaunchSpecFor('opencode')!, { + store: { + getRecord: () => ({ + ...agentSessionRecordFixture(), + provider: 'opencode', + providerHandleChain: [], + accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } } + }) + }, + readJournal: () => null, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => loginShell.env, + resolveLaunchEnv: () => settings.agentDefaultEnv.opencode, + inheritedEnv: {} + })({ + identity: { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'opencode', + providerHandle: null + } + }) + expect(launch.command).toBe(join(bin, 'opencode')) + expect(asked.map(({ program }) => program)).toEqual([launch.command, launch.command]) + } + ) +}) diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts new file mode 100644 index 000000000000..5f81b2c91c5b --- /dev/null +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -0,0 +1,85 @@ +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' +import { isTuiAgent } from '../../shared/tui-agent-config' +import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { + resolveStructuredAgentSessionCreateSupport, + warnStructuredAgentSessionCreateUnsupported, + type StructuredAgentSessionCreateSupport +} from '../native-chat/structured-agent-session-create-support' +import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' +import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' +import { structuredAgentBaseEnvironment } from './structured-agent-shell-environment' + +type LaunchEnvironmentSettings = Pick< + GlobalSettings, + 'agentDefaultEnv' | 'nativeChatInheritShellEnvironment' | 'nativeChatShellEnvironmentVariables' +> & + Partial> + +/** The environment every agent's launch on this host starts from, for a check made before the + * session host is built. */ +async function resolveHostStructuredAgentBaseEnvironment( + settings: LaunchEnvironmentSettings +): Promise> { + return structuredAgentBaseEnvironment({ + shellEnv: await resolveLoginShellEnvironment(), + policy: nativeChatShellEnvironmentPolicy(settings) + }) +} + +/** What the check reads from the runtime asking it. */ +type LaunchSupportRuntime = { + requireStore(): { getSettings(): LaunchEnvironmentSettings } + resolveRuntimeFileTarget(selector: string): Promise<{ worktree: { path: string } }> +} + +/** The agent's own check of what is installed on this host, with the environment and Command + * setting its launch starts from, in the workspace it would launch in; true for an agent whose + * location alone decides. */ +export async function structuredAgentSupportsLaunch( + agent: string, + worktreeSelector: string, + runtime: LaunchSupportRuntime +): Promise { + const supportsLaunch = structuredAgentRuntimeRegistration(agent)?.supportsLaunch + if (!supportsLaunch) { + return true + } + const settings = runtime.requireStore().getSettings() + const env = { + ...(await resolveHostStructuredAgentBaseEnvironment(settings)), + ...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) : {}) + } + const cwd = (await runtime.resolveRuntimeFileTarget(worktreeSelector)).worktree.path + return supportsLaunch({ cwd, env, commandSettings: settings }) +} + +/** `agentSession.createSupport` on this host: every refusal it can know before spawning. The + * agent's location rule and installed-agent check from its registration, without installing the + * host. A refusal logs which check said no. */ +export async function resolveHostStructuredAgentCreateSupport(input: { + agent: StructuredAgentId + worktreeSelector: string + location: AgentSessionExecutionLocation + runtime: LaunchSupportRuntime +}): Promise { + const { agent, location } = input + const supportsLocation = + structuredAgentRuntimeRegistration(agent)?.supportsLocation(location) ?? false + const supportsLaunch = + supportsLocation && + (await structuredAgentSupportsLaunch(agent, input.worktreeSelector, input.runtime)) + const support = resolveStructuredAgentSessionCreateSupport({ + location, + adapterSupportsCreate: supportsLaunch + }) + warnStructuredAgentSessionCreateUnsupported( + agent, + support, + supportsLocation ? 'installed-agent' : 'location' + ) + return support +} diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts index 5dfc2f7404d2..43b11bdee1cf 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -4,12 +4,13 @@ import { structuredAgentRuntimeRegistration, type StructuredAgentAccountHomeServices } from './structured-agent-runtime-registrations' +import { decideAgentLaunchMode } from '../agent-launch/agent-launch-mode' describe('ACP agents in the runtime registrations', () => { it('registers Grok beside Claude and Codex with its declared capabilities', () => { expect( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) - ).toEqual(['codex', 'claude', 'grok']) + ).toEqual(['pi', 'codex', 'claude', 'grok', 'opencode', 'omp']) expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({ handleTransport: 'acp', accountHomeVariable: 'GROK_HOME', @@ -25,6 +26,76 @@ describe('ACP agents in the runtime registrations', () => { }) }) + it('registers OpenCode on the ACP lane pinning its tagged account, with image prompts', () => { + const definition = structuredAgentRuntimeRegistration('opencode')?.definition + expect(definition).toMatchObject({ + agent: 'opencode', + handleTransport: 'acp', + accountLocatorKind: 'opencode', + capabilities: { imagePrompts: true, steering: 'queue', approvalEnforcement: 'orca' } + }) + expect(definition?.accountHomeVariable).toBeUndefined() + }) + + it('leaves the `opencode2` agent on its terminal-backed chat, and asks `opencode` its version', () => { + expect(structuredAgentRuntimeRegistration('opencode2')).toBeNull() + expect(structuredAgentRuntimeRegistration('opencode')?.supportsLaunch).toBeTypeOf('function') + // Grok runs whatever is installed: its create asks nothing of the binary. + expect(structuredAgentRuntimeRegistration('grok')?.supportsLaunch).toBeUndefined() + }) + + it('registers OMP on the ACP lane pinning its agent directory, asking its version at create', () => { + const registration = structuredAgentRuntimeRegistration('omp') + expect(registration?.definition).toMatchObject({ + agent: 'omp', + handleTransport: 'acp', + accountHomeVariable: 'PI_CODING_AGENT_DIR', + capabilities: { imagePrompts: false, steering: 'queue', approvalEnforcement: 'orca' } + }) + expect(registration?.supportsLaunch).toBeTypeOf('function') + }) + + it('opens OMP as a structured chat only with the updated structured chat setting on', () => { + const settings = { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + experimentalStructuredNativeChat: true + } + const placement = { agent: 'omp', workspaceKind: 'git-worktree' } as const + expect(decideAgentLaunchMode({ placement, settings }).mode).toBe('structured') + // Off: OMP keeps the terminal-backed chat it has always had. + expect( + decideAgentLaunchMode({ + placement, + settings: { ...settings, experimentalStructuredNativeChat: false } + }) + ).toMatchObject({ mode: 'terminal', reason: 'user_default' }) + }) + + it("finds OMP's agent directory from the launch env, else OMP's default", async () => { + const { resolveAccountHome } = structuredAgentRuntimeRegistration('omp')! + const unused = (): never => { + throw new Error('OMP resolves its account home without the runtime') + } + const resolve = async (launchEnv: NodeJS.ProcessEnv) => + resolveAccountHome( + { launchEnv, location: null, purpose: 'read', workspacePath: null }, + { + getClaudeConfigDirectory: unused, + prepareCodexLaunchHome: unused, + readCodexLaunchHome: unused, + workspaceTrustSettings: unused + } + ) + await expect(resolve({ PI_CODING_AGENT_DIR: '/data/omp-agent' })).resolves.toEqual({ + variable: 'PI_CODING_AGENT_DIR', + path: '/data/omp-agent' + }) + await expect(resolve({ PI_CODING_AGENT_DIR: '' })).resolves.toMatchObject({ + path: expect.stringMatching(/[\\/]\.omp[\\/]agent$/) + }) + }) + it('takes model and effort picks at rest, and keeps no model list of its own', () => { const resting = structuredAgentRuntimeRegistration('grok')!.definition.restingOptions expect(['model', 'effort'].map(resting.acceptsKey)).toEqual([true, true]) @@ -33,7 +104,7 @@ describe('ACP agents in the runtime registrations', () => { }) it('finds the account home on this runtime from the launch env, else the default', async () => { - const { resolveAccountHomePath } = structuredAgentRuntimeRegistration('grok')! + const { resolveAccountHome } = structuredAgentRuntimeRegistration('grok')! // Grok's resolver asks the runtime for nothing: its home is the launch env's, else the default. const unused = (): never => { throw new Error('Grok resolves its account home without the runtime') @@ -44,13 +115,18 @@ describe('ACP agents in the runtime registrations', () => { readCodexLaunchHome: unused, workspaceTrustSettings: unused } - const resolve = (launchEnv: NodeJS.ProcessEnv) => - resolveAccountHomePath( + const resolve = async (launchEnv: NodeJS.ProcessEnv) => + resolveAccountHome( { launchEnv, location: null, purpose: 'read', workspacePath: null }, services ) - await expect(resolve({ GROK_HOME: '/data/grok' })).resolves.toBe('/data/grok') - await expect(resolve({ GROK_HOME: 'relative/grok' })).resolves.toMatch(/\.grok$/) + await expect(resolve({ GROK_HOME: '/data/grok' })).resolves.toEqual({ + variable: 'GROK_HOME', + path: '/data/grok' + }) + await expect(resolve({ GROK_HOME: 'relative/grok' })).resolves.toMatchObject({ + path: expect.stringMatching(/\.grok$/) + }) }) it('runs Grok only where this runtime supervises the child itself', () => { diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index a0c0089c8d33..060a87153530 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -6,17 +6,27 @@ import { supportsClaudeStructuredLocation } from '../claude/claude-structured-lo import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { + agentSessionAccountHome, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' import { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLifecycleEvent +import { + isAgentSessionPreSpawnError, + type StructuredAgentSessionAdapter, + type StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { readClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' +import { + createNativeChatVisualsDelivery, + type PrepareNativeChatVisuals +} from '../native-chat/native-chat-visuals-delivery' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { replayJournal } from '../native-chat/agent-session-journal/journal-open' import type { AgentSessionRecordStore } from './agent-session-record-store' @@ -27,15 +37,19 @@ import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtim import { resolveStructuredClaudeAccountHomePath, resolveStructuredCodexAccountHomePath, - resolveStructuredEnvAccountHomePath, type StructuredClaudeAccountHomeDeps, type StructuredCodexAccountHomeDeps } from './structured-agent-account-home' import { ACP_LAUNCH_SPECS, type AcpLaunchSpec } from '../acp/acp-launch-specs' import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' import { createAcpAgentConnection } from '../acp/acp-agent-connection' -import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' +import { + acpLaunchVersionSupported, + createAcpStructuredLaunchResolver, + resolveAcpLaunchCommand +} from '../acp/acp-structured-launch-resolution' import { AcpStructuredSessionAdapter } from '../acp/acp-structured-session-adapter' +import { PI_RPC_RUNTIME_REGISTRATION } from '../pi/rpc-runtime-registration' /** What an agent's adapter is built from: the open store and the runtime around it. */ export type StructuredAgentAdapterContext = { @@ -81,11 +95,32 @@ export type StructuredAgentRuntimeRegistration = { createAdapter: (context: StructuredAgentAdapterContext) => StructuredAgentRuntimeAdapter /** Whether this agent's chats can run at `location`; answered without building the host. */ supportsLocation: (location: AgentSessionExecutionLocation) => boolean - /** The account home a chat of this agent pins; see `StructuredAgentAccountHomeRequest`. */ - resolveAccountHomePath: ( + /** Whether the agent installed on this host runs a structured chat, asked at create with the + * environment a launch starts from; absent when the location alone decides. */ + supportsLaunch?: (input: { + cwd: string + env: Record + commandSettings: StructuredAgentCommandSettings + }) => Promise + /** The account a chat of this agent pins; see `StructuredAgentAccountHomeRequest`. */ + resolveAccountHome: ( request: StructuredAgentAccountHomeRequest, services: StructuredAgentAccountHomeServices - ) => Promise + ) => Promise +} + +function nativeChatVisualsFor(deps: StructuredAgentSessionRuntimeDeps): { + prepareVisuals?: PrepareNativeChatVisuals +} { + return deps.nativeChatVisuals + ? { + prepareVisuals: createNativeChatVisualsDelivery({ + stateDirectory: deps.stateDirectory, + logger: deps.logger, + isEnabled: deps.nativeChatVisuals.isEnabled + }) + } + : {} } function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { @@ -99,7 +134,8 @@ function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredA ...(deps.resolveCodexPermissionPolicy ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } : {}), - ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) + ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}), + ...nativeChatVisualsFor(deps) }), ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), @@ -126,7 +162,8 @@ function createClaudeAdapter( store, resolveWorkspacePath: deps.resolveWorkspacePath, ...(deps.resolveClaudeCommand ? { resolveClaudeCommand: deps.resolveClaudeCommand } : {}), - ...(deps.claudeThinkingDisplay ? { claudeThinkingDisplay: deps.claudeThinkingDisplay } : {}), + ...(deps.claudeCliFlags ? { claudeCliFlags: deps.claudeCliFlags } : {}), + ...nativeChatVisualsFor(deps), ...(deps.resolveClaudeLaunchEnv ? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv } : {}), resolveClaudeInheritedEnv: context.environment.resolveClaudeInheritedEnv, resolveClaudeLaunchArgs: () => deps.resolveLaunchArgs('claude'), @@ -134,12 +171,7 @@ function createClaudeAdapter( ...(deps.resolveClaudePermissionMode ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } : {}), - ...(deps.getClaudeManagedAccountGateSettings - ? { - readClaudeManagedAccountGate: () => - readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) - } - : {}), + attachmentDirectory: agentSessionAttachmentStoreRoot(deps.stateDirectory), onLifecycleEvent: context.deliverLifecycle, logger: deps.logger, onChildWorkEvidence: (sessionId, evidence) => @@ -156,22 +188,41 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio return { definition: acpStructuredAgentDefinition(spec), supportsLocation: (location) => supportsSupervisedProviderChildLocation(location), - resolveAccountHomePath: async ({ launchEnv }) => - resolveStructuredEnvAccountHomePath({ - launchEnv, - variable: spec.accountHomeVariable, - defaultPath: spec.defaultAccountHome - }), + ...(spec.supportsVersion + ? { + supportsLaunch: async ({ cwd, env, commandSettings }) => { + const launchEnv = { ...env, ...spec.env } + let command: string + try { + command = resolveAcpLaunchCommand(spec, launchEnv, { commandSettings }) + } catch (error) { + // An unrunnable Command setting is the launch's refusal to state, not a terminal. + if (isAgentSessionPreSpawnError(error)) { + return true + } + throw error + } + return acpLaunchVersionSupported(spec, { command, cwd, env: launchEnv }) + } + } + : {}), + resolveAccountHome: ({ launchEnv }) => spec.account.resolve({ launchEnv }), createAdapter: (context) => { const { deps, store, followUps } = context + const readJournal = (sessionId: string) => + replayJournal(context.journalDatabase.db, sessionId) return new AcpStructuredSessionAdapter({ spec, + readJournal, resolveLaunch: createAcpStructuredLaunchResolver(spec, { store, - readJournal: (sessionId) => replayJournal(context.journalDatabase.db, sessionId), + readJournal, resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: context.environment.resolveBaseEnvironment, ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), + ...(deps.resolveAgentCommandSettings + ? { resolveCommandSettings: deps.resolveAgentCommandSettings } + : {}), ...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}) }), connect: (launch, options) => createAcpAgentConnection(launch, options), @@ -210,22 +261,30 @@ async function resolveCodexAccountHomePath( export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRuntimeRegistration[] = [ + PI_RPC_RUNTIME_REGISTRATION, { definition: CODEX_STRUCTURED_AGENT, createAdapter: createCodexAdapter, supportsLocation: (location) => supportsCodexStructuredLocation(location), - resolveAccountHomePath: resolveCodexAccountHomePath + resolveAccountHome: async (request, services) => + agentSessionAccountHome( + CODEX_STRUCTURED_AGENT, + await resolveCodexAccountHomePath(request, services) + ) }, { definition: CLAUDE_STRUCTURED_AGENT, createAdapter: createClaudeAdapter, supportsLocation: supportsClaudeStructuredLocation, - resolveAccountHomePath: async ({ launchEnv, location }, services) => - resolveStructuredClaudeAccountHomePath({ - launchEnv, - wslDistro: location?.wslDistro ?? null, - getClaudeConfigDirectory: services.getClaudeConfigDirectory - }) + resolveAccountHome: async ({ launchEnv, location }, services) => + agentSessionAccountHome( + CLAUDE_STRUCTURED_AGENT, + resolveStructuredClaudeAccountHomePath({ + launchEnv, + wslDistro: location?.wslDistro ?? null, + getClaudeConfigDirectory: services.getClaudeConfigDirectory + }) + ) }, ...ACP_LAUNCH_SPECS.map(acpRegistration) ] diff --git a/src/main/runtime/structured-agent-session-attachment-wiring.test.ts b/src/main/runtime/structured-agent-session-attachment-wiring.test.ts new file mode 100644 index 000000000000..02323d20fc80 --- /dev/null +++ b/src/main/runtime/structured-agent-session-attachment-wiring.test.ts @@ -0,0 +1,67 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getAgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store-registry' +import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type Database from '../sqlite/sync-database' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + installAgentSessionAttachments, + stopAgentSessionAttachments +} from './structured-agent-session-attachment-wiring' + +let stateDirectory: string + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-wiring-')) +}) + +afterEach(async () => { + stopAgentSessionAttachments() + await rm(stateDirectory, { recursive: true, force: true }) +}) + +function install(directory = stateDirectory): void { + const records: Pick = { + getRecord: () => null, + isSessionUnreadable: () => false + } + // Written by a newer Orca: the sweep reads no claims, so nothing here touches the connection. + const journal: Pick = { + readOnly: true, + isClosed: false, + get db(): Database.Database { + throw new Error('not opened in this test') + } + } + installAgentSessionAttachments({ + stateDirectory: directory, + store: records, + journalDatabase: journal, + logger: createStructuredAgentSessionLogger() + }) +} + +describe('installAgentSessionAttachments', () => { + // Claude takes the store as an added directory only if it exists when Claude starts. + it('creates the store root the Claude launch is granted, before any upload', () => { + install() + const root = agentSessionAttachmentStoreRoot(stateDirectory) + expect(existsSync(root)).toBe(true) + expect(getAgentSessionAttachmentStore()?.rootDir).toBe(root) + }) + + it('still installs the store when the root cannot be created', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // A file where the state directory should be: nothing can be created under it. + const blocked = join(stateDirectory, 'not-a-directory') + await writeFile(blocked, '') + expect(() => install(blocked)).not.toThrow() + expect(getAgentSessionAttachmentStore()?.rootDir).toBe(agentSessionAttachmentStoreRoot(blocked)) + warn.mockRestore() + }) +}) diff --git a/src/main/runtime/structured-agent-session-attachment-wiring.ts b/src/main/runtime/structured-agent-session-attachment-wiring.ts new file mode 100644 index 000000000000..9ee32ec10f41 --- /dev/null +++ b/src/main/runtime/structured-agent-session-attachment-wiring.ts @@ -0,0 +1,68 @@ +// Installs the chat attachment store beside the structured host, and its sweeps. The store lives in +// the same state directory as the journal, whose database holds the claims the sweeps honor. + +import { mkdirSync } from 'node:fs' +import { AgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store' +import { setAgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store-registry' +import { + startAgentSessionAttachmentSweeps, + type AgentSessionAttachmentSweeper +} from '../native-chat/agent-session-attachments/agent-session-attachment-sweep' +import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { AgentSessionRecordStore } from './agent-session-record-store' + +const FIRST_SWEEP_DELAY_MS = 60 * 1000 +const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000 + +let sweeper: AgentSessionAttachmentSweeper | null = null + +export function installAgentSessionAttachments(deps: { + stateDirectory: string + store: Pick + journalDatabase: Pick + logger: StructuredAgentSessionLogger +}): void { + stopAgentSessionAttachments() + const root = agentSessionAttachmentStoreRoot(deps.stateDirectory) + // Before any Claude starts: Claude drops an added directory that does not exist yet, for good. + try { + mkdirSync(root, { recursive: true }) + } catch (error) { + deps.logger.warn('chat attachment store could not be created', { + scope: 'attachment-store', + error + }) + } + const attachments = new AgentSessionAttachmentStore(root, { + hasSession: (sessionId) => deps.store.getRecord(sessionId) !== null + }) + setAgentSessionAttachmentStore(attachments) + sweeper = startAgentSessionAttachmentSweeps( + attachments, + { + database: () => + deps.journalDatabase.readOnly || deps.journalDatabase.isClosed + ? null + : deps.journalDatabase.db, + recordedSessionIds: () => ({ + // Readable or not: an unreadable chat's claims still protect its uploads. + has: (sessionId) => + deps.store.getRecord(sessionId) !== null || deps.store.isSessionUnreadable(sessionId) + }) + }, + { + initialDelayMs: FIRST_SWEEP_DELAY_MS, + intervalMs: SWEEP_INTERVAL_MS, + onError: (error) => + deps.logger.warn('chat attachment sweep failed', { scope: 'attachment-sweep', error }) + } + ) +} + +export function stopAgentSessionAttachments(): void { + sweeper?.stop() + sweeper = null + setAgentSessionAttachmentStore(null) +} diff --git a/src/main/runtime/structured-agent-session-runtime-teardown.ts b/src/main/runtime/structured-agent-session-runtime-teardown.ts index 3275939a4fa0..46e1e456763e 100644 --- a/src/main/runtime/structured-agent-session-runtime-teardown.ts +++ b/src/main/runtime/structured-agent-session-runtime-teardown.ts @@ -7,6 +7,7 @@ import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { recordAgentSessionRuntimeEnd } from './agent-session-runtime-end-record' export type InstalledRuntime = { host: StructuredAgentSessionHost @@ -16,6 +17,8 @@ export type InstalledRuntime = { /** Resolves after every observed adapter exit has published, and every * recovery callback it raised has settled. */ waitForRecovery: () => Promise + /** Host bookkeeping that runs on timers, stopped before anything else. */ + stopBackgroundWork?: () => void } /** Why the app is going away, for the resume markers teardown stamps. A module-level latch rather @@ -35,8 +38,12 @@ export async function tearDownRuntime( installed: InstalledRuntime, trigger: AgentSessionResumeTrigger ): Promise { + // First, before any wait: should this quit not finish, the next start must still know it was a + // quit and not a crash. + recordAgentSessionRuntimeEnd(trigger) // An exit settled while recovery drains wakes delivery, which would start a fresh child for // teardown to kill; queued messages wait for the next launch instead. + installed.stopBackgroundWork?.() installed.host.stopDelivery() // Drain an in-flight recovery before stopping children; recovery may still // be writing lifecycle rows or acquiring a replacement child. diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 877516e97488..d57ef89e402a 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -10,6 +10,7 @@ // A process whose journal will not open installs none and answers every // structured request with the refusal that says why. +import type { PiRpcSessionDeps } from '../pi/rpc-session' import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { existsSync } from 'node:fs' import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -22,6 +23,7 @@ import { } from './structured-agent-session-runtime-teardown' import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule' import type { CodexStructuredPermissionPolicy } from '../codex/codex-structured-permission-policy' +import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' @@ -29,7 +31,10 @@ import type { StructuredAgentSessionHostDeps } from '../native-chat/agent-sessio import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' +import { + installAgentSessionAttachments, + stopAgentSessionAttachments +} from './structured-agent-session-attachment-wiring' import { openAgentSessionRecordStoreOnce, releaseAgentSessionRecordStore, @@ -59,7 +64,11 @@ import { modelCatalogHostDeps, type RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' -import type { ClaudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support' +import type { ClaudeCliFlagSupport } from '../claude/claude-cli-flag-support' +import { + scheduleNativeChatVisualsSweep, + type NativeChatVisualsSweepDeps +} from '../native-chat/native-chat-visuals-sweep' /** Whether this profile holds a structured chat: a record or tab in the journal database. */ export function hasPersistedStructuredAgentSessionStore( @@ -89,8 +98,16 @@ export type StructuredAgentSessionRuntimeDeps = { resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string resolveClaudeCommand?: () => string - /** Whether a Claude CLI takes the thinking-display flag; absent never passes it. */ - claudeThinkingDisplay?: ClaudeThinkingDisplaySupport + openPiConnection?: PiRpcSessionDeps['openConnection'] + /** Which version-gated flags a Claude CLI takes; absent never passes one. */ + claudeCliFlags?: ClaudeCliFlagSupport + /** Gives each chat a visuals folder and the skill that teaches it, and sweeps folders whose chat + * is gone. Wired by the real hosts only, so a test runtime never loads the bundled skill. */ + nativeChatVisuals?: { + /** Read this host's preference when a chat starts a provider process. */ + isEnabled: () => boolean + workspaceVerdicts: NonNullable + } /** Provider transports are overridden only to drive the runtime against scripted children. */ openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] @@ -111,8 +128,8 @@ export type StructuredAgentSessionRuntimeDeps = { resolveAgentFullAccess?: (agent: string) => boolean /** The user's per-agent environment overlay, for agents with no lane-specific resolver. */ resolveAgentLaunchEnv?: (agent: string) => Record - /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ - getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings + /** The settings a per-agent Command override is read from, for the same agents. */ + resolveAgentCommandSettings?: () => StructuredAgentCommandSettings resolveEnvironment?: () => Promise /** Which login-shell variables Codex and Claude children inherit; absent inherits all. */ resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy @@ -193,6 +210,7 @@ export async function stopStructuredAgentSessionRuntime(options?: { const pending = installing installing = null setStructuredAgentSessionHost(null) + stopAgentSessionAttachments() const outstanding = [...pendingTeardown] pendingTeardown.clear() const installed = pending ? await pending.catch(() => null) : null @@ -320,10 +338,26 @@ async function installOnJournal( }) } setStructuredAgentSessionHost(host) + installAgentSessionAttachments({ + stateDirectory: deps.stateDirectory, + store, + journalDatabase, + logger: deps.logger + }) + const stopVisualsSweep = deps.nativeChatVisuals + ? scheduleNativeChatVisualsSweep({ + stateDirectory: deps.stateDirectory, + listHeldSessionIds: () => (store.readOnly ? null : store.listHeldSessionIds()), + locationOf: (sessionId) => store.getRecord(sessionId)?.location ?? null, + workspaceVerdicts: deps.nativeChatVisuals.workspaceVerdicts, + logger: deps.logger + }) + : undefined return { host, adapter, journalDatabase, - waitForRecovery: lifecycle.drain + waitForRecovery: lifecycle.drain, + ...(stopVisualsSweep ? { stopBackgroundWork: stopVisualsSweep } : {}) } } diff --git a/src/main/runtime/structured-claude-attachment-grant.test.ts b/src/main/runtime/structured-claude-attachment-grant.test.ts new file mode 100644 index 000000000000..9971f93ccdbf --- /dev/null +++ b/src/main/runtime/structured-claude-attachment-grant.test.ts @@ -0,0 +1,82 @@ +// The chat attachment store reaches Claude's launch as an added directory: the runtime hands its +// root to the Claude adapter, and the adapter to the launch resolver. Drop either hand-off and +// Claude asks before reading every attached document. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as LaunchResolution from '../claude/claude-structured-launch-resolution' +import type * as ClaudeRuntimeAdapter from './structured-claude-runtime-adapter' + +type GrantDeps = { attachmentDirectory?: string } + +const captured = vi.hoisted(() => { + const adapter: GrantDeps[] = [] + const resolver: GrantDeps[] = [] + return { adapter, resolver } +}) + +vi.mock('./structured-claude-runtime-adapter', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createStructuredClaudeRuntimeAdapter: ( + deps: Parameters[0] + ) => { + captured.adapter.push(deps) + return actual.createStructuredClaudeRuntimeAdapter(deps) + } + } +}) + +vi.mock('../claude/claude-structured-launch-resolution', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createClaudeStructuredLaunchResolver: ( + deps: Parameters[0] + ) => { + captured.resolver.push(deps) + return actual.createClaudeStructuredLaunchResolver(deps) + } + } +}) + +import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let stateDirectory: string | null = null + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (stateDirectory) { + await rm(stateDirectory, { recursive: true, force: true }) + stateDirectory = null + } +}) + +describe("structured Claude's read grant for chat attachments", () => { + it('hands the store root from the runtime through the adapter to the launch resolver', async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-grant-')) + const directory = stateDirectory + await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: directory, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => directory, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveLaunchArgs: () => [], + resolveEnvironment: async () => ({}) + }) + + const root = agentSessionAttachmentStoreRoot(directory) + expect(captured.adapter.at(-1)?.attachmentDirectory).toBe(root) + expect(captured.resolver.at(-1)?.attachmentDirectory).toBe(root) + }) +}) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 288c7a7ddd59..d9abf1cf3885 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -15,19 +15,22 @@ import { recordAgentSessionProviderHandle, reviseAgentSessionProviderResumePoint } from './agent-session-provider-handle-transition' -import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import type { AgentSessionRecordStore } from './agent-session-record-store' import { ClaudeAtRestCommandCatalog } from '../claude/claude-at-rest-commands' import { openClaudeStreamJsonConnection } from '../claude/claude-stream-json-connection' -import type { ClaudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support' +import type { ClaudeCliFlagSupport } from '../claude/claude-cli-flag-support' +import { prewarmClaudeCliFlags } from '../claude/claude-cli-flag-prewarm' +import type { PrepareNativeChatVisuals } from '../native-chat/native-chat-visuals-delivery' export type StructuredClaudeRuntimeAdapterDeps = { store: AgentSessionRecordStore resolveWorkspacePath: (workspaceId: string) => Promise resolveClaudeLaunchArgs: () => Promise | string[] resolveClaudeCommand?: () => string - /** Whether a Claude CLI takes the thinking-display flag; absent never passes it. */ - claudeThinkingDisplay?: ClaudeThinkingDisplaySupport + /** Which version-gated flags a Claude CLI takes; absent never passes one. */ + claudeCliFlags?: ClaudeCliFlagSupport + /** Each chat's visuals folder and skill; absent leaves chats without visuals. */ + prepareVisuals?: PrepareNativeChatVisuals resolveClaudeLaunchEnv?: () => Promise> | Record /** The env a Claude child inherits before auth stripping; absent inherits Orca's own. */ resolveClaudeInheritedEnv?: () => Promise> @@ -36,7 +39,8 @@ export type StructuredClaudeRuntimeAdapterDeps = { resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy /** The user's Agent Permissions setting for Claude; absent means prompting. */ resolveClaudePermissionMode?: () => Promise | PermissionMode - readClaudeManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null + /** Where the host stores chat attachments; granted to the agent as a readable directory. */ + attachmentDirectory?: string openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] modelCatalog?: ClaudeStructuredSessionAdapterDeps['modelCatalog'] @@ -82,6 +86,18 @@ export function createStructuredClaudeRuntimeAdapter( deps: StructuredClaudeRuntimeAdapterDeps ): ClaudeStructuredSessionAdapter { const { store } = deps + if (deps.claudeCliFlags) { + // Before any chat launches, so the first one rarely waits on the version check. + void prewarmClaudeCliFlags({ + cliFlags: deps.claudeCliFlags, + store, + resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand, + ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}), + ...(deps.resolveClaudeInheritedEnv + ? { resolveInheritedEnv: deps.resolveClaudeInheritedEnv } + : {}) + }) + } return new ClaudeStructuredSessionAdapter({ atRestCommands: new ClaudeAtRestCommandCatalog({ resolveWorkspacePath: deps.resolveWorkspacePath @@ -99,10 +115,9 @@ export function createStructuredClaudeRuntimeAdapter( ...(deps.resolveClaudePermissionMode ? { resolvePermissionMode: deps.resolveClaudePermissionMode } : {}), - ...(deps.readClaudeManagedAccountGate - ? { readManagedAccountGate: deps.readClaudeManagedAccountGate } - : {}), - ...(deps.claudeThinkingDisplay ? { thinkingDisplay: deps.claudeThinkingDisplay } : {}) + ...(deps.attachmentDirectory ? { attachmentDirectory: deps.attachmentDirectory } : {}), + ...(deps.claudeCliFlags ? { cliFlags: deps.claudeCliFlags } : {}), + ...(deps.prepareVisuals ? { prepareVisuals: deps.prepareVisuals } : {}) }), persistHandle: async ({ sessionId, providerSessionId, leafUuid, fence }) => { const currentFence = store.getRecord(sessionId)?.lease.runtimeFence ?? fence @@ -148,12 +163,12 @@ export function createStructuredClaudeRuntimeAdapter( }) } -/** The child's connection, watched for a CLI refusing the thinking-display flag, so the start that +/** The child's connection, watched for a CLI refusing a version-gated flag, so the start that * failed on it is the last one to pass it. */ export function openClaudeConnectionOf( - deps: Pick + deps: Pick ): Pick { - const support = deps.claudeThinkingDisplay + const support = deps.claudeCliFlags if (!support) { return deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {} } diff --git a/src/main/runtime/structured-claude-thinking-display-refusal.test.ts b/src/main/runtime/structured-claude-thinking-display-refusal.test.ts index 817add96e676..6d5f4925cf50 100644 --- a/src/main/runtime/structured-claude-thinking-display-refusal.test.ts +++ b/src/main/runtime/structured-claude-thinking-display-refusal.test.ts @@ -6,7 +6,10 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from '../claude/claude-structured-launch-resolution' import { fakeClaude } from '../claude/claude-structured-session-test-support' -import { createClaudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support' +import { + CLAUDE_THINKING_DISPLAY_FLAG, + createClaudeCliFlagSupport +} from '../claude/claude-cli-flag-support' import { openClaudeConnectionOf } from './structured-claude-runtime-adapter' const FAKE_CLI = join( @@ -31,14 +34,20 @@ async function startRefusing(stderr: string) { writeFileSync(scenarioPath, JSON.stringify({ steps: [{ stderr }, { exit: 1 }] })) const env = { PATH: process.env.PATH ?? '', ORCA_SDK_CONTRACT_SCENARIO_PATH: scenarioPath } const probe = vi.fn(async () => '2.1.280') - const support = createClaudeThinkingDisplaySupport({ + const support = createClaudeCliFlagSupport({ probe, keyOf: async (command, cwd) => `${command}\n${cwd}`, budgetMs: 1_000, now: () => performance.now() }) - const flag = await support.argsFor({ command: FAKE_CLI, cwd: dir, env }) - const { openConnection } = openClaudeConnectionOf({ claudeThinkingDisplay: support }) + const flag = (await support.supports(CLAUDE_THINKING_DISPLAY_FLAG, { + command: FAKE_CLI, + cwd: dir, + env + })) + ? { 'thinking-display': 'summarized' } + : null + const { openConnection } = openClaudeConnectionOf({ claudeCliFlags: support }) let exited: Error | null = null const connection = await openConnection!( { @@ -63,20 +72,22 @@ describe('a Claude CLI that refuses the thinking-display flag', () => { "error: unknown option '--thinking-display'\n" ) expect(flag).toEqual({ 'thinking-display': 'summarized' }) - await vi.waitFor(async () => expect(await support.argsFor(launch)).toEqual({})) + await vi.waitFor(async () => + expect(await support.supports(CLAUDE_THINKING_DISPLAY_FLAG, launch)).toBe(false) + ) expect(probe).toHaveBeenCalledTimes(1) }) it('hands the session whether its close was the one Orca began', async () => { const claude = fakeClaude() - const support = createClaudeThinkingDisplaySupport({ + const support = createClaudeCliFlagSupport({ probe: async () => '2.1.280', keyOf: async () => null, budgetMs: 1_000, now: () => performance.now() }) const { openConnection } = openClaudeConnectionOf({ - claudeThinkingDisplay: support, + claudeCliFlags: support, openClaudeConnection: claude.openConnection }) const onExit = vi.fn() @@ -92,6 +103,6 @@ describe('a Claude CLI that refuses the thinking-display flag', () => { it('records nothing when the start failed for another reason', async () => { const { support, launch } = await startRefusing('claude: not signed in\n') - await expect(support.argsFor(launch)).resolves.toEqual({ 'thinking-display': 'summarized' }) + await expect(support.supports(CLAUDE_THINKING_DISPLAY_FLAG, launch)).resolves.toBe(true) }) }) diff --git a/src/main/runtime/structured-session-worktree-teardown.test.ts b/src/main/runtime/structured-session-worktree-teardown.test.ts index f33d7889b31f..4c4145bab2e5 100644 --- a/src/main/runtime/structured-session-worktree-teardown.test.ts +++ b/src/main/runtime/structured-session-worktree-teardown.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { IPtyProvider } from '../providers/types' +import { ABANDONED_SWEEP_GRACE_MS } from './forced-sweep-settlement' const hostRef: { current: unknown } = { current: null } @@ -512,74 +513,107 @@ describe('worktree teardown and structured agent sessions', () => { }) it('refuses in agent-session wording when the close outlives the sweep budget', async () => { - // A structured close that runs out of time used to reject with the PTY timeout sentinel, which - // the classifier reads FIRST — so the toast blamed terminals, and the Force Delete meant to - // clear the wedge hit the same rejection again (#11960). - installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) - const error = await killAllProcessesForWorktree( - WORKTREE, - destructiveDeps({ timeoutMs: 5 }) - ).catch((thrown: Error) => thrown.message) - expect(error).toContain('could not confirm these closed: 1 agent session (claude)') - expect(isUnstoppedPtyRemovalError(error as string)).toBe(false) - expect(classifyWorktreeForceDeleteReason(error as string, true)).toBe('running-agent-session') + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + // A structured close that runs out of time used to reject with the PTY timeout sentinel, which + // the classifier reads FIRST — so the toast blamed terminals, and the Force Delete meant to + // clear the wedge hit the same rejection again (#11960). + installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) + const outcome = killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ timeoutMs: 5 }) + ).catch((thrown: Error) => thrown.message) + await vi.advanceTimersByTimeAsync(5) + const error = await outcome + expect(error).toContain('could not confirm these closed: 1 agent session (claude)') + expect(isUnstoppedPtyRemovalError(error as string)).toBe(false) + expect(classifyWorktreeForceDeleteReason(error as string, true)).toBe('running-agent-session') + } finally { + vi.useRealTimers() + } }) it('never wedges Force Delete on a close that will not settle', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) - await expect( - killAllProcessesForWorktree( + try { + installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) + const outcome = killAllProcessesForWorktree( WORKTREE, destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 5 }) ) - ).resolves.toMatchObject({ runtimeStopped: 0 }) - const message = structuredSessionWarning(warn) - expect(message).toContain('could not confirm these closed: 1 agent session (claude)') - // The pin: a close that ran out of time was never watched stay attached. This warn is the only - // record a forced removal leaves, and the removal.ts split exists precisely so "we could not - // confirm" is never reported as "we saw it running" — including here. - expect(message).not.toContain('still attached') - warn.mockRestore() + void outcome.catch(() => undefined) + await vi.advanceTimersByTimeAsync(5 + ABANDONED_SWEEP_GRACE_MS) + await expect(outcome).resolves.toMatchObject({ runtimeStopped: 0 }) + const message = structuredSessionWarning(warn) + expect(message).toContain('could not confirm these closed: 1 agent session (claude)') + // The pin: a close that ran out of time was never watched stay attached. This warn is the only + // record a forced removal leaves, and the removal.ts split exists precisely so "we could not + // confirm" is never reported as "we saw it running" — including here. + expect(message).not.toContain('still attached') + } finally { + warn.mockRestore() + vi.useRealTimers() + } }) it('names only the sessions still open when the budget expires mid-close', async () => { - // The close loop is serial, so a deadline can land part-way through it. A fallback assembled - // at the deadline could only name the whole list — so a removal that had already closed the - // first chat still told the user both were still there, which is the exact thing this sweep - // exists to stop doing: never report state nobody observed. - installHost({ - records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], - closeGates: { s2: new Promise(() => {}) } - }) - const error = await killAllProcessesForWorktree( - WORKTREE, - destructiveDeps({ timeoutMs: 40 }) - ).catch((thrown: Error) => thrown.message) - expect(error).toContain('could not confirm these closed: 1 agent session (codex)') - expect(error).not.toContain('claude') + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + // The close loop is serial, so a deadline can land part-way through it. A fallback assembled + // at the deadline could only name the whole list — so a removal that had already closed the + // first chat still told the user both were still there, which is the exact thing this sweep + // exists to stop doing: never report state nobody observed. + installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], + closeGates: { s2: new Promise(() => {}) } + }) + const outcome = killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ timeoutMs: 40 }) + ).catch((thrown: Error) => thrown.message) + await vi.advanceTimersByTimeAsync(40) + const error = await outcome + expect(error).toContain('could not confirm these closed: 1 agent session (codex)') + expect(error).not.toContain('claude') + } finally { + vi.useRealTimers() + } }) it('counts the closes that landed before the budget expired', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) // The other half of the same fallback: it reported zero closes, so the removal log said // `structured=0` for a chat it had just ended. const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const slowClose = new Promise((resolve) => { setTimeout(resolve, 300) }) - installHost({ - records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], - closeGates: { s2: slowClose } - }) - const result = await killAllProcessesForWorktree( - WORKTREE, - destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 40 }) - ) - expect(result.structuredStopped).toBe(1) - expect(structuredSessionWarning(warn)).toContain( - 'could not confirm these closed: 1 agent session (codex)' - ) - warn.mockRestore() + try { + installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], + closeGates: { s2: slowClose } + }) + const outcome = killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 40 }) + ) + void outcome.catch(() => undefined) + await vi.advanceTimersByTimeAsync(300) + const result = await outcome + expect(result.structuredStopped).toBe(1) + expect(structuredSessionWarning(warn)).toContain( + 'could not confirm these closed: 1 agent session (codex)' + ) + } finally { + try { + await vi.advanceTimersByTimeAsync(300) + await slowClose + } finally { + warn.mockRestore() + vi.useRealTimers() + } + } }) it('stops issuing new closes once the budget is spent', async () => { diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index b79ea7a3270a..84d7e2042639 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -3,7 +3,8 @@ import { readFileSync } from 'node:fs' import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS } from './orca-runtime-core' import { createTranscriptPane, type TranscriptPaneOptions, @@ -56,6 +57,8 @@ async function createPane( // that as `working`; the title is identical whether it is running a command or waiting. const CURSOR_TITLE = '⠇ Cursor Agent' +afterEach(() => vi.useRealTimers()) + describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { describe('cursor-agent approval menu, the case no hook reports', () => { it('names the pending approval on the pane a coordinator inspects', async () => { @@ -374,13 +377,21 @@ describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { foregroundProbeHangs: true }) - await Promise.all([ - runtime.getTerminalInteractiveWait(handle), - runtime.getTerminalInteractiveWait(handle), - runtime.getTerminalInteractiveWait(handle) - ]) - - expect(probes).toBe(1) + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + const probing = Promise.all([ + runtime.getTerminalInteractiveWait(handle), + runtime.getTerminalInteractiveWait(handle), + runtime.getTerminalInteractiveWait(handle) + ]) + void probing.catch(() => undefined) + await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) + await probing + + expect(probes).toBe(1) + } finally { + vi.useRealTimers() + } }, 20_000) it('accepts a menu whose keys are rendered as glyphs', async () => { @@ -419,9 +430,20 @@ describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { foregroundProbeHangs: true }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeUndefined() - const show = (await runtime.showTerminal(handle)) as Record - expect('agentWait' in show).toBe(false) + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + const waiting = runtime.getTerminalInteractiveWait(handle) + void waiting.catch(() => undefined) + await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) + await expect(waiting).resolves.toBeUndefined() + const showing = runtime.showTerminal(handle) + void showing.catch(() => undefined) + await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) + const show = await showing + expect('agentWait' in show).toBe(false) + } finally { + vi.useRealTimers() + } }, 15_000) it('answers undefined rather than "not waiting" for a pane it cannot read', async () => { diff --git a/src/main/runtime/terminal-tail-redraw-buffer.ts b/src/main/runtime/terminal-tail-redraw-buffer.ts index d8971f7ea8be..2dd6d72badd4 100644 --- a/src/main/runtime/terminal-tail-redraw-buffer.ts +++ b/src/main/runtime/terminal-tail-redraw-buffer.ts @@ -73,7 +73,7 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( ensureCursorRow() rows[cursorRow]!.completed = false } - const writeChar = (char: string): void => { + const writeText = (text: string): void => { ensureCursorRow() markCursorRowRewritten() const row = rows[cursorRow]! @@ -82,9 +82,9 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( } row.text = cursorColumn >= row.text.length - ? `${row.text}${char}` - : `${row.text.slice(0, cursorColumn)}${char}${row.text.slice(cursorColumn + 1)}` - cursorColumn += 1 + ? `${row.text}${text}` + : `${row.text.slice(0, cursorColumn)}${text}${row.text.slice(cursorColumn + text.length)}` + cursorColumn += text.length } const eraseLine = (mode: number): void => { ensureCursorRow() @@ -100,13 +100,28 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( } } + let textStart = 0 + const writePendingText = (end: number): void => { + if (end > textStart) { + writeText( + end === textStart + 1 ? normalizedChunk[textStart]! : normalizedChunk.slice(textStart, end) + ) + } + textStart = end + 1 + } for (let index = 0; index < normalizedChunk.length; index += 1) { const char = normalizedChunk[index] if (char === '\n') { + writePendingText(index) ensureCursorRow() rows[cursorRow]!.completed = true newCompleteLines += 1 - retainNewlyCompletedLine(trimTerminalLineRight(rows[cursorRow]!.text)) + // Own only retained text; a short erase-to-end result can otherwise pin a large span. + const row = rows[cursorRow]! + const trimmed = trimTerminalLineRight(row.text) + const completedLine = ownRetainedString(trimmed) + row.text = trimmed === row.text ? completedLine : ownRetainedString(row.text) + retainNewlyCompletedLine(completedLine) cursorRow += 1 cursorColumn = 0 ensureCursorRow() @@ -114,19 +129,23 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( continue } if (char === '\r') { + writePendingText(index) cursorColumn = 0 continue } if (char === '\u0008') { + writePendingText(index) cursorColumn = Math.max(0, cursorColumn - 1) continue } if (char === '\u001b') { + writePendingText(index) const parsed = parseAnsiControlSequence(normalizedChunk, index) if (!parsed) { continue } index = parsed.endIndex + textStart = index + 1 if (parsed.kind !== 'csi' || !hasCanonicalNumericCsiParams(parsed.params)) { continue } @@ -145,8 +164,8 @@ export function appendNormalizedToMultilineTailBufferUnwindowed( } continue } - writeChar(char) } + writePendingText(normalizedChunk.length) return finalizeRetainedTerminalRows( rows, @@ -186,7 +205,10 @@ function finalizeRetainedTerminalRows( newlyCompletedLines: string[] } { let truncated = initialTruncated - let retainedRows = rows.map((row) => ({ ...row, text: trimTerminalLineRight(row.text) })) + let retainedRows = rows.map((row) => { + const trimmed = trimTerminalLineRight(row.text) + return { ...row, text: trimmed === row.text ? row.text : ownRetainedString(trimmed) } + }) if (retainedRows.length > MAX_TAIL_LINES + 1) { const removeCount = retainedRows.length - (MAX_TAIL_LINES + 1) @@ -242,10 +264,7 @@ function finalizeRetainedTerminalRows( return { lines, - // Why only the partial: redraw rows are built character by character and never sliced from - // the chunk, but the partial is re-sliced from its own row on every chunk, so it alone can - // accumulate a backing string across frames. Owning the rows too costs 20-36% on TUI floods - // for no measured retention. + // Completed rows own their storage at newline; the final partial may still reference a span. partialLine: ownRetainedString(partialLine), redrawCursor, truncated, diff --git a/src/main/runtime/terminal-tail-redraw-spans.test.ts b/src/main/runtime/terminal-tail-redraw-spans.test.ts new file mode 100644 index 000000000000..d68ca1ad0302 --- /dev/null +++ b/src/main/runtime/terminal-tail-redraw-spans.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' + +describe('retained terminal redraw text spans', () => { + it('overwrites a long row without copying its suffix for every code unit', () => { + const previous = 'p'.repeat(16 * 1024) + const replacement = 'x'.repeat(previous.length) + const slice = vi.spyOn(String.prototype, 'slice') + let copiedSuffixes = 0 + try { + const result = appendNormalizedToTailBuffer([previous], '', `\x1b[1A\r${replacement}\n`) + copiedSuffixes = slice.mock.calls.length + expect(result.lines).toEqual([replacement]) + expect(result.newlyCompletedLines).toEqual([replacement]) + expect(result.redrawCursor).toBeNull() + } finally { + slice.mockRestore() + } + expect(copiedSuffixes).toBeLessThan(20) + }) + + it.each([ + ['shorter overwrite', '\x1b[1A\rXY\n', 'XYcdef'], + ['extending overwrite', '\x1b[1A\r123456789\n', '123456789'], + ['erase to end', '\x1b[1A\rXY\x1b[KZ\n', 'XYZ'], + ['erase to start', '\x1b[1A\rXY\x1b[2C!\x1b[1KZ\n', ' Z'], + ['erase whole line', '\x1b[1A\rXY\x1b[2KZ\n', ' Z'], + ['backspace', '\x1b[1A\rXY\bZ\n', 'XZcdef'], + ['carriage return', '\x1b[1A\rXY\rZ\n', 'ZYcdef'], + ['split surrogate overwrite', '\x1b[1A\r😀\bZ\n', '\ud83dZcdef'], + [ + 'Unicode and non-special controls', + '\x1b[1A\r漢😀e\u0301\ud800\t\u0000\udfff\bZ\n', + '漢😀e\u0301\ud800\t\u0000Z' + ] + ])('preserves %s', (_name, chunk, expected) => { + const result = appendNormalizedToTailBuffer(['abcdef'], '', chunk) + expect(result).toEqual({ + lines: [expected], + partialLine: '', + redrawCursor: null, + truncated: false, + newCompleteLines: 1, + newlyCompletedLines: [expected] + }) + }) + + it('fills a cursor gap before a text span and retains its code-unit column', () => { + const result = appendNormalizedToTailBuffer(['abcdef'], '', '\x1b[1A\x1b[9G😀') + expect(result.lines).toEqual([]) + expect(result.partialLine).toBe('abcdef 😀') + expect(result.redrawCursor).toBeNull() + }) + + it('keeps completed trailing spaces available to a later cursor-up rewrite', () => { + const result = appendNormalizedToTailBuffer(['abcdef'], '', '\x1b[1A\rab \n\x1b[1A\x1b[5GX\n') + expect(result.lines).toEqual(['ab Xf']) + expect(result.newlyCompletedLines).toEqual(['ab f', 'ab Xf']) + }) +}) diff --git a/src/main/runtime/terminal-tail-row-retention.test.ts b/src/main/runtime/terminal-tail-row-retention.test.ts index 0e4effbf9688..e49fd7fdd0ca 100644 --- a/src/main/runtime/terminal-tail-row-retention.test.ts +++ b/src/main/runtime/terminal-tail-row-retention.test.ts @@ -79,6 +79,21 @@ describe('retained terminal tail row storage', () => { } }) + it('does not pin overwritten multiline redraw chunks through completed rows', () => { + let lines: string[] = [] + const before = collectHeap() + for (let index = 0; index < 200; index += 1) { + const chunk = `\x1b[0A${'x'.repeat(64 * 1024)}\rstep ${index} done\x1b[K\n` + const next = appendNormalizedToTailBuffer(lines, '', chunk) + lines = next.lines + } + const retained = collectHeap() - before + + expect(lines).toHaveLength(200) + expect(lines.at(-1)).toBe('step 199 done') + expect(retained).toBeLessThan(4 * 1024 * 1024) + }) + it('owns the redraw partial line without re-owning carried rows', () => { const own = vi.spyOn(ownership, 'ownRetainedString') try { @@ -93,7 +108,7 @@ describe('retained terminal tail row storage', () => { expect(redrawn.lines).toEqual(['row one']) expect(redrawn.partialLine).toBe('rewritten two') - // One call for the partial only: redraw rows are built character by character. + // Only the final partial is retained; carried rows need no copies. expect(own).toHaveBeenCalledTimes(1) expect(own).toHaveBeenLastCalledWith('rewritten two') } finally { diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index 9e86ebdd15ca..edc24c7a44a5 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -68,6 +68,8 @@ async function expectWrapperFiles(transport: string, root: string): Promise { + vi.restoreAllMocks() await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) @@ -262,3 +264,75 @@ describe('buildSkillDeletePlan freshness guard', () => { expect(resolved.plan.skills[0].blocked).toBe('missing') }) }) + +describe('buildSkillDeletePlan placement matching', () => { + it('classifies matching placements without rescanning every unrelated skill', async () => { + const { home } = await fixture() + const selected: SkillDeleteRequest['skills'] = [] + for (let index = 0; index < 32; index++) { + const name = `skill-${index}` + const file = await writeSkill(join(home, '.agents', 'skills', name), name) + if (index < 8) { + selected.push({ ...(await request(file)).skills[0], id: name, name }) + } + } + selected.reverse() + const classification = vi.spyOn(SkillDeleteGuards, 'classifySkillPlacement') + const resolved = await buildSkillDeletePlan({ + request: { operationId: 'batch-match', skills: selected }, + target: { kind: 'native-host', cwd: home }, + repos: [], + filesystem: nativeSkillInstallFilesystem, + homeDir: home + }) + + expect(resolved.plan.skills.map((skill) => skill.id)).toEqual(selected.map((skill) => skill.id)) + expect(resolved.plan.skills.map((skill) => skill.placements)).toEqual( + selected.map((skill) => [ + expect.objectContaining({ path: skill.directoryPath, kind: 'canonical' }) + ]) + ) + expect(classification).toHaveBeenCalledTimes(selected.length) + }) + + it.skipIf(WINDOWS)( + 'keeps directory and file identities distinct in original placement order', + async () => { + const { home } = await fixture() + const canonical = join(home, '.agents', 'skills', 'demo') + const file = await writeSkill(canonical, 'demo') + await mkdir(join(home, '.claude', 'skills'), { recursive: true }) + await symlink(canonical, join(home, '.claude', 'skills', 'demo'), 'dir') + const aliasFile = join(home, '.codex', 'skills', 'demo') + await mkdir(aliasFile, { recursive: true }) + await symlink(file, join(aliasFile, 'SKILL.md')) + await mkdir(join(home, '.grok', 'skills'), { recursive: true }) + await symlink(file, join(home, '.grok', 'skills', 'points-to-file')) + const wrongFile = join(home, '.pi', 'agent', 'skills', 'points-to-directory') + await mkdir(wrongFile, { recursive: true }) + await symlink(canonical, join(wrongFile, 'SKILL.md'), 'dir') + + const resolved = await buildSkillDeletePlan({ + request: await request(file), + target: { kind: 'native-host', cwd: home }, + repos: [], + filesystem: nativeSkillInstallFilesystem, + homeDir: home + }) + + expect(resolved.plan.skills[0].placements.map((placement) => placement.kind)).toEqual([ + 'alias-dir', + 'alias-file', + 'canonical' + ]) + expect(resolved.plan.skills[0].placements.map((placement) => placement.path)).toEqual([ + join(home, '.claude', 'skills', 'demo'), + aliasFile, + canonical + ]) + expect( + resolved.placementRoots.get('skill-id')?.map((placement) => placement.root.sourceKind) + ).toEqual(['home', 'home', 'home']) + } + ) +}) diff --git a/src/main/skills/skill-delete/plan.ts b/src/main/skills/skill-delete/plan.ts index a17266cad216..4f075ba9ccbb 100644 --- a/src/main/skills/skill-delete/plan.ts +++ b/src/main/skills/skill-delete/plan.ts @@ -11,7 +11,11 @@ import { normalizedSkillPath, type SkillPathSemantics } from '../../../shared/skill-path-containment' -import { enumerateSkillPlacementCandidates, requireEnumerableFilesystem } from './enumeration' +import { + enumerateSkillPlacementCandidates, + requireEnumerableFilesystem, + type SkillPlacementCandidate +} from './enumeration' import { blockedCanonicalReason, classifySkillPlacement, @@ -26,6 +30,8 @@ import type { SkillInstallFilesystem } from '../skill-install-filesystem' import type { SkillProviderRootOverrides } from '../skill-provider-destinations' import type { SkillScanRoot } from '../skill-discovery-sources' +type IndexedSkillPlacement = { candidate: SkillPlacementCandidate; ordinal: number } + /** Overlapping roots can reach one directory twice. Staging the same path twice * would fail the second rename and roll the whole skill back. */ function dedupePlacements( @@ -122,6 +128,21 @@ export async function buildSkillDeletePlan( toFilesystemPath }) + const candidatesByFile = new Map() + const candidatesByDirectory = new Map() + for (const [ordinal, candidate] of candidates.entries()) { + const isDirectoryAlias = candidate.entryKind === 'symlink' + const identity = isDirectoryAlias ? candidate.directoryRealpath : candidate.skillFileRealpath + if (!identity) { + continue + } + const index = isDirectoryAlias ? candidatesByDirectory : candidatesByFile + const key = normalizedSkillPath(identity, semantics) + const bucket = index.get(key) ?? [] + bucket.push({ candidate, ordinal }) + index.set(key, bucket) + } + const placementRoots = new Map() const skills = requested.map((skill): SkillDeletePlanEntry => { const inspection = inspections.get(skill.skillFilePath) @@ -140,8 +161,13 @@ export async function buildSkillDeletePlan( return blockedEntry(skill, canonicalPath, blocked) } const placements = dedupePlacements( - candidates - .map((candidate) => classifySkillPlacement(candidate, canonicalPath, context)) + [ + ...(candidatesByFile.get(normalizedSkillPath(canonicalPath, semantics)) ?? []), + ...(candidatesByDirectory.get(normalizedSkillPath(api.dirname(canonicalPath), semantics)) ?? + []) + ] + .sort((left, right) => left.ordinal - right.ordinal) + .map(({ candidate }) => classifySkillPlacement(candidate, canonicalPath, context)) .filter((placement): placement is ClassifiedPlacement => placement !== null), semantics ) diff --git a/src/main/skills/skill-package-download-response-lifetime.test.ts b/src/main/skills/skill-package-download-response-lifetime.test.ts new file mode 100644 index 000000000000..a7c4572801af --- /dev/null +++ b/src/main/skills/skill-package-download-response-lifetime.test.ts @@ -0,0 +1,167 @@ +import { createHash } from 'node:crypto' +import * as fs from 'node:fs/promises' +import { createServer, type ServerResponse } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { gzipSync } from 'node:zlib' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SKILL_PACKAGE_CONTENT_TYPE } from '../../shared/skill-package-manifest' +import { downloadSkillPackageGrant } from './skill-package-download' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, open: vi.fn(actual.open) } +}) + +const roots: string[] = [] +const packageBytes = gzipSync(Buffer.from('downloaded package bytes')) +const archiveSha256 = createHash('sha256').update(packageBytes).digest('hex') + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))) +}) + +async function downloadRoot(): Promise { + const root = await fs.mkdtemp(join(tmpdir(), 'orca-skill-response-lifetime-')) + roots.push(root) + return root +} + +async function storageServer(respond: (path: string, response: ServerResponse) => void) { + const streaming = new Set() + const server = createServer((request, response) => { + respond(request.url ?? '/', response) + if (response.writableEnded) { + return + } + streaming.add(response) + response.write(Buffer.alloc(1024)) + const timer = setInterval(() => response.write(Buffer.alloc(1024)), 10) + response.once('close', () => { + clearInterval(timer) + streaming.delete(response) + }) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('storage server did not bind') + } + const origin = `http://127.0.0.1:${address.port}` + return { + origin, + streaming, + async close(): Promise { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + } +} + +async function downloadInput(origin: string) { + return { + url: `${origin}/package`, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + expectedArchiveSha256: archiveSha256, + expectedCompressedBytes: packageBytes.length, + temporaryRoot: await downloadRoot(), + allowedOrigins: [origin], + requireHttps: false + } +} + +describe('skill download response ownership', () => { + it.each([ + { kind: 'http-error', error: 'skill-download-transport-failed' }, + { kind: 'content-type', error: 'skill-download-content-type-invalid' }, + { kind: 'content-length', error: 'skill-download-size-mismatch' }, + { kind: 'staging-directory', error: /EEXIST|ENOTDIR/ }, + { kind: 'archive-open', error: 'archive open failed' } + ])('closes an unread streaming response after $kind failure', async ({ kind, error }) => { + const storage = await storageServer((_path, response) => { + response.writeHead(kind === 'http-error' ? 403 : 200, { + 'content-type': kind === 'content-type' ? 'text/html' : SKILL_PACKAGE_CONTENT_TYPE, + ...(kind === 'content-length' ? { 'content-length': '41943040' } : {}) + }) + }) + try { + const input = await downloadInput(storage.origin) + if (kind === 'staging-directory') { + input.temporaryRoot = join(input.temporaryRoot, 'file') + await fs.writeFile(input.temporaryRoot, 'not a directory') + } + if (kind === 'archive-open') { + vi.mocked(fs.open).mockRejectedValueOnce(new Error('archive open failed')) + } + await expect(downloadSkillPackageGrant(input)).rejects.toThrow(error) + await vi.waitFor(() => expect(storage.streaming.size).toBe(0)) + } finally { + await storage.close() + } + }) + + it.each([ + { kind: 'allowed', error: null }, + { kind: 'cross-origin', error: 'skill-download-cross-origin-redirect' }, + { kind: 'missing-location', error: 'skill-download-redirect-invalid' }, + { kind: 'redirect-limit', error: 'skill-download-redirect-limit' } + ])('closes unread $kind redirect bodies', async ({ kind, error }) => { + const storage = await storageServer((path, response) => { + if (path === '/final') { + response.writeHead(200, { 'content-type': SKILL_PACKAGE_CONTENT_TYPE }) + response.end(packageBytes) + return + } + response.writeHead( + 307, + kind === 'missing-location' + ? {} + : { + location: + kind === 'cross-origin' + ? 'http://localhost:1/package' + : kind === 'redirect-limit' + ? '/package' + : '/final' + } + ) + }) + try { + const input = await downloadInput(storage.origin) + input.allowedOrigins.push('http://localhost:1') + if (error) { + await expect(downloadSkillPackageGrant(input)).rejects.toThrow(error) + } else { + const result = await downloadSkillPackageGrant(input) + expect(await fs.readFile(result.archivePath)).toEqual(packageBytes) + expect(result.archiveSha256).toBe(archiveSha256) + await result.cleanup() + } + await vi.waitFor(() => expect(storage.streaming.size).toBe(0)) + } finally { + await storage.close() + } + }) + + it('preserves complete downloads and checksum failures on a real streaming connection', async () => { + const storage = await storageServer((_path, response) => { + response.writeHead(200, { 'content-type': SKILL_PACKAGE_CONTENT_TYPE }) + response.write(packageBytes.subarray(0, 5)) + response.end(packageBytes.subarray(5)) + }) + try { + const input = await downloadInput(storage.origin) + const result = await downloadSkillPackageGrant(input) + expect(result.compressedBytes).toBe(packageBytes.length) + expect(result.archiveSha256).toBe(archiveSha256) + expect(await fs.readFile(result.archivePath)).toEqual(packageBytes) + await result.cleanup() + await expect( + downloadSkillPackageGrant({ ...input, expectedArchiveSha256: '0'.repeat(64) }) + ).rejects.toThrow('skill-download-archive-digest-mismatch') + } finally { + await storage.close() + } + }) +}) diff --git a/src/main/skills/skill-package-download.ts b/src/main/skills/skill-package-download.ts index 595b568eeff7..db081f69388f 100644 --- a/src/main/skills/skill-package-download.ts +++ b/src/main/skills/skill-package-download.ts @@ -11,6 +11,7 @@ import { throwIfSkillDownloadUnavailable } from './skill-package-download-availability' import { startSkillPhaseOperation } from './skill-operation-observability' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) const MAX_REDIRECTS = 3 @@ -102,18 +103,26 @@ async function fetchWithoutCredentialRedirect( if (!REDIRECT_STATUSES.has(response.status)) { return response } - if (redirectCount >= MAX_REDIRECTS) { - throw new Error('skill-download-redirect-limit') - } - const location = response.headers.get('location') - if (!location) { - throw new Error('skill-download-redirect-invalid') - } - const redirected = validateUrl(new URL(location, url).href, allowedOrigins, input.requireHttps) - if (redirected.origin !== url.origin) { - throw new Error('skill-download-cross-origin-redirect') + try { + if (redirectCount >= MAX_REDIRECTS) { + throw new Error('skill-download-redirect-limit') + } + const location = response.headers.get('location') + if (!location) { + throw new Error('skill-download-redirect-invalid') + } + const redirected = validateUrl( + new URL(location, url).href, + allowedOrigins, + input.requireHttps + ) + if (redirected.origin !== url.origin) { + throw new Error('skill-download-cross-origin-redirect') + } + url = redirected + } finally { + await cancelUnreadResponseBody(response) } - url = redirected } } @@ -187,8 +196,9 @@ async function downloadSkillPackageGrantUnobserved( now: input.now!, expiresAt }) + let response: Response | undefined try { - const response = await fetchWithoutCredentialRedirect( + response = await fetchWithoutCredentialRedirect( input, allowedOrigins, expiresAt, @@ -276,6 +286,9 @@ async function downloadSkillPackageGrantUnobserved( throw error } } finally { + if (response && !response.bodyUsed) { + await cancelUnreadResponseBody(response) + } availability.cleanup() } } diff --git a/src/main/skills/skill-package-tar.test.ts b/src/main/skills/skill-package-tar.test.ts index 4354c5d04431..5ed98a39d549 100644 --- a/src/main/skills/skill-package-tar.test.ts +++ b/src/main/skills/skill-package-tar.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest' -import { parseSkillTarHeader, SKILL_TAR_BLOCK_BYTES } from './skill-package-tar' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + openSkillTarGzip, + parseSkillTarHeader, + SKILL_TAR_BLOCK_BYTES, + writeSkillTarGzip +} from './skill-package-tar' function writeOctal(header: Buffer, offset: number, length: number, value: number): void { header.write(`${value.toString(8).padStart(length - 1, '0')}\0`, offset, length, 'ascii') @@ -62,3 +70,26 @@ describe('skill package tar envelope', () => { } }) }) + +describe('skill tar reader error ownership', () => { + it('observes an abort after the pipeline completes before the first reader request', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-tar-abort-')) + try { + const archivePath = join(root, 'archive.tar.gz') + await writeSkillTarGzip(archivePath, [ + { path: 'SKILL.md', size: 1, executable: false, bytes: Buffer.from('x') } + ]) + const archive = await openSkillTarGzip(archivePath) + await archive.archiveIdentity + const failure = Object.assign(new Error('staging directory already exists'), { + code: 'EEXIST' + }) + archive.abort(failure) + // Observe destruction before lazily attaching the reader's error listener. + await new Promise((resolve) => setImmediate(resolve)) + await expect(archive.reader.readExact(SKILL_TAR_BLOCK_BYTES)).rejects.toBe(failure) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/skills/skill-package-tar.ts b/src/main/skills/skill-package-tar.ts index f8cac20c6332..4a67f7ca5530 100644 --- a/src/main/skills/skill-package-tar.ts +++ b/src/main/skills/skill-package-tar.ts @@ -262,6 +262,8 @@ export async function openSkillTarGzip(archivePath: string): Promise<{ } }) const gunzip = createGunzip() + // Observe late aborts before the archive reader starts. + gunzip.on('error', () => undefined) const completion = pipeline(source, verifier, gunzip) const archiveIdentity = completion.then(() => ({ archiveSha256: archiveHash.digest('hex'), diff --git a/src/main/ssh/orcad-activation-gate.ts b/src/main/ssh/orcad-activation-gate.ts index d514d6858e48..3320dae54efe 100644 --- a/src/main/ssh/orcad-activation-gate.ts +++ b/src/main/ssh/orcad-activation-gate.ts @@ -39,7 +39,7 @@ export type OrcadActivationVerdict = | { decision: 'reject'; code: OrcadActivationRejectCode; reason: string } export type OrcadActivationExpectation = { - /** sha256(orcad.js).slice(0,16) computed from the bytes this client just uploaded. */ + /** Entry-file content hash computed from the bytes this client just uploaded. */ buildHash: string /** The full content-hashed version this deploy installed. */ fullVersion: string diff --git a/src/main/ssh/orcad-local-build-hash.ts b/src/main/ssh/orcad-local-build-hash.ts index 3740ea6d55c5..30f0bb74e3a1 100644 --- a/src/main/ssh/orcad-local-build-hash.ts +++ b/src/main/ssh/orcad-local-build-hash.ts @@ -1,22 +1,10 @@ -/** - * The build identity the deploy expects the host to answer with. - * - * It must be computed the same way `computeOrcadBuildHash` computes it on the host — - * sha256 of `orcad.js`, first 16 hex characters — or the activation gate would reject every - * healthy candidate. Keeping the two in one comment is deliberate: they are one contract - * split across a network, and the version string cannot stand in for it, because - * `ORCA_VERSION` is whatever the launch command exported and two builds can carry one value. - */ -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' +/** Shares the host build identity so activation verifies the shipped entry bytes. */ import { join } from 'node:path' +import { hashOrcadLauncher } from '../../shared/orcad-build-identity' +import { ORCAD_LAUNCHER_FILENAME } from '../../shared/orcad-artifacts' -export const ORCAD_BUILD_HASH_LENGTH = 16 +export { ORCAD_BUILD_HASH_LENGTH } from '../../shared/orcad-build-identity' export function computeLocalOrcadBuildHash(localOrcadDir: string): string { - const entry = join(localOrcadDir, 'orcad.js') - return createHash('sha256') - .update(readFileSync(entry)) - .digest('hex') - .slice(0, ORCAD_BUILD_HASH_LENGTH) + return hashOrcadLauncher(join(localOrcadDir, ORCAD_LAUNCHER_FILENAME)) } diff --git a/src/main/ssh/orcad-remote-build-hash.integration.test.ts b/src/main/ssh/orcad-remote-build-hash.integration.test.ts new file mode 100644 index 000000000000..df09abefc17a --- /dev/null +++ b/src/main/ssh/orcad-remote-build-hash.integration.test.ts @@ -0,0 +1,42 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { remoteOrcadBuildHashCommand } from './orcad-remote-build-hash' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +let directory = '' +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "orcad-hash-'quoted ")) +}) +afterEach(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +it.skipIf(process.platform === 'win32').each([false, true])( + 'reads the installed build identity for split entry %s through a real POSIX shell', + (splitEntry) => { + const launcher = join(directory, 'orcad.js') + writeFileSync(launcher, 'launcher\n') + if (splitEntry) { + writeFileSync(join(directory, 'orcad-server.js'), 'server\n') + } + const command = remoteOrcadBuildHashCommand(getRemoteHostPlatform('linux-x64'), directory) + const run = (prefix = ''): string => + execFileSync('/bin/sh', ['-c', prefix + command], { encoding: 'utf8' }).trim() + const legacyHash = createHash('sha256').update('launcher\n').digest('hex').slice(0, 16) + const expected = `__ORCAD_BUILD_HASH__ ${legacyHash}` + expect(run()).toBe(expected) + expect(run('command() { [ "$2" = shasum ]; }; ')).toBe(expected) + } +) + +it.skipIf(process.platform === 'win32')( + 'refuses an absent launcher without publishing an empty-file hash', + () => { + const command = remoteOrcadBuildHashCommand(getRemoteHostPlatform('linux-x64'), directory) + expect(() => execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' })).toThrow() + } +) diff --git a/src/main/ssh/orcad-remote-build-hash.ts b/src/main/ssh/orcad-remote-build-hash.ts index 1308c4868f04..203da86b593c 100644 --- a/src/main/ssh/orcad-remote-build-hash.ts +++ b/src/main/ssh/orcad-remote-build-hash.ts @@ -1,4 +1,5 @@ -/** The installed slot's `orcad.js` identity, the same 16-hex prefix orcad reports in its health. */ +/** The installed entry bytes, hashed as the server reports them for activation and rollback. */ +import { ORCAD_LAUNCHER_FILENAME } from '../../shared/orcad-artifacts' import { shellEscape } from './ssh-connection-utils' import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' import { orcadWindowsBaseDir, orcadWindowsHostOpCommand } from './orcad-remote-windows-node' @@ -29,12 +30,13 @@ export function remoteOrcadBuildHashCommand( host, orcadWindowsBaseDir(host, remoteInstallDir), 'build-hash', - [joinRemotePath(host, remoteInstallDir, 'orcad.js')] + [joinRemotePath(host, remoteInstallDir, ORCAD_LAUNCHER_FILENAME)] ) } - const path = shellEscape(joinRemotePath(host, remoteInstallDir, 'orcad.js')) + const path = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_LAUNCHER_FILENAME)) // Why both tools: GNU/busybox ship sha256sum, macOS ships shasum; either prints the digest first. return [ + `[ -f ${path} ] && [ -r ${path} ] || exit 1;`, `orca_hash=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum ${path} | awk '{print $1}';`, `elif command -v shasum >/dev/null 2>&1; then shasum -a 256 ${path} | awk '{print $1}'; fi);`, `case "$orca_hash" in [0-9a-fA-F][0-9a-fA-F]*) printf '%s %.16s\\n' ${shellEscape(BUILD_HASH_MARKER)} "$orca_hash";; esac` diff --git a/src/main/ssh/orcad-remote-launch-windows.ts b/src/main/ssh/orcad-remote-launch-windows.ts index ba255e8e3217..b18387f59472 100644 --- a/src/main/ssh/orcad-remote-launch-windows.ts +++ b/src/main/ssh/orcad-remote-launch-windows.ts @@ -2,7 +2,7 @@ * Starting a candidate orcad on a Windows SSH host. * * Win32-OpenSSH kills the session's job when the session ends, so orcad must leave that job. - * `orcad.js --windows-breakaway-launch` does it the way the relay does: one CreateProcessW with + * The server entry's `--windows-breakaway-launch` mode does it the way the relay does: one CreateProcessW with * CREATE_BREAKAWAY_FROM_JOB through the slot's staged process-tree addon. Unlike the relay there * is no WMI fallback: Win32_Process.Create is EDR-scored remote execution and refused to a * standard user's network logon, so a host that cannot break away refuses the launch. @@ -28,7 +28,10 @@ import { orcadWindowsNodeCommandLine, readOrcadWindowsEncodedAnswer } from './orcad-remote-windows-node' -import { ORCAD_WINDOWS_RUNTIME_MARKER } from './orcad-windows-host-script' +import { + ORCAD_WINDOWS_ENTRY_MARKER, + ORCAD_WINDOWS_RUNTIME_MARKER +} from './orcad-windows-host-script' import { ORCAD_LOG_FILENAME, ORCAD_READINESS_FILENAME, @@ -67,14 +70,27 @@ export function readWindowsOrcadSlotRuntime(output: string): string { return runtime } +/** Older host-script answers omit the entry; those slots used the single-file launcher. */ +export function readWindowsOrcadSlotEntry( + output: string, + host: RemoteHostPlatform, + slotDir: string +): string { + return ( + readOrcadWindowsEncodedAnswer(output, ORCAD_WINDOWS_ENTRY_MARKER) ?? + joinRemotePath(host, slotDir, 'orcad.js') + ) +} + export function windowsOrcadLaunchCommand( host: RemoteHostPlatform, spec: OrcadLaunchSpec, - slotRuntime: string + slotRuntime: string, + slotEntry = joinRemotePath(host, spec.remoteInstallDir, 'orcad.js') ): string { const dir = spec.remoteInstallDir return orcadWindowsNodeCommandLine(slotRuntime, [ - joinRemotePath(host, dir, 'orcad.js'), + slotEntry, WINDOWS_BREAKAWAY_LAUNCH_FLAG, // The addon creates both with CREATE_ALWAYS, so a previous run's readiness line is gone // before the launcher returns. diff --git a/src/main/ssh/orcad-remote-launch.ts b/src/main/ssh/orcad-remote-launch.ts index c8468fffbb7d..b1b69f53d9f7 100644 --- a/src/main/ssh/orcad-remote-launch.ts +++ b/src/main/ssh/orcad-remote-launch.ts @@ -12,6 +12,7 @@ * the process's whole life; redirecting means the deploy can disconnect and the supervisor * still owns a running service. */ +import { ORCAD_LAUNCHER_FILENAME, ORCAD_SERVER_ENTRY_FILENAME } from '../../shared/orcad-artifacts' import { shellEscape } from './ssh-connection-utils' import { isWindowsRemoteHost, @@ -89,7 +90,7 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp ) const log = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_LOG_FILENAME)) const pidFile = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_PID_FILENAME)) - const entry = shellEscape(joinRemotePath(host, spec.remoteInstallDir, 'orcad.js')) + const entry = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_LAUNCHER_FILENAME)) const baseDir = remoteDirname(spec.remoteInstallDir.replace(/\/+$/u, ''), host) // Only `~/.orca-remote` itself; never tighten an unrelated parent a custom slot path names. const privateDirs = [ @@ -116,6 +117,7 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp `ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`, ...orcadManagedLaunchEnv(spec).map(([name, value]) => `${name}=${shellEscape(value)}`), // Keep $! equal to the runtime PID rather than a waiting shell's PID. + // Older clients identify the live PID by orcad.js; the pinned launcher loads the body in-place. `exec nohup "$orcad_runtime" ${entry}`, `--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`, `> ${readiness} 2>> ${log} < /dev/null &`, @@ -151,14 +153,15 @@ export function orcadLivenessProbeCommand( } const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) - const entry = shellEscape(joinRemotePath(host, remoteInstallDir, 'orcad.js')) + const launcher = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_LAUNCHER_FILENAME)) + const server = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_SERVER_ENTRY_FILENAME)) return [ posixProcessAliveShellFunction({ refuseUnverifiable: true }), // A PID is no identity once reused: a process whose command line does not run this slot's - // orcad.js is not it. No `ps` answer leaves the plain liveness check to decide. - `orcad_entry=${entry};`, + // entry is not it. No `ps` answer leaves the plain liveness check to decide. + `orcad_launcher=${launcher}; orcad_server=${server};`, 'orcad_reused() { args=$(ps -o args= -p "$1" 2>/dev/null) && [ -n "$args" ] && ' + - 'case "$args" in *"$orcad_entry"*) return 1;; *) return 0;; esac; };', + 'case "$args" in *"$orcad_launcher"* | *"$orcad_server"*) return 1;; *) return 0;; esac; };', `pid=$(cat ${pidFile} 2>/dev/null);`, 'case "$pid" in', `"" ) if [ -e ${pidFile} ] || [ -e ${readiness} ]; then echo UNKNOWN; else echo ${ORCAD_NEVER_LAUNCHED}; fi;;`, diff --git a/src/main/ssh/orcad-remote-preflight.ts b/src/main/ssh/orcad-remote-preflight.ts index 85e41339422e..31357c7b3eb4 100644 --- a/src/main/ssh/orcad-remote-preflight.ts +++ b/src/main/ssh/orcad-remote-preflight.ts @@ -13,8 +13,9 @@ import { orcadWindowsNodeCommandLine, readOrcadWindowsEncodedAnswer } from './orcad-remote-windows-node' +import { readWindowsOrcadSlotEntry } from './orcad-remote-launch-windows' import { ORCAD_WINDOWS_RUNTIME_MARKER } from './orcad-windows-host-script' -import { orcadNodeSlotRuntimeCommand } from './orcad-remote-runtime' +import { orcadNodeSlotRuntimeCommand, selectOrcadSlotEntryCommand } from './orcad-remote-runtime' import { execCommand } from './ssh-relay-deploy-helpers' import { shellEscape } from './ssh-connection-utils' import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' @@ -31,11 +32,11 @@ export function orcadProfilePreflightCommand( const launch = [ 'ORCA_BACKGROUND_LAUNCH=1', '"$orcad_runtime"', - shellEscape(joinRemotePath(host, directory, 'orcad.js')), + '"$orcad_entry"', ORCAD_PROFILE_PREFLIGHT_FLAG, shellEscape(nonce) ].join(' ') - return `[ -e ${marker} ] || exit 78; ${orcadNodeSlotRuntimeCommand(host, directory)}${launch}` + return `[ -e ${marker} ] || exit 78; ${orcadNodeSlotRuntimeCommand(host, directory)}${selectOrcadSlotEntryCommand(host, directory)}; ${launch}` } /** Failure leaves the incumbent and its data untouched, including an unconfirmed SSH exit. */ @@ -59,7 +60,7 @@ export async function preflightInstalledOrcad(options: { } /** - * Windows: resolve the slot's node.exe, then run its `orcad.js` with plain argv. No + * Windows: resolve the slot's node.exe, then run its selected entry with plain argv. No * ORCA_BACKGROUND_LAUNCH here: orcad opens no window, and argv cannot set environment. */ async function windowsOrcadProfilePreflightCommand( @@ -72,21 +73,19 @@ async function windowsOrcadProfilePreflightCommand( nonce: string ): Promise { const { host, remoteInstallDir } = options - const runtime = readOrcadWindowsEncodedAnswer( - await execCommand( - options.conn, - orcadWindowsHostOpCommand(host, orcadWindowsBaseDir(host, remoteInstallDir), 'slot-runtime', [ - remoteInstallDir - ]), - { signal: options.signal, wrapCommand: false } - ), - ORCAD_WINDOWS_RUNTIME_MARKER + const slotAnswer = await execCommand( + options.conn, + orcadWindowsHostOpCommand(host, orcadWindowsBaseDir(host, remoteInstallDir), 'slot-runtime', [ + remoteInstallDir + ]), + { signal: options.signal, wrapCommand: false } ) + const runtime = readOrcadWindowsEncodedAnswer(slotAnswer, ORCAD_WINDOWS_RUNTIME_MARKER) if (!runtime) { throw new Error('The Windows host did not name the runtime this orcad slot needs.') } return orcadWindowsNodeCommandLine(runtime, [ - joinRemotePath(host, remoteInstallDir, 'orcad.js'), + readWindowsOrcadSlotEntry(slotAnswer, host, remoteInstallDir), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce ]) diff --git a/src/main/ssh/orcad-remote-runtime-control.ts b/src/main/ssh/orcad-remote-runtime-control.ts index 590834b487b8..9a43e20f4a3b 100644 --- a/src/main/ssh/orcad-remote-runtime-control.ts +++ b/src/main/ssh/orcad-remote-runtime-control.ts @@ -9,6 +9,7 @@ import { import { readWindowsOrcadLaunchReport, readWindowsOrcadSlotRuntime, + readWindowsOrcadSlotEntry, windowsOrcadLaunchCommand, windowsOrcadLaunchRuntimeCommand } from './orcad-remote-launch-windows' @@ -104,15 +105,18 @@ export async function launchOrcadAndAwaitReadiness( spec: OrcadLaunchSpec ): Promise { if (isWindowsRemoteHost(target.host)) { - const slotRuntime = readWindowsOrcadSlotRuntime( + const slotAnswer = await execOrcadRemote( + target, + windowsOrcadLaunchRuntimeCommand(target.host, spec.remoteInstallDir) + ) + const slotRuntime = readWindowsOrcadSlotRuntime(slotAnswer) + const slotEntry = readWindowsOrcadSlotEntry(slotAnswer, target.host, spec.remoteInstallDir) + readWindowsOrcadLaunchReport( await execOrcadRemote( target, - windowsOrcadLaunchRuntimeCommand(target.host, spec.remoteInstallDir) + windowsOrcadLaunchCommand(target.host, spec, slotRuntime, slotEntry) ) ) - readWindowsOrcadLaunchReport( - await execOrcadRemote(target, windowsOrcadLaunchCommand(target.host, spec, slotRuntime)) - ) } else { await execOrcadRemote(target, orcadLaunchCommand(target.host, spec)) } diff --git a/src/main/ssh/orcad-remote-runtime.ts b/src/main/ssh/orcad-remote-runtime.ts index df63b5a9c185..672c8df6282f 100644 --- a/src/main/ssh/orcad-remote-runtime.ts +++ b/src/main/ssh/orcad-remote-runtime.ts @@ -1,5 +1,7 @@ import { ORCAD_BUILD_TARGET_FILENAME, + ORCAD_LAUNCHER_FILENAME, + ORCAD_SERVER_ENTRY_FILENAME, ORCAD_NODE_RUNTIME_DIR_PREFIX, ORCAD_NODE_RUNTIME_MARKER_FILENAME, ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE, @@ -10,6 +12,14 @@ import { assertPosixOrcadHost } from './orcad-remote-host-support' import { shellEscape } from './ssh-connection-utils' import { joinRemotePath, remoteDirname, type RemoteHostPlatform } from './ssh-remote-platform' +/** New slots start the server directly; rollback still accepts older single-entry slots. */ +export function selectOrcadSlotEntryCommand(host: RemoteHostPlatform, directory: string): string { + assertPosixOrcadHost(host) + const server = shellEscape(joinRemotePath(host, directory, ORCAD_SERVER_ENTRY_FILENAME)) + const launcher = shellEscape(joinRemotePath(host, directory, ORCAD_LAUNCHER_FILENAME)) + return `if [ -f ${server} ]; then orcad_entry=${server}; else orcad_entry=${launcher}; fi` +} + /** * Sets `$orcad_runtime` to the pinned Node a slot's `.runtime-node` names, or exits 78. * Assumes the marker exists; callers decide what its absence means. diff --git a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts index 9227ccd9a00b..fffede27a02a 100644 --- a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts +++ b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts @@ -97,7 +97,8 @@ afterEach(() => { async function launchTestRuntime( legacyWrapper = false, - stopRequests = false + stopRequests = false, + splitEntry = false ): Promise<{ runtimePid: number recordedPid: number @@ -106,7 +107,7 @@ async function launchTestRuntime( const terminatedFile = join(versionDir, 'terminated') const requestFile = join(versionDir, ORCAD_STOP_REQUEST_FILENAME) writeFileSync( - join(versionDir, 'orcad.js'), + join(versionDir, splitEntry ? 'orcad-server.js' : 'orcad.js'), [ `const fs = require('node:fs');`, `process.on('SIGTERM', () => {`, @@ -131,6 +132,9 @@ async function launchTestRuntime( `setTimeout(() => process.exit(1), 10_000);` ].join('\n') ) + if (splitEntry) { + writeFileSync(join(versionDir, 'orcad.js'), 'require("./orcad-server.js")') + } let command = orcadLaunchCommand(host, { remoteInstallDir: versionDir, nodePath: process.execPath, @@ -372,16 +376,30 @@ describe('state snapshot commands, run for real', () => { }) describe('liveness and stop commands, run for real', () => { - it('records the runtime PID and waits for that runtime to exit when stopped', async () => { - const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime() - expect(recordedPid).toBe(runtimePid) - expect(stopTestRuntime()).toBe('stopped') - expect(readFileSync(terminatedFile, 'utf8')).toBe('terminated') - // An unreaped zombie has exited even though kill -0 still succeeds. - expect(sh(`ps -o stat= -p ${runtimePid} || true`).trim()).toMatch(/^(?:Z.*)?$/) - expect(existsSync(join(versionDir, ORCAD_PID_FILENAME))).toBe(true) - expect(stopTestRuntime()).toBe('already-exited') - }) + it.each([false, true])( + 'records and stops the runtime PID with split entry %s', + async (splitEntry) => { + const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime( + false, + false, + splitEntry + ) + expect(recordedPid).toBe(runtimePid) + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('LIVE') + // The prior client recognizes only orcad.js when deciding whether this PID was reused. + const legacyIdentity = sh( + `args=$(ps -o args= -p ${recordedPid}); ` + + `case "$args" in *${shellEscape(join(versionDir, 'orcad.js'))}*) echo LIVE;; *) echo DEAD;; esac` + ).trim() + expect(legacyIdentity).toBe('LIVE') + expect(stopTestRuntime()).toBe('stopped') + expect(readFileSync(terminatedFile, 'utf8')).toBe('terminated') + // An unreaped zombie has exited even though kill -0 still succeeds. + expect(sh(`ps -o stat= -p ${runtimePid} || true`).trim()).toMatch(/^(?:Z.*)?$/) + expect(existsSync(join(versionDir, ORCAD_PID_FILENAME))).toBe(true) + expect(stopTestRuntime()).toBe('already-exited') + } + ) it('stops a build that consumes stop requests by request file, not by signal', async () => { const { terminatedFile } = await launchTestRuntime(false, true) @@ -513,31 +531,34 @@ describe('liveness and stop commands, run for real', () => { } }) - it('reports LIVE for a running process and stops it with SIGTERM', async () => { - // Stands in for orcad: its command line runs this slot's orcad.js. - const child = spawn( - process.execPath, - ['-e', 'setTimeout(() => {}, 30000)', join(versionDir, 'orcad.js')], - { stdio: 'ignore' } - ) - try { - writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(child.pid)) - expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('LIVE') - - const exited = new Promise((resolve) => - child.once('exit', (_code, signal) => resolve(signal)) + it.each(['orcad.js', 'orcad-server.js'])( + 'reports LIVE and stops a running %s even without entry files', + async (entry) => { + // Stands in for orcad: its command line runs this slot's orcad.js. + const child = spawn( + process.execPath, + ['-e', 'setTimeout(() => {}, 30000)', join(versionDir, entry)], + { stdio: 'ignore' } ) - expect( - parseOrcadStopOutcome( - sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10, justLaunched: true })) + try { + writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(child.pid)) + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('LIVE') + + const exited = new Promise((resolve) => + child.once('exit', (_code, signal) => resolve(signal)) ) - ).toBe('stopped') - expect(await exited).toBe('SIGTERM') - expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') - } finally { - child.kill('SIGKILL') + expect( + parseOrcadStopOutcome( + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10, justLaunched: true })) + ) + ).toBe('stopped') + expect(await exited).toBe('SIGTERM') + expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') + } finally { + child.kill('SIGKILL') + } } - }) + ) // `kill -0` succeeds on a zombie, so a probe built on it alone calls an exited process // live: the stop loop would time out on a process that is already gone, and GC would keep diff --git a/src/main/ssh/orcad-remote-windows-commands.test.ts b/src/main/ssh/orcad-remote-windows-commands.test.ts index 013000dc9553..ede08c7d2844 100644 --- a/src/main/ssh/orcad-remote-windows-commands.test.ts +++ b/src/main/ssh/orcad-remote-windows-commands.test.ts @@ -14,6 +14,7 @@ import { orcadLivenessProbeCommand, type OrcadLaunchSpec } from './orcad-remote- import { OrcadWindowsLaunchRefusedError, readWindowsOrcadLaunchReport, + readWindowsOrcadSlotEntry, windowsOrcadLaunchCommand, windowsOrcadLaunchRuntimeCommand } from './orcad-remote-launch-windows' @@ -86,6 +87,18 @@ describe('Windows orcad commands run node.exe directly', () => { ['build hash', remoteOrcadBuildHashCommand(host, slot)] ] + it('selects the split server entry and preserves older host-script answers', () => { + const server = `${slot}/orcad-server.js` + const selected = readWindowsOrcadSlotEntry(encoded('__ORCAD_ENTRY__', server), host, slot) + expect(selected).toBe(server) + expect(windowsOrcadLaunchCommand(host, spec, SLOT_NODE, selected)).toContain( + `${server} --windows-breakaway-launch` + ) + expect(readWindowsOrcadSlotEntry(encoded('__ORCAD_RUNTIME__', SLOT_NODE), host, slot)).toBe( + `${slot}/orcad.js` + ) + }) + it.each(commands())('%s: no PowerShell hop, no encoding, no WMI or signal', (_name, command) => { expect(command).toMatch(/^C:\\Users\\u\\\.orca-remote\\runtimes\\node-[0-9a-f]+\\node\.exe /u) expect(command).not.toMatch( @@ -126,7 +139,10 @@ describe('Windows orcad commands run node.exe directly', () => { it('never polls across SSH: runtime, launch, then one host-side wait', async () => { mockExec - .mockResolvedValueOnce(encoded('__ORCAD_RUNTIME__', SLOT_NODE)) + .mockResolvedValueOnce( + encoded('__ORCAD_RUNTIME__', SLOT_NODE) + + encoded('__ORCAD_ENTRY__', `${slot}/orcad-server.js`) + ) .mockResolvedValueOnce( 'ORCA_ORCAD_LAUNCH {"method":"breakaway","pid":4242,"inJob":false}\r\n' ) @@ -144,6 +160,9 @@ describe('Windows orcad commands run node.exe directly', () => { expect(result).toMatchObject({ state: 'ready', readiness: { runtimeId: 'r1' } }) expect(mockExec).toHaveBeenCalledTimes(3) expect(String(mockExec.mock.calls[1]?.[1]).startsWith(`${SLOT_NODE} `)).toBe(true) + expect(String(mockExec.mock.calls[1]?.[1])).toContain( + `${slot}/orcad-server.js --windows-breakaway-launch` + ) expect(sleep).not.toHaveBeenCalled() }) }) diff --git a/src/main/ssh/orcad-remote-windows-scripts.test.ts b/src/main/ssh/orcad-remote-windows-scripts.test.ts index 69d3dfc77b09..d1699ac18d31 100644 --- a/src/main/ssh/orcad-remote-windows-scripts.test.ts +++ b/src/main/ssh/orcad-remote-windows-scripts.test.ts @@ -5,6 +5,7 @@ * every platform. The process-tree addon is faked by a preload that loads `*.node` as a table of * creation times, so the identity rules run for real without Windows. */ +import { createHash } from 'node:crypto' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -22,6 +23,7 @@ import { ORCAD_WINDOWS_HOST_SCRIPT, ORCAD_WINDOWS_READINESS_MARKER, ORCAD_WINDOWS_RUNTIME_MARKER, + ORCAD_WINDOWS_ENTRY_MARKER, type OrcadWindowsHostOp } from './orcad-windows-host-script' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -317,6 +319,14 @@ describe('Windows slot runtime and file removal', () => { expect(readOrcadWindowsEncodedAnswer(plain.stdout, ORCAD_WINDOWS_RUNTIME_MARKER)).toBe( join(dir, 'runtimes', `node-${sha}`, 'node.exe') ) + expect(readOrcadWindowsEncodedAnswer(plain.stdout, ORCAD_WINDOWS_ENTRY_MARKER)).toBe( + join(slot(), 'orcad.js') + ) + writeFileSync(join(slot(), 'orcad-server.js'), '') + const split = await runOp('slot-runtime', [slot()], false) + expect(readOrcadWindowsEncodedAnswer(split.stdout, ORCAD_WINDOWS_ENTRY_MARKER)).toBe( + join(slot(), 'orcad-server.js') + ) expect(existsSync(join(slot(), ORCAD_STOP_REQUEST_FILENAME))).toBe(true) await runOp('slot-runtime', [slot(), 'clear-stop-request'], false) expect(existsSync(join(slot(), ORCAD_STOP_REQUEST_FILENAME))).toBe(false) @@ -345,3 +355,17 @@ describe('Windows slot runtime and file removal', () => { it('decodes nothing from output that lacks the marker', () => { expect(readOrcadWindowsEncodedAnswer('noise\r\n', ORCAD_WINDOWS_READINESS_MARKER)).toBeNull() }) + +describe('Windows installed build identity', () => { + it.each([false, true])('matches the server hash for split entry %s', async (splitEntry) => { + const launcher = join(dir, 'orcad.js') + writeFileSync(launcher, 'launcher\n') + if (splitEntry) { + writeFileSync(join(dir, 'orcad-server.js'), 'server\n') + } + const result = await runOp('build-hash', [launcher], false) + expect(result.code).toBe(0) + const legacyHash = createHash('sha256').update('launcher\n').digest('hex').slice(0, 16) + expect(result.stdout.trim()).toBe(`__ORCAD_BUILD_HASH__ ${legacyHash}`) + }) +}) diff --git a/src/main/ssh/orcad-windows-host-script.ts b/src/main/ssh/orcad-windows-host-script.ts index 9b6957c3d840..c731244c6ffe 100644 --- a/src/main/ssh/orcad-windows-host-script.ts +++ b/src/main/ssh/orcad-windows-host-script.ts @@ -13,6 +13,8 @@ */ import { createHash } from 'node:crypto' import { + ORCAD_LAUNCHER_FILENAME, + ORCAD_SERVER_ENTRY_FILENAME, ORCAD_NODE_RUNTIME_DIR_PREFIX, ORCAD_NODE_RUNTIME_MARKER_FILENAME, ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE, @@ -43,6 +45,7 @@ export const ORCAD_RECORD_PRESENT_MARKER = '__ORCAD_RECORD_PRESENT__' export const ORCAD_BUILD_HASH_MARKER = '__ORCAD_BUILD_HASH__' export const ORCAD_WINDOWS_READINESS_MARKER = '__ORCAD_READINESS__' export const ORCAD_WINDOWS_RUNTIME_MARKER = '__ORCAD_RUNTIME__' +export const ORCAD_WINDOWS_ENTRY_MARKER = '__ORCAD_ENTRY__' export const ORCAD_WINDOWS_LIVENESS_MANY_MARKER = '__ORCAD_LIVENESS__' export const ORCAD_WINDOWS_LOG_TAIL_MARKER = '__ORCAD_LOG_TAIL__' /** A slot whose marker names no usable runtime; the POSIX selector exits the same way. */ @@ -271,7 +274,13 @@ const ops = { if (mode === 'clear-stop-request') { try { fs.unlinkSync(path.join(slotDir, ${text(ORCAD_STOP_REQUEST_FILENAME)})) } catch (error) { if (error.code !== 'ENOENT') process.exit(1) } } - encoded(${text(ORCAD_WINDOWS_RUNTIME_MARKER)}, Buffer.from(runtime, 'utf8')) + const server = path.join(slotDir, ${text(ORCAD_SERVER_ENTRY_FILENAME)}) + const entry = fs.existsSync(server) ? server : path.join(slotDir, ${text(ORCAD_LAUNCHER_FILENAME)}) + answer([ + [${text(ORCAD_WINDOWS_RUNTIME_MARKER)}, Buffer.from(runtime, 'utf8').toString('base64')].join(' '), + [${text(ORCAD_WINDOWS_ENTRY_MARKER)}, Buffer.from(entry, 'utf8').toString('base64')].join(' '), + '' + ].join('\\n')) }, // Never exits on its own: it lives as long as the client's exec channel or orcad's socket. diff --git a/src/main/ssh/pinned-runtime-materializer-node.test.ts b/src/main/ssh/pinned-runtime-materializer-node.test.ts index be2c29c7201b..1d479fe44fb9 100644 --- a/src/main/ssh/pinned-runtime-materializer-node.test.ts +++ b/src/main/ssh/pinned-runtime-materializer-node.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -19,8 +19,13 @@ function sha256(bytes: Uint8Array): string { return createHash('sha256').update(bytes).digest('hex') } -function fetcherFor(body: Uint8Array): typeof fetch { - return vi.fn(async () => new Response(Buffer.from(body), { status: 200 })) +function fetcherFor(body: Uint8Array, signals: AbortSignal[] = []): typeof fetch { + return vi.fn(async (_url, options) => { + if (options?.signal) { + signals.push(options.signal) + } + return new Response(Buffer.from(body), { status: 200 }) + }) } /** A real .tar.gz laid out like the official one, so extraction runs the host's tar. */ @@ -53,7 +58,8 @@ describe.skipIf(process.platform === 'win32')('pinned Node runtime materializer' const archive = await nodeDistArchiveFixture(new TextEncoder().encode('node')) Object.assign(NODE_RUNTIME_ASSETS[TARGET], { archiveSha256: sha256(archive) }) const cacheRoot = join(root, 'cache') - const fetcher = fetcherFor(archive) + const signals: AbortSignal[] = [] + const fetcher = fetcherFor(archive, signals) const cached = await materializeNodeRuntimeArchive(TARGET, cacheRoot, { fetcher }) expect(cached.endsWith(originalAsset.archive)).toBe(true) @@ -68,6 +74,8 @@ describe.skipIf(process.platform === 'win32')('pinned Node runtime materializer' const repaired = await materializeNodeRuntimeArchive(TARGET, cacheRoot, { fetcher }) expect(repaired).not.toBe(cached) expect(sha256(new Uint8Array(await readFile(repaired)))).toBe(sha256(archive)) + expect(signals).toHaveLength(2) + expect(signals.every((signal) => !signal.aborted)).toBe(true) }) it('refuses an archive that does not match the pin before caching it', async () => { @@ -77,3 +85,69 @@ describe.skipIf(process.platform === 'win32')('pinned Node runtime materializer' ).rejects.toThrow('Node archive checksum mismatch') }) }) + +describe('pinned runtime archive request ownership', () => { + it.each(['http-error', 'oversized-declaration', 'staging-collision'] as const)( + 'retires the owned %s request without cancelling its caller', + async (failure) => { + const cacheRoot = join(root, 'cache') + const archiveRoot = join(cacheRoot, 'node', 'archives') + const parent = new AbortController() + const signals: AbortSignal[] = [] + const cancel = vi.fn() + const fetcher = vi.fn(async (_url, options) => { + if (options?.signal) { + signals.push(options.signal) + } + if (failure === 'staging-collision') { + const stage = (await readdir(archiveRoot)).find((name) => name.startsWith('.download-')) + if (!stage) { + throw new Error('Missing download staging directory') + } + await mkdir(join(archiveRoot, stage, originalAsset.archive)) + } + return new Response(new ReadableStream({ cancel }), { + status: failure === 'http-error' ? 404 : 200, + statusText: 'Unavailable', + headers: + failure === 'oversized-declaration' + ? { 'content-length': String(200 * 1024 * 1024 + 1) } + : {} + }) + }) + const pending = materializeNodeRuntimeArchive(TARGET, cacheRoot, { + fetcher, + signal: parent.signal + }) + await (failure === 'staging-collision' + ? expect(pending).rejects.toMatchObject({ code: 'EEXIST' }) + : expect(pending).rejects.toThrow( + failure === 'http-error' + ? 'Node download failed: 404 Unavailable' + : 'Node download exceeded the archive size limit' + )) + expect(cancel).toHaveBeenCalledOnce() + expect(signals).toHaveLength(1) + expect(signals[0]?.aborted).toBe(true) + expect(parent.signal.aborted).toBe(false) + expect(await readdir(archiveRoot)).toEqual([]) + } + ) + + it('keeps the caller cancellation reason and removes the incomplete stage', async () => { + const cacheRoot = join(root, 'cache') + const parent = new AbortController() + const reason = new Error('deployment cancelled') + const cancel = vi.fn() + const fetcher = vi.fn(async () => { + queueMicrotask(() => parent.abort(reason)) + return new Response(new ReadableStream({ cancel })) + }) + await expect( + materializeNodeRuntimeArchive(TARGET, cacheRoot, { fetcher, signal: parent.signal }) + ).rejects.toBe(reason) + expect(parent.signal.reason).toBe(reason) + expect(cancel).toHaveBeenCalledOnce() + expect(await readdir(join(cacheRoot, 'node', 'archives'))).toEqual([]) + }) +}) diff --git a/src/main/ssh/runtime-archive-download.test.ts b/src/main/ssh/runtime-archive-download.test.ts index af5a6e7ebd8b..b715e2e39ff1 100644 --- a/src/main/ssh/runtime-archive-download.test.ts +++ b/src/main/ssh/runtime-archive-download.test.ts @@ -21,6 +21,13 @@ function archive(body: Uint8Array = BODY): PinnedRuntimeArchive { const ok = (body: Uint8Array = BODY): Response => new Response(Buffer.from(body), { status: 200 }) +function requestSignal(options: RequestInit | undefined): AbortSignal { + if (!options?.signal) { + throw new Error('Expected an owned archive request signal') + } + return options.signal +} + /** A body that delivers its first half, then fails as Chromium does when the network drops. */ function droppedMidway(): Response { const stream = new ReadableStream({ @@ -52,29 +59,56 @@ describe('downloading a pinned runtime archive over a flaky network', () => { ['a server error', () => Promise.resolve(new Response('busy', { status: 503 }))], ['a reset partway through the body', () => Promise.resolve(droppedMidway())] ])('retries after %s and still verifies the checksum', async (_name, first) => { - const fetcher = vi.fn().mockImplementationOnce(first).mockResolvedValue(ok()) + const signals: AbortSignal[] = [] + let retiredBeforeRetry = false + const fetcher = vi + .fn() + .mockImplementationOnce(async (_url, options) => { + signals.push(requestSignal(options)) + return first() + }) + .mockImplementation(async (_url, options) => { + retiredBeforeRetry = signals[0]?.aborted === true + signals.push(requestSignal(options)) + return ok() + }) await downloadVerifiedArchive(archive(), destination, fetcher, undefined, NO_DELAY) expect(fetcher).toHaveBeenCalledTimes(2) expect(new Uint8Array(await readFile(destination))).toEqual(BODY) + expect(retiredBeforeRetry).toBe(true) + expect(signals[0]?.aborted).toBe(true) + expect(signals[1]?.aborted).toBe(false) }) it('gives up after its bounded attempts with the last error', async () => { - const fetcher = vi.fn().mockRejectedValue(new Error('net::ERR_NETWORK_CHANGED')) + const signals: AbortSignal[] = [] + const failure = new Error('net::ERR_NETWORK_CHANGED') + const fetcher = vi.fn(async (_url, options) => { + signals.push(requestSignal(options)) + throw failure + }) await expect( downloadVerifiedArchive(archive(), destination, fetcher, undefined, NO_DELAY) - ).rejects.toThrow('net::ERR_NETWORK_CHANGED') + ).rejects.toBe(failure) expect(fetcher).toHaveBeenCalledTimes(NO_DELAY.length + 1) + expect(signals).toHaveLength(NO_DELAY.length + 1) + expect(signals.every((signal) => signal.aborted)).toBe(true) }) it.each([ ['a missing asset', () => new Response('gone', { status: 404 })], ['a checksum mismatch', () => ok(new Uint8Array([9, 9]))] ])('never retries %s', async (_name, response) => { - const fetcher = vi.fn(async () => response()) + const signals: AbortSignal[] = [] + const fetcher = vi.fn(async (_url, options) => { + signals.push(requestSignal(options)) + return response() + }) await expect( downloadVerifiedArchive(archive(), destination, fetcher, undefined, NO_DELAY) ).rejects.toThrow() expect(fetcher).toHaveBeenCalledTimes(1) + expect(signals[0]?.aborted).toBe(true) }) it('stops waiting to retry once the caller cancels', async () => { diff --git a/src/main/ssh/runtime-archive-download.ts b/src/main/ssh/runtime-archive-download.ts index 57706a419bcf..a37fcca9f2c9 100644 --- a/src/main/ssh/runtime-archive-download.ts +++ b/src/main/ssh/runtime-archive-download.ts @@ -151,7 +151,9 @@ async function downloadVerifiedArchiveOnce( } } catch (error) { // Why: the fetch may reject a stalled abort with its own AbortError rather than our reason. - throw stall.signal.aborted && !signal?.aborted ? stalled : error + const downloadError = stall.signal.aborted && !signal?.aborted ? stalled : error + stall.abort() + throw downloadError } finally { clearTimeout(stallTimer) } diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts index ce223ae0b9c8..7231b20f6e44 100644 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts @@ -452,6 +452,8 @@ describe('SshRelaySession reconnect incarnation ordering', () => { mayReviveRetiredSurface: false, origin: 'relay_reattach' }) + // Into the pane's home partition: a `local` copy shadowed it at the next startup (STA-9544). + expect(mockStore.persistPtyBinding).toHaveBeenCalledWith(expect.any(Function), 'ssh:target-1') expect(vi.mocked(mockStore.persistPtyBinding).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(mockStore.markSshRemotePtyLeasesAttachedAsync).mock.invocationCallOrder[0]! ) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index d11587af4cf3..e7f7c60a15c0 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -2995,33 +2995,33 @@ export class SshRelaySession { if (lease?.worktreeId && lease.tabId && lease.leafId) { const { worktreeId, leafId, tabId: leaseTabId } = lease let tabId = lease.tabId + // The pane's home is this target's partition, where its spawn bound it. Binding into `local` + // left a copy there that startup then preferred over the home copy (STA-9544). + const hostId = toSshExecutionHostId(this.targetId) const bound = await this.store.persistPtyBinding(() => { if (!shouldContinue()) { return null } - const session = this.store.getWorkspaceSession?.() + const session = this.store.getWorkspaceSession?.(hostId) + // Rows an older build left in `local`; the renderer moves them home on its next save. + const legacySession = this.store.getWorkspaceSession?.() // The lease froze its tabId at write time; `detachTerminalPaneToTab` moves a live pane, so // trusting it would fence this reattach to the tab the pane LEFT and refuse a pane that // merely moved. Leaf is the identity, the tab is only where it currently sits. - // SSH spawns bind panes into `ssh:` while this reattach binds into `local`, so a - // fence that consulted only one partition would read "no pane" for a pane the other holds. - const hostSession = this.store.getWorkspaceSession?.(toSshExecutionHostId(this.targetId)) tabId = findTerminalTabIdForLeaf(session, leafId) ?? - findTerminalTabIdForLeaf(hostSession, leafId) ?? + findTerminalTabIdForLeaf(legacySession, leafId) ?? leaseTabId // Absence of the pane only means "the user closed it" once the persisted membership // speaks for this worktree. Before that it means the renderer has not published its // layout yet, and refusing there drops a tab the user still has — the regression that // reverted this fix twice. Losing a tab is worse than keeping a duplicate, so an // unauthoritative session still gets the creating write. - // Authority is read from `local` because that is the partition this write lands in — it - // is local's absence we would be interpreting. But a pane the other partition still holds - // is not gone, so it keeps its creating write: refusing there would strand a live pane + // A pane only `local` still holds is not gone either: refusing it would strand a live pane // behind a binding reattach can no longer reach. const mayCreate = !hasHostAuthoritativeTerminalMembership(session, worktreeId) || - findTerminalTabIdForLeaf(hostSession, leafId) !== undefined + findTerminalTabIdForLeaf(legacySession, leafId) !== undefined return { worktreeId: worktreeId, tabId, @@ -3032,7 +3032,7 @@ export class SshRelaySession { mayReviveRetiredSurface: false, origin: 'relay_reattach' as const } - }) + }, hostId) if (!shouldContinue()) { return 'missing-surface' } diff --git a/src/main/startup/ensure-virtual-display.test.ts b/src/main/startup/ensure-virtual-display.test.ts index ded8360bce5c..a895ee74edf8 100644 --- a/src/main/startup/ensure-virtual-display.test.ts +++ b/src/main/startup/ensure-virtual-display.test.ts @@ -240,8 +240,20 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { spawnMock.mockReturnValue({ pid: 4242, once: vi.fn(), kill: vi.fn(), killed: false }) const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') - expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(false) - expect(process.env.DISPLAY).toBeUndefined() + vi.useFakeTimers({ toFake: ['Date'] }) + const sleep = vi + .spyOn(Atomics, 'wait') + .mockImplementation((_array, _index, _value, timeout) => { + vi.setSystemTime(Date.now() + (timeout ?? 0)) + return 'timed-out' + }) + try { + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(false) + expect(process.env.DISPLAY).toBeUndefined() + } finally { + sleep.mockRestore() + vi.useRealTimers() + } }) it('accepts the display once the spawned Xvfb owns its lock', async () => { diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index b7d41f6aae5e..1ddd43b0470b 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -179,7 +179,6 @@ export function initializeMainProcessAccountServices(): void { .filter((account) => !activeIds.has(account.id)) .map((account) => ({ id: account.id, - managedAuthPath: account.managedAuthPath, managedAuthRuntime: account.managedAuthRuntime, wslDistro: account.wslDistro, wslLinuxAuthPath: account.wslLinuxAuthPath diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 4403659a2b92..9e4156aaa6d2 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -24,11 +24,6 @@ import { setDefaultWslDistroOverride } from '../git/runner' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' -import { - attachClaudeLivePtyPersistence, - onLiveClaudePtysDrained, - seedLiveClaudePtysFromPersistence -} from '../claude-accounts/live-pty-gate' import { applyAppIcon } from '../app-icon' import { shouldSuppressDevEducation, @@ -263,21 +258,6 @@ export async function initializeReadyFoundation(): Promise { } } }) - // Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session. - attachClaudeLivePtyPersistence(store) - // Why: while a live claude defers the managed OAuth refresh, usage shows - // "Waiting for Claude session"; refetch when the last live PTY exits so the - // error clears immediately instead of after the failure backoff. - onLiveClaudePtysDrained(() => { - void state.rateLimits?.refreshAfterClaudeLivePtysDrained() - }) - const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() - seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) - if (persistedClaudePtyIds.length > 0) { - console.log( - `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` - ) - } applyAppIcon(store.getSettings().appIcon) if (shouldSuppressDevEducation({ isDev: is.dev })) { suppressDevEducationForStore(store) diff --git a/src/main/startup/main-process-ready-identity-write.test.ts b/src/main/startup/main-process-ready-identity-write.test.ts index 4d2285e3b8e4..19bb07714e92 100644 --- a/src/main/startup/main-process-ready-identity-write.test.ts +++ b/src/main/startup/main-process-ready-identity-write.test.ts @@ -73,9 +73,6 @@ vi.mock('../persistence', () => ({ return {} } onSettingsChanged() {} - getClaudeLivePtySessionIds() { - return [] - } getSshTargets() { return [] } @@ -87,7 +84,6 @@ vi.mock('../persistence/profile-state/profile-state-startup-authority', () => ({ store: { getSettings: () => ({}), onSettingsChanged: () => {}, - getClaudeLivePtySessionIds: () => [], getSshTargets: () => [] } }) @@ -146,11 +142,6 @@ vi.mock('../git/runner', () => ({ vi.mock('../agent-hooks/wsl-hook-relay-manager', () => ({ wslHookRelayManager: { setManagedHookSettingsResolver: vi.fn() } })) -vi.mock('../claude-accounts/live-pty-gate', () => ({ - attachClaudeLivePtyPersistence: vi.fn(), - onLiveClaudePtysDrained: vi.fn(), - seedLiveClaudePtysFromPersistence: vi.fn() -})) vi.mock('../app-icon', () => ({ applyAppIcon: vi.fn() })) vi.mock('./dev-education-suppression', () => ({ shouldSuppressDevEducation: () => false, diff --git a/src/main/startup/main-window-core-services.test.ts b/src/main/startup/main-window-core-services.test.ts index ef986e43ab87..aedb8dfccada 100644 --- a/src/main/startup/main-window-core-services.test.ts +++ b/src/main/startup/main-window-core-services.test.ts @@ -112,7 +112,6 @@ describe('main window profile-state update preparation', () => { expect(preserveAgentAuthBeforeRestartMock).toHaveBeenCalledWith({ codexRuntimeHome: state.codexRuntimeHome, - claudeRuntimeAuth: state.claudeRuntimeAuth, store }) expect(store.writeLatestProfileStateJsonExportAsync).not.toHaveBeenCalled() diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 55fe55b7657b..c5386af283b4 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -92,11 +92,7 @@ export function attachMainWindowCoreServices( state.isQuitting = true stopDesktopRelayStartup() state.desktopRelayService?.fenceAndCloseNow() - await preserveAgentAuthBeforeRestart({ - codexRuntimeHome, - claudeRuntimeAuth, - store - }) + await preserveAgentAuthBeforeRestart({ codexRuntimeHome, store }) }, onOrcaProfileAuthMutation: () => notifyDesktopRelayAuthMutated(state.runtimeRpc), // Sign-out is the one fence a paired phone can be told about; quit and @@ -132,7 +128,7 @@ export function attachMainWindowCoreServices( onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit, onBeforeUpdateQuit: async () => { - await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) + await preserveAgentAuthBeforeRestart({ codexRuntimeHome, store }) await store.flushPendingOrThrowAsync({ fullCheckpoint: true }) }, onBeforeUpdateQuitFailure: 'abort', diff --git a/src/main/startup/single-instance-lock-headless-exit.test.ts b/src/main/startup/single-instance-lock-headless-exit.test.ts index f79afa3f5bae..519b57742b1f 100644 --- a/src/main/startup/single-instance-lock-headless-exit.test.ts +++ b/src/main/startup/single-instance-lock-headless-exit.test.ts @@ -1,30 +1,17 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE } from './single-instance-lock' // Why #11935: a pre-`ready` graceful quit is deferred, so a lock-losing headless `orca serve` // kept booting into Linux Ozone/X11 init, died with SIGSEGV, and systemd restarted it forever // until the leaked AppImage FUSE mounts hit the kernel's 1000-mount ceiling. -function readSystemdUnitBlocks(doc: string): Map { - const blocks = new Map() - // Why: key on the unit's path comment — splitting on directives mixes `[Unit]` and `[Service]` across blocks. - for (const match of doc.matchAll(/^# \/etc\/systemd\/system\/(\S+\.service)$/gm)) { - const start = match.index + match[0].length - const name = match[1] - blocks.set(name, [...(blocks.get(name) ?? []), doc.slice(start, doc.indexOf('```', start))]) - } - return blocks -} - describe('headless lock-loss exit contract', () => { const preflightSource = readFileSync( join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), 'utf8' ) const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const doc = readFileSync(join(process.cwd(), 'docs/reference/headless-linux-server.md'), 'utf8') it('exits the lock-losing launch immediately instead of scheduling a graceful quit', () => { const gateStart = preflightSource.indexOf('if (!hasLock) {') @@ -48,41 +35,4 @@ describe('headless lock-loss exit contract', () => { 'shouldActivateDesktopForSecondInstance(argv)' ) }) - - it('makes every documented serve unit treat a duplicate owner as terminal', () => { - const serveUnits = readSystemdUnitBlocks(doc).get('orca-serve.service') ?? [] - - expect(serveUnits.length).toBeGreaterThan(0) - for (const unit of serveUnits) { - expect(unit).toContain( - `RestartPreventExitStatus=${SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE}` - ) - expect(unit).toContain('StartLimitIntervalSec=') - expect(unit).toContain('StartLimitBurst=') - } - }) - - it('clears the start limit before every scripted start, which a tripped burst would refuse', () => { - const lines = doc.split('\n') - const startLines = lines.flatMap((line, index) => - /^\s*sudo systemctl start orca-serve/.test(line) ? [index] : [] - ) - - expect(startLines.length).toBeGreaterThan(0) - for (const index of startLines) { - expect(lines.slice(Math.max(0, index - 3), index).join('\n')).toContain( - 'systemctl reset-failed orca-serve' - ) - } - }) - - it('leaves the Xvfb unit free to self-heal from a transient display flap', () => { - const xvfbUnits = readSystemdUnitBlocks(doc).get('orca-xvfb.service') ?? [] - - expect(xvfbUnits.length).toBeGreaterThan(0) - // Why: a start limit here would down the display unit permanently and take orca-serve with it. - for (const unit of xvfbUnits) { - expect(unit).not.toContain('StartLimitBurst=') - } - }) }) diff --git a/src/main/update-install-exit-watchdog.test.ts b/src/main/update-install-exit-watchdog.test.ts index 80457230a3c8..43fc6d369b4d 100644 --- a/src/main/update-install-exit-watchdog.test.ts +++ b/src/main/update-install-exit-watchdog.test.ts @@ -1,14 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { appMock, recordUpdaterLifecycleMock } = vi.hoisted(() => ({ - appMock: { exit: vi.fn() }, - recordUpdaterLifecycleMock: vi.fn() -})) +const { appMock, recordUpdaterLifecycleMock, recordAgentSessionRuntimeEndMock } = vi.hoisted( + () => ({ + appMock: { exit: vi.fn() }, + recordUpdaterLifecycleMock: vi.fn(), + recordAgentSessionRuntimeEndMock: vi.fn() + }) +) vi.mock('electron', () => ({ app: appMock })) vi.mock('./updater-lifecycle-diagnostics', () => ({ recordUpdaterLifecycle: recordUpdaterLifecycleMock })) +vi.mock('./runtime/agent-session-runtime-end-record', () => ({ + recordAgentSessionRuntimeEnd: recordAgentSessionRuntimeEndMock +})) import { armUpdateInstallExitWatchdog, @@ -21,6 +27,7 @@ describe('update install exit watchdog', () => { vi.useFakeTimers() appMock.exit.mockClear() recordUpdaterLifecycleMock.mockClear() + recordAgentSessionRuntimeEndMock.mockClear() }) afterEach(() => { @@ -36,6 +43,8 @@ describe('update install exit watchdog', () => { vi.advanceTimersByTime(1) expect(appMock.exit).toHaveBeenCalledExactlyOnceWith(0) + // A wedged teardown may not have reached its own record of the update. + expect(recordAgentSessionRuntimeEndMock).toHaveBeenCalledExactlyOnceWith('update') expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( 'install_exit_watchdog_fired', { timeoutMs: UPDATE_INSTALL_EXIT_TIMEOUT_MS }, diff --git a/src/main/update-install-exit-watchdog.ts b/src/main/update-install-exit-watchdog.ts index 69986d1154ea..4517b5f41c5f 100644 --- a/src/main/update-install-exit-watchdog.ts +++ b/src/main/update-install-exit-watchdog.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' +import { recordAgentSessionRuntimeEnd } from './runtime/agent-session-runtime-end-record' // Why 20s: comfortably above a healthy shutdown (renderer buffer capture, // daemon final checkpoints, bounded 2s telemetry flush) but bounded — a wedged @@ -35,6 +36,7 @@ export function armUpdateInstallExitWatchdog(timeoutMs = UPDATE_INSTALL_EXIT_TIM ) // Why exit(0): the quit is already committed and cleanup is wedged, not // failed — a clean code keeps ShipIt/launchd on the normal relaunch path. + recordAgentSessionRuntimeEnd('update') app.exit(0) }, timeoutMs) // Why unref: the watchdog must never be the thing keeping the process alive. diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 6a685f2f9826..bd37b20307ca 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -628,81 +628,6 @@ describe('attachMainWindowServices', () => { expect(browserManagerUnregisterAllMock).toHaveBeenCalledTimes(1) }) - it('removes the native file-drop relay when the main window closes', () => { - const mainWindowOnMock = vi.fn() - const mainWindow = createMainWindow({ send: vi.fn() }) - mainWindow.on = mainWindowOnMock - - attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) - - const channel = 'terminal:file-dropped-from-preload' - const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1] - expect(relayHandler).toBeTypeOf('function') - expect(removeAllListenersMock).toHaveBeenCalledWith(channel) - - const closedHandlers = getClosedHandlers(mainWindowOnMock) - for (const handler of closedHandlers) { - handler() - } - - expect(removeListenerMock).toHaveBeenCalledWith(channel, relayHandler) - }) - - it('relays native file drops only from the owning renderer webContents', () => { - const sendMock = vi.fn() - const mainWindow = createMainWindow({ send: sendMock }) - - attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) - - const channel = 'terminal:file-dropped-from-preload' - const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1] - const payload = { paths: ['/tmp/a'], target: 'editor' } - - relayHandler?.({ sender: { id: 999 } }, payload) - - expect(sendMock).not.toHaveBeenCalled() - - relayHandler?.({ sender: mainWindow.webContents }, payload) - - expect(sendMock).toHaveBeenCalledWith('terminal:file-drop', payload) - }) - - it('ignores malformed native file-drop payloads from the owning renderer', () => { - const sendMock = vi.fn() - const mainWindow = createMainWindow({ send: sendMock }) - - attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) - - const channel = 'terminal:file-dropped-from-preload' - const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1] - - relayHandler?.( - { sender: mainWindow.webContents }, - { paths: ['C:\\Users\\alice\\secret.txt'], target: 'browser' } - ) - relayHandler?.( - { sender: mainWindow.webContents }, - { paths: ['/tmp/a'], target: 'file-explorer' } - ) - - expect(sendMock).not.toHaveBeenCalled() - }) - - it('ignores native file drops after the owning webContents is destroyed', () => { - const sendMock = vi.fn() - const mainWindow = createMainWindow({ send: sendMock }) - - attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) - - const channel = 'terminal:file-dropped-from-preload' - const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1] - mainWindow.webContents.isDestroyed?.mockReturnValue(true) - - relayHandler?.({ sender: mainWindow.webContents }, { paths: ['/tmp/a'], target: 'editor' }) - - expect(sendMock).not.toHaveBeenCalled() - }) - it('clears the runtime notifier when the owning window closes', () => { const mainWindowOnMock = vi.fn() const mainWindow = createMainWindow() diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index b448681857ec..4c412e9fedf7 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -39,7 +39,7 @@ import { import { startFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade' import { scheduleMainWindowAutoUpdaterSetup } from './main-window-updater' import { registerRuntimeWindowLifecycle } from './runtime-window-lifecycle' -import { registerFileDropRelay } from './native-file-drop-relay' +import { registerDroppedPathPreparation } from './dropped-path-preparation-ipc' export { ensureAutoUpdaterConfigured, registerUpdaterHandlers } from './main-window-updater' @@ -126,7 +126,7 @@ export function attachMainWindowServices( } registerSshHandlers(store, () => mainWindow, runtime) registerRemoteWorkspaceHandlers(store, () => mainWindow, runtime) - registerFileDropRelay(mainWindow) + registerDroppedPathPreparation(mainWindow) registerTccPromptNoticeHandlers(mainWindow) scheduleMainWindowAutoUpdaterSetup(mainWindow, store, options) registerRuntimeWindowLifecycle(mainWindow, runtime) diff --git a/src/main/window/clipboard-agent-session-attachment-paste.test.ts b/src/main/window/clipboard-agent-session-attachment-paste.test.ts new file mode 100644 index 000000000000..8db77235abf8 --- /dev/null +++ b/src/main/window/clipboard-agent-session-attachment-paste.test.ts @@ -0,0 +1,120 @@ +// A structured chat on a paired server keeps pasted images in that server's attachment store, not +// in its temp directory: a temp path has no owner and nothing ever removes it. +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + handle: vi.fn(), + readImage: vi.fn(), + callRuntimeEnvironment: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp/client-user-data') }, + clipboard: { readImage: mocks.readImage }, + ipcMain: { removeHandler: vi.fn(), handle: mocks.handle }, + nativeImage: { createFromBuffer: vi.fn() } +})) +vi.mock('../ipc/runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: mocks.callRuntimeEnvironment +})) +vi.mock('../../shared/runtime-environment-store', () => ({ + resolveEnvironment: (_userDataPath: string, selector: string) => ({ id: selector }) +})) +vi.mock('./clipboard-remote-file-copy', () => ({ + cleanupExpiredRemoteClipboardFiles: vi.fn(async () => {}), + scheduleLegacyRemoteClipboardFileCleanup: vi.fn(), + writeRemoteFileToClipboard: vi.fn() +})) +vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer: () => false })) + +import { + registerClipboardHandlers, + setTrustedClipboardRendererWebContentsId +} from './clipboard-ipc-handlers' + +function saveHandler(): (...args: unknown[]) => Promise { + const entry = mocks.handle.mock.calls.find( + ([channel]) => channel === 'clipboard:saveImageAsTempFile' + ) + if (!entry) { + throw new Error('save handler was not registered') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ipcMain.handle was called with this channel's async handler. + return entry[1] as (...args: unknown[]) => Promise +} + +const event = { + sender: { + id: 17, + getType: () => 'window', + getURL: () => 'file:///orca/index.html', + isDestroyed: () => false + } +} + +beforeEach(() => { + vi.clearAllMocks() + setTrustedClipboardRendererWebContentsId(17) + mocks.readImage.mockReturnValue({ + getSize: () => ({ height: 1, width: 1 }), + isEmpty: () => false, + toPNG: () => Buffer.from('png') + }) + mocks.callRuntimeEnvironment.mockImplementation(async (_userData, _env, method: string) => { + if (method === 'agentSessionAttachment.uploadStart') { + return { ok: true, result: { uploadId: 'upload-1' } } + } + if (method === 'agentSessionAttachment.uploadAppend') { + return { ok: true, result: { receivedBytes: 3 } } + } + if (method === 'agentSessionAttachment.uploadCommit') { + return { + ok: true, + result: { path: '/srv/agent-session-attachments/s/upload-1/p.png', name: 'p.png' } + } + } + throw new Error(`unexpected method: ${method}`) + }) +}) + +describe('clipboard:saveImageAsTempFile for a structured chat on a paired server', () => { + it("stores the image in the chat's attachment store, pinned to the pairing and server", async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the image save path never reads the store. + registerClipboardHandlers({} as never) + + await expect( + saveHandler()(event, { + runtimeEnvironmentId: 'remote-host-1', + agentSessionAttachment: { + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a' + } + }) + ).resolves.toBe('/srv/agent-session-attachments/s/upload-1/p.png') + + const methods = mocks.callRuntimeEnvironment.mock.calls.map((call) => call[2]) + expect(methods).toEqual([ + 'agentSessionAttachment.uploadStart', + 'agentSessionAttachment.uploadAppend', + 'agentSessionAttachment.uploadCommit' + ]) + expect(mocks.callRuntimeEnvironment.mock.calls[0][3]).toMatchObject({ + sessionId: 'session-1', + name: expect.stringMatching(/^orca-paste-.+\.png$/), + byteLength: 3 + }) + for (const call of mocks.callRuntimeEnvironment.mock.calls) { + expect(call[5]).toBe(7) + expect(call[7]).toMatchObject({ expectedEnvironmentRuntimeId: 'runtime-a' }) + } + }) + + it('keeps the server temp directory for a paste that names no chat', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ ok: true, result: { uploadId: 'u' } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the image save path never reads the store. + registerClipboardHandlers({} as never) + await saveHandler()(event, { runtimeEnvironmentId: 'remote-host-1' }).catch(() => {}) + expect(mocks.callRuntimeEnvironment.mock.calls[0][2]).toBe('clipboard.startImageUpload') + }) +}) diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index 3a599e4ac5e2..223b89c79a63 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -6,11 +6,14 @@ import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispat 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' export type SaveClipboardImageAsTempFileArgs = { connectionId?: string | null runtimeEnvironmentId?: string | null + /** With `runtimeEnvironmentId`: store the image as an attachment of this structured chat. */ + agentSessionAttachment?: AgentSessionAttachmentClipboardTarget /** A native-chat composer paste: kept in Orca's paste folder so its draft can bring it back. */ forNativeChatDraft?: boolean } diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index f5fb9032f2dc..05a09a54bb81 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -39,6 +39,7 @@ import { writeRemoteFileToClipboard } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' +import { uploadPastedImageToAgentSessionAttachments } from '../ipc/agent-session-attachment-upload' import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file' import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths' import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail' @@ -59,6 +60,15 @@ async function saveClipboardImageBufferForTarget( ): Promise { assertClipboardImageByteLengthWithinLimit(buffer.byteLength) const runtimeEnvironmentId = args?.runtimeEnvironmentId?.trim() + // A structured chat on a paired server keeps its pasted images in that server's store. + if (runtimeEnvironmentId && args?.agentSessionAttachment) { + return uploadPastedImageToAgentSessionAttachments( + args.agentSessionAttachment, + runtimeEnvironmentId, + app.getPath('userData'), + buffer + ) + } // Why (#17679): with a runtime owner, a connectionId names one of the RUNTIME's SSH // connections (nested Remote Server -> SSH), not one this process dialed. Looking it up // in the local provider registry can only miss, so the runtime must perform the save. diff --git a/src/main/window/createMainWindow-tray-minimize-close.test.ts b/src/main/window/createMainWindow-tray-minimize-close.test.ts index 469d83138304..417991f8a0df 100644 --- a/src/main/window/createMainWindow-tray-minimize-close.test.ts +++ b/src/main/window/createMainWindow-tray-minimize-close.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const { recordAgentSessionRuntimeEndMock } = vi.hoisted(() => ({ + recordAgentSessionRuntimeEndMock: vi.fn() +})) + vi.mock('electron', async () => (await import('./createMainWindow-test-harness')).electronModuleMock() ) @@ -13,6 +17,9 @@ vi.mock('../app-icon', async () => (await import('./createMainWindow-test-harnes vi.mock('../browser/browser-manager', async () => (await import('./createMainWindow-test-harness')).browserManagerMock() ) +vi.mock('../runtime/agent-session-runtime-end-record', () => ({ + recordAgentSessionRuntimeEnd: recordAgentSessionRuntimeEndMock +})) import { createMainWindow } from './createMainWindow' import { ipcMain } from 'electron' @@ -140,6 +147,17 @@ describe('createMainWindow', () => { ]) }) + it("records the Orca runtime's end, since Windows emits no will-quit for it", () => { + setPlatform('win32') + recordAgentSessionRuntimeEndMock.mockClear() + const { windowHandlers } = setupCloseWindow() + + createMainWindow(null) + windowHandlers['session-end']?.({ reasons: ['logoff'] }) + + expect(recordAgentSessionRuntimeEndMock).toHaveBeenCalledExactlyOnceWith('quit') + }) + it.each(['darwin', 'linux'] as const)( 'does not mark session teardown state on %s', (platform) => { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 41d84d1ab93b..89c01dff078b 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -31,6 +31,7 @@ import { import { installMainWindowWebviewSecurity } from './main-window-webview-security' import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation' import { installWindowsPathRegistryChangeListener } from '../pty/windows-path-registry-change' +import { recordAgentSessionRuntimeEnd } from '../runtime/agent-session-runtime-end-record' export { WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS } @@ -155,6 +156,8 @@ export function createMainWindow( ? event.reasons.filter((reason) => typeof reason === 'string').join(',') : '' }) + // Windows emits no will-quit for an OS shutdown or logoff, so its teardown never records it. + recordAgentSessionRuntimeEnd('quit') }) } diff --git a/src/main/window/dropped-path-preparation-ipc.test.ts b/src/main/window/dropped-path-preparation-ipc.test.ts new file mode 100644 index 000000000000..74b8a29a00ac --- /dev/null +++ b/src/main/window/dropped-path-preparation-ipc.test.ts @@ -0,0 +1,192 @@ +import type { BrowserWindow } from 'electron' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DragTempFileCopy from './dragged-temp-file-copy' +import type { DragTempCopyItemResult } from './dragged-temp-file-copy' + +type IpcListener = (event: { sender: unknown }, payload: unknown) => void +type InvokeHandler = (event: { sender: unknown }, payload: unknown) => Promise + +const { materializeMock, sweepMock, ipcListeners, ipcHandlers } = vi.hoisted(() => ({ + materializeMock: vi.fn(), + sweepMock: vi.fn(), + ipcListeners: new Map(), + ipcHandlers: new Map() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => '/app-temp' }, + ipcMain: { + handle: (channel: string, handler: InvokeHandler) => ipcHandlers.set(channel, handler), + removeHandler: (channel: string) => ipcHandlers.delete(channel), + on: (channel: string, listener: IpcListener) => ipcListeners.set(channel, listener), + removeListener: (channel: string, listener: IpcListener) => { + if (ipcListeners.get(channel) === listener) { + ipcListeners.delete(channel) + } + }, + removeAllListeners: (channel: string) => ipcListeners.delete(channel) + } +})) + +vi.mock('./dragged-temp-file-copy', async (importOriginal) => ({ + ...(await importOriginal()), + materializeDragTempPaths: materializeMock, + scheduleDragTempCopySweep: sweepMock +})) + +vi.mock('./darwin-user-temp-dir', () => ({ + getDarwinUserTempDir: async () => '/private/var/folders/ab/xyz/T' +})) + +import { registerDroppedPathPreparation } from './dropped-path-preparation-ipc' + +const DRAG_TEMP = join('/', 'var', 'T', 'TemporaryItems', 'NSIRD_screencaptureui_1', 'Shot.png') +const COPY = join('/', 'var', 'T', 'orca-drops-501', 'orca-drop-abc123', 'Shot.png') +const FINDER = join('/', 'Users', 'me', 'Desktop', 'notes.txt') + +function copied(sourcePath: string, destPath = sourcePath): DragTempCopyItemResult { + return { sourcePath, status: 'imported', destPath } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +async function settle(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +beforeEach(() => { + materializeMock.mockReset() +}) + +function createWindow() { + let destroyed = false + const windowListeners = new Map void>() + const webContents = { + isDestroyed: () => destroyed, + send: vi.fn(), + once: vi.fn<(event: string, listener: () => void) => void>(), + removeListener: vi.fn() + } + const fake = { + isDestroyed: () => destroyed, + on: (event: string, listener: () => void) => windowListeners.set(event, listener), + webContents + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the preparation handler only touches the members stubbed above. + const window = fake as unknown as BrowserWindow + return { + window, + webContents, + close: () => { + destroyed = true + windowListeners.get('closed')?.() + }, + destroy: () => { + destroyed = true + webContents.once.mock.calls.find(([event]) => event === 'destroyed')?.[1]() + } + } +} + +describe('registerDroppedPathPreparation', () => { + beforeEach(() => { + ipcListeners.clear() + ipcHandlers.clear() + sweepMock.mockReset() + }) + + it('registers only the preparation request and starts copy expiry', () => { + const { window } = createWindow() + registerDroppedPathPreparation(window) + expect([...ipcHandlers.keys()]).toEqual(['fs:prepareDroppedPaths']) + expect(ipcListeners.size).toBe(0) + expect(sweepMock).toHaveBeenCalledOnce() + }) + + it('prepares ordinary paths only for its own live renderer and validates the request', async () => { + const { window, webContents, close } = createWindow() + registerDroppedPathPreparation(window) + const handler = ipcHandlers.get('fs:prepareDroppedPaths')! + const request = { paths: [FINDER], consumer: 'agent' } + expect(() => handler({ sender: {} }, request)).toThrow('owning window') + for (const invalid of [ + null, + { paths: [FINDER], consumer: 'other' }, + { paths: [42], consumer: 'agent' } + ]) { + expect(() => handler({ sender: webContents }, invalid)).toThrow('Invalid') + } + await expect(handler({ sender: webContents }, request)).resolves.toEqual({ + paths: [FINDER], + failures: [] + }) + expect(webContents.send).not.toHaveBeenCalled() + close() + expect(ipcHandlers.has('fs:prepareDroppedPaths')).toBe(false) + expect(() => handler({ sender: webContents }, request)).toThrow('owning window') + }) + + it('enforces both request caps before preparing any paths', async () => { + const { window, webContents } = createWindow() + registerDroppedPathPreparation(window) + const handler = ipcHandlers.get('fs:prepareDroppedPaths')! + await expect( + handler( + { sender: webContents }, + { paths: Array.from({ length: 257 }, () => DRAG_TEMP), consumer: 'agent' } + ) + ).resolves.toMatchObject({ + paths: [], + failures: [{ reason: 'too-many-paths', pathCount: 257 }] + }) + await expect( + handler({ sender: webContents }, { paths: ['x'.repeat(256 * 1024 + 1)], consumer: 'agent' }) + ).resolves.toMatchObject({ paths: [], failures: [{ reason: 'paths-too-large' }] }) + expect(materializeMock).not.toHaveBeenCalled() + }) + + it('keeps the replacement window handler when an older window closes late', async () => { + const first = createWindow() + const second = createWindow() + registerDroppedPathPreparation(first.window) + registerDroppedPathPreparation(second.window) + const handler = ipcHandlers.get('fs:prepareDroppedPaths')! + first.close() + expect(ipcHandlers.get('fs:prepareDroppedPaths')).toBe(handler) + await expect( + handler({ sender: second.webContents }, { paths: [FINDER], consumer: 'main-reader' }) + ).resolves.toEqual({ paths: [FINDER], failures: [] }) + }) + + it.skipIf(process.platform !== 'darwin')( + 'copies from the macOS user temp dir and aborts when the renderer is gone', + async () => { + const copy = deferred() + materializeMock.mockReturnValueOnce(copy.promise) + const { window, webContents, destroy } = createWindow() + registerDroppedPathPreparation(window) + const handler = ipcHandlers.get('fs:prepareDroppedPaths')! + const pending = handler({ sender: webContents }, { paths: [DRAG_TEMP], consumer: 'agent' }) + await settle() + expect(materializeMock.mock.calls[0][1]).toMatchObject({ + platform: 'darwin', + sourceTempRoot: '/private/var/folders/ab/xyz/T' + }) + const signal = materializeMock.mock.calls[0][2] + expect(signal).toBeInstanceOf(AbortSignal) + const rejected = expect(pending).rejects.toThrow('went away') + destroy() + copy.resolve([copied(DRAG_TEMP, COPY)]) + await rejected + expect(signal.aborted).toBe(true) + expect(webContents.send).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/window/dropped-path-preparation-ipc.ts b/src/main/window/dropped-path-preparation-ipc.ts new file mode 100644 index 000000000000..df87886c0a78 --- /dev/null +++ b/src/main/window/dropped-path-preparation-ipc.ts @@ -0,0 +1,78 @@ +import { app, ipcMain, type BrowserWindow } from 'electron' +import { + createRejectedNativeFileDropPayload, + validateNativeFileDropPaths +} from '../../shared/native-file-drop' +import type { + PrepareDroppedPathsRequest, + PreparedDroppedPaths +} from '../../shared/native-file-drop-preparation' +import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort' +import { getDragTempCopyRoot, scheduleDragTempCopySweep } from './dragged-temp-file-copy' +import { + createDroppedPathPreparationQueue, + windowDragTempCopyLane +} from './dropped-path-preparation' +import { getDarwinUserTempDir } from './darwin-user-temp-dir' + +export function registerDroppedPathPreparation(mainWindow: BrowserWindow): void { + const prepareChannel = 'fs:prepareDroppedPaths' + const mainWebContents = mainWindow.webContents + const isWindowGone = (): boolean => mainWindow.isDestroyed() || mainWebContents.isDestroyed() + ipcMain.removeHandler(prepareChannel) + const prepare = createDroppedPathPreparationQueue( + { + platform: process.platform, + getCopyEnvironment: async () => ({ + platform: process.platform, + sourceTempRoot: await getDarwinUserTempDir(), + copyRoot: getDragTempCopyRoot(app.getPath('temp')) + }), + watchRenderer: () => abortWhenRendererGone(mainWebContents) + }, + windowDragTempCopyLane + ) + const prepareHandler = ( + event: Electron.IpcMainInvokeEvent, + args: unknown + ): Promise => { + if (isWindowGone() || event.sender !== mainWebContents) { + throw new Error('Dropped paths must come from the owning window') + } + if (!isPrepareDroppedPathsRequest(args)) { + throw new Error('Invalid dropped paths request') + } + const validation = validateNativeFileDropPaths(args.paths) + if (validation.status === 'rejected') { + return Promise.resolve({ + paths: [], + failures: [createRejectedNativeFileDropPayload(validation)] + }) + } + return Promise.resolve(prepare(args)) + } + ipcMain.handle(prepareChannel, prepareHandler) + activePrepareHandler = prepareHandler + mainWindow.on('closed', () => { + // Why: an old window closing late must not remove the replacement window's handler. + if (activePrepareHandler === prepareHandler) { + ipcMain.removeHandler(prepareChannel) + activePrepareHandler = undefined + } + }) + scheduleDragTempCopySweep(() => getDragTempCopyRoot(app.getPath('temp'))) +} + +let activePrepareHandler: unknown + +function isPrepareDroppedPathsRequest(value: unknown): value is PrepareDroppedPathsRequest { + return ( + !!value && + typeof value === 'object' && + 'consumer' in value && + (value.consumer === 'agent' || value.consumer === 'main-reader') && + 'paths' in value && + Array.isArray(value.paths) && + value.paths.every((path: unknown) => typeof path === 'string') + ) +} diff --git a/src/main/window/dropped-path-preparation.test.ts b/src/main/window/dropped-path-preparation.test.ts index d04aa90ef607..54d288d32fae 100644 --- a/src/main/window/dropped-path-preparation.test.ts +++ b/src/main/window/dropped-path-preparation.test.ts @@ -2,9 +2,11 @@ import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DragTempCopyItemResult } from './dragged-temp-file-copy' import type * as DragTempFileCopy from './dragged-temp-file-copy' -import type { NativeFileDropPayload } from '../../shared/native-file-drop' -import { createDragTempCopyLane, MAX_PENDING_DRAG_TEMP_COPIES } from './dropped-path-preparation' -import { createNativeFileDropQueue } from './native-file-drop-relay' +import { + createDragTempCopyLane, + createDroppedPathPreparationQueue, + MAX_PENDING_DRAG_TEMP_COPIES +} from './dropped-path-preparation' const materialize = vi.hoisted(() => vi.fn()) vi.mock('electron', () => ({ app: {}, ipcMain: {} })) @@ -19,19 +21,21 @@ const env = { platform: 'darwin' as const, sourceTempRoot: '/var/T', copyRoot: ' function queue(lane = createDragTempCopyLane(), copyTimeoutMs = 1000) { const controller = new AbortController() - const forwarded: NativeFileDropPayload[] = [] const dispose = vi.fn() - const enqueue = createNativeFileDropQueue( + const enqueue = createDroppedPathPreparationQueue( { platform: 'darwin', getCopyEnvironment: async () => env, watchRenderer: () => ({ signal: controller.signal, dispose }), - forward: (payload) => forwarded.push(payload), copyTimeoutMs }, lane ) - return { enqueue, controller, forwarded, dispose } + return { + enqueue: async (request: Parameters[0]) => enqueue(request), + controller, + dispose + } } function deferred() { let resolve!: (value: DragTempCopyItemResult[]) => void @@ -47,12 +51,12 @@ beforeEach(() => { materialize.mockReset() }) -describe('dropped path preparation through the shared relay queue', () => { +describe('dropped path preparation through the shared copy queue', () => { it.each(['agent', 'main-reader'] as const)( 'passes ordinary paths without copying for %s', async (consumer) => { const { enqueue } = queue() - await expect(enqueue.prepare({ paths: [ORDINARY], consumer })).resolves.toEqual({ + await expect(enqueue({ paths: [ORDINARY], consumer })).resolves.toEqual({ paths: [ORDINARY], failures: [] }) @@ -68,7 +72,7 @@ describe('dropped path preparation through the shared relay queue', () => { { sourcePath: TEMP, status: 'uncopied', reason: 'storage-full' } ]) const { enqueue } = queue() - const result = await enqueue.prepare({ paths: [ORDINARY, TEMP], consumer }) + const result = await enqueue({ paths: [ORDINARY, TEMP], consumer }) expect(result.paths).toEqual(consumer === 'agent' ? [ORDINARY] : [ORDINARY, TEMP]) expect(result.failures).toEqual( consumer === 'agent' @@ -92,7 +96,7 @@ describe('dropped path preparation through the shared relay queue', () => { { sourcePath: TEMP, status: 'imported', destPath: COPY }, { sourcePath: TEMP, status: 'failed', reason: 'missing' } ]) - const result = await queue().enqueue.prepare({ + const result = await queue().enqueue({ paths: [ORDINARY, TEMP, TEMP], consumer: 'agent' }) @@ -100,20 +104,19 @@ describe('dropped path preparation through the shared relay queue', () => { expect(result.failures[0]).toMatchObject({ pathCount: 1, commonReason: 'missing' }) }) - it('serializes requests and the legacy relay together', async () => { + it('serializes copy requests from different owners', async () => { const first = deferred() materialize .mockReturnValueOnce(first.promise) .mockResolvedValueOnce([{ sourcePath: TEMP, status: 'imported', destPath: COPY }]) - const { enqueue, forwarded } = queue() - enqueue({ paths: [TEMP], target: 'terminal' }) - const requested = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) + const { enqueue } = queue() + const chat = enqueue({ paths: [TEMP], consumer: 'agent' }) + const editor = enqueue({ paths: [TEMP], consumer: 'main-reader' }) await settle() expect(materialize).toHaveBeenCalledTimes(1) first.resolve([{ sourcePath: TEMP, status: 'imported', destPath: COPY }]) - await expect(requested).resolves.toEqual({ paths: [COPY], failures: [] }) - await settle() - expect(forwarded).toEqual([{ paths: [COPY], target: 'terminal' }]) + await expect(chat).resolves.toEqual({ paths: [COPY], failures: [] }) + await expect(editor).resolves.toEqual({ paths: [COPY], failures: [] }) expect(materialize).toHaveBeenCalledTimes(2) }) @@ -123,10 +126,10 @@ describe('dropped path preparation through the shared relay queue', () => { const first = queue(lane) const second = queue(lane) const results = Array.from({ length: MAX_PENDING_DRAG_TEMP_COPIES }, () => - first.enqueue.prepare({ paths: [TEMP], consumer: 'agent' }).catch(() => undefined) + first.enqueue({ paths: [TEMP], consumer: 'agent' }).catch(() => undefined) ) await expect( - second.enqueue.prepare({ paths: [ORDINARY, TEMP], consumer: 'agent' }) + second.enqueue({ paths: [ORDINARY, TEMP], consumer: 'agent' }) ).resolves.toMatchObject({ paths: [ORDINARY], failures: [{ commonReason: 'busy' }] }) first.controller.abort(new Error('test ended')) await Promise.all(results) @@ -138,8 +141,8 @@ describe('dropped path preparation through the shared relay queue', () => { const copy = deferred() materialize.mockReturnValueOnce(copy.promise) const { enqueue } = queue() - const pending = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) - await expect(enqueue.prepare({ paths: [ORDINARY], consumer: 'main-reader' })).resolves.toEqual({ + const pending = enqueue({ paths: [TEMP], consumer: 'agent' }) + await expect(enqueue({ paths: [ORDINARY], consumer: 'main-reader' })).resolves.toEqual({ paths: [ORDINARY], failures: [] }) @@ -150,8 +153,8 @@ describe('dropped path preparation through the shared relay queue', () => { it('aborts running and queued requests on reload and releases their listeners', async () => { materialize.mockReturnValue(new Promise(() => undefined)) const { enqueue, controller, dispose } = queue() - const running = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) - const queued = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) + const running = enqueue({ paths: [TEMP], consumer: 'agent' }) + const queued = enqueue({ paths: [TEMP], consumer: 'agent' }) const assertions = [ expect(running).rejects.toThrow('reloaded'), expect(queued).rejects.toThrow('reloaded') @@ -169,12 +172,53 @@ describe('dropped path preparation through the shared relay queue', () => { .mockReturnValueOnce(new Promise(() => undefined)) .mockResolvedValueOnce([{ sourcePath: TEMP, status: 'imported', destPath: COPY }]) const { enqueue } = queue(undefined, 5) - const first = enqueue.prepare({ paths: [ORDINARY, TEMP], consumer: 'agent' }) - const next = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) + const first = enqueue({ paths: [ORDINARY, TEMP], consumer: 'agent' }) + const next = enqueue({ paths: [TEMP], consumer: 'agent' }) await expect(first).resolves.toMatchObject({ paths: [ORDINARY], failures: [{ commonReason: 'timed-out' }] }) await expect(next).resolves.toEqual({ paths: [COPY], failures: [] }) }) + it('passes drag-temp-looking paths unchanged on Linux and Windows', () => { + for (const platform of ['linux', 'win32'] as const) { + const enqueue = createDroppedPathPreparationQueue({ + platform, + getCopyEnvironment: async () => env, + watchRenderer: () => ({ signal: new AbortController().signal, dispose: vi.fn() }) + }) + expect(enqueue({ paths: [TEMP], consumer: 'agent' })).toEqual({ paths: [TEMP], failures: [] }) + } + expect(materialize).not.toHaveBeenCalled() + }) + + it('reports all lost paths without a shared reason when their failures differ', async () => { + materialize.mockResolvedValue([ + { sourcePath: TEMP, status: 'failed', reason: 'missing' }, + { sourcePath: TEMP, status: 'failed', reason: 'changed' } + ]) + await expect(queue().enqueue({ paths: [TEMP, TEMP], consumer: 'agent' })).resolves.toEqual({ + paths: [], + failures: [{ byteLength: 0, pathCount: 2, reason: 'temp-copy-failed', target: 'rejected' }] + }) + }) + + it('keeps ordinary paths and serves the next drop when the copy environment fails', async () => { + const getCopyEnvironment = vi.fn(async () => env) + getCopyEnvironment.mockRejectedValueOnce(new Error('no temp path')) + const enqueue = createDroppedPathPreparationQueue({ + platform: 'darwin', + getCopyEnvironment, + watchRenderer: () => ({ signal: new AbortController().signal, dispose: vi.fn() }) + }) + await expect(enqueue({ paths: [ORDINARY, TEMP], consumer: 'agent' })).resolves.toEqual({ + paths: [ORDINARY], + failures: [{ byteLength: 0, pathCount: 1, reason: 'temp-copy-failed', target: 'rejected' }] + }) + materialize.mockResolvedValue([{ sourcePath: TEMP, status: 'imported', destPath: COPY }]) + await expect(enqueue({ paths: [TEMP], consumer: 'agent' })).resolves.toEqual({ + paths: [COPY], + failures: [] + }) + }) }) diff --git a/src/main/window/history-gc-worktree-ids.ts b/src/main/window/history-gc-worktree-ids.ts index df6149671928..679375e038fa 100644 --- a/src/main/window/history-gc-worktree-ids.ts +++ b/src/main/window/history-gc-worktree-ids.ts @@ -1,6 +1,6 @@ import type { Store } from '../persistence' import { folderWorkspaceKey } from '../../shared/workspace-scope' -import { getOtherProfileWorktreeIdsForHistoryGc } from './history-gc-profile-worktree-ids' +import { getOtherProfileWorktreeIdsForHistoryGc } from '../orca-profiles/other-profile-workspace-catalog' /** * Every workspace key that owns shell history, for the history GC's live set. diff --git a/src/main/plugins/plugin-panel-navigation-guard.test.ts b/src/main/window/host-frame-navigation-guard.test.ts similarity index 51% rename from src/main/plugins/plugin-panel-navigation-guard.test.ts rename to src/main/window/host-frame-navigation-guard.test.ts index 2889e57cf707..1a4aae3d0420 100644 --- a/src/main/plugins/plugin-panel-navigation-guard.test.ts +++ b/src/main/window/host-frame-navigation-guard.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../shared/plugins/plugin-panel-bridge' -import { PluginPanelNavigationRegistry } from './plugin-panel-navigation-guard' +import { NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX } from '../../shared/native-chat-visual-shell' +import { HostFrameNavigationRegistry, hostFrameKindForName } from './host-frame-navigation-guard' function frame(input: { id: number; name?: string; url?: string }) { let destroyed = false @@ -14,9 +15,9 @@ function frame(input: { id: number; name?: string; url?: string }) { } } -describe('PluginPanelNavigationRegistry', () => { +describe('HostFrameNavigationRegistry', () => { it('blocks only host-marked plugin srcdoc frames', () => { - const registry = new PluginPanelNavigationRegistry() + const registry = new HostFrameNavigationRegistry() const plugin = frame({ id: 1, name: `${PLUGIN_PANEL_FRAME_NAME_PREFIX}demo` }) const notebook = frame({ id: 2 }) registry.register(plugin) @@ -28,7 +29,7 @@ describe('PluginPanelNavigationRegistry', () => { }) it('keeps pre-parse identity after name mutation and prunes destroyed frames', () => { - const registry = new PluginPanelNavigationRegistry() + const registry = new HostFrameNavigationRegistry() const plugin = frame({ id: 1, name: `${PLUGIN_PANEL_FRAME_NAME_PREFIX}demo` }) registry.register(plugin) plugin.name = '' @@ -38,4 +39,20 @@ describe('PluginPanelNavigationRegistry', () => { plugin.destroy() expect(registry.shouldBlock(plugin, plugin, 'https://example.com')).toBe(false) }) + + it('contains chat visual frames the same way, as their own kind', () => { + const registry = new HostFrameNavigationRegistry() + const visual = frame({ id: 3, name: `${NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX}abc` }) + registry.register(visual) + expect(hostFrameKindForName(visual.name)).toBe('chat-visual') + expect(hostFrameKindForName(`${PLUGIN_PANEL_FRAME_NAME_PREFIX}demo`)).toBe('plugin-panel') + expect(hostFrameKindForName('orca-chat-visual')).toBeNull() + + expect(registry.shouldBlock(visual, null, 'about:srcdoc')).toBe(false) + // Its own navigation (location, meta refresh, link) and any child it starts are refused. + expect(registry.shouldBlock(visual, visual, 'https://example.com')).toBe(true) + expect(registry.shouldBlock(visual, null, 'https://example.com')).toBe(true) + const child = frame({ id: 4 }) + expect(registry.shouldBlock(child, visual, 'https://example.com')).toBe(true) + }) }) diff --git a/src/main/plugins/plugin-panel-navigation-guard.ts b/src/main/window/host-frame-navigation-guard.ts similarity index 62% rename from src/main/plugins/plugin-panel-navigation-guard.ts rename to src/main/window/host-frame-navigation-guard.ts index d183eae98881..110b3705c5d4 100644 --- a/src/main/plugins/plugin-panel-navigation-guard.ts +++ b/src/main/window/host-frame-navigation-guard.ts @@ -1,22 +1,40 @@ import type { WebContents, WebFrameMain } from 'electron' +import { NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX } from '../../shared/native-chat-visual-shell' import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../shared/plugins/plugin-panel-bridge' type NavigationFrame = Pick +/** + * Host-built srcdoc frames that hold content Orca did not write. Each kind is only a navigation + * containment class: registering a frame grants it nothing (no plugin identity, no actions). + */ +const HOST_FRAME_KINDS = [ + { kind: 'plugin-panel', namePrefix: PLUGIN_PANEL_FRAME_NAME_PREFIX }, + { kind: 'chat-visual', namePrefix: NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX } +] as const + +export type HostFrameKind = (typeof HOST_FRAME_KINDS)[number]['kind'] + type RegisteredFrame = { frame: NavigationFrame + kind: HostFrameKind initialSrcdocPending: boolean } -/** Records host-marked panel frame identities at browsing-context creation, - * before plugin parsing can mutate window.name. */ -export class PluginPanelNavigationRegistry { +export function hostFrameKindForName(name: string): HostFrameKind | null { + return HOST_FRAME_KINDS.find((entry) => name.startsWith(entry.namePrefix))?.kind ?? null +} + +/** Records host-marked frame identities at browsing-context creation, before their content can + * mutate window.name. */ +export class HostFrameNavigationRegistry { private readonly frames = new Map() register(frame: NavigationFrame): void { this.prune() - if (frame.name.startsWith(PLUGIN_PANEL_FRAME_NAME_PREFIX)) { - this.frames.set(frame.frameTreeNodeId, { frame, initialSrcdocPending: true }) + const kind = hostFrameKindForName(frame.name) + if (kind) { + this.frames.set(frame.frameTreeNodeId, { frame, kind, initialSrcdocPending: true }) } } @@ -52,8 +70,8 @@ export class PluginPanelNavigationRegistry { } } -export function registerPluginPanelNavigationGuard(webContents: WebContents): void { - const registry = new PluginPanelNavigationRegistry() +export function registerHostFrameNavigationGuard(webContents: WebContents): void { + const registry = new HostFrameNavigationRegistry() webContents.on('frame-created', (_event, { frame }) => { if (frame) { registry.register(frame) @@ -62,7 +80,7 @@ export function registerPluginPanelNavigationGuard(webContents: WebContents): vo webContents.on('did-start-navigation', (event) => { if (!event.isMainFrame && event.url === 'about:srcdoc' && event.frame) { // Some Chromium builds populate the frame name only when navigation - // starts; this event still precedes document parsing and plugin code. + // starts; this event still precedes document parsing and frame content. registry.register(event.frame) } }) diff --git a/src/main/window/main-window-webview-security.test.ts b/src/main/window/main-window-webview-security.test.ts index e54dc6a68369..d47a2f8118ef 100644 --- a/src/main/window/main-window-webview-security.test.ts +++ b/src/main/window/main-window-webview-security.test.ts @@ -15,8 +15,8 @@ vi.mock('../browser/browser-manager', () => ({ vi.mock('../browser/browser-session-registry', () => ({ browserSessionRegistry: { isAllowedPartition: mocks.isAllowedPartition } })) -vi.mock('../plugins/plugin-panel-navigation-guard', () => ({ - registerPluginPanelNavigationGuard: mocks.registerPluginGuard +vi.mock('./host-frame-navigation-guard', () => ({ + registerHostFrameNavigationGuard: mocks.registerPluginGuard })) vi.mock('./privileged-window-navigation', () => ({ installPrivilegedWindowNavigationPolicy: mocks.installNavigationPolicy diff --git a/src/main/window/main-window-webview-security.ts b/src/main/window/main-window-webview-security.ts index a662449eb580..7c026d7cf4a3 100644 --- a/src/main/window/main-window-webview-security.ts +++ b/src/main/window/main-window-webview-security.ts @@ -20,7 +20,7 @@ import { revokeAllDocPreviewGrants } from '../browser/doc-preview-grant-registry' import { isDocPreviewSession } from '../browser/doc-preview-protocol' -import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard' +import { registerHostFrameNavigationGuard } from './host-frame-navigation-guard' import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' /** @@ -54,9 +54,9 @@ export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): voi setDocPreviewFailureSink(null) revokeAllDocPreviewGrants() }) - // Why: containment must be listening before any plugin panel frame is created, - // so register it with the window's other navigation policy. - registerPluginPanelNavigationGuard(mainWindow.webContents) + // Why: containment must be listening before any plugin panel or chat visual frame is + // created, so register it with the window's other navigation policy. + registerHostFrameNavigationGuard(mainWindow.webContents) const browserWindowClosePreload = join(__dirname, 'browser-window-close-preload.js') // Why a preview gets a preload at all: it is our own editor surface, not a browsing guest. This diff --git a/src/main/window/native-file-drop-relay.test.ts b/src/main/window/native-file-drop-relay.test.ts deleted file mode 100644 index 7200be84eba4..000000000000 --- a/src/main/window/native-file-drop-relay.test.ts +++ /dev/null @@ -1,536 +0,0 @@ -import type { BrowserWindow } from 'electron' -import { join } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { NativeFileDropPayload } from '../../shared/native-file-drop' -import type * as DragTempFileCopy from './dragged-temp-file-copy' -import type { DragTempCopyItemResult } from './dragged-temp-file-copy' - -type IpcListener = (event: { sender: unknown }, payload: unknown) => void -type InvokeHandler = (event: { sender: unknown }, payload: unknown) => Promise - -const { materializeMock, sweepMock, ipcListeners, ipcHandlers } = vi.hoisted(() => ({ - materializeMock: vi.fn(), - sweepMock: vi.fn(), - ipcListeners: new Map(), - ipcHandlers: new Map() -})) - -vi.mock('electron', () => ({ - app: { getPath: () => '/app-temp' }, - ipcMain: { - handle: (channel: string, handler: InvokeHandler) => ipcHandlers.set(channel, handler), - removeHandler: (channel: string) => ipcHandlers.delete(channel), - on: (channel: string, listener: IpcListener) => ipcListeners.set(channel, listener), - removeListener: (channel: string, listener: IpcListener) => { - if (ipcListeners.get(channel) === listener) { - ipcListeners.delete(channel) - } - }, - removeAllListeners: (channel: string) => ipcListeners.delete(channel) - } -})) - -vi.mock('./dragged-temp-file-copy', async (importOriginal) => ({ - ...(await importOriginal()), - materializeDragTempPaths: materializeMock, - scheduleDragTempCopySweep: sweepMock -})) - -vi.mock('./darwin-user-temp-dir', () => ({ - getDarwinUserTempDir: async () => '/private/var/folders/ab/xyz/T' -})) - -import { - createNativeFileDropQueue, - MAX_PENDING_DRAG_TEMP_COPIES, - registerFileDropRelay -} from './native-file-drop-relay' - -const DRAG_TEMP = join('/', 'var', 'T', 'TemporaryItems', 'NSIRD_screencaptureui_1', 'Shot.png') -const OTHER_DRAG_TEMP = join( - '/', - 'var', - 'T', - 'TemporaryItems', - 'NSIRD_screencaptureui_1', - 'Other.png' -) -const COPY = join('/', 'var', 'T', 'orca-drops-501', 'orca-drop-abc123', 'Shot.png') -const FINDER = join('/', 'Users', 'me', 'Desktop', 'notes.txt') -const env = { platform: 'darwin' as const, sourceTempRoot: '/var/T', copyRoot: '/var/T/drops' } - -function copied(sourcePath: string, destPath = sourcePath): DragTempCopyItemResult { - return { sourcePath, status: 'imported', destPath } -} - -function createQueue( - overrides: { - getCopyEnvironment?: () => Promise - forward?: (payload: NativeFileDropPayload) => void - copyTimeoutMs?: number - } = {} -) { - const forwarded: NativeFileDropPayload[] = [] - const controller = new AbortController() - const enqueue = createNativeFileDropQueue({ - forward: overrides.forward ?? ((payload) => forwarded.push(payload)), - platform: 'darwin', - getCopyEnvironment: overrides.getCopyEnvironment ?? (async () => env), - watchRenderer: () => ({ signal: controller.signal, dispose: () => undefined }), - copyTimeoutMs: overrides.copyTimeoutMs - }) - return { enqueue, forwarded, controller } -} - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((settle) => { - resolve = settle - }) - return { promise, resolve } -} - -async function settle(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)) -} - -beforeEach(() => { - materializeMock.mockReset() -}) - -describe('createNativeFileDropQueue', () => { - it('forwards a drop with no drag-temp path synchronously without copying', () => { - const { enqueue, forwarded } = createQueue() - const payload: NativeFileDropPayload = { paths: [FINDER], target: 'editor' } - - enqueue(payload) - - expect(forwarded).toEqual([payload]) - expect(materializeMock).not.toHaveBeenCalled() - }) - - it('forwards drag-temp-looking paths untouched off macOS', () => { - const forwarded: NativeFileDropPayload[] = [] - const enqueue = createNativeFileDropQueue({ - forward: (payload) => forwarded.push(payload), - platform: 'linux', - getCopyEnvironment: async () => env, - watchRenderer: () => ({ signal: new AbortController().signal, dispose: () => undefined }) - }) - - enqueue({ paths: [DRAG_TEMP], target: 'composer' }) - - expect(forwarded).toEqual([{ paths: [DRAG_TEMP], target: 'composer' }]) - expect(materializeMock).not.toHaveBeenCalled() - }) - - it('swaps in the copy and keeps the drop target fields', async () => { - materializeMock.mockResolvedValue([copied(FINDER), copied(DRAG_TEMP, COPY)]) - const { enqueue, forwarded } = createQueue() - - enqueue({ paths: [FINDER, DRAG_TEMP], target: 'terminal', tabId: 't1', paneLeafId: 'p1' }) - await settle() - - expect(materializeMock).toHaveBeenCalledWith([FINDER, DRAG_TEMP], env, expect.any(AbortSignal)) - expect(forwarded).toEqual([ - { paths: [FINDER, COPY], target: 'terminal', tabId: 't1', paneLeafId: 'p1' } - ]) - }) - - it('holds a later plain drop until an earlier copy is forwarded', async () => { - const copy = deferred() - materializeMock.mockReturnValueOnce(copy.promise) - const { enqueue, forwarded } = createQueue() - - enqueue({ paths: [DRAG_TEMP], target: 'composer' }) - enqueue({ paths: [FINDER], target: 'composer' }) - await settle() - expect(forwarded).toEqual([]) - - copy.resolve([copied(DRAG_TEMP, COPY)]) - await settle() - - expect(forwarded).toEqual([ - { paths: [COPY], target: 'composer' }, - { paths: [FINDER], target: 'composer' } - ]) - enqueue({ paths: [FINDER], target: 'editor' }) - expect(forwarded).toHaveLength(3) - }) - - it('copies drag-temp drops one at a time, in arrival order', async () => { - const copy = deferred() - materializeMock - .mockReturnValueOnce(copy.promise) - .mockResolvedValueOnce([copied(OTHER_DRAG_TEMP, COPY)]) - const { enqueue, forwarded } = createQueue() - - enqueue({ paths: [DRAG_TEMP], target: 'composer' }) - enqueue({ paths: [OTHER_DRAG_TEMP], target: 'terminal' }) - await settle() - expect(materializeMock).toHaveBeenCalledTimes(1) - - copy.resolve([copied(DRAG_TEMP, COPY)]) - await settle() - - expect(forwarded.map((payload) => payload.target)).toEqual(['composer', 'terminal']) - }) - - it('hands an uncopied file to a target main reads, without reporting it', async () => { - materializeMock.mockResolvedValue([ - { sourcePath: DRAG_TEMP, status: 'uncopied', reason: 'too-large' } - ]) - const { enqueue, forwarded } = createQueue() - - enqueue({ paths: [DRAG_TEMP], target: 'editor' }) - await settle() - - expect(forwarded).toEqual([{ paths: [DRAG_TEMP], target: 'editor' }]) - }) - - it('withholds an uncopied file from terminals and composers and reports it', async () => { - materializeMock.mockResolvedValue([ - copied(FINDER), - { sourcePath: DRAG_TEMP, status: 'uncopied', reason: 'storage-full' } - ]) - const { enqueue, forwarded } = createQueue() - - enqueue({ paths: [FINDER, DRAG_TEMP], target: 'terminal' }) - await settle() - - expect(forwarded).toEqual([ - { paths: [FINDER], target: 'terminal' }, - { - byteLength: 0, - pathCount: 1, - reason: 'temp-copy-failed', - target: 'rejected', - commonReason: 'storage-full' - } - ]) - }) - - it('forwards what it could copy and reports the rest with their shared reason', async () => { - materializeMock.mockResolvedValue([ - copied(DRAG_TEMP, COPY), - { sourcePath: OTHER_DRAG_TEMP, status: 'failed', reason: 'permission-denied' } - ]) - const { enqueue, forwarded } = createQueue() - - enqueue({ paths: [DRAG_TEMP, OTHER_DRAG_TEMP], target: 'composer', scopeKey: 'pane-1' }) - await settle() - - expect(forwarded).toEqual([ - { paths: [COPY], target: 'composer', scopeKey: 'pane-1' }, - { - byteLength: 0, - pathCount: 1, - reason: 'temp-copy-failed', - target: 'rejected', - commonReason: 'permission-denied' - } - ]) - }) - - it('reports a drop that lost every file, with no shared reason when they differ', async () => { - materializeMock.mockResolvedValue([ - { sourcePath: DRAG_TEMP, status: 'failed', reason: 'missing' }, - { sourcePath: DRAG_TEMP, status: 'failed', reason: 'changed' } - ]) - const { enqueue, forwarded } = createQueue() - - enqueue({ paths: [DRAG_TEMP, DRAG_TEMP], target: 'terminal' }) - await settle() - - expect(forwarded).toEqual([ - { byteLength: 0, pathCount: 2, reason: 'temp-copy-failed', target: 'rejected' } - ]) - }) - - it('drops a copy whose renderer went away, and keeps serving later drops', async () => { - const { enqueue, forwarded, controller } = createQueue() - materializeMock.mockImplementationOnce(async () => { - controller.abort(new Error('renderer gone')) - throw new Error('renderer gone') - }) - - enqueue({ paths: [DRAG_TEMP], target: 'terminal' }) - await settle() - enqueue({ paths: [FINDER], target: 'editor' }) - - expect(forwarded).toEqual([{ paths: [FINDER], target: 'editor' }]) - }) - - it('drops queued drops from a document that reloaded, and serves the new document', async () => { - const copy = deferred() - materializeMock - .mockReturnValueOnce(copy.promise) - .mockImplementation(async (paths: string[]) => paths.map((path) => copied(path, COPY))) - let document = new AbortController() - const forwarded: NativeFileDropPayload[] = [] - const enqueue = createNativeFileDropQueue({ - forward: (payload) => forwarded.push(payload), - platform: 'darwin', - getCopyEnvironment: async () => env, - watchRenderer: () => ({ signal: document.signal, dispose: () => undefined }) - }) - - enqueue({ paths: [DRAG_TEMP], target: 'terminal' }) - await settle() - enqueue({ paths: [OTHER_DRAG_TEMP], target: 'editor' }) - document.abort(new Error('reloaded')) - document = new AbortController() - enqueue({ paths: [OTHER_DRAG_TEMP], target: 'composer' }) - copy.resolve([copied(DRAG_TEMP, COPY)]) - await settle() - - expect(forwarded).toEqual([{ paths: [COPY], target: 'composer' }]) - }) - - it('still delivers the ordinary paths of a drop whose copy stage failed outright', async () => { - const { enqueue, forwarded } = createQueue({ - getCopyEnvironment: async () => { - throw new Error('no temp path') - } - }) - - enqueue({ paths: [FINDER, DRAG_TEMP], target: 'composer', scopeKey: 'pane-1' }) - await settle() - - expect(forwarded).toEqual([ - { paths: [FINDER], target: 'composer', scopeKey: 'pane-1' }, - { byteLength: 0, pathCount: 1, reason: 'temp-copy-failed', target: 'rejected' } - ]) - }) - - it('forwards a rejected drop at once, without waiting on the copy environment', () => { - const getCopyEnvironment = vi.fn(() => new Promise(() => undefined)) - const { enqueue, forwarded } = createQueue({ getCopyEnvironment }) - enqueue({ paths: [DRAG_TEMP], target: 'terminal' }) - const rejected: NativeFileDropPayload = { - byteLength: 0, - pathCount: 300, - reason: 'too-many-paths', - target: 'rejected' - } - - enqueue(rejected) - - expect(forwarded).toEqual([rejected]) - }) - - it('gives up on a hung copy, says why, and serves the copies behind it', async () => { - let copySignal: AbortSignal | undefined - materializeMock.mockImplementationOnce((_paths, _env, signal: AbortSignal) => { - copySignal = signal - return new Promise(() => undefined) - }) - materializeMock.mockResolvedValueOnce([copied(OTHER_DRAG_TEMP, COPY)]) - const { enqueue, forwarded } = createQueue({ copyTimeoutMs: 5 }) - - enqueue({ paths: [FINDER, DRAG_TEMP], target: 'terminal' }) - enqueue({ paths: [OTHER_DRAG_TEMP], target: 'editor' }) - await new Promise((resolve) => setTimeout(resolve, 20)) - - expect(copySignal?.aborted).toBe(true) - expect(forwarded).toEqual([ - { paths: [FINDER], target: 'terminal' }, - { - byteLength: 0, - pathCount: 1, - reason: 'temp-copy-failed', - target: 'rejected', - commonReason: 'timed-out' - }, - { paths: [COPY], target: 'editor' } - ]) - }) - - it('refuses drag-temp files past the pending-copy limit but keeps their ordinary paths', async () => { - materializeMock.mockReturnValue(new Promise(() => undefined)) - const { enqueue, forwarded, controller } = createQueue() - - for (let i = 0; i < MAX_PENDING_DRAG_TEMP_COPIES; i += 1) { - enqueue({ paths: [DRAG_TEMP], target: 'terminal' }) - } - enqueue({ paths: [FINDER, DRAG_TEMP], target: 'composer' }) - - // The ordinary path waits behind the earlier drops; the refusal does not. - expect(forwarded).toEqual([ - { - byteLength: 0, - pathCount: 1, - reason: 'temp-copy-failed', - target: 'rejected', - commonReason: 'busy' - } - ]) - controller.abort(new Error('test done')) - }) - - it('does not report a copied drop as a failed copy when forwarding it throws', async () => { - materializeMock.mockResolvedValueOnce([copied(DRAG_TEMP, COPY)]) - const forward = vi.fn((_payload: NativeFileDropPayload) => { - throw new Error('send failed') - }) - const { enqueue } = createQueue({ forward }) - - enqueue({ paths: [DRAG_TEMP], target: 'terminal' }) - await settle() - - expect(forward.mock.calls).toEqual([[{ paths: [COPY], target: 'terminal' }]]) - }) -}) - -const CHANNEL = 'terminal:file-dropped-from-preload' - -function createWindow() { - let destroyed = false - const windowListeners = new Map void>() - const webContents = { - isDestroyed: () => destroyed, - send: vi.fn(), - once: vi.fn(), - removeListener: vi.fn() - } - const fake = { - isDestroyed: () => destroyed, - on: (event: string, listener: () => void) => windowListeners.set(event, listener), - webContents - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the relay only touches the members stubbed above. - const window = fake as unknown as BrowserWindow - return { - window, - webContents, - close: () => { - destroyed = true - windowListeners.get('closed')?.() - }, - destroy: () => { - destroyed = true - } - } -} - -function relay(): IpcListener { - const listener = ipcListeners.get(CHANNEL) - expect(listener).toBeDefined() - return listener! -} - -describe('registerFileDropRelay', () => { - beforeEach(() => { - ipcListeners.clear() - ipcHandlers.clear() - sweepMock.mockReset() - }) - - it('relays only well-formed drops from its own renderer, until the window closes', () => { - const { window, webContents, close } = createWindow() - registerFileDropRelay(window) - const drop: NativeFileDropPayload = { paths: [FINDER], target: 'editor' } - - relay()({ sender: { id: 'other-window' } }, drop) - relay()({ sender: webContents }, { paths: 'not-a-list', target: 'editor' }) - // Only main may report a failed copy; a renderer claiming one is ignored. - relay()( - { sender: webContents }, - { byteLength: 0, pathCount: 1, reason: 'temp-copy-failed', target: 'rejected' } - ) - relay()({ sender: webContents }, drop) - - expect(webContents.send.mock.calls).toEqual([['terminal:file-drop', drop]]) - expect(sweepMock).toHaveBeenCalledTimes(1) - close() - expect(ipcListeners.has(CHANNEL)).toBe(false) - }) - - it('replaces a listener left by an earlier window', () => { - const first = createWindow() - const second = createWindow() - registerFileDropRelay(first.window) - registerFileDropRelay(second.window) - - relay()({ sender: first.webContents }, { paths: [FINDER], target: 'editor' }) - - expect(first.webContents.send).not.toHaveBeenCalled() - }) - - it('prepares ordinary paths only for its own live renderer and validates the request', async () => { - const { window, webContents, close } = createWindow() - registerFileDropRelay(window) - const handler = ipcHandlers.get('fs:prepareDroppedPaths')! - const request = { paths: [FINDER], consumer: 'agent' } - expect(() => handler({ sender: {} }, request)).toThrow('owning window') - for (const invalid of [ - null, - { paths: [FINDER], consumer: 'other' }, - { paths: [42], consumer: 'agent' } - ]) { - expect(() => handler({ sender: webContents }, invalid)).toThrow('Invalid') - } - await expect(handler({ sender: webContents }, request)).resolves.toEqual({ - paths: [FINDER], - failures: [] - }) - expect(webContents.send).not.toHaveBeenCalled() - close() - expect(ipcHandlers.has('fs:prepareDroppedPaths')).toBe(false) - expect(() => handler({ sender: webContents }, request)).toThrow('owning window') - }) - - it('enforces both request caps before preparing any paths', async () => { - const { window, webContents } = createWindow() - registerFileDropRelay(window) - const handler = ipcHandlers.get('fs:prepareDroppedPaths')! - await expect( - handler( - { sender: webContents }, - { paths: Array.from({ length: 257 }, () => DRAG_TEMP), consumer: 'agent' } - ) - ).resolves.toMatchObject({ - paths: [], - failures: [{ reason: 'too-many-paths', pathCount: 257 }] - }) - await expect( - handler({ sender: webContents }, { paths: ['x'.repeat(256 * 1024 + 1)], consumer: 'agent' }) - ).resolves.toMatchObject({ paths: [], failures: [{ reason: 'paths-too-large' }] }) - expect(materializeMock).not.toHaveBeenCalled() - }) - - it('keeps the replacement window handler when an older window closes late', async () => { - const first = createWindow() - const second = createWindow() - registerFileDropRelay(first.window) - registerFileDropRelay(second.window) - const handler = ipcHandlers.get('fs:prepareDroppedPaths')! - first.close() - expect(ipcHandlers.get('fs:prepareDroppedPaths')).toBe(handler) - await expect( - handler({ sender: second.webContents }, { paths: [FINDER], consumer: 'main-reader' }) - ).resolves.toEqual({ paths: [FINDER], failures: [] }) - }) - - it.skipIf(process.platform !== 'darwin')( - 'copies from the macOS user temp dir and sends nothing once the window is gone', - async () => { - const copy = deferred() - materializeMock.mockReturnValueOnce(copy.promise) - const { window, webContents, destroy } = createWindow() - registerFileDropRelay(window) - - relay()({ sender: webContents }, { paths: [DRAG_TEMP], target: 'terminal' }) - await settle() - destroy() - copy.resolve([copied(DRAG_TEMP, COPY)]) - await settle() - - expect(materializeMock.mock.calls[0][1]).toMatchObject({ - platform: 'darwin', - sourceTempRoot: '/private/var/folders/ab/xyz/T' - }) - expect(webContents.send).not.toHaveBeenCalled() - } - ) -}) diff --git a/src/main/window/native-file-drop-relay.ts b/src/main/window/native-file-drop-relay.ts deleted file mode 100644 index 3d7d4c8f50bb..000000000000 --- a/src/main/window/native-file-drop-relay.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { app, ipcMain } from 'electron' -import type { BrowserWindow } from 'electron' -import { - createRejectedNativeFileDropPayload, - isNativeFileDropPayload, - NATIVE_FILE_DROP_TARGET, - validateNativeFileDropPaths, - type NativeFileDropPayload, - type NativeFileDropRejectedPayload -} from '../../shared/native-file-drop' -import type { - PrepareDroppedPathsRequest, - PreparedDroppedPaths -} from '../../shared/native-file-drop-preparation' -import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort' -import { getDragTempCopyRoot, scheduleDragTempCopySweep } from './dragged-temp-file-copy' -import { - createDroppedPathPreparationQueue, - type createDragTempCopyLane, - prepareDroppedPaths, - windowDragTempCopyLane, - type DroppedPathPreparationDeps -} from './dropped-path-preparation' -import type { DragTempCopyEnvironment } from './dragged-temp-file-copy' -import { getDarwinUserTempDir } from './darwin-user-temp-dir' - -export { MAX_PENDING_DRAG_TEMP_COPIES } from './dropped-path-preparation' - -type AcceptedNativeFileDropPayload = Exclude -type NativeFileDropQueueDeps = DroppedPathPreparationDeps & { - forward: (payload: NativeFileDropPayload) => void -} -type NativeFileDropQueue = ((payload: NativeFileDropPayload) => void) & { - prepare: (request: PrepareDroppedPathsRequest) => Promise -} - -export function registerFileDropRelay(mainWindow: BrowserWindow): void { - const channel = 'terminal:file-dropped-from-preload' - const prepareChannel = 'fs:prepareDroppedPaths' - const mainWebContents = mainWindow.webContents - const isWindowGone = (): boolean => mainWindow.isDestroyed() || mainWebContents.isDestroyed() - ipcMain.removeAllListeners(channel) - ipcMain.removeHandler(prepareChannel) - const enqueue = createNativeFileDropQueue( - { - forward: (payload) => { - if (!isWindowGone()) { - mainWebContents.send('terminal:file-drop', payload) - } - }, - platform: process.platform, - getCopyEnvironment: async () => ({ - platform: process.platform, - sourceTempRoot: await getDarwinUserTempDir(), - copyRoot: getDragTempCopyRoot(app.getPath('temp')) - }), - watchRenderer: () => abortWhenRendererGone(mainWebContents) - }, - windowDragTempCopyLane - ) - const relayFileDrop = (event: Electron.IpcMainEvent, args: NativeFileDropPayload): void => { - if (isWindowGone() || event.sender !== mainWebContents) { - return - } - if (!isNativeFileDropPayload(args) || isTempCopyFailure(args)) { - return - } - enqueue(args) - } - ipcMain.on(channel, relayFileDrop) - const prepareHandler = ( - event: Electron.IpcMainInvokeEvent, - args: unknown - ): Promise => { - if (isWindowGone() || event.sender !== mainWebContents) { - throw new Error('Dropped paths must come from the owning window') - } - if (!isPrepareDroppedPathsRequest(args)) { - throw new Error('Invalid dropped paths request') - } - const validation = validateNativeFileDropPaths(args.paths) - if (validation.status === 'rejected') { - return Promise.resolve({ - paths: [], - failures: [createRejectedNativeFileDropPayload(validation)] - }) - } - return enqueue.prepare(args) - } - ipcMain.handle(prepareChannel, prepareHandler) - activePrepareHandler = prepareHandler - mainWindow.on('closed', () => { - ipcMain.removeListener(channel, relayFileDrop) - // Why: an old window closing late must not remove the replacement window's handler. - if (activePrepareHandler === prepareHandler) { - ipcMain.removeHandler(prepareChannel) - activePrepareHandler = undefined - } - }) - scheduleDragTempCopySweep(() => getDragTempCopyRoot(app.getPath('temp'))) -} - -let activePrepareHandler: unknown - -/** Legacy delivery stays ordered; new owners share only the serialized copy work. */ -export function createNativeFileDropQueue( - deps: NativeFileDropQueueDeps, - lane?: ReturnType -): NativeFileDropQueue { - const prepare = createDroppedPathPreparationQueue(deps, lane) - let deliveryTail = Promise.resolve() - let queuedDeliveries = 0 - const deliver = (payloads: NativeFileDropPayload[]): void => { - for (const payload of payloads) { - deps.forward(payload) - } - } - const enqueueDelivery = (work: () => Promise | void): void => { - queuedDeliveries += 1 - deliveryTail = deliveryTail - .then(work) - .catch(() => undefined) - .finally(() => { - queuedDeliveries -= 1 - }) - } - const forwardInOrder = (payload: NativeFileDropPayload): void => { - if (payload.target === 'rejected' || queuedDeliveries === 0) { - deps.forward(payload) - return - } - const lifetime = deps.watchRenderer() - enqueueDelivery(() => { - try { - if (!lifetime.signal.aborted) { - deps.forward(payload) - } - } finally { - lifetime.dispose() - } - }) - } - const enqueue = (payload: NativeFileDropPayload): void => { - if (payload.target === 'rejected') { - forwardInOrder(payload) - return - } - const prepared = prepare({ paths: payload.paths, consumer: legacyConsumer(payload) }) - if (!(prepared instanceof Promise)) { - if (payload.paths.length === 0) { - forwardInOrder(payload) - return - } - for (const item of legacyPayloads(payload, prepared)) { - forwardInOrder(item) - } - return - } - // Why: observe rejection now, even while legacy delivery waits behind an earlier drop. - const result = prepared.then( - (value) => legacyPayloads(payload, value), - () => [] - ) - enqueueDelivery(async () => deliver(await result)) - } - return Object.assign(enqueue, { - prepare: async (request: PrepareDroppedPathsRequest) => prepare(request) - }) -} - -export async function prepareNativeFileDrop( - payload: AcceptedNativeFileDropPayload, - env: DragTempCopyEnvironment, - signal?: AbortSignal -): Promise { - return legacyPayloads( - payload, - await prepareDroppedPaths( - { paths: payload.paths, consumer: legacyConsumer(payload) }, - env, - signal - ) - ) -} - -function legacyConsumer( - payload: AcceptedNativeFileDropPayload -): PrepareDroppedPathsRequest['consumer'] { - return payload.target === NATIVE_FILE_DROP_TARGET.terminal || - payload.target === NATIVE_FILE_DROP_TARGET.composer - ? 'agent' - : 'main-reader' -} - -function legacyPayloads( - payload: AcceptedNativeFileDropPayload, - prepared: PreparedDroppedPaths -): NativeFileDropPayload[] { - return [ - ...(prepared.paths.length > 0 ? [{ ...payload, paths: prepared.paths }] : []), - ...prepared.failures - ] -} - -function isTempCopyFailure(payload: NativeFileDropPayload): boolean { - return payload.target === 'rejected' && payload.reason === 'temp-copy-failed' -} - -function isPrepareDroppedPathsRequest(value: unknown): value is PrepareDroppedPathsRequest { - return ( - !!value && - typeof value === 'object' && - 'consumer' in value && - (value.consumer === 'agent' || value.consumer === 'main-reader') && - 'paths' in value && - Array.isArray(value.paths) && - value.paths.every((path: unknown) => typeof path === 'string') - ) -} diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index a4adf03e7f43..8ed8df5b9461 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -189,7 +189,8 @@ export function registerRuntimeWindowLifecycle( newLeafId: opts.newLeafId }) }, - renameTerminal: (tabId, title) => send('ui:renameTerminal', { tabId, title }), + renameTerminal: (tabId, title, options) => + send('ui:renameTerminal', { tabId, title, ...options }), focusTerminal: (tabId, worktreeId, leafId) => send('ui:focusTerminal', { tabId, worktreeId, leafId }), focusEditorTab: (tabId, worktreeId) => send('ui:focusEditorTab', { tabId, worktreeId }), diff --git a/src/main/worktree-removal-close-records.test.ts b/src/main/worktree-removal-close-records.test.ts index 9d2d2e32e71d..0b3722482c8a 100644 --- a/src/main/worktree-removal-close-records.test.ts +++ b/src/main/worktree-removal-close-records.test.ts @@ -1,4 +1,7 @@ -import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' +import { closeTestStores, createSqliteTestStore, testState } from './persistence-test-harness' +import { Store as CanonicalStore } from './persistence/loading-store/store' +import { initDataPath as initCanonicalDataPath } from './persistence/loading-store/user-data-path' +import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' // Why this file exists: removing a workspace's session rows must take its close records with it, // or they hold cap slots until the TTL and read as "emptied on purpose" for a new workspace at the // same path. @@ -15,7 +18,7 @@ import { type ClosedTerminalTabTombstonesByTabId } from '../shared/closed-terminal-tab-tombstones' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(), @@ -37,6 +40,13 @@ vi.mock('./telemetry/client', () => ({ track: vi.fn() })) vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) async function createStore() { + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + initCanonicalDataPath() + return createSqliteTestStore(CanonicalStore, { + dataFile: join(testState.dir, 'orca-data.json') + }) + } vi.resetModules() const { Store, initDataPath } = await import('./persistence') initDataPath() @@ -70,6 +80,8 @@ function sessionWithRecords(map: ClosedTerminalTabTombstonesByTabId) { describe('close records on workspace removal', () => { beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) diff --git a/src/main/worktree-removal-session-partition-fencing.test.ts b/src/main/worktree-removal-session-partition-fencing.test.ts index 03dc50a3dd54..8a5f1dad8a45 100644 --- a/src/main/worktree-removal-session-partition-fencing.test.ts +++ b/src/main/worktree-removal-session-partition-fencing.test.ts @@ -1,4 +1,7 @@ -import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' +import { closeTestStores, createSqliteTestStore, testState } from './persistence-test-harness' +import { Store as CanonicalStore } from './persistence/loading-store/store' +import { initDataPath as initCanonicalDataPath } from './persistence/loading-store/user-data-path' +import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' // Why this file exists: worktree removal writes to host session partitions, and the two hazards below // are only visible across a removal followed by a renderer session write — persistence.test.ts covers // removal and partitioning separately, so neither suite catches the interaction. @@ -10,7 +13,7 @@ import type { TerminalTab } from '../shared/terminal-tab-types' import { getDefaultWorkspaceSession } from '../shared/constants' import { toRuntimeExecutionHostId } from '../shared/execution-host' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(), @@ -32,6 +35,13 @@ vi.mock('./telemetry/client', () => ({ track: vi.fn() })) vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) async function createStore() { + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + initCanonicalDataPath() + return createSqliteTestStore(CanonicalStore, { + dataFile: join(testState.dir, 'orca-data.json') + }) + } vi.resetModules() const { Store, initDataPath } = await import('./persistence') initDataPath() @@ -56,6 +66,8 @@ const LIVE = 'repo-gone::/workspace/live' describe('worktree removal across host session partitions', () => { beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 737a1b87594a..5e822328195a 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -27,6 +27,10 @@ import type { PythonEnvironments } from '../../shared/notebook-kernel-types' import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' +import type { + AgentSessionAttachmentPathUploadResult, + AgentSessionAttachmentUploadTarget +} from '../../shared/agent-session-attachments' import type { PrepareDroppedPathsRequest, PreparedDroppedPaths @@ -201,6 +205,10 @@ export type FilesystemApi = { connectionId?: string } & SshMutationExpectation ) => Promise + /** Uploads client-local files into a paired server's store for one structured chat. */ + uploadPathsToAgentSessionAttachments: ( + args: AgentSessionAttachmentUploadTarget & { paths: string[] } + ) => Promise watchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise unwatchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index c60917d427a1..6fc8e9b69d8c 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -7,6 +7,10 @@ import type { import type { SshMutationExpectation } from '../../shared/ssh-types' import type { LocalFileAccess } from '../../shared/local-file-access' import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' +import type { + AgentSessionAttachmentPathUploadResult, + AgentSessionAttachmentUploadTarget +} from '../../shared/agent-session-attachments' import type { SearchResult } from '../../shared/code-search-types' import type { FsChangedPayload } from '../../shared/filesystem-entry-types' import type { @@ -211,6 +215,10 @@ export const fsApi = { } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), + uploadPathsToAgentSessionAttachments: ( + args: AgentSessionAttachmentUploadTarget & { paths: string[] } + ): Promise => + ipcRenderer.invoke('fs:uploadPathsToAgentSessionAttachments', args), watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:watchWorktree', args), unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => diff --git a/src/preload/api/gh-bridge-pull-requests-and-work-items.ts b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts index 3e4a5f6ce2a9..22294f6dc4e3 100644 --- a/src/preload/api/gh-bridge-pull-requests-and-work-items.ts +++ b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts @@ -59,6 +59,7 @@ export const ghPullRequestsAndWorkItemsApi = { workItemByOwnerRepo: (args: { repoPath: string repoId?: string | null + sourceContext?: TaskSourceContext | null owner: string repo: string host?: string diff --git a/src/preload/api/github-work-item-api.ts b/src/preload/api/github-work-item-api.ts index db5842eb2cfb..47a098e2cdc7 100644 --- a/src/preload/api/github-work-item-api.ts +++ b/src/preload/api/github-work-item-api.ts @@ -65,6 +65,7 @@ export type GithubWorkItemApi = { workItemByOwnerRepo: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null owner: string repo: string host?: string diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 229ef1a5414c..cbcf78a4abe2 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -145,6 +145,8 @@ export type PtyApi = { /** Runs the fix with the pane's own Codex; true only once verified. Local panes only. */ disableCodexSharedServerAutoStart: (id: string) => Promise stopCodexSharedServer: (id: string) => Promise + /** A local pane whose daemon predates per-account Claude folders; false for any other pane. */ + openedBeforeClaudeAccounts: (id: string) => Promise getCwd: (id: string) => Promise getSize: (id: string) => Promise<{ cols: number; rows: number } | null> listSessions: (scope?: PtySessionListScope) => Promise diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index e0b2e7f2bf67..3d72ac5e518f 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -23,6 +23,8 @@ export const ptyStreamAndSerializationApi = { ipcRenderer.invoke('pty:disableCodexSharedServerAutoStart', { id }), stopCodexSharedServer: (id: string): Promise => ipcRenderer.invoke('pty:stopCodexSharedServer', { id }), + openedBeforeClaudeAccounts: (id: string): Promise => + ipcRenderer.invoke('pty:openedBeforeClaudeAccounts', { id }), getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), getSize: (id: string): Promise<{ cols: number; rows: number } | null> => ipcRenderer.invoke('pty:getSize', { id }), diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index b7d9345daf70..73b800c0e3d8 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -1,3 +1,4 @@ +import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments' import { ipcRenderer, webFrame } from 'electron' import type { RuntimeMobileMarkdownRequest, @@ -9,12 +10,10 @@ import { type RichMarkdownContextMenuCommandPayload, type RichMarkdownContextMenuTableTarget } from '../../shared/rich-markdown-context-menu' -import type { NativeFileDropPayload } from '../../shared/native-file-drop' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import type { ClipboardImageThumbnail } from '../../shared/clipboard-image' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' -import { subscribeNativeFileDrop } from '../preload-runtime-support' import type { PreloadApi } from '../api-types' export const uiClipboardAndWindowControlsApi = { @@ -93,6 +92,7 @@ export const uiClipboardAndWindowControlsApi = { saveClipboardImageAsTempFile: (args?: { connectionId?: string | null runtimeEnvironmentId?: string | null + agentSessionAttachment?: AgentSessionAttachmentClipboardTarget /** A native-chat composer paste, kept where its draft can bring it back. */ forNativeChatDraft?: boolean }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), @@ -128,8 +128,6 @@ export const uiClipboardAndWindowControlsApi = { } | string ): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('clipboard:writeFile', args), - onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) => - subscribeNativeFileDrop(callback), getZoomLevel: (): number => webFrame.getZoomLevel(), setZoomLevel: (level: number): void => webFrame.setZoomLevel(level), syncTrafficLights: (zoomFactor: number): void => diff --git a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts index 458a50c05841..a9555fae256d 100644 --- a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -145,11 +145,11 @@ export const uiTerminalAndSessionTabsApi = { return () => ipcRenderer.removeListener('ui:splitTerminal', listener) }, onRenameTerminal: ( - callback: (data: { tabId: string; title: string | null }) => void + callback: (data: { tabId: string; title: string | null; recordInteraction?: false }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { tabId: string; title: string | null } + data: { tabId: string; title: string | null; recordInteraction?: false } ) => callback(data) ipcRenderer.on('ui:renameTerminal', listener) return () => ipcRenderer.removeListener('ui:renameTerminal', listener) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 2e8c61a00d06..fd5b632c78a5 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -202,7 +202,7 @@ export type UiCommandEventApi = { }) => void ) => () => void onRenameTerminal: ( - callback: (data: { tabId: string; title: string | null }) => void + callback: (data: { tabId: string; title: string | null; recordInteraction?: false }) => void ) => () => void onFocusTerminal: ( callback: (data: { diff --git a/src/preload/api/ui-window-api.ts b/src/preload/api/ui-window-api.ts index e18fa1513948..c06dd79dab70 100644 --- a/src/preload/api/ui-window-api.ts +++ b/src/preload/api/ui-window-api.ts @@ -1,6 +1,6 @@ +import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments' import type { ClipboardImageThumbnail } from '../../shared/clipboard-image' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' -import type { NativeFileDropPayload } from '../../shared/native-file-drop' import type { RichMarkdownContextMenuCommandPayload, RichMarkdownContextMenuTableTarget @@ -12,6 +12,7 @@ export type UiWindowApi = { saveClipboardImageAsTempFile: (args?: { connectionId?: string | null runtimeEnvironmentId?: string | null + agentSessionAttachment?: AgentSessionAttachmentClipboardTarget /** A native-chat composer paste, kept where its draft can bring it back. */ forNativeChatDraft?: boolean }) => Promise @@ -37,7 +38,6 @@ export type UiWindowApi = { } | string ) => Promise<{ ok: boolean; reason?: string }> - onFileDrop: (callback: (data: NativeFileDropPayload) => void) => () => void getZoomLevel: () => number setZoomLevel: (level: number) => void syncTrafficLights: (zoomFactor: number) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 5f64a1e2e351..fcccbffe7d44 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,9 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron' import type { PreloadApi } from './api-types' -import { - installBrowserFindListener, - installNativeFileDropHandlers -} from './preload-runtime-support' +import { installBrowserFindListener } from './preload-runtime-support' import { appApi } from './api/app-bridge' import { orcaProfilesApi } from './api/orca-profiles-bridge' import { platformApi } from './api/platform-bridge' @@ -87,7 +84,6 @@ import { mobileApi } from './api/mobile-bridge' import { agentStatusApi } from './api/agent-status-bridge' import { speechApi } from './api/speech-bridge' -installNativeFileDropHandlers() installBrowserFindListener() // Custom APIs for renderer. Each domain bridge owns its IPC contract. diff --git a/src/preload/preload-file-drop-absence.test.ts b/src/preload/preload-file-drop-absence.test.ts new file mode 100644 index 000000000000..618a683b6b3d --- /dev/null +++ b/src/preload/preload-file-drop-absence.test.ts @@ -0,0 +1,25 @@ +// @vitest-environment happy-dom +import { expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + ipcRenderer: { on: vi.fn(), removeListener: vi.fn(), send: vi.fn() }, + webUtils: { getPathForFile: vi.fn() } +})) + +it('registers no document drop or dragover listeners in preload', async () => { + const addListener = vi.spyOn(document, 'addEventListener') + try { + const preload = await import('./preload-runtime-support') + if ( + 'installNativeFileDropHandlers' in preload && + typeof preload.installNativeFileDropHandlers === 'function' + ) { + preload.installNativeFileDropHandlers() + } + expect( + addListener.mock.calls.filter(([type]) => type === 'drop' || type === 'dragover') + ).toEqual([]) + } finally { + addListener.mockRestore() + } +}) diff --git a/src/preload/preload-native-file-drop-release.test.ts b/src/preload/preload-native-file-drop-release.test.ts deleted file mode 100644 index dd556fb2e5ea..000000000000 --- a/src/preload/preload-native-file-drop-release.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -// @vitest-environment happy-dom -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { OS_FILE_DROP_OWNER_ATTRIBUTE } from '../shared/native-file-drop-preparation' -import { ORCA_INTERNAL_FILE_DRAG_TYPE } from '../shared/native-file-drop' - -const electron = vi.hoisted(() => ({ - send: vi.fn(), - getPathForFile: vi.fn((file: File) => `/drop/${file.name}`), - on: vi.fn(), - removeListener: vi.fn() -})) -vi.mock('electron', () => ({ - ipcRenderer: electron, - webUtils: { getPathForFile: electron.getPathForFile } -})) -import { installNativeFileDropHandlers } from './preload-runtime-support' - -function dispatch(target: Element, type = 'drop', types = ['Files']) { - const event = new Event(type, { bubbles: true, cancelable: true, composed: true }) - const transfer = { types, files: [new File(['x'], 'notes.txt')], dropEffect: 'none' } - Object.defineProperty(event, 'dataTransfer', { value: transfer }) - const stop = vi.spyOn(event, 'stopPropagation') - target.dispatchEvent(event) - return { event, transfer, stop } -} - -function root(owner: boolean, legacy = false) { - const element = document.createElement('div') - if (owner) { - element.setAttribute(OS_FILE_DROP_OWNER_ATTRIBUTE, '') - } - if (legacy) { - element.dataset.nativeFileDropTarget = 'terminal' - } - document.body.append(element) - return element -} - -beforeAll(() => { - installNativeFileDropHandlers() - installNativeFileDropHandlers() -}) -beforeEach(() => { - document.body.replaceChildren() - electron.send.mockClear() - electron.getPathForFile.mockClear() -}) - -describe('preload migration release', () => { - it.each([false, true])( - 'keeps unmarked and legacy roots on the relay exactly once: legacy=%s', - (legacy) => { - const { event, stop } = dispatch(root(false, legacy)) - expect(event.defaultPrevented).toBe(true) - expect(stop).toHaveBeenCalledTimes(1) - expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', { - paths: ['/drop/notes.txt'], - target: legacy ? 'terminal' : 'editor' - }) - } - ) - - it.each(['drop', 'dragover'])('leaves %s inside a migrated owner untouched', (type) => { - const owner = root(true) - const child = document.createElement('span') - owner.append(child) - const { event, transfer, stop } = dispatch(child, type) - expect(event.defaultPrevented).toBe(false) - expect(stop).not.toHaveBeenCalled() - expect(transfer.dropEffect).toBe('none') - expect(electron.send).not.toHaveBeenCalled() - expect(electron.getPathForFile).not.toHaveBeenCalled() - }) - - it.each(['drop', 'dragover'])('keeps a nested legacy boundary on the legacy %s path', (type) => { - const owner = root(true) - const legacy = root(false, true) - owner.append(legacy) - const { event, transfer } = dispatch(legacy, type) - expect(event.defaultPrevented).toBe(true) - if (type === 'drop') { - expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', { - paths: ['/drop/notes.txt'], - target: 'terminal' - }) - } else { - expect(transfer.dropEffect).toBe('copy') - expect(electron.send).not.toHaveBeenCalled() - } - }) - - it('chooses an inner owner over an outer legacy boundary', () => { - const legacy = root(false, true) - const owner = root(true) - legacy.append(owner) - expect(dispatch(owner).event.defaultPrevented).toBe(false) - expect(electron.send).not.toHaveBeenCalled() - }) - - it.each(['drop', 'dragover'])( - 'leaves internal %s unchanged even inside a migrated owner', - (type) => { - const { event, transfer, stop } = dispatch(root(true), type, [ - 'Files', - ORCA_INTERNAL_FILE_DRAG_TYPE - ]) - expect(event.defaultPrevented).toBe(false) - expect(stop).not.toHaveBeenCalled() - expect(transfer.dropEffect).toBe('none') - expect(electron.send).not.toHaveBeenCalled() - } - ) - - it('keeps the existing non-file drop cancellation, regardless of owner markers', () => { - const event = new Event('drop', { bubbles: true, cancelable: true }) - Object.defineProperty(event, 'dataTransfer', { value: { types: ['text/plain'], files: [] } }) - root(true).dispatchEvent(event) - expect(event.defaultPrevented).toBe(true) - expect(electron.send).not.toHaveBeenCalled() - expect(dispatch(root(true), 'dragover', ['text/plain']).event.defaultPrevented).toBe(false) - }) -}) diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts index 424e276418ed..2d043bb8cd0e 100644 --- a/src/preload/preload-runtime-support.ts +++ b/src/preload/preload-runtime-support.ts @@ -1,23 +1,9 @@ -import { ipcRenderer, webUtils } from 'electron' +import { ipcRenderer } from 'electron' import { createBrowserClientPageRendererRequests } from './browser-client-page-renderer-requests' import { createBrowserFindSubscriptions } from './browser-find-subscriptions' import { registerRendererRestartIpcRelays } from './renderer-restart-wiring' import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation' import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events' -import { OS_FILE_DROP_OWNER_ATTRIBUTE } from '../shared/native-file-drop-preparation' -import { - ORCA_INTERNAL_FILE_DRAG_TYPE, - createNativeFileDropPayload, - createRejectedNativeFileDropPayload, - hasNativeFileDragTypes, - NATIVE_FILE_DROP_MAX_PATHS, - resolveNativeFileDropPath, - type NativeDropResolution, - type NativeFileDropPayload, - type NativeFileDropPathEntry, - type NativeFileDropRejectedPayload -} from '../shared/native-file-drop' - /** Joins the synchronous unload checkpoint with its durable renderer write. */ export async function awaitBeforeUnloadCheckpoint(): Promise { const result = (await ipcRenderer.invoke('app:await-before-unload-checkpoint')) as { @@ -44,148 +30,6 @@ export function getLinuxDisplayServer(): 'wayland' | 'x11' | null { return process.env.DISPLAY ? 'x11' : null } -type NativeFileDropCallback = (data: NativeFileDropPayload) => void -const nativeFileDropCallbacks: NativeFileDropCallback[] = [] -let nativeFileDropListenerRegistered = false -let nativeFileDropHandlersInstalled = false - -const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => { - for (const callback of Array.from(nativeFileDropCallbacks)) { - callback(data) - } -} - -export function subscribeNativeFileDrop(callback: NativeFileDropCallback): () => void { - nativeFileDropCallbacks.push(callback) - if (!nativeFileDropListenerRegistered) { - ipcRenderer.on('terminal:file-drop', onNativeFileDrop) - nativeFileDropListenerRegistered = true - } - return () => { - const callbackIndex = nativeFileDropCallbacks.indexOf(callback) - if (callbackIndex !== -1) { - nativeFileDropCallbacks.splice(callbackIndex, 1) - } - if (nativeFileDropCallbacks.length === 0 && nativeFileDropListenerRegistered) { - ipcRenderer.removeListener('terminal:file-drop', onNativeFileDrop) - nativeFileDropListenerRegistered = false - } - } -} - -function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { - const pathEntries: NativeFileDropPathEntry[] = [] - for (const entry of event.composedPath()) { - if (entry instanceof HTMLElement) { - pathEntries.push({ - nativeFileDropTarget: entry.dataset.nativeFileDropTarget, - nativeFileDropDir: entry.dataset.nativeFileDropDir, - composerScopeKey: entry.dataset.composerScopeKey, - terminalTabId: entry.dataset.terminalTabId, - terminalPaneLeafId: entry.dataset.terminalPaneLeafId ?? entry.dataset.leafId - }) - } - } - return resolveNativeFileDropPath(pathEntries) -} - -function nearestDropBoundaryIsOwner(event: DragEvent): boolean { - for (const entry of event.composedPath()) { - if (!(entry instanceof HTMLElement)) { - continue - } - if (entry.hasAttribute(OS_FILE_DROP_OWNER_ATTRIBUTE)) { - return true - } - if (entry.hasAttribute('data-native-file-drop-target')) { - return false - } - } - return false -} - -/** Installs the one preload-side listener that converts native File objects to paths. */ -export function installNativeFileDropHandlers(): void { - // Preload entry points can be evaluated more than once in tests and during development reloads; - // duplicate document listeners retain every closure and process each drop repeatedly. - if (nativeFileDropHandlersInstalled) { - return - } - document.addEventListener( - 'dragover', - (event) => { - if (event.dataTransfer && !hasNativeFileDragTypes(event.dataTransfer.types)) { - return - } - if (hasNativeFileDragTypes(event.dataTransfer?.types) && nearestDropBoundaryIsOwner(event)) { - return - } - event.preventDefault() - if (event.dataTransfer) { - event.dataTransfer.dropEffect = 'copy' - } - }, - true - ) - document.addEventListener( - 'drop', - (event) => { - if (event.dataTransfer?.types.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)) { - return - } - if (hasNativeFileDragTypes(event.dataTransfer?.types) && nearestDropBoundaryIsOwner(event)) { - return - } - event.preventDefault() - event.stopPropagation() - const files = event.dataTransfer?.files - if (!files || files.length === 0) { - return - } - const resolution = resolveNativeFileDrop(event) - if (files.length > NATIVE_FILE_DROP_MAX_PATHS) { - ipcRenderer.send( - 'terminal:file-dropped-from-preload', - createRejectedNativeFileDropPayload({ - byteLength: 0, - pathCount: files.length, - reason: 'too-many-paths', - status: 'rejected' - }) - ) - return - } - const paths: string[] = [] - for (let index = 0; index < files.length; index += 1) { - const filePath = webUtils.getPathForFile(files[index]) - if (filePath) { - paths.push(filePath) - } - } - if (resolution?.target === 'rejected') { - return - } - if (paths.length === 0) { - // The OS offered file items we could read no path from (promised or - // virtual files). Report it — silence here is #15782. - ipcRenderer.send('terminal:file-dropped-from-preload', { - byteLength: 0, - pathCount: files.length, - reason: 'unresolved-paths', - target: 'rejected' - } satisfies NativeFileDropRejectedPayload) - return - } - const payload = createNativeFileDropPayload(resolution, paths) - if (payload) { - ipcRenderer.send('terminal:file-dropped-from-preload', payload) - } - }, - true - ) - nativeFileDropHandlersInstalled = true -} - export const browserFindSubscriptions = createBrowserFindSubscriptions() export const browserClientPageRendererRequests = createBrowserClientPageRendererRequests({ ipc: ipcRenderer, diff --git a/src/relay/dispatcher-bulk-admission.test.ts b/src/relay/dispatcher-bulk-admission.test.ts new file mode 100644 index 000000000000..e5b0fbf2aaf3 --- /dev/null +++ b/src/relay/dispatcher-bulk-admission.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest' +import { createBulkWriteHarness, nextBulkWriteTurn } from './dispatcher-bulk-write-test-harness' + +describe('bulk admission and sink settlement', () => { + it('streams more than four fixed-bulk frames without tripping the teardown watchdog', async () => { + const harness = createBulkWriteHarness() + try { + const sequences = Array.from({ length: 8 }, (_, seq) => seq) + const completion = Promise.all( + sequences.map((seq) => + harness.dispatcher.notifyBulk('fs.streamChunk', { streamId: 1, seq, data: 'fixed' }) + ) + ).then( + () => ({ ok: true }), + (error: unknown) => ({ ok: false, error }) + ) + await nextBulkWriteTurn() + await harness.drain() + expect(await completion).toEqual({ ok: true }) + expect(harness.frames.filter((frame) => frame.method === 'fs.streamChunk')).toEqual( + sequences.map((seq) => ({ method: 'fs.streamChunk', seq })) + ) + } finally { + harness.dispose() + } + }) + + it('retires a fixed-bulk retry when a closed writer settles it before its client closes', async () => { + const harness = createBulkWriteHarness(2048, 800) + try { + expect(harness.fillProducerQueue()).toBeGreaterThan(1000) + const completion = harness.dispatcher.notifyBulk('fs.streamChunk', { + streamId: 1, + data: 'fixed' + }) + const assertion = expect(completion).rejects.toThrow('Relay writer is closed') + await nextBulkWriteTurn() + expect(harness.dispatcher.capacityRetryCount).toBe(1) + expect(harness.dispatcher.fixedBulkAdmissions).toEqual([]) + expect(harness.frames).toEqual([{ method: 'pty.data' }]) + expect(harness.pending).toHaveLength(1) + harness.sink.destroy(new Error('stream destroyed')) + await nextBulkWriteTurn() + const pendingWrite = harness.pending.shift() + if (!pendingWrite) { + throw new Error('Expected the native writable callback to remain pending') + } + pendingWrite.complete(new Error('pending native write failed')) + await assertion + expect(harness.sink.writableLength).toBe(0) + expect(harness.dispatcher.fixedBulkAdmissions).toEqual([ + { + clientClosed: false, + writerCanAdmitZero: false, + retainedProducerBytes: 0, + settledBeforeReturn: true, + accepted: false + } + ]) + expect(harness.dispatcher.capacityRetryCount).toBe(0) + expect(harness.dispatcher.retainedPublicationBytes).toBe(0) + } finally { + harness.dispose() + } + }) + + it('ignores a captured capacity retry after its frame is admitted', async () => { + const harness = createBulkWriteHarness() + try { + harness.fillProducerQueue() + const completion = harness.dispatcher.notifyBulk('git.responseChunk', { + streamId: 1, + seq: 0, + data: 'g'.repeat(40 * 1024) + }) + await nextBulkWriteTurn() + const retry = harness.dispatcher.lastCapacityRetry + if (!retry) { + throw new Error('Expected a capacity-blocked retry') + } + await harness.reachBulk('git.responseChunk') + expect(harness.dispatcher.capacityRetryCount).toBe(0) + retry() + await harness.drain() + await completion + expect(harness.frames.filter((frame) => frame.method === 'git.responseChunk')).toEqual([ + { method: 'git.responseChunk', seq: 0 } + ]) + } finally { + harness.dispose() + } + }) + + it('sends a capacity-blocked frame once and advances the client chain after its callback', async () => { + const harness = createBulkWriteHarness() + try { + expect(harness.fillProducerQueue()).toBeGreaterThan(0) + let firstSettled = false + const first = harness.dispatcher + .notifyBulk('git.responseChunk', { streamId: 1, seq: 0, data: 'g'.repeat(40 * 1024) }) + .then(() => { + firstSettled = true + }) + const second = harness.dispatcher.notifyBulk('git.responseChunk', { + streamId: 1, + seq: 1, + data: 'following' + }) + await nextBulkWriteTurn() + await harness.reachBulk('git.responseChunk') + expect(firstSettled).toBe(false) + expect(harness.frames.some((frame) => frame.seq === 1)).toBe(false) + await harness.releaseOne() + await harness.drain() + await Promise.all([first, second]) + expect(harness.frames.filter((frame) => frame.method === 'git.responseChunk')).toEqual([ + { method: 'git.responseChunk', seq: 0 }, + { method: 'git.responseChunk', seq: 1 } + ]) + } finally { + harness.dispose() + } + }) + + it('preserves the admitted frame’s sink failure', async () => { + const harness = createBulkWriteHarness() + try { + const error = new Error('owned sink failure') + const result = harness.dispatcher.notifyBulk('git.responseChunk', { streamId: 1, seq: 0 }) + const assertion = expect(result).rejects.toBe(error) + await nextBulkWriteTurn() + await harness.releaseOne(error) + await assertion + } finally { + harness.dispose() + } + }) + + it('settles disposal while an admitted bulk frame awaits its callback', async () => { + const harness = createBulkWriteHarness() + try { + let settled = false + const result = harness.dispatcher + .notifyBulk('git.responseChunk', { streamId: 1, seq: 0 }) + .then(() => { + settled = true + }) + await nextBulkWriteTurn() + expect(settled).toBe(false) + harness.dispatcher.dispose() + await result + expect(settled).toBe(true) + } finally { + harness.dispose() + } + }) + + it('keeps fixed bulk behind retained producers and sends it once', async () => { + const harness = createBulkWriteHarness() + try { + const producerFrames = harness.fillProducerQueue() + let settled = false + const result = harness.dispatcher + .notifyBulk('fs.streamChunk', { streamId: 1, data: 'fixed' }) + .then(() => { + settled = true + }) + await nextBulkWriteTurn() + expect(harness.frames.some((frame) => frame.method === 'fs.streamChunk')).toBe(false) + await harness.reachBulk('fs.streamChunk') + expect(harness.frames.filter((frame) => frame.method === 'pty.data')).toHaveLength( + producerFrames + ) + expect(settled).toBe(false) + await harness.releaseOne() + await result + expect(harness.frames.filter((frame) => frame.method === 'fs.streamChunk')).toEqual([ + { method: 'fs.streamChunk' } + ]) + } finally { + harness.dispose() + } + }) +}) diff --git a/src/relay/dispatcher-bulk-failed-admission.test.ts b/src/relay/dispatcher-bulk-failed-admission.test.ts new file mode 100644 index 000000000000..3d207b0b1b93 --- /dev/null +++ b/src/relay/dispatcher-bulk-failed-admission.test.ts @@ -0,0 +1,56 @@ +import { expect, it } from 'vitest' +import { RelayDispatcher } from './dispatcher' +import type { PreparedRelayFrame, RelayClient } from './dispatcher-contract' +import type { SinkWriteSettlement } from './dispatcher-client-writer' + +class FailedAdmissionDispatcher extends RelayDispatcher { + attempts = 0 + + wake(): void { + this.notifyLegacyCapacityIfLow() + } + + listeners(): number { + return this.legacyCapacityListeners.size + } + + protected override publishPreparedToClient( + client: RelayClient, + frame: PreparedRelayFrame, + lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', + onSettled: (result: SinkWriteSettlement) => void = () => {} + ): boolean { + this.attempts++ + if (this.attempts === 1) { + return false + } + if (this.attempts === 2) { + this.notifyLegacyCapacityIfLow() + return false + } + if (this.attempts > 6) { + throw new Error('Admission retry did not remain bounded') + } + return super.publishPreparedToClient(client, frame, lane, onSettled) + } +} + +it('preserves a capacity wake during a failed admission without needing another event', async () => { + let writes = 0 + const dispatcher = new FailedAdmissionDispatcher(() => { + writes++ + return true + }) + try { + const completion = dispatcher.notifyBulk('git.responseChunk', { streamId: 1, seq: 0 }) + await new Promise((resolve) => setImmediate(resolve)) + expect(dispatcher.attempts).toBe(1) + dispatcher.wake() + expect(dispatcher.attempts).toBe(3) + await completion + expect(writes).toBe(1) + expect(dispatcher.listeners()).toBe(0) + } finally { + dispatcher.dispose() + } +}) diff --git a/src/relay/dispatcher-bulk-retirement-admission.test.ts b/src/relay/dispatcher-bulk-retirement-admission.test.ts new file mode 100644 index 000000000000..d0d026088e4e --- /dev/null +++ b/src/relay/dispatcher-bulk-retirement-admission.test.ts @@ -0,0 +1,48 @@ +import { expect, it } from 'vitest' +import { createBulkWriteHarness, nextBulkWriteTurn } from './dispatcher-bulk-write-test-harness' + +it('admits each bulk frame once when retiring PTY writes releases capacity during admission', async () => { + const calibration = createBulkWriteHarness() + let capacity: number + try { + capacity = calibration.fillProducerQueue('live') + } finally { + calibration.dispose() + } + + const failures: { retired: number; sequences: (number | undefined)[] }[] = [] + let admissionRetryWakeCount = 0 + const midpoint = Math.floor(capacity / 2) + for (let retired = midpoint - 100; retired <= midpoint + 20; retired++) { + const harness = createBulkWriteHarness() + let ptyAdmitted = true + harness.dispatcher.registerPtyDataPublicationAdmission( + (_client, params) => ptyAdmitted || params.id !== 'dying' + ) + try { + harness.fillProducerQueue('live', capacity - retired) + harness.fillProducerQueue('dying', retired) + const completion = harness.dispatcher.notifyBulk('git.responseChunk', { + streamId: 1, + seq: 0, + data: 'g'.repeat(40 * 1024) + }) + await nextBulkWriteTurn() + expect(harness.dispatcher.capacityRetryCount).toBe(1) + ptyAdmitted = false + await harness.drain() + await completion + admissionRetryWakeCount += harness.dispatcher.admissionRetryWakeCount + const sequences = harness.frames + .filter((frame) => frame.method === 'git.responseChunk') + .map((frame) => frame.seq) + if (sequences.length !== 1 || sequences[0] !== 0) { + failures.push({ retired, sequences }) + } + } finally { + harness.dispose() + } + } + expect(failures).toEqual([]) + expect(admissionRetryWakeCount).toBeGreaterThan(0) +}) diff --git a/src/relay/dispatcher-bulk-write-test-harness.ts b/src/relay/dispatcher-bulk-write-test-harness.ts new file mode 100644 index 000000000000..29f1c02d4f8f --- /dev/null +++ b/src/relay/dispatcher-bulk-write-test-harness.ts @@ -0,0 +1,192 @@ +import { Writable } from 'node:stream' +import { RelayDispatcher } from './dispatcher' +import type { PreparedRelayFrame, RelayClient } from './dispatcher-contract' +import type { DispatcherWriterLane, SinkWriteSettlement } from './dispatcher-client-writer' +import { FrameDecoder, MessageType, parseJsonRpcMessage } from './protocol' + +type WrittenFrame = { method: string; seq?: number } +type PendingWrite = { frame: WrittenFrame; complete: (error?: Error | null) => void } +type FixedBulkAdmission = { + clientClosed: boolean + writerCanAdmitZero: boolean + retainedProducerBytes: number + settledBeforeReturn: boolean + accepted: boolean +} +export const nextBulkWriteTurn = (): Promise => + new Promise((resolve) => setImmediate(resolve)) + +class BulkWriteDispatcher extends RelayDispatcher { + admissionRetryWakeCount = 0 + fixedBulkAdmissions: FixedBulkAdmission[] = [] + lastCapacityRetry: (() => void) | null = null + private bulkAdmissionDepth = 0 + private consecutiveFixedBulkRejections = 0 + + get capacityRetryCount(): number { + return this.legacyCapacityListeners.size + } + + get retainedPublicationBytes(): number { + return this.publicationLedger.retainedBytes + } + + override onLegacyPtyCapacity(listener: () => void): () => void { + this.lastCapacityRetry = listener + return super.onLegacyPtyCapacity(listener) + } + + protected override publishPreparedToClient( + client: RelayClient, + frame: PreparedRelayFrame, + lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', + onSettled: (result: SinkWriteSettlement) => void = () => {} + ): boolean { + if (lane !== 'bulk') { + return super.publishPreparedToClient(client, frame, lane, onSettled) + } + this.bulkAdmissionDepth++ + try { + return super.publishPreparedToClient(client, frame, lane, onSettled) + } finally { + this.bulkAdmissionDepth-- + } + } + + protected override notifyLegacyCapacityIfLow(): void { + if ( + this.bulkAdmissionDepth > 0 && + this.capacityRetryCount > 0 && + this.legacyRetentionBelowLowWater + ) { + this.admissionRetryWakeCount++ + } + super.notifyLegacyCapacityIfLow() + } + + protected override enqueuePreparedFrame( + client: RelayClient, + frame: PreparedRelayFrame, + lane: DispatcherWriterLane, + onSettled: (result: SinkWriteSettlement) => void = () => {}, + controlOverflow: 'close-client' | 'reject' = 'close-client' + ): boolean { + if (lane !== 'fixed-bulk') { + return super.enqueuePreparedFrame(client, frame, lane, onSettled, controlOverflow) + } + if (this.consecutiveFixedBulkRejections >= 4) { + throw new Error('Settled fixed-bulk admission retried during writer teardown') + } + const before = { + clientClosed: client.closed, + writerCanAdmitZero: client.writer.canEnqueueProducer(0), + retainedProducerBytes: client.writer.retainedProducerBytes + } + let settledBeforeReturn = false + const accepted = super.enqueuePreparedFrame( + client, + frame, + lane, + (result) => { + settledBeforeReturn = true + onSettled(result) + }, + controlOverflow + ) + this.fixedBulkAdmissions.push({ ...before, settledBeforeReturn, accepted }) + this.consecutiveFixedBulkRejections = accepted ? 0 : this.consecutiveFixedBulkRejections + 1 + return accepted + } +} + +export function createBulkWriteHarness(highWaterMark = 64 * 1024, producerDataLength = 1024) { + const frames: WrittenFrame[] = [] + const pending: PendingWrite[] = [] + const sink = new Writable({ + highWaterMark, + write(bytes: Buffer, _encoding, complete) { + let frame: WrittenFrame | undefined + const decoder = new FrameDecoder((decoded) => { + if (decoded.type !== MessageType.Regular) { + return + } + const message = parseJsonRpcMessage(decoded.payload) + if (!('method' in message)) { + return + } + frame = { + method: message.method, + ...(typeof message.params?.seq === 'number' ? { seq: message.params.seq } : {}) + } + }) + decoder.feed(bytes) + if (!frame) { + throw new Error('Expected one notification frame') + } + frames.push(frame) + pending.push({ frame, complete }) + } + }) + sink.on('error', () => {}) + const dispatcher = new BulkWriteDispatcher( + (bytes, settled) => + sink.write(bytes, (error) => settled(error ? { ok: false, error } : { ok: true })), + { + supportsWriteCallback: true, + writableLength: () => sink.writableLength, + writableHighWaterMark: () => sink.writableHighWaterMark, + waitWriteDrain: (callback) => { + sink.once('drain', callback) + return () => sink.off('drain', callback) + } + } + ) + return { + dispatcher, + sink, + frames, + pending, + fillProducerQueue(id = 'test-pty', limit = Number.POSITIVE_INFINITY): number { + let admitted = 0 + const params = { id, data: 'p'.repeat(producerDataLength) } + while (admitted < limit && dispatcher.tryNotifyPtyData(params)) { + admitted++ + } + return admitted + }, + async releaseOne(error?: Error): Promise { + pending.shift()?.complete(error) + await nextBulkWriteTurn() + }, + async reachBulk(method: string): Promise { + for (let count = 0; count < 3000; count++) { + if (pending[0]?.frame.method === method) { + return + } + if (!pending.length) { + throw new Error('No pending write before bulk admission') + } + pending.shift()?.complete() + await nextBulkWriteTurn() + } + throw new Error('Bulk write did not arrive within admitted queue bound') + }, + async drain(): Promise { + for (let count = 0; count < 4000; count++) { + if (!pending.length) { + await nextBulkWriteTurn() + if (!pending.length) { + return + } + } + pending.shift()?.complete() + await nextBulkWriteTurn() + } + throw new Error('Sink did not drain within admitted queue bound') + }, + dispose(): void { + dispatcher.dispose() + sink.destroy() + } + } +} diff --git a/src/relay/dispatcher-producer-transport.ts b/src/relay/dispatcher-producer-transport.ts index 089a69577d1e..5c1b721531cf 100644 --- a/src/relay/dispatcher-producer-transport.ts +++ b/src/relay/dispatcher-producer-transport.ts @@ -113,31 +113,43 @@ export abstract class RelayDispatcherProducerTransport extends RelayDispatcherRp } return new Promise((resolve, reject) => { let removeCapacityListener: (() => void) | null = null + let retryFinished = false const finish = (): void => { + retryFinished = true removeCapacityListener?.() removeCapacityListener = null } const tryPublish = (): void => { - if (this.disposed || client.closed) { - finish() - resolve() - return - } - if ( - this.publishPreparedToClient(client, frame, lane, (result) => { + // Retiring queued PTY output can release capacity before admission returns. + this.runPublicationTransaction(() => { + if (retryFinished) { + return + } + if (this.disposed || client.closed) { finish() - if (result.ok || this.disposed || client.closed) { - resolve() - } else { - reject(result.error) - } - }) - ) { - return - } - if (!removeCapacityListener) { - removeCapacityListener = this.onLegacyPtyCapacity(tryPublish) - } + resolve() + return + } + if ( + this.publishPreparedToClient(client, frame, lane, (result) => { + finish() + if (result.ok || this.disposed || client.closed) { + resolve() + } else { + reject(result.error) + } + }) + ) { + finish() + return + } + if (retryFinished) { + return + } + if (!removeCapacityListener) { + removeCapacityListener = this.onLegacyPtyCapacity(tryPublish) + } + }) } tryPublish() }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 892c445d100b..1ed1194a66bb 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -101,8 +101,8 @@ function App(): React.JSX.Element { + - diff --git a/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx b/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx index 81aaf945e367..3b65dbd380f3 100644 --- a/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx +++ b/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx @@ -21,7 +21,8 @@ import type { import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' import type { DirectSshPreparationInput } from '../hooks/direct-ssh-reconnect-coordinator' import { createRemoteWorkspaceTargetSync } from '../hooks/remote-workspace-target-sync' -import { makeWorktree } from '../store/slices/store-test-helpers' +import { isDirectSshRemoteWorkspaceApplyInProgress } from '../hooks/remote-workspace-snapshot-apply' +import { makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { @@ -31,6 +32,7 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { import { useAppStore } from '@/store' import { useAppSessionPersistence } from './use-app-session-persistence' +import * as workspaceSessionHostPersistence from '../lib/workspace-session-host-persistence' const TARGET_ID = 'target-a' const REPO_ROOT = '/srv/proj' @@ -111,6 +113,7 @@ function installWindowApi(sessionPatch = vi.fn(async () => {})): void { function seedStore(withHostCatalog: boolean): void { useAppStore.setState({ + ...useAppStore.getInitialState(), workspaceSessionReady: true, hydrationSucceeded: true, repos: [ @@ -199,6 +202,30 @@ function createSync( }) } +// The initial full write must settle before measured upload responses are installed. +async function renderSettledPersistence() { + const pendingWrites: Promise[] = [] + const persistByHost = workspaceSessionHostPersistence.patchWorkspaceSessionByHost + const observeWrite = vi.spyOn(workspaceSessionHostPersistence, 'patchWorkspaceSessionByHost') + observeWrite.mockImplementation((...args) => { + const result = persistByHost(...args) + pendingWrites.push(result.written) + return result + }) + try { + installWindowApi() + const persistence = renderHook(() => useAppSessionPersistence()) + await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) + expect(pendingWrites).toHaveLength(1) + await Promise.all(pendingWrites) + expect(uploads).not.toHaveBeenCalled() + return persistence + } finally { + await Promise.allSettled(pendingWrites) + observeWrite.mockRestore() + } +} + /** A local edit the persistence subscriber must want to write, then let it settle. */ async function touchSessionAndSettle(marker: string): Promise { await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) @@ -230,16 +257,25 @@ function authorizeUploadsAtRevision(revision: number): void { }) } +let nextCaseTime = Date.now() + beforeEach(() => { - vi.useFakeTimers() + vi.useFakeTimers({ now: nextCaseTime }) uploads.mockReset() uploads.mockResolvedValue([]) installWindowApi() }) -afterEach(() => { - cleanup() - vi.useRealTimers() +afterEach(async () => { + try { + cleanup() + await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) + expect(isDirectSshRemoteWorkspaceApplyInProgress()).toBe(false) + } finally { + // The shared apply deadline must not outlive a rollback of this fixture's clock. + nextCaseTime = Date.now() + vi.useRealTimers() + } }) describe('uploads from a client that could not place the host tabs', () => { @@ -363,6 +399,7 @@ describe('uploads from a client that could not place the host tabs', () => { it('keeps a later same-lineage upload after the earlier result advances the revision', async () => { seedStore(true) + const persistence = await renderSettledPersistence() authorizeUploadsAtRevision(7) const secondLocalWrite = deferred() let localWriteCount = 0 @@ -379,7 +416,6 @@ describe('uploads from a client that could not place the host tabs', () => { } ]) installWindowApi(sessionPatch) - const persistence = renderHook(() => useAppSessionPersistence()) await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) useAppStore.setState({ activeTabId: 'first-local-write' }) @@ -423,6 +459,7 @@ describe('uploads from a client that could not place the host tabs', () => { it('retains transient upload authority so the next local edit retries', async () => { seedStore(true) + const persistence = await renderSettledPersistence() authorizeUploadsAtRevision(7) uploads.mockResolvedValueOnce([ { @@ -436,7 +473,6 @@ describe('uploads from a client that could not place the host tabs', () => { result: { ok: true, snapshot: snapshot(8, 'observation-7') } } ]) - const persistence = renderHook(() => useAppSessionPersistence()) await touchSessionAndSettle('transient-failure') await flushMicrotasks() diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index d64925a8a3b3..246670406f99 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -4,7 +4,6 @@ import { useIpcEvents } from '../hooks/useIpcEvents' import { useAutomationDispatchEvents } from '../hooks/useAutomationDispatchEvents' import { useAutoAckViewedAgent } from '../hooks/useAutoAckViewedAgent' import { useEditorExternalWatch } from '../hooks/useEditorExternalWatch' -import { useGlobalFileDrop } from '../hooks/useGlobalFileDrop' import { useAppMenuPaste } from '../hooks/useAppMenuPaste' import { useAppMenuSelectionActions } from '../hooks/useAppMenuSelectionActions' import { useLargeTextControlPaste } from '../hooks/useLargeTextControlPaste' @@ -23,6 +22,7 @@ import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-p import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice' import { useCodexSharedSettingsNotice } from '../components/terminal-pane/codex-shared-settings-notice' +import { useClaudeAccountSignInNotice } from '../lib/claude-account-sign-in-notice' import { useVisibleReviewRefreshReporting } from './use-visible-review-refresh-reporting' import { useVisibleHostedReviewRefresh } from './use-visible-hosted-review-refresh' @@ -54,7 +54,6 @@ export function useAppShellServices(): void { useGitStatusPolling({ enabled: workspaceSessionReady }) // Why: wire file-change watching at App level so the editor keeps hearing FS changes when Explorer unmounts (right-sidebar switches to Source Control/Checks). useEditorExternalWatch() - useGlobalFileDrop() useAutoAckViewedAgent() useAppMenuPaste() useAppMenuSelectionActions() @@ -64,4 +63,5 @@ export function useAppShellServices(): void { useBrowserIdentityMigrationNotice() useCodexTerminalServerIsolationNotice() useCodexSharedSettingsNotice() + useClaudeAccountSignInNotice() } diff --git a/src/renderer/src/app-shell/use-visible-hosted-review-refresh.test.tsx b/src/renderer/src/app-shell/use-visible-hosted-review-refresh.test.tsx index f1936bfe77a2..c876c5f4a08e 100644 --- a/src/renderer/src/app-shell/use-visible-hosted-review-refresh.test.tsx +++ b/src/renderer/src/app-shell/use-visible-hosted-review-refresh.test.tsx @@ -15,6 +15,7 @@ vi.mock('@/store', () => ({ vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: mocks.web })) vi.mock('@/store/github/visible-hosted-review-refresh-targets', () => ({ visibleHostedReviewRefreshInputsChanged: () => true, + getVisibleHostedReviewWorkspaces: () => [], getVisibleHostedReviewRefreshTargets: () => [ { key: 'review', diff --git a/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts b/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts index 033a3657c629..455ee69d12ab 100644 --- a/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts +++ b/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts @@ -3,6 +3,7 @@ import { useAppStore } from '@/store' import { isWindowVisible } from '@/lib/window-visibility-interval' import { isWebClientLocation } from '@/lib/web-client-location' import { createVisibleHostedReviewRefreshScheduler } from '@/store/github/visible-hosted-review-refresh-scheduler' +import { getWorkspaceReferenceRefreshTargets } from '@/components/sidebar/workspace-reference-refresh-targets' import { getVisibleHostedReviewRefreshTargets, visibleHostedReviewRefreshInputsChanged @@ -14,13 +15,19 @@ export function useVisibleHostedReviewRefresh({ enabled }: { enabled: boolean }) return } const scheduler = createVisibleHostedReviewRefreshScheduler() - const update = (): void => - scheduler.update( - getVisibleHostedReviewRefreshTargets(useAppStore.getState(), useAppStore.getState, { - selectedOnly: isWebClientLocation() - }) - ) - const visibilityChanged = (): void => scheduler.setVisible(isWindowVisible()) + const update = (): void => { + const state = useAppStore.getState() + const selectedOnly = isWebClientLocation() + scheduler.update([ + ...getVisibleHostedReviewRefreshTargets(state, useAppStore.getState, { selectedOnly }), + ...getWorkspaceReferenceRefreshTargets(state, { selectedOnly }) + ]) + } + const visibilityChanged = (): void => { + scheduler.setVisible(false) + update() + scheduler.setVisible(isWindowVisible()) + } const unsubscribe = useAppStore.subscribe((state, previous) => { if (visibleHostedReviewRefreshInputsChanged(state, previous)) { update() diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 629d711e93cc..cb5f526fe999 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -121,6 +121,8 @@ --color-status-warning: var(--status-warning); --color-status-warning-background: var(--status-warning-background); --color-status-warning-border: var(--status-warning-border); + --color-callout-note: var(--callout-note); + --color-callout-important: var(--callout-important); --color-workspace-status-done: var(--workspace-status-done); --color-workspace-status-review: var(--workspace-status-review); --color-workspace-status-progress: var(--workspace-status-progress); @@ -393,6 +395,9 @@ --status-warning: #ca8a04; --status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent); --status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent); + /* GitHub's own hues for the two callout kinds no status token covers. */ + --callout-note: #0969da; + --callout-important: #8250df; --git-decoration-added: #587c0c; --git-decoration-modified: #895503; --git-decoration-deleted: #ad0707; @@ -416,6 +421,8 @@ --status-warning: #eab308; --status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent); --status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent); + --callout-note: #4493f8; + --callout-important: #ab7df8; --git-decoration-added: #81b88b; --git-decoration-modified: #e2c08d; --git-decoration-deleted: #c74e39; @@ -3333,3 +3340,58 @@ html.native-shell .app-layout { inset 0 1px 0 0 color-mix(in srgb, var(--foreground) 5%, transparent), 0 10px 24px rgba(0, 0, 0, 0.18); } + +.workspace-reference-stack { + isolation: isolate; + position: relative; + display: inline-flex; + width: 14px; + height: 14px; + flex-shrink: 0; + color: var(--muted-foreground); +} + +.workspace-reference-stack-front, +.workspace-reference-stack-ghost { + position: absolute; + inset: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.workspace-reference-stack-front { + z-index: 2; +} + +.workspace-reference-stack-ghost[data-depth='1'] { + transform: translate(-1.5px, 2px); + opacity: 0.45; + z-index: 1; +} + +.workspace-reference-stack-ghost[data-depth='2'] { + transform: translate(-3px, 4px); + opacity: 0.25; +} + +.workspace-reference-stack svg { + width: 14px; + height: 14px; +} + +.workspace-reference-stack-count { + position: absolute; + top: -8px; + right: -5px; + z-index: 3; + font-size: 11px; + font-weight: 600; + line-height: 11px; + color: var(--worktree-sidebar-foreground); + text-shadow: 0px 1px 1px var(--background); +} + +.workspace-reference-stack-count[data-multiple-digits='true'] { + letter-spacing: -0.02em; +} diff --git a/src/renderer/src/assets/markdown-preview.css b/src/renderer/src/assets/markdown-preview.css index 16c948345513..7f6c520d15f5 100644 --- a/src/renderer/src/assets/markdown-preview.css +++ b/src/renderer/src/assets/markdown-preview.css @@ -822,6 +822,14 @@ color: rgba(0, 0, 0, 0.6); } +/* Border and title colors come from the callout component. */ +.markdown-body [data-callout] { + margin: 0.75em 0; + padding: 0.25em 1em; + border-left-width: 3px; + border-left-style: solid; +} + .markdown-body table { margin: 1em 0; border-collapse: collapse; diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index 6572692929ae..e8676b14b698 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -1,7 +1,7 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: Jira issue hydration, comments, transitions, priorities, and user options are loaded from provider IPC for the selected issue. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { VisuallyHidden } from 'radix-ui' +import * as VisuallyHidden from 'radix-ui/visually-hidden' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' import { getCommentBodySubmitState, diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx index e5808aa5459a..cafe1be15060 100644 --- a/src/renderer/src/components/LinearIssueWorkspace.tsx +++ b/src/renderer/src/components/LinearIssueWorkspace.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo } from 'react' -import { VisuallyHidden } from 'radix-ui' +import * as VisuallyHidden from 'radix-ui/visually-hidden' import { LinearIssueTextEditor } from '@/components/LinearIssueTextEditor' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' diff --git a/src/renderer/src/components/NativeChatResumeFailureDetails.tsx b/src/renderer/src/components/NativeChatResumeFailureDetails.tsx index f0bd41a1792f..968c0fc0769b 100644 --- a/src/renderer/src/components/NativeChatResumeFailureDetails.tsx +++ b/src/renderer/src/components/NativeChatResumeFailureDetails.tsx @@ -99,7 +99,7 @@ export function ResumeFailureGuidanceLine({ }): React.JSX.Element { const guidance = resumeFailureGuidance(failure) return ( -
+
{translate('auto.components.NativeChatResumeOutcomeRow.toResume', 'To resume:')} diff --git a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx index 84d9aa4eba1c..6d6120e6e8d0 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx @@ -1,4 +1,4 @@ -import { Checkbox } from './ui/checkbox' +import { useContext } from 'react' import { AgentIcon } from '@/lib/agent-catalog-icon' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { formatShortTimeAgo } from '@/lib/short-time-ago' @@ -10,10 +10,13 @@ import { } from './native-chat-resume-failure-guidance' import { ResumeFailureGuidanceLine, ResumeFailureStatus } from './NativeChatResumeFailureDetails' import { resumeActivityLabel } from './native-chat-resume-activity-label' +import { ResumeTreeRow } from './NativeChatResumeTreeRow' +import { ResumeTreeDepthContext } from './native-chat-resume-tree-state' /** - * One offered chat, laid out like the sidebar's compact agent row: provider glyph, the chat's name, - * then the model and an age on the right. + * One offered chat as a leaf of the resume tree: its checkbox in the list's shared left column, then, + * indented for its depth, the provider glyph, "name - what it was doing", the model and an age — + * the pieces of the sidebar's compact agent row. * * The sidebar's own `CompactAgentRow` cannot be reused — it takes a `DashboardAgentRow`, which * requires a live pane, tab and status entry, and every chat here is by definition stopped. The @@ -26,11 +29,13 @@ import { resumeActivityLabel } from './native-chat-resume-activity-label' * a fault, `done` a finish, `working` a spinner. A missing dot beats a dot that says these agents * are running. * - * Under the name, what the chat was doing when Orca went away — mid-reply, waiting on the user, + * After the name, what the chat was doing when Orca went away — mid-reply, waiting on the user, * subagents or monitoring — so rows the sidebar showed as working for different reasons differ. * * A chat an earlier resume could not carry on is the same row — selectable where a retry can run, * so Resume retries it — plus a status icon, a dismiss control, and a line saying what to do. + * + * A leaf of the resume tree; the enclosing workspace node supplies its depth. */ export function ResumeCandidateRow({ candidate, @@ -59,35 +64,50 @@ export function ResumeCandidateRow({ translate('auto.components.NativeChatResumeOnRestartModal.untitled', 'Untitled chat') const model = candidate.model?.trim() ?? '' const activity = resumeActivityLabel(candidate.activity) - const row = ( - - ) - if (!failure) { - return
  • {row}
  • - } - const act = (action: ResumeFailureAction) => onFailureAction?.(action, candidate.sessionId) - return ( -
  • - {/* Outside the label, so pressing them never toggles the checkbox. */} -
    - {row} - -
    - -
  • + ) } diff --git a/src/renderer/src/components/NativeChatResumeOnRestartGroups.selection.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartGroups.selection.test.tsx new file mode 100644 index 000000000000..0e24e13d8bf4 --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeOnRestartGroups.selection.test.tsx @@ -0,0 +1,187 @@ +// @vitest-environment happy-dom + +// The resume tree as a control: tri-state at every level, exclusions, busy, and collapsing. + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { useAppStore } from '../store' +import { getHostContextLabel } from '../../../shared/worktree/host-context-labels' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { + candidate, + chatBox, + countOf, + disclosureOf, + nodeBox, + onToggleSpy, + queryChatBox, + seedTree, + treeOutline, + unretryable, + worktree +} from './native-chat-resume-tree.test-support' +import { ResumeTreeHarness as Harness } from './NativeChatResumeTreeHarness.test-support' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true +let root: Root +let container: HTMLElement + +function render(props: React.ComponentProps): void { + act(() => root.render()) +} + +function state(box: HTMLElement): string | null { + return box.getAttribute('aria-checked') +} + +beforeEach(() => { + onToggleSpy.mockReset() + useAppStore.setState(useAppStore.getInitialState(), true) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +it('is tri-state at every level, each node over every chat under it', () => { + const remote: ExecutionHostId = 'ssh:build-server' + render({ candidates: seedTree({ parent: remote, child: remote }) }) + const machine = getHostContextLabel(remote) + expect([machine, 'orca', 'parent', 'child', 'other'].map((name) => countOf(name))).toEqual([ + '4 of 4', + '4 of 4', + '3 of 3', + '2 of 2', + '1 of 1' + ]) + + act(() => chatBox('in-child').click()) + expect([machine, 'orca', 'parent', 'child', 'other'].map((name) => state(nodeBox(name)))).toEqual( + ['mixed', 'mixed', 'mixed', 'mixed', 'true'] + ) + expect(countOf(machine)).toBe('3 of 4') + expect(countOf('child')).toBe('1 of 2') + + // Partly selected: ticking selects everything under the node, nested workspaces included. + act(() => nodeBox('parent').click()) + expect(state(chatBox('in-child'))).toBe('true') + expect(state(nodeBox(machine))).toBe('true') + + act(() => nodeBox(machine).click()) + for (const id of ['in-parent', 'in-child', 'also-in-child', 'in-other']) { + expect(state(chatBox(id))).toBe('false') + } + expect(countOf('orca')).toBe('0 of 4') +}) + +it('selects every eligible chat in a project, never an unretryable one or another project’s', () => { + const candidates = seedTree() + const mobile = worktree('mobile', { repoId: 'repo-2', id: 'repo-2::/mobile/mobile' }) + useAppStore.setState((current) => ({ + repos: [ + ...current.repos, + { id: 'repo-2', path: '/mobile', displayName: 'orca-mobile', badgeColor: '#999', addedAt: 1 } + ], + worktreesByRepo: { ...current.worktreesByRepo, 'repo-2': [mobile] } + })) + const stuck = unretryable(candidates[3]!) + render({ + candidates: [...candidates, candidate('in-mobile', mobile)], + initiallySelected: ['in-mobile'], + failureFor: (sessionId) => (sessionId === 'in-other' ? stuck : undefined) + }) + expect(countOf('orca')).toBe('0 of 3') + expect(chatBox('in-other').hasAttribute('disabled')).toBe(true) + + act(() => nodeBox('orca').click()) + expect(countOf('orca')).toBe('3 of 3') + expect(state(nodeBox('orca'))).toBe('true') + const touched = onToggleSpy.mock.calls.map(([sessionId]) => sessionId) + expect(touched).not.toContain('in-other') + expect(touched).not.toContain('in-mobile') + expect(countOf('orca-mobile')).toBe('1 of 1') +}) + +it('disables a node with nothing it could select', () => { + const candidates = seedTree() + render({ + candidates, + initiallySelected: [], + failureFor: (sessionId) => (sessionId === 'in-other' ? unretryable(candidates[3]!) : undefined) + }) + + expect(nodeBox('other').hasAttribute('disabled')).toBe(true) + expect(countOf('other')).toBeUndefined() +}) + +it('disables every checkbox while a resume runs', () => { + render({ candidates: seedTree(), busy: true }) + + const boxes = [...container.querySelectorAll('[role="checkbox"]')] + expect(boxes).toHaveLength(8) + for (const box of boxes) { + expect(box.hasAttribute('disabled')).toBe(true) + } +}) + +it('selects a whole row by clicking anywhere on it, but its arrow only folds', () => { + render({ candidates: seedTree(), initiallySelected: [] }) + + act(() => itemLabelBody('child').click()) + expect(state(chatBox('in-child'))).toBe('true') + + act(() => disclosureOf('other').click()) + expect(state(nodeBox('other'))).toBe('false') + expect(queryChatBox('in-other')).toBeNull() +}) + +function itemLabelBody(name: string): HTMLElement { + return nodeBox(name).closest('label')!.querySelector(':scope > :last-child')! +} + +it('starts expanded; collapsing hides a node’s rows and keeps their selection', () => { + render({ candidates: seedTree() }) + const parent = disclosureOf('parent') + expect(parent.getAttribute('aria-expanded')).toBe('true') + expect(parent.getAttribute('aria-label')).toBe('Collapse parent') + expect(parent.tabIndex).toBe(-1) + act(() => chatBox('in-child').click()) + + act(() => parent.click()) + expect(treeOutline()).toEqual([ + '1:project:orca', + '2:workspace:parent', + '2:workspace:other', + '3:chat:in-other' + ]) + expect(disclosureOf('parent').getAttribute('aria-label')).toBe('Expand parent') + expect(nodeBox('parent').closest('[role="treeitem"]')?.getAttribute('aria-expanded')).toBe( + 'false' + ) + // Hidden, not forgotten: the node still counts the pick made inside it. + expect(state(nodeBox('parent'))).toBe('mixed') + expect(countOf('parent')).toBe('2 of 3') + + act(() => disclosureOf('parent').click()) + expect(state(chatBox('in-child'))).toBe('false') + expect(state(chatBox('also-in-child'))).toBe('true') +}) + +it('toggles the chats of a collapsed node', () => { + render({ candidates: seedTree(), initiallySelected: [] }) + + act(() => disclosureOf('parent').click()) + act(() => nodeBox('parent').click()) + expect(countOf('parent')).toBe('3 of 3') + expect(onToggleSpy.mock.calls.map(([sessionId]) => sessionId).toSorted()).toEqual([ + 'also-in-child', + 'in-child', + 'in-parent' + ]) +}) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartGroups.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartGroups.test.tsx new file mode 100644 index 000000000000..70c7dfdbf24e --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeOnRestartGroups.test.tsx @@ -0,0 +1,251 @@ +// @vitest-environment happy-dom + +// The resume tree's shape: levels, depth, where child workspaces go, and when machines show. + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { useAppStore } from '../store' +import { getDefaultSettings } from '../../../shared/constants' +import { getHostContextLabel } from '../../../shared/worktree/host-context-labels' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import { + candidate, + chatBox, + itemOf, + lineage, + nodeBox, + onToggleSpy, + seedTree, + treeOutline, + worktree +} from './native-chat-resume-tree.test-support' +import { ResumeTreeHarness as Harness } from './NativeChatResumeTreeHarness.test-support' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true +let root: Root +let container: HTMLElement + +function render(props: React.ComponentProps): void { + act(() => root.render()) +} + +beforeEach(() => { + onToggleSpy.mockReset() + useAppStore.setState(useAppStore.getInitialState(), true) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +const LOCAL_TREE = [ + '1:project:orca', + '2:workspace:parent', + '3:chat:in-parent', + '3:workspace:child', + '4:chat:in-child', + '4:chat:also-in-child', + '2:workspace:other', + '3:chat:in-other' +] + +it('lists project, workspace and chat levels, a child workspace after its parent’s chats', () => { + render({ candidates: seedTree() }) + + expect(treeOutline()).toEqual(LOCAL_TREE) +}) + +it('keeps every checkbox in the left column and indents what follows it 18px a level', () => { + render({ candidates: seedTree() }) + + for (const item of container.querySelectorAll('[role="treeitem"]')) { + const row = item.querySelector('label')! + const cell = (n: number) => row.querySelector(`:scope > :nth-child(${n})`) + const disclosure = cell(3) + // The checkbox cell comes first, before any indent. + expect(cell(1)?.querySelector('[role="checkbox"]')).not.toBeNull() + const level = Number(item.getAttribute('aria-level')) + expect(cell(2)?.style.width).toBe(`${(level - 1) * 18}px`) + // Every row keeps the arrow's slot, so icons line up; only a node with children has an arrow. + expect(disclosure?.getAttribute('aria-hidden')).toBe('true') + const arrow = item.querySelector('button[aria-expanded]') + expect(arrow !== null).toBe(item.hasAttribute('aria-expanded')) + // Laid over its slot, outside the label, so it never toggles the checkbox. + expect(arrow?.closest('label') ?? null).toBeNull() + if (arrow) { + expect(arrow.style.left).toBe(`${28 + (level - 1) * 18}px`) + } + } +}) + +it('reads levels apart by weight, with no row dividers or bands', () => { + render({ candidates: seedTree() }) + + expect(itemOf(nodeBox('orca')).querySelector('.font-semibold')?.textContent).toBe('orca') + expect(itemOf(nodeBox('parent')).querySelector('.font-medium')?.textContent).toBe('parent') + // Every row element but the checkboxes, which keep their own outline. + const rows = container.querySelectorAll( + '[role="treeitem"], [role="treeitem"] *:not([role="checkbox"])' + ) + for (const element of rows) { + expect([...element.classList].filter((name) => /^(border|bg-)/.test(name))).toEqual([]) + } +}) + +it('shows the branch for a git worktree, muted after its name', () => { + render({ candidates: seedTree() }) + + const branch = [...itemOf(nodeBox('parent')).querySelectorAll('span')].find( + (span) => span.textContent === 'parent-branch' + ) + expect(branch?.classList.contains('text-muted-foreground')).toBe(true) +}) + +it('starts at projects when every chat is on this machine', () => { + render({ candidates: seedTree() }) + + expect(container.textContent).not.toContain(getHostContextLabel('local')) + expect(container.querySelector('[aria-label^="Select all chats on"]')).toBeNull() +}) + +it('heads a single SSH host with its machine node, named and marked SSH', () => { + const remote: ExecutionHostId = 'ssh:build-server' + render({ candidates: seedTree({ parent: remote, child: remote }) }) + + const machine = getHostContextLabel(remote) + expect(treeOutline()).toEqual([ + `1:machine:${machine}`, + ...LOCAL_TREE.map((row) => row.replace(/^(\d)/, (level) => String(Number(level) + 1))) + ]) + expect(itemOf(nodeBox(machine)).textContent).toContain('SSH') + // The machine node names the host, so no workspace repeats it. + expect(itemOf(nodeBox('parent')).textContent).not.toContain(machine) +}) + +// A paired Orca server's runtime host is not this machine, but it is not SSH either. +it('heads a runtime host with its machine node and no SSH chip', () => { + const runtime: ExecutionHostId = 'runtime:env-1' + render({ candidates: seedTree({ parent: runtime, child: runtime }) }) + + const machine = getHostContextLabel(runtime) + expect(treeOutline()[0]).toBe(`1:machine:${machine}`) + expect(itemOf(nodeBox(machine)).textContent).not.toContain('SSH') +}) + +// A target saved through Add SSH target gets a generated id; the sidebar names it by its label. +it('names an SSH machine by its saved name, not its target id', () => { + const targetId = 'ssh-1728291234567-abc12d' + const remote: ExecutionHostId = `ssh:${targetId}` + const candidates = seedTree({ parent: remote, child: remote }) + useAppStore.setState({ sshTargetLabels: new Map([[targetId, 'devbox']]) }) + render({ candidates }) + + expect(itemOf(nodeBox('devbox')).getAttribute('aria-level')).toBe('1') + expect(container.textContent).not.toContain(targetId) +}) + +it('gives each machine its own branch when chats span hosts', () => { + const remote: ExecutionHostId = 'ssh:build-server' + const candidates = seedTree({ parent: 'local', child: remote }) + render({ candidates }) + + const local = getHostContextLabel('local') + const machine = getHostContextLabel(remote) + // The child is on another host, so it does not nest under the local parent. + expect(treeOutline()).toEqual([ + `1:machine:${machine}`, + '2:project:orca', + '3:workspace:child', + '4:chat:in-child', + '4:chat:also-in-child', + `1:machine:${local}`, + '2:project:orca', + '3:workspace:parent', + '4:chat:in-parent', + '3:workspace:other', + '4:chat:in-other' + ]) + expect(itemOf(nodeBox(local)).textContent).not.toContain('SSH') +}) + +it('lists every offered chat exactly once, even with one workspace id on two hosts', () => { + const remote: ExecutionHostId = 'ssh:build-server' + const candidates = seedTree() + const remoteParent = worktree('parent', { hostId: remote, instanceId: 'instance-remote' }) + useAppStore.setState((state) => ({ + worktreesByRepo: { 'repo-1': [...state.worktreesByRepo['repo-1']!, remoteParent] } + })) + const twin = candidate('on-remote', remoteParent) + render({ candidates: [...candidates, twin] }) + + for (const entry of [...candidates, twin]) { + expect( + container.querySelectorAll(`[role="checkbox"][aria-label*="Prompt ${entry.sessionId}"]`) + ).toHaveLength(1) + } + // Same id, different machine: two separate workspace nodes. + expect(container.querySelectorAll('[aria-label="Select all chats in parent"]')).toHaveLength(2) +}) + +it('names a workspace the store does not know by its id, with the kind the host recorded', () => { + const unknown: ResumeCandidate = { + ...candidate('lost', worktree('gone')), + workspaceId: 'folder:missing-folder', + workspaceKind: 'folder' + } + render({ candidates: [unknown] }) + + const row = itemOf(nodeBox('folder:missing-folder')) + expect(row.querySelector('svg.lucide-folder')).not.toBeNull() + expect(chatBox('lost').getAttribute('aria-label')).toContain('in folder:missing-folder') +}) + +// Its project is unknown too: a nameless "Select all chats in " checkbox would help no one. +it('gives workspaces the store cannot place no project node', () => { + const unknown: ResumeCandidate = { + ...candidate('lost', worktree('gone')), + workspaceId: 'folder:missing-folder', + workspaceKind: 'folder' + } + render({ candidates: [...seedTree(), unknown] }) + + expect(treeOutline()).toEqual([...LOCAL_TREE, '1:workspace:folder:missing-folder', '2:chat:lost']) + expect(container.querySelector('[aria-label="Select all chats in "]')).toBeNull() +}) + +// Why: the sidebar nests a child only under a parent on its own host; no host id matches only none. +it.each([ + ['both local', 'local', 'local', true], + ['neither with a host id', undefined, undefined, true], + ['parent without a host id, child local', undefined, 'local', false], + ['parent local, child without a host id', 'local', undefined, false] +] as const)( + '%s: nests the child exactly as the sidebar does', + (_, parentHost, childHost, nests) => { + render({ candidates: seedTree({ parent: parentHost, child: childHost }) }) + + expect(treeOutline()).toContain(nests ? '3:workspace:child' : '2:workspace:child') + expect(container.querySelectorAll('[role="checkbox"][aria-label^="Resume"]')).toHaveLength(4) + } +) + +it('lists every chat when workspace lineage loops', () => { + const a = worktree('a') + const b = worktree('b') + useAppStore.setState({ + settings: getDefaultSettings(''), + worktreesByRepo: { 'repo-1': [a, b] }, + worktreeLineageById: { [a.id]: lineage(a, b), [b.id]: lineage(b, a) } + }) + render({ candidates: [candidate('in-a', a), candidate('in-b', b)] }) + + expect(chatBox('in-a')).toBeTruthy() + expect(chatBox('in-b')).toBeTruthy() +}) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx index 6f8bb64f4f1e..0444159c26b8 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx @@ -1,162 +1,338 @@ -import { Folder, FolderTree, GitBranch } from 'lucide-react' +import { useMemo } from 'react' +import { Folder, FolderTree, GitBranch, Monitor } from 'lucide-react' import { RepoIconGlyph } from '@/components/repo/repo-icon' import { WorktreeHostContextBadge } from '@/components/sidebar/WorktreeHostContextBadge' +import { useSidebarHostScopeOptions } from '@/components/sidebar/use-sidebar-host-scope-options' +import { translate } from '@/i18n/i18n' +import { + resolveWorktreeBranchLabel, + resolveWorktreeDisplayName +} from '@/lib/worktree-default-display-name' import { getHostContextLabel } from '../../../shared/worktree/host-context-labels' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import type { AgentSessionWorkspaceKind } from '../../../shared/agent-session-record' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' import { useAppStore } from '../store' +import { + useLineageAncestors, + useRepoIdByWorkspace, + useWorkspaceWorktree +} from './native-chat-resume-workspace-lookups' import { ResumeCandidateRow } from './NativeChatResumeOnRestartAgentRow' +import { ResumeTreeCount, ResumeTreeRow } from './NativeChatResumeTreeRow' +import { + chatState, + coveredKeys, + ResumeTreeDepthContext, + useResumeTreeExpansion, + type FailureProps, + type TreeProps +} from './native-chat-resume-tree-state' import { groupResumeCandidates, + groupResumeCandidatesByHost, groupResumeWorkspacesByRepo, + nestResumeWorkspaces, resolveResumeGroupHeader, + resumeSelectionState, resumeWorkspaceKind, + resumeWorkspaceCandidates, + toggleResumeSelection, type ResumeCandidate, - type ResumeFailure, - type ResumeWorkspaceGroup + type ResumeWorkspaceGroup, + type ResumeWorkspaceNode } from './native-chat-resume-on-restart-grouping' -import type { ResumeFailureAction } from './native-chat-resume-failure-guidance' export type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' /** - * The offered chats in the sidebar's three tiers: repo/project, then workspace, then agent sessions. + * The offered chats as a tree: machine, then project, then workspace (a child workspace is a node + * inside its parent, after the parent's chats), then the chats. * - * Reused from the sidebar rather than rebuilt: `RepoIconGlyph` for a repo's own glyph, `FolderTree` - * from the sidebar's `PROJECT_GROUP_META` for a project group, and `WorktreeHostContextBadge` — - * extracted from the sidebar card's meta row so both surfaces render one chip. The label inside it - * comes from `getHostContextLabel`, which is the sidebar's own source for "Local Mac". + * Every node's checkbox sits in one column at the left edge and covers every selectable chat under + * it; levels read apart by icon and weight, nesting by indent and the disclosure arrows. The machine + * level shows only when the machine is not obvious (a remote host, or chats on more than one), and + * then it is what names the host, so no workspace repeats it. * - * The sidebar has NO resolver for git-worktree vs folder-workspace glyphs: both kinds render the - * same card, and the apparent difference is its status lane picking `GitBranch` when the workspace - * has a branch identity. That single sidebar precedent is what the workspace glyph follows here. + * A workspace the store does not know yet (host connecting, or deleted) is named by its id. */ -type StoreState = ReturnType - -/** Lets a row that an earlier resume could not carry on show what went wrong and what to do. */ -type FailureProps = { - failureFor?: (sessionId: string) => ResumeFailure | undefined - onFailureAction?: (action: ResumeFailureAction, sessionId: string) => void -} - -function resolveWorkspaceWorktree(store: StoreState, workspaceId: string) { +/** A group node: its row, then (while open) what is under it. */ +function GroupNode({ + nodeKey, + depth, + name, + checkboxLabel, + covered, + tree, + label, + children +}: { + nodeKey: string + depth: number + name: string + checkboxLabel: string + /** Every selectable chat under this node. */ + covered: readonly string[] + tree: TreeProps + label: React.ReactNode + children: React.ReactNode +}): React.JSX.Element { + const selection = resumeSelectionState(covered, tree.selected) + const expanded = tree.isExpanded(nodeKey) return ( - store.getKnownWorktreeById(workspaceId) ?? - store.allWorktrees().find((entry) => entry.id === workspaceId) - ) -} - -/** Selectors return primitives, so repeated runs cannot churn equality. */ -function useWorkspaceName(workspaceId: string): string { - return useAppStore( - (store) => resolveWorkspaceWorktree(store, workspaceId)?.displayName ?? workspaceId - ) -} - -/** - * The repo owning each workspace, as one narrow subscription. - * - * Selected as a joined string rather than a map so the selector returns a PRIMITIVE: a fresh object - * or array would fail the equality check on every store change and re-render the whole list. - */ -function useRepoIdByWorkspace( - workspaceIds: readonly string[] -): (workspaceId: string) => string | null { - const key = workspaceIds.join('\0') - const joined = useAppStore((store) => - key - .split('\0') - .map((id) => resolveWorkspaceWorktree(store, id)?.repoId ?? '') - .join('\0') + <> + tree.setExpanded(nodeKey, next)} + name={name} + checked={selection.checked} + disabled={tree.busy || selection.total === 0} + onCheckedChange={() => toggleResumeSelection(covered, selection, tree.onToggle)} + checkboxLabel={checkboxLabel} + > + {label} + + + {/* Collapsing unmounts the rows only; their selection lives with the dialog. */} + {expanded && children} + ) - const repoIds = joined.split('\0') - return (workspaceId: string) => { - const index = workspaceIds.indexOf(workspaceId) - const repoId = index === -1 ? '' : (repoIds[index] ?? '') - return repoId === '' ? null : repoId - } } -/** The glyph for the workspace itself. Kind comes from the host's record, never from a name. */ -function WorkspaceKindGlyph({ kind }: { kind: AgentSessionWorkspaceKind }): React.JSX.Element { - return kind === 'folder' ? ( -
    + ) +} + +/** A group node's "x of y" on the right of its row. */ +export function ResumeTreeCount({ + selectedCount, + total +}: { + selectedCount: number + total: number +}): React.JSX.Element | null { + if (total === 0) { + return null + } + return ( + + {translate( + 'auto.components.NativeChatResumeOnRestartModal.workspaceSelectedCount', + '{{value0}} of {{value1}}', + { value0: selectedCount, value1: total } + )} + + ) +} diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx new file mode 100644 index 000000000000..7146e77d6d13 --- /dev/null +++ b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx @@ -0,0 +1,278 @@ +// @vitest-environment happy-dom +import type * as ReactI18nextModule from 'react-i18next' +import { act, renderHook } from '@testing-library/react' +import { useNewWorkspaceComposerFileDrop } from './new-workspace/use-new-workspace-composer-file-drop' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderCard, unmountCard } from './NewWorkspaceComposerCard.test-fixture' +import { createRef } from 'react' +import { resolveComposerAttachmentTarget } from '../hooks/composer-state/composer-attachment-target' +import { useAttachmentDropState } from '../hooks/composer-state/attachment-drop-state' +import type { ProjectGroup } from '../../../shared/project-group-types' + +const uploads = vi.hoisted(() => ({ importPaths: vi.fn() })) +vi.mock('@/runtime/runtime-file-client', () => ({ + importExternalPathsToRuntime: uploads.importPaths +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: unknown) => unknown) => selector({ settings: {}, projects: [], repos: [] }), + { + getState: () => ({ + sshConnectionStates: connections, + sshStateByEnvironment: runtimeConnections + }) + } + ) +})) +vi.mock('react-i18next', async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({}) +})) +vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({ + useContextualTour: () => {} +})) +vi.mock('@/components/sidebar/AddRemoteHostDialog', () => ({ AddRemoteHostDialog: () => null })) +vi.mock('./new-workspace/NewWorkspaceComposerProjectSection', () => ({ + NewWorkspaceComposerProjectSection: () => null +})) +vi.mock('./new-workspace/NewWorkspaceComposerNameSection', () => ({ + NewWorkspaceComposerNameSection: () => null +})) +vi.mock('./new-workspace/NewWorkspaceComposerAgentSection', () => ({ + NewWorkspaceComposerAgentSection: () => null +})) +vi.mock('./new-workspace/NewWorkspaceComposerAdvancedSection', () => ({ + NewWorkspaceComposerAdvancedSection: () => null +})) +vi.mock('./new-workspace/NewWorkspaceComposerFooter', () => ({ + NewWorkspaceComposerFooter: () =>