diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e653e07e433..e0bbc7da303 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -52,8 +52,3 @@ Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Re - [ ] Self-reviewed for correctness, security, and performance - [ ] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A) - [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred) - -## Author - -- X / Twitter: @your_handle - diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 011b08695c6..d0f6e1fbf61 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -260,6 +260,11 @@ jobs: shell_contracts: name: shell contracts runs-on: ubuntu-latest + # Why: this job's cost is almost entirely package download, and a stalled mirror has + # no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this + # is generous; it exists so a wedge fails the job instead of holding the whole run + # open for the 6h GitHub default — which also blocks `gh run rerun --failed`. + timeout-minutes: 15 env: # Why: the suites below gate their live fish tests on the binary, which is # right on a developer machine and wrong here — this job is a required check @@ -294,16 +299,39 @@ jobs: # an update on each side of it this step refreshed them three times over, and # the Azure archive mirror alone costs ~15-30s a pass. The PPA index is the # only thing the repo list gains here, and the single update below fetches it. + # Why bound acquisition: measured on a *passing* run, this step spent 40s + # fetching 11.4 MB of index and then 2m17s fetching 8.9 MB of packages at + # 65 kB/s — it is dominated by download throughput, not by work. apt applies + # no wall-clock bound to a stalled mirror, so a slow Launchpad or archive + # host wedges the step for tens of minutes. This job is a required check, so + # a wedge holds the entire run open and blocks `gh run rerun --failed`. + # Bounded timeouts plus retries turn an unbounded hang into a fast, legible + # failure. Set in apt.conf.d rather than on each command line so the two + # invocations below stay exactly as pr-workflow-parallelism.test.mjs parses + # them. Retries are 1, not 3: a first attempt at these bounds already multiplied + # 30s x 3 retries across every index file into a ~15 minute stall on a dead + # mirror, which is worse than failing once and moving on. + sudo tee /etc/apt/apt.conf.d/99-orca-shell-contracts >/dev/null <<'APTCONF' + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + Acquire::Retries "1"; + APTCONF for attempt in 1 2 3; do sudo add-apt-repository -y -n ppa:fish-shell/release-4 && break echo "add-apt-repository attempt ${attempt} failed; retrying" >&2 sudo add-apt-repository -y -n -r ppa:fish-shell/release-4 || true sleep 5 done - sudo apt-get update || true + # Why a wall-clock bound on each command: apt's Acquire timeouts are per-connection, + # so a dead mirror costs timeout x retries x every index file. Measured: the archive + # mirror stalled with zero bytes and the step burned 14m26s before the job bound + # killed it. `timeout` is the only thing that bounds the command as a whole. + # The update is already tolerant by design (see above), so bounding it just caps + # what a dead mirror can cost before the install runs against whatever index exists. + timeout 120 sudo apt-get update || true # Why both shells on one line: pr-workflow-parallelism.test.mjs parses only the # first install command in this step to prove the lane really installs them. - sudo apt-get install -y zsh fish + timeout 300 sudo apt-get install -y zsh fish # Separate from the install so the failure names the contract, not an apt error. # ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds diff --git a/README.md b/README.md index 0f15d134f93..b980471d4f3 100644 --- a/README.md +++ b/README.md @@ -238,9 +238,10 @@ Pair with your desktop app to monitor and steer your agents from your phone. - **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements. -- **WeChat:** Scan to join the Orca community WeChat group 7. +- **WeChat:** Scan to join the Orca community WeChat group 7. If it is full, use group 8. - WeChat group 7 QR code for the Orca community + WeChat group 7 QR code for the Orca community   + WeChat group 8 QR code for the Orca community - **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues). - **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out. diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 671989d23e4..efd27c8ca95 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1558,6 +1558,124 @@ ], "demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if index-only churn can emit worktrees:changed, or if structural add/remove/HEAD/lock changes fail to converge." }, + { + "id": "runtime.multi-client-view-isolation", + "title": "A client's workspace create navigates only the caller unless follow is explicit", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "runtime-client-event-authority", + "surfaces": [ + "paired desktop client", + "paired web client", + "headed Orca desktop server", + "headless orca serve", + "worktree.create with activate", + "runtime.clientEvents stream" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "orca-server"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "orca-server"], + "coverageNotes": "A real two-client integration harness pairs two authenticated devices to one OrcaRuntimeService over WebSockets with E2EE and drives worktree.create through the real RPC dispatcher, in both headed-notifier and headless orca-serve shapes. The headless case is a modeled `orca serve` runtime shape — no notifier, no attached window — not a CLI bootstrap, and the in-process reveal non-regression calls createManagedWorktree directly. A headed Electron journey runs the desktop app as the remote server with two independently paired web clients in separate partitions and asserts each client's rendered active worktree. Windows and Linux are covered by the platform-neutral contract only; the reported field topology was a Windows server with mixed-OS clients.", + "motivatingLinks": ["https://github.com/stablyai/orca/issues/11027"], + "invariant": "View intent belongs to the connection that requested it. With no explicit navigation target, a workspace create arriving over a paired desktop/web client connection activates only that caller; it must not emit an activateWorktree client event to sibling connections or drive the host renderer. An explicit navigation of host/clients/all remains an allowed, tested follow intent. Shared model state — repo/worktree catalogs, terminals, agent output — must keep propagating to every client unchanged. In-process and CLI creates keep notifying the host and all clients so a headless server's only viewer still gets its reveal.", + "oracle": "notifyActivateWorktree takes its navigation target as a required parameter, so a create call site that forgets to forward it is a compile error rather than a silent fallback to broadcasting — mutation testing confirmed all six call sites (folder, git, and remote/SSH branches, startup and no-startup each) are killed by typecheck. Behaviorally: two paired clients subscribe to runtime.clientEvents. Client A creates a workspace with activate:true; a reposChanged fence emitted after the create must be the next frame each client sees, proving no activation frame was interleaved, while worktreesChanged still arrives on both streams. The host notifier's activateWorktree is asserted not called and the host's mirrored selection is unchanged. Repeated ordinary worktree.activate calls by A are the negative control. The same file asserts the in-process create still reaches host and clients, headed and headless. The Electron journey asserts the observer's data-rendered-active-worktree-id is unchanged while the created workspace still appears in its sidebar.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/multi-client-navigation-isolation.integration.test.ts src/main/runtime/rpc/methods/worktree-create-navigation.test.ts", + "pnpm run test:e2e:multi-client-navigation -- tests/e2e/multi-client-navigation-isolation.spec.ts" + ], + "testFiles": [ + "src/main/runtime/multi-client-navigation-isolation.integration.test.ts", + "src/main/runtime/rpc/methods/worktree-create-navigation.test.ts", + "tests/e2e/multi-client-navigation-isolation.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/runtime/multi-client-navigation-isolation.integration.test.ts", + "assertions": [ + "keeps a paired client workspace create-with-activate off every other client and the host", + "still reveals to every client when a paired caller asks for all-surface navigation", + "does not replay a missed create activation to a reconnecting observer", + "keeps create activation caller-scoped on a headless orca serve host", + "still reveals a host-originated create-with-activate on the host and every client", + "keeps worktree navigation local to each paired runtime client by default", + "routes explicit host and paired-client follow intent without changing the default" + ] + }, + { + "file": "src/main/runtime/rpc/methods/worktree-create-navigation.test.ts", + "assertions": [ + "resolves create activation from the paired runtime client kind", + "resolves create activation from the paired mobile client kind", + "honors an explicit follow navigation on create", + "keeps an explicit all-surface reveal from a paired caller" + ] + }, + { + "file": "tests/e2e/multi-client-navigation-isolation.spec.ts", + "assertions": [ + "keeps a paired client workspace create-with-agent off the other client and the host", + "keeps two paired browser clients and the host on independent worktrees" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-18", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/multi-client-navigation-isolation.integration.test.ts src/main/runtime/rpc/methods/worktree-create-navigation.test.ts", + "result": "passed", + "durationSeconds": 6, + "summary": "Eleven paired-runtime contracts pass, including the headless orca-serve shape, the in-process reveal non-regression, and the CLI-shaped explicit all-surface reveal. With the caller scoping removed the observer's stream carried activateWorktree instead of the fence." + }, + { + "date": "2026-08-18", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e:multi-client-navigation -- tests/e2e/multi-client-navigation-isolation.spec.ts", + "result": "passed", + "durationSeconds": 80, + "summary": "Headed Orca desktop server with two paired web clients: the observer stayed on its own worktree while the creator's workspace appeared in its sidebar. With the fix disabled the same journey failed with the observer rendering the created workspace." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "two-client WebSocket integration contracts plus one headed Electron two-web-client journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New contracts. The integration file is fence-ordered rather than timing-based and passed repeatedly locally; the Electron journey passed twice locally on macOS. CI and soak history have not started." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Both layers were observed red on origin/main 4ec6bbf588 before any fix: the observer client received ['worktreesChanged', 'activateWorktree'] instead of the fence, and the Electron observer's data-rendered-active-worktree-id resolved to the workspace the other client had just created. Both went green with the caller scoping and red again when only resolveRuntimeNavigationTarget's clientKind input was removed." + }, + "performanceBudget": { + "required": false, + "evidence": "The change adds one enum resolution per create request and two boolean checks before an existing fan-out. No new listeners, subscriptions, provider calls, or timers; the client-event fan-out itself is unchanged in shape and frequency." + }, + "promotionCriteria": [ + "Collect CI soak history for the Electron journey on macOS plus at least one Linux run.", + "Add a live Windows-server topology run matching the reported field setup.", + "Extend the oracle to mobile observers and to a client with two windows open on one connection." + ], + "knownGaps": [ + "No live Windows or Linux server run yet; the reported field topology was a Windows 11 server.", + "Mobile observers are covered only by the platform-neutral clientKind contract, not by a live phone journey.", + "Host-originated CLI creates deliberately still reveal on every client; that broader authority question is untouched by this gate.", + "Mobile creates deliberately keep the all-surface reveal: a phone has no renderer to provision repo setup/default tabs off the activation event.", + "The Electron journey drives the store create action, so the composer's automatic self-navigation on create is proved by worktree-creation-flow.test.ts and worktree.spec.ts rather than by this journey.", + "Headless coverage is a modeled runtime shape; CLI bootstrap and argument parsing for orca serve are not exercised here.", + "Host-first rollout: the fix is enforced by the host, so a NEW client paired to an OLD always-on server still sees the original cross-client jump. Shipping clients alone fixes nothing for anyone; the runtime/server must be updated. Nothing breaks under that skew — the old host simply keeps its old behavior — but release notes and support triage must check the host version, not the client version. No skew combination is worse than pre-fix main: an old CLI is recognized by its cliProvenanceRequest marker so its --activate reveal survives an updated host.", + "A future contributor could still hardcode a wrong navigation target at a call site: the required object key makes omission a compile error, but not substitution. Only the folder/no-startup branch has a behavioral test that would catch it; adding one for the remote/SSH branch needs new SSH provider scaffolding and was judged not worth the surface.", + "Out of scope, host-only and unreachable from the product UI: a paired caller that explicitly requests terminal.create with presentation 'focused' still navigates the host renderer (the Orca client always sends 'background'); agent-session already downgrades that presentation for paired callers.", + "The remote/SSH create branch (createManagedRemoteWorktree) has compile-time propagation coverage but no behavioral navigation test; the folder branch is covered by the integration suite and the git branch by the Electron journey.", + "Out of scope: browser.tabCreate/tabSwitch carry no paired-client navigation policy, so they can change the host's active browser tab when the host is already on that worktree. They never cross worktrees, and the paired web client sends activate:true deliberately." + ], + "demotionRule": "Demote if a create, activate, or reveal path can move a viewer that did not request it, or if scoping regresses the in-process/CLI reveal that a headless server's only remote viewer depends on." + }, { "id": "runtime.headless-desktop-promotion-continuity", "title": "Headless serve opens its desktop without replacing live terminal sessions", diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index b0f3c5c3062..507b5396650 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -129,6 +129,29 @@ describe('PR workflow parallelism', () => { ) }) + it('bounds the shell lane so a stalled apt mirror cannot hold the run open', () => { + const job = workflow.jobs.shell_contracts + // A passing run of this job takes ~4.5 minutes, almost all of it package download. + // Without a job bound a stalled mirror runs to GitHub's 6h default, and because this + // is a required check it holds the whole run open and blocks `gh run rerun --failed`. + expect(job['timeout-minutes']).toBeGreaterThan(0) + expect(job['timeout-minutes']).toBeLessThanOrEqual(30) + + const installStep = job.steps.find((step) => step.name === 'Install zsh and fish') + // apt applies no wall-clock bound to a stalled mirror on its own. These turn an + // unbounded hang into a bounded, retried, legible failure. + expect(installStep.run).toMatch(/Acquire::http::Timeout/) + expect(installStep.run).toMatch(/Acquire::https::Timeout/) + expect(installStep.run).toMatch(/Acquire::Retries/) + // Retries multiply: a first attempt at 30s x 3 retries across every index file turned + // a dead mirror into a ~15 minute stall. One attempt, then move on. + expect(installStep.run).toMatch(/Acquire::Retries "1"/) + // Acquire timeouts are per-connection, so they cannot bound the command as a whole. + // Only a wall-clock bound can, and both apt invocations need one. + expect(installStep.run).toMatch(/timeout \d+ sudo apt-get update/) + expect(installStep.run).toMatch(/timeout \d+ sudo apt-get install/) + }) + it('keeps every real-zsh test in the dedicated shell lane', () => { const discoveredFiles = globSync(testFilePatterns) .filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8'))) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 059a9c9ee6b..d96d45ac835 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 22m + + downloads: 23m @@ -15,7 +15,7 @@ downloads downloads - 22m - 22m + 23m + 23m diff --git a/docs/assets/wechat-qr-group8.jpg b/docs/assets/wechat-qr-group8.jpg new file mode 100644 index 00000000000..540b751820a Binary files /dev/null and b/docs/assets/wechat-qr-group8.jpg differ diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 93b6639db32..020261e26da 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -235,9 +235,10 @@ yay -S stably-orca-bin - **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。 - **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。 -- **微信:** 扫码加入 Orca 社区微信第 7 群。 +- **微信:** 扫码加入 Orca 社区微信第 7 群。如果第 7 群已满,请使用第 8 群。 - Orca 社区微信第 7 群二维码 + Orca 社区微信第 7 群二维码   + Orca 社区微信第 8 群二维码 - **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。 - **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。 diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 779ab492d96..3400fd3694b 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -30,6 +30,13 @@ import { } from '../../../src/transport/client-context' import { useWorktreeResync } from '../../../src/transport/use-worktree-resync' import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh' +import { + applyWorktreeRowDisplayState, + clearConfirmedActiveWorktreeIdentity, + getWorktreeRowIdentity, + removeWorktreeRow, + retainLiveSleptWorktreeIdentities +} from '../../../src/worktree/worktree-host-row-identity' import { useLastConnectedAt, useRelayRecoveryStatus, @@ -158,7 +165,9 @@ export function HostScreen({ // path renders as a failure instead of an empty host. Cleared on the next success. const [catalogError, setCatalogError] = useState(null) // Why: track the locally-opened worktree so the active-row highlight moves instantly instead of waiting for the next poll. - const [optimisticActiveWorktreeId, setOptimisticActiveWorktreeId] = useState(null) + const [optimisticActiveWorktreeIdentity, setOptimisticActiveWorktreeIdentity] = useState< + string | null + >(null) // One tick drives every visible agent row's relative timestamp. const now = useNow(30_000) const [repoColorsByName, setRepoColorsByName] = useState>(new Map()) @@ -472,26 +481,12 @@ export function HostScreen({ setCachedWorktrees(hostId, confirmed, { proven: true }) } // Drop the optimistic active override once the host reports it active, so later desktop changes win. - setOptimisticActiveWorktreeId((pending) => - pending && confirmed.some((w) => w.worktreeId === pending && w.isActive) - ? null - : pending + setOptimisticActiveWorktreeIdentity((pending) => + clearConfirmedActiveWorktreeIdentity(pending, confirmed) ) // Clear optimistic sleep overrides once the server confirms inactive (liveTerminalCount === 0). - setSleptIds((prev) => { - if (prev.size === 0) { - return prev - } - const still = new Set() - for (const id of prev) { - const wt = confirmed.find((w) => w.worktreeId === id) - if (wt && wt.liveTerminalCount > 0) { - still.add(id) - } - } - return still.size === prev.size ? prev : still - }) + setSleptIds((prev) => retainLiveSleptWorktreeIdentities(prev, confirmed)) // Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage. const serverPinned = new Set(confirmed.filter((w) => w.isPinned).map((w) => w.worktreeId)) @@ -610,8 +605,7 @@ export function HostScreen({ return } - const removeFromList = (list: Worktree[]) => - list.filter((w) => w.worktreeId !== item.worktreeId) + const removeFromList = (list: Worktree[]) => removeWorktreeRow(list, item) setWorktrees(removeFromList) setLastKnownWorktrees(removeFromList) @@ -667,7 +661,7 @@ export function HostScreen({ const openWorktreeSession = useCallback( (item: Worktree) => { - setOptimisticActiveWorktreeId(item.worktreeId) + setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item)) if (client && connState === 'connected') { void client .sendRequest('worktree.activate', { @@ -746,21 +740,8 @@ export function HostScreen({ // Why: live `worktrees` is authoritative only while connected; under the amber // mount default, connecting/handshaking must keep the pre-reconnect list too. const base = connState === 'connected' ? worktrees : lastKnownWorktrees - if (sleptIds.size === 0 && optimisticActiveWorktreeId === null) { - return base - } - return base.map((w) => { - const slept = sleptIds.has(w.worktreeId) - ? { liveTerminalCount: 0, hasAttachedPty: false, status: 'inactive' as const } - : null - // Force the just-opened worktree active until the next poll confirms it, so the highlight doesn't lag. - const active = - optimisticActiveWorktreeId !== null - ? { isActive: w.worktreeId === optimisticActiveWorktreeId } - : null - return slept || active ? { ...w, ...slept, ...active } : w - }) - }, [connState, worktrees, lastKnownWorktrees, sleptIds, optimisticActiveWorktreeId]) + return applyWorktreeRowDisplayState(base, sleptIds, optimisticActiveWorktreeIdentity) + }, [connState, worktrees, lastKnownWorktrees, sleptIds, optimisticActiveWorktreeIdentity]) const toggleCollapsed = useCallback( (key: string) => { @@ -773,7 +754,7 @@ export function HostScreen({ [persistViewSettings] ) const toggleWorktreeLineage = useCallback( - (item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item.worktreeId)), + (item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item)), [toggleCollapsed] ) const { sections, rawSections, uniqueRepos, uniqueRepoColors } = useWorkspaceSections({ @@ -1146,7 +1127,7 @@ export function HostScreen({ w.sectionListKey ?? w.worktreeId} + keyExtractor={(w) => w.sectionListKey ?? getWorktreeRowIdentity(w)} stickySectionHeadersEnabled={false} // Why: keep the search IME up while tapping clear / scrolling results. keyboardShouldPersistTaps="handled" @@ -1355,7 +1336,9 @@ export function HostScreen({ icon: Moon, onPress: () => { if (client) { - setSleptIds((prev) => new Set(prev).add(actionTarget.worktreeId)) + setSleptIds((prev) => + new Set(prev).add(getWorktreeRowIdentity(actionTarget)) + ) void client .sendRequest('worktree.sleep', { worktree: `id:${actionTarget.worktreeId}` diff --git a/mobile/src/worktree/mobile-workspace-lineage.test.ts b/mobile/src/worktree/mobile-workspace-lineage.test.ts index f844593e371..9455b3a550f 100644 --- a/mobile/src/worktree/mobile-workspace-lineage.test.ts +++ b/mobile/src/worktree/mobile-workspace-lineage.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { applyMobileWorkspaceLineage } from './mobile-workspace-lineage' +import { + applyMobileWorkspaceLineage, + getMobileWorkspaceLineageGroupKey +} from './mobile-workspace-lineage' import type { Worktree } from './workspace-list-sections' function worktree(overrides: Partial = {}): Worktree { @@ -61,7 +64,10 @@ describe('applyMobileWorkspaceLineage', () => { const parent = worktree({ worktreeId: 'parent' }) const child = worktree({ worktreeId: 'child', parentWorktreeId: 'parent' }) - const rows = applyMobileWorkspaceLineage([child, parent], new Set(['workspace-lineage:parent'])) + const rows = applyMobileWorkspaceLineage( + [child, parent], + new Set([getMobileWorkspaceLineageGroupKey(parent)]) + ) expect(rows.map((row) => row.worktreeId)).toEqual(['parent']) expect(rows[0]?.lineageChildCount).toBe(1) @@ -101,4 +107,13 @@ describe('applyMobileWorkspaceLineage', () => { expect(rows.map((row) => row.worktreeId).sort()).toEqual(['first', 'second']) }) + + it('keeps same-id rows on different hosts visible', () => { + const local = worktree({ worktreeId: 'shared', hostId: 'local' }) + const remote = worktree({ worktreeId: 'shared', hostId: 'ssh:builder' }) + + const rows = applyMobileWorkspaceLineage([local, remote]) + + expect(rows.map((row) => row.hostId)).toEqual(['local', 'ssh:builder']) + }) }) diff --git a/mobile/src/worktree/mobile-workspace-lineage.ts b/mobile/src/worktree/mobile-workspace-lineage.ts index bdf1ae2d8a1..9ffe1d731c6 100644 --- a/mobile/src/worktree/mobile-workspace-lineage.ts +++ b/mobile/src/worktree/mobile-workspace-lineage.ts @@ -1,7 +1,10 @@ import type { Worktree } from './workspace-list-types' +import { getWorktreeRowIdentity } from './worktree-host-row-identity' -export function getMobileWorkspaceLineageGroupKey(worktreeId: string): string { - return `workspace-lineage:${encodeURIComponent(worktreeId)}` +type WorktreeLineageIdentitySource = Pick + +export function getMobileWorkspaceLineageGroupKey(worktree: WorktreeLineageIdentitySource): string { + return `workspace-lineage:${encodeURIComponent(getWorktreeRowIdentity(worktree))}` } function hasValidLineageParent(worktree: Worktree, parent: Worktree): boolean { @@ -23,48 +26,55 @@ export function applyMobileWorkspaceLineage( worktrees: readonly Worktree[], collapsedGroups: ReadonlySet = new Set() ): Worktree[] { - const visibleIds = new Set(worktrees.map((worktree) => worktree.worktreeId)) - const worktreeById = new Map(worktrees.map((worktree) => [worktree.worktreeId, worktree])) + const visibleIds = new Set(worktrees.map((worktree) => getWorktreeRowIdentity(worktree))) + const worktreeById = new Map( + worktrees.map((worktree) => [getWorktreeRowIdentity(worktree), worktree]) + ) const childrenByParentId = new Map() const childIds = new Set() for (const worktree of worktrees) { + const worktreeId = getWorktreeRowIdentity(worktree) const parentId = worktree.parentWorktreeId - const parent = parentId ? worktreeById.get(parentId) : undefined + const parentIdentity = parentId + ? getWorktreeRowIdentity({ worktreeId: parentId, hostId: worktree.hostId }) + : null + const parent = parentIdentity ? worktreeById.get(parentIdentity) : undefined if ( - !parentId || - parentId === worktree.worktreeId || - !visibleIds.has(parentId) || + !parentIdentity || + parentIdentity === worktreeId || + !visibleIds.has(parentIdentity) || !parent || !hasValidLineageParent(worktree, parent) ) { continue } - childIds.add(worktree.worktreeId) - const children = childrenByParentId.get(parentId) ?? [] + childIds.add(worktreeId) + const children = childrenByParentId.get(parentIdentity) ?? [] children.push(worktree) - childrenByParentId.set(parentId, children) + childrenByParentId.set(parentIdentity, children) } const result: Worktree[] = [] const emitted = new Set() const markDescendantsEmitted = (worktree: Worktree): void => { - for (const child of childrenByParentId.get(worktree.worktreeId) ?? []) { - if (!emitted.has(child.worktreeId)) { - emitted.add(child.worktreeId) + for (const child of childrenByParentId.get(getWorktreeRowIdentity(worktree)) ?? []) { + const childId = getWorktreeRowIdentity(child) + if (!emitted.has(childId)) { + emitted.add(childId) markDescendantsEmitted(child) } } } const emit = (worktree: Worktree, depth: number, isLastChild: boolean): void => { - if (emitted.has(worktree.worktreeId)) { + const worktreeId = getWorktreeRowIdentity(worktree) + if (emitted.has(worktreeId)) { return } - const children = childrenByParentId.get(worktree.worktreeId) ?? [] + const children = childrenByParentId.get(worktreeId) ?? [] const lineageCollapsed = - children.length > 0 && - collapsedGroups.has(getMobileWorkspaceLineageGroupKey(worktree.worktreeId)) - emitted.add(worktree.worktreeId) + children.length > 0 && collapsedGroups.has(getMobileWorkspaceLineageGroupKey(worktree)) + emitted.add(worktreeId) result.push({ ...worktree, lineageDepth: depth, @@ -81,13 +91,13 @@ export function applyMobileWorkspaceLineage( }) } - const roots = worktrees.filter((worktree) => !childIds.has(worktree.worktreeId)) + const roots = worktrees.filter((worktree) => !childIds.has(getWorktreeRowIdentity(worktree))) roots.forEach((worktree, index) => { emit(worktree, 0, index === roots.length - 1) }) for (const worktree of worktrees) { - if (!emitted.has(worktree.worktreeId)) { + if (!emitted.has(getWorktreeRowIdentity(worktree))) { // Why: malformed cyclic lineage should not hide every participant. emit(worktree, 0, true) } diff --git a/mobile/src/worktree/workspace-list-sections.test.ts b/mobile/src/worktree/workspace-list-sections.test.ts index d1fc267c09f..48227fff78f 100644 --- a/mobile/src/worktree/workspace-list-sections.test.ts +++ b/mobile/src/worktree/workspace-list-sections.test.ts @@ -9,6 +9,7 @@ import { sortWorktrees } from './workspace-list-sections' import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses' +import { getMobileWorkspaceLineageGroupKey } from './mobile-workspace-lineage' function worktree(overrides: Partial = {}): Worktree { const worktreePath = join('/tmp', 'orca', 'worktrees', 'feature') @@ -240,6 +241,24 @@ describe('getWorktreeStatus', () => { }) describe('buildSections', () => { + it('keys same-id rows by host inside a section', () => { + const worktreeId = 'repo-1::/work/orca' + const sections = buildSections( + [worktree({ worktreeId, hostId: 'local' }), worktree({ worktreeId, hostId: 'ssh:builder' })], + 'name', + { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, + '', + 'none', + new Set() + ) + const keys = sections[0]?.data.map((item) => item.sectionListKey) ?? [] + + expect(new Set(keys).size).toBe(2) + expect(keys).toEqual( + expect.arrayContaining([`all:local|${worktreeId}`, `all:ssh:builder|${worktreeId}`]) + ) + }) + it('matches desktop Name sort by display name', () => { const beta = worktree({ worktreeId: 'beta', displayName: 'Beta', repo: 'aaa' }) const alpha = worktree({ worktreeId: 'alpha', displayName: 'Alpha', repo: 'zzz' }) @@ -776,7 +795,7 @@ describe('buildSections', () => { new Set(), new Map(), DEFAULT_MOBILE_WORKSPACE_STATUSES, - new Set(['workspace-lineage:parent']) + new Set([getMobileWorkspaceLineageGroupKey(parent)]) ) expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['parent']) diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index 2ff06f4fee3..16ef9ae9f5d 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -10,6 +10,7 @@ import { getPRGroupKey, PR_GROUP_LABELS, PR_GROUP_ORDER } from './workspace-pr-s import type { FilterState, Section, Worktree } from './workspace-list-types' import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings' import { sortWorktrees } from './workspace-list-ordering' +import { getWorktreeRowIdentity } from './worktree-host-row-identity' export type { FilterState, Section, Worktree } from './workspace-list-types' export { CREATE_GRACE_MS, getWorktreeStatus, sortWorktrees } from './workspace-list-ordering' @@ -28,7 +29,7 @@ function makeSection( ...(icon ? { icon } : {}), data: rows.map((worktree) => ({ ...worktree, - sectionListKey: `${key}:${worktree.worktreeId}` + sectionListKey: `${key}:${getWorktreeRowIdentity(worktree)}` })) } } diff --git a/mobile/src/worktree/worktree-host-row-identity.test.ts b/mobile/src/worktree/worktree-host-row-identity.test.ts new file mode 100644 index 00000000000..549f83232b0 --- /dev/null +++ b/mobile/src/worktree/worktree-host-row-identity.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { + applyWorktreeRowDisplayState, + clearConfirmedActiveWorktreeIdentity, + getWorktreeRowIdentity, + removeWorktreeRow, + retainLiveSleptWorktreeIdentities +} from './worktree-host-row-identity' +import type { Worktree } from './workspace-list-types' + +function row(worktreeId: string, hostId: string, overrides: Partial = {}): Worktree { + return { worktreeId, hostId, displayName: worktreeId, ...overrides } as Worktree +} + +describe('removeWorktreeRow', () => { + // Deleting on one host used to clear the other host's identically-named row from the list. + it('keeps a same-id workspace that lives on another host', () => { + const local = row('shared', 'host-a') + const remote = row('shared', 'host-b') + + expect(removeWorktreeRow([local, remote], local)).toEqual([remote]) + }) + + it('removes the matching row', () => { + const only = row('shared', 'host-a') + + expect(removeWorktreeRow([only], only)).toEqual([]) + }) +}) + +describe('getWorktreeRowIdentity', () => { + it('uses the shared host-qualified identity format', () => { + expect(getWorktreeRowIdentity(row('shared', 'ssh:builder'))).toBe('ssh:builder|shared') + }) +}) + +describe('host-qualified row display state', () => { + it('clears the optimistic active row only when the same host confirms it', () => { + const pending = getWorktreeRowIdentity(row('shared', 'host-a')) + + expect( + clearConfirmedActiveWorktreeIdentity(pending, [row('shared', 'host-b', { isActive: true })]) + ).toBe(pending) + expect( + clearConfirmedActiveWorktreeIdentity(pending, [row('shared', 'host-a', { isActive: true })]) + ).toBeNull() + }) + + it('retains slept rows only while the same host still has live terminals', () => { + const local = row('shared', 'host-a') + const remote = row('shared', 'host-b') + const retained = retainLiveSleptWorktreeIdentities( + new Set([getWorktreeRowIdentity(local), getWorktreeRowIdentity(remote)]), + [ + row('shared', 'host-a', { liveTerminalCount: 0 }), + row('shared', 'host-b', { liveTerminalCount: 1 }) + ] + ) + + expect([...retained]).toEqual([getWorktreeRowIdentity(remote)]) + }) + + it('applies active and slept overrides to the matching host row only', () => { + const local = row('shared', 'host-a') + const remote = row('shared', 'host-b') + + const rows = applyWorktreeRowDisplayState( + [local, remote], + new Set([getWorktreeRowIdentity(local)]), + getWorktreeRowIdentity(remote) + ) + + expect(rows).toMatchObject([ + { hostId: 'host-a', liveTerminalCount: 0, status: 'inactive', isActive: false }, + { hostId: 'host-b', isActive: true } + ]) + }) +}) diff --git a/mobile/src/worktree/worktree-host-row-identity.ts b/mobile/src/worktree/worktree-host-row-identity.ts new file mode 100644 index 00000000000..3c861dc06ce --- /dev/null +++ b/mobile/src/worktree/worktree-host-row-identity.ts @@ -0,0 +1,69 @@ +import { composeWorktreeHostIdentity } from '../../../src/shared/worktree/host-qualified-identity' +import type { Worktree } from './workspace-list-types' + +type WorktreeRowIdentitySource = Pick + +export function getWorktreeRowIdentity(row: WorktreeRowIdentitySource): string { + return composeWorktreeHostIdentity(row.hostId, row.worktreeId) +} + +export function isSameWorktreeRow( + a: WorktreeRowIdentitySource, + b: WorktreeRowIdentitySource +): boolean { + return getWorktreeRowIdentity(a) === getWorktreeRowIdentity(b) +} + +/** Drops only the removed row, leaving a same-id workspace on another host visible. */ +export function removeWorktreeRow( + list: readonly Worktree[], + removed: WorktreeRowIdentitySource +): Worktree[] { + return list.filter((entry) => !isSameWorktreeRow(entry, removed)) +} + +export function clearConfirmedActiveWorktreeIdentity( + pending: string | null, + confirmed: readonly Worktree[] +): string | null { + return pending && confirmed.some((w) => getWorktreeRowIdentity(w) === pending && w.isActive) + ? null + : pending +} + +export function retainLiveSleptWorktreeIdentities( + previous: Set, + confirmed: readonly Worktree[] +): Set { + if (previous.size === 0) { + return previous + } + const still = new Set() + for (const id of previous) { + const wt = confirmed.find((w) => getWorktreeRowIdentity(w) === id) + if (wt && wt.liveTerminalCount > 0) { + still.add(id) + } + } + return still.size === previous.size ? previous : still +} + +export function applyWorktreeRowDisplayState( + base: Worktree[], + sleptIds: ReadonlySet, + optimisticActiveIdentity: string | null +): Worktree[] { + if (sleptIds.size === 0 && optimisticActiveIdentity === null) { + return base + } + return base.map((w) => { + const slept = sleptIds.has(getWorktreeRowIdentity(w)) + ? { liveTerminalCount: 0, hasAttachedPty: false, status: 'inactive' as const } + : null + const active = + optimisticActiveIdentity !== null + ? { isActive: getWorktreeRowIdentity(w) === optimisticActiveIdentity } + : null + return slept || active ? { ...w, ...slept, ...active } : w + }) +} diff --git a/src/cli/args.ts b/src/cli/args.ts index 2f6bcdaeb0f..68ae1cae913 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -37,6 +37,7 @@ export const BOOLEAN_FLAGS = new Set([ 'mobile', 'mobile-pairing', 'no-pairing', + 'screen', 'parent-current', 'provision', 'ready', diff --git a/src/cli/format.ts b/src/cli/format.ts index c76f13bd944..ebe30e68305 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -177,6 +177,9 @@ function localCliErrorData(error: unknown, context: CliErrorContext): unknown { export function formatCliStatus(status: CliStatusResult): string { return [ + ...(status.target && status.target.kind === 'environment' + ? [`target: environment ${status.target.environment}`] + : []), `appRunning: ${status.app.running}`, `pid: ${status.app.pid ?? 'none'}`, `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, diff --git a/src/cli/handlers/project.ts b/src/cli/handlers/project.ts index 6ee8266b97c..83c13a93448 100644 --- a/src/cli/handlers/project.ts +++ b/src/cli/handlers/project.ts @@ -12,7 +12,7 @@ import type { } from '../../shared/project-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { RepoKind } from '../../shared/repo-types' -import type { CommandHandler } from '../dispatch' +import type { CommandHandler, HandlerContext } from '../dispatch' import { formatProjectHostSetupCreateResult, formatProjectHostSetupDeleteResult, @@ -25,7 +25,33 @@ import { import { hostFilterMatchesHostId, parseHostFlag } from '../execution-host-flag' import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' import { resolveRepoPathArgument } from '../repo-path-arguments' -import { RuntimeClientError } from '../runtime-client' +import { RuntimeClientError, type RuntimeRpcSuccess } from '../runtime-client' + +// Why: an Orca server that predates project host setup answers `method_not_found`, which reads +// as an Orca bug rather than a version gap — and since --host runtime: now routes these +// commands to that server, a client can reach an older host without meaning to. The desktop +// already names this case; match it instead of surfacing the raw dispatcher error. +async function callProjectHostSetup( + client: HandlerContext['client'], + method: string, + params?: unknown +): Promise> { + try { + // Why: forward the exact arity the caller used; passing an explicit undefined would change + // the request shape for the no-params methods. + return params === undefined + ? await client.call(method) + : await client.call(method, params) + } catch (error) { + if (error instanceof RuntimeClientError && error.code === 'method_not_found') { + throw new RuntimeClientError( + 'incompatible_runtime', + 'This Orca server does not support project host setup yet. Update Orca on the server and try again.' + ) + } + throw error + } +} function getRequiredHostId(flags: Map): ExecutionHostId { const host = parseHostFlag(flags) @@ -54,7 +80,10 @@ export const PROJECT_HANDLERS: Record = { 'project setups': async ({ flags, client, json }) => { const projectFilter = getOptionalStringFlag(flags, 'project') const hostFilter = parseHostFlag(flags) - const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list') + const result = await callProjectHostSetup<{ setups: ProjectHostSetup[] }>( + client, + 'projectHostSetup.list' + ) const setups = result.result.setups.filter( (setup) => (projectFilter === undefined || setup.projectId === projectFilter) && @@ -71,7 +100,8 @@ export const PROJECT_HANDLERS: Record = { kind: getOptionalRepoKind(flags), displayName: getOptionalStringFlag(flags, 'display-name') } - const result = await client.call<{ result: ProjectHostSetupResult }>( + const result = await callProjectHostSetup<{ result: ProjectHostSetupResult }>( + client, 'projectHostSetup.setupExistingFolder', args ) @@ -91,7 +121,8 @@ export const PROJECT_HANDLERS: Record = { ), displayName: getOptionalStringFlag(flags, 'display-name') } - const result = await client.call<{ result: ProjectHostSetupResult }>( + const result = await callProjectHostSetup<{ result: ProjectHostSetupResult }>( + client, 'projectHostSetup.clone', args ) @@ -114,7 +145,8 @@ export const PROJECT_HANDLERS: Record = { setupState: getOptionalSetupState(flags), setupMethod: getOptionalIndependentSetupMethod(flags) } - const result = await client.call<{ result: ProjectHostSetupCreateResult }>( + const result = await callProjectHostSetup<{ result: ProjectHostSetupCreateResult }>( + client, 'projectHostSetup.create', args ) @@ -137,14 +169,16 @@ export const PROJECT_HANDLERS: Record = { setupMethod: getOptionalSetupMethod(flags) } } - const result = await client.call<{ result: ProjectHostSetupUpdateResult }>( + const result = await callProjectHostSetup<{ result: ProjectHostSetupUpdateResult }>( + client, 'projectHostSetup.update', args ) printResult(result, json, formatProjectHostSetupUpdateResult) }, 'project setup-delete': async ({ flags, client, json }) => { - const result = await client.call<{ result: ProjectHostSetupDeleteResult }>( + const result = await callProjectHostSetup<{ result: ProjectHostSetupDeleteResult }>( + client, 'projectHostSetup.delete', { setupId: getRequiredStringFlag(flags, 'setup') diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index 04ac703cd53..00c0a5cd807 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -76,11 +76,30 @@ export const TERMINAL_HANDLERS: Record = { if (cursorFlag !== undefined && cursor === undefined) { throw new RuntimeClientError('invalid_argument', '--cursor must be a non-negative integer') } + const screen = flags.get('screen') === true + // Why: a cursor pages through accumulated output. A screen read is the current frame and has + // nothing behind it to page, so accepting both would imply history that is not there. + if (screen && cursorFlag !== undefined) { + throw new RuntimeClientError( + 'invalid_argument', + '--screen reads the current rendered screen, which has no cursor to page from. Use --cursor without --screen to page through accumulated output.' + ) + } const result = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { terminal: await getTerminalHandle(flags, cwd, client), ...(cursor !== undefined ? { cursor } : {}), + ...(screen ? { screen: true } : {}), limit: getOptionalPositiveIntegerFlag(flags, 'limit') }) + // Why: an older host drops the unknown `screen` param and answers with its ordinary stream + // read, which carries no source. Returning that silently is the exact failure this flag + // exists to prevent, so refuse rather than hand back the other question's answer. + if (screen && result.result.terminal.source === undefined) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'This Orca host does not support --screen reads, so it answered with accumulated output instead of the rendered screen. Update Orca on the host, or drop --screen to read accumulated output deliberately.' + ) + } printResult(result, json, formatTerminalRead) }, 'terminal send': async ({ flags, client, cwd, json }) => { diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index d3ba0328840..3b951672a71 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -229,6 +229,7 @@ export const WORKTREE_HANDLERS: Record = { } } const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue') + const activate = flags.get('activate') === true || flags.get('run-hooks') === true const result = await client.call('worktree.create', { repo: await getCreateRepoSelector(flags, cwdParentWorktree, client), name: getRequiredStringFlag(flags, 'name'), @@ -237,7 +238,10 @@ export const WORKTREE_HANDLERS: Record = { ...linearIssueLink, comment: getOptionalStringFlag(flags, 'comment'), runHooks: flags.get('run-hooks') === true, - activate: flags.get('activate') === true || flags.get('run-hooks') === true, + activate, + // Why: the CLI pairs as a runtime device but is not a viewer, so caller-scoped + // delivery would make --activate a no-op against a remote runtime. + ...(activate ? { navigation: 'all' as const } : {}), ...(setupDecision ? { setupDecision } : {}), parentWorktree: explicitParentWorktree, ...(explicitParentWorkspace ? { parentWorkspace: explicitParentWorkspace } : {}), diff --git a/src/cli/index-project-setup.test.ts b/src/cli/index-project-setup.test.ts index 7e9b5a72f56..f5efead6a80 100644 --- a/src/cli/index-project-setup.test.ts +++ b/src/cli/index-project-setup.test.ts @@ -191,6 +191,28 @@ describe('orca cli worktree awareness', () => { expect(logSpy.mock.calls[0]?.[0]).not.toContain('setup-by-client') }) + // Why: --host runtime: routes to a paired server, so an older one is reachable without the + // caller meaning to. A raw method_not_found reads as an Orca bug rather than a version gap. + it('names the version gap when the server predates project host setup', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'old-server') + const { RuntimeClientError } = await import('./runtime/types.js') + callMock.mockRejectedValueOnce( + new RuntimeClientError('method_not_found', 'Unknown method: projectHostSetup.list') + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['project', 'setups', '--host', 'runtime:old-server', '--json'], '/tmp/repo') + + const printed = [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n') + expect(printed).toContain('does not support project host setup yet') + expect(printed).not.toContain('Unknown method') + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + it('rejects a runtime host id that no paired server owns instead of answering empty', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -408,6 +430,71 @@ describe('orca cli worktree awareness', () => { } ) + // Why: STA-4792 defect 2. `--host runtime:` used to leave the client local, so a Windows + // destination fell into resolve(cwd, ...) and became a literal directory next to the caller. + // Routing makes the client remote, which is what sends the path through untouched. + it('sends a windows destination to the routed host instead of joining it to the local cwd', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'awin') + queueFixtures( + callMock, + okFixture('req_project_setup_clone', { + result: { + project: { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#7c3aed', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1 + }, + setup: { + id: 'setup-awin', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: 'repo-awin', + path: 'C:\\orca-probe\\orca', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'cloned', + createdAt: 1, + updatedAt: 1 + }, + repo: { + id: 'repo-awin', + path: 'C:\\orca-probe\\orca', + displayName: 'Orca', + badgeColor: '#7c3aed', + addedAt: 1 + } + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'project', + 'setup-clone', + '--project', + 'github:stablyai/orca', + '--host', + 'runtime:awin', + '--url', + 'https://github.com/stablyai/orca.git', + '--destination', + 'C:\\orca-probe', + '--json' + ], + '/Users/nwparker/orca/workspaces/orca/IME-koko' + ) + + expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, 'awin') + expect(callMock).toHaveBeenCalledWith( + 'projectHostSetup.clone', + expect.objectContaining({ destination: 'C:\\orca-probe' }) + ) + }) + it('updates project host setup metadata through the project-first runtime API', async () => { queueFixtures( callMock, diff --git a/src/cli/index-worktree-create-agent.test.ts b/src/cli/index-worktree-create-agent.test.ts index c2829e48817..025275c28ce 100644 --- a/src/cli/index-worktree-create-agent.test.ts +++ b/src/cli/index-worktree-create-agent.test.ts @@ -77,6 +77,9 @@ describe('orca cli worktree awareness', () => { comment: undefined, runHooks: true, activate: true, + // Why: the CLI pairs as a runtime device but has no viewer, so --activate must + // stay an explicit all-surface reveal rather than caller-scoped navigation. + navigation: 'all', parentWorktree: undefined, cwdParentWorktree: 'id:repo-1::/tmp/repo', noParent: false, @@ -171,6 +174,7 @@ describe('orca cli worktree awareness', () => { comment: undefined, runHooks: false, activate: true, + navigation: 'all', parentWorktree: undefined, cwdParentWorktree: 'id:repo-1::/tmp/repo', noParent: false, diff --git a/src/cli/index-worktree-create-target.test.ts b/src/cli/index-worktree-create-target.test.ts index 92ed80fec7e..840aea90f1e 100644 --- a/src/cli/index-worktree-create-target.test.ts +++ b/src/cli/index-worktree-create-target.test.ts @@ -77,6 +77,7 @@ describe('orca cli worktree awareness', () => { comment: undefined, runHooks: false, activate: true, + navigation: 'all', parentWorktree: undefined, cwdParentWorktree: 'id:repo-1::/tmp/repo', noParent: false, diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 06c1af65e4f..2fa6130ce3d 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -216,6 +216,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { const status = await client.getCliStatus() expect(status.result).toEqual({ + target: { kind: 'local' }, app: { running: false, pid: null diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 3a8951de554..e97c8e28c9e 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -8,7 +8,7 @@ import { import { parsePairingCode, type PairingOffer } from '../../shared/pairing' import { launchOrcaApp } from './launch' import { getDefaultUserDataPath, readMetadata } from './metadata' -import { getCliStatus, resolveDesktopWindowStatus } from './status' +import { getCliStatus, projectRemoteAppStatus } from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments' @@ -159,18 +159,11 @@ export class RuntimeClient { id: response.id, ok: true, result: { - // Why: remote status proves the paired runtime is reachable, not - // that this client machine has a local Orca desktop process. - app: { - running: false, - pid: null, - // Why: reuse the shared resolver so remote status honors the same - // authoritativeWindowId fallback as local status for old runtimes. - ...(() => { - const desktopWindowStatus = resolveDesktopWindowStatus(response.result) - return desktopWindowStatus ? { desktopWindowStatus } : {} - })() + target: { + kind: 'environment', + environment: this.environmentSelector ?? 'pairing-code' }, + app: projectRemoteAppStatus(response.result), runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, diff --git a/src/cli/runtime/status.test.ts b/src/cli/runtime/status.test.ts index 7d074dd8050..68dbbf0e962 100644 --- a/src/cli/runtime/status.test.ts +++ b/src/cli/runtime/status.test.ts @@ -4,7 +4,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { getRuntimeMetadataPath } from '../../shared/runtime-bootstrap' +import type { RuntimeStatus } from '../../shared/runtime-types' import { RuntimeClient } from './client' +import { projectRemoteAppStatus } from './status' const servers = new Set>() const sockets = new Set() @@ -74,3 +76,61 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { }) }) }) + +describe('projectRemoteAppStatus', () => { + function remoteStatus(overrides: Partial = {}): RuntimeStatus { + return { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + authoritativeWindowId: null, + ...overrides + } as RuntimeStatus + } + + // STA-4792 defect 4: the reported output was running:false alongside desktopWindowStatus + // 'available', while the target's GUI was demonstrably up. + it('reports the target app as running when a renderer owns the graph there', () => { + expect(projectRemoteAppStatus(remoteStatus({ desktopWindowStatus: 'available' }))).toEqual({ + running: true, + pid: null, + desktopWindowStatus: 'available' + }) + }) + + it.each(['openable', 'initializing', 'blocked'] as const)( + 'does not claim a running desktop for window status %s', + (desktopWindowStatus) => { + expect(projectRemoteAppStatus(remoteStatus({ desktopWindowStatus })).running).toBe(false) + } + ) + + // A headless `serve` reports no window at all. + it('reports not running when the target has no desktop window status', () => { + expect(projectRemoteAppStatus(remoteStatus())).toEqual({ running: false, pid: null }) + }) + + // Why: old runtimes predate the explicit status but a positive window id still proves a window. + it('honors the authoritativeWindowId fallback for older runtimes', () => { + expect(projectRemoteAppStatus(remoteStatus({ authoritativeWindowId: 3 }))).toEqual({ + running: true, + pid: null, + desktopWindowStatus: 'available' + }) + }) + + // Why: the SSH host-passthrough answers for the Orca host the caller reached, and used to + // claim running:true unconditionally. Both transports now share this projection. + it('does not claim a desktop app for a headless serve on any transport', () => { + expect(projectRemoteAppStatus(remoteStatus({ desktopWindowStatus: 'openable' }))).toEqual({ + running: false, + pid: null, + desktopWindowStatus: 'openable' + }) + }) + + it('never reports a remote pid', () => { + expect( + projectRemoteAppStatus(remoteStatus({ desktopWindowStatus: 'available' })).pid + ).toBeNull() + }) +}) diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index 53bb7f8da75..299527eb4e8 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -2,8 +2,14 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' import { findTransport } from '../../shared/runtime-bootstrap' import { tryReadMetadata } from './metadata' import { sendRequest } from './transport' +import { + projectRemoteAppStatus, + resolveDesktopWindowStatus +} from '../../shared/cli-app-status-projection' import { RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' +export { projectRemoteAppStatus, resolveDesktopWindowStatus } + export async function getCliStatus( userDataPath: string ): Promise> { @@ -75,24 +81,11 @@ export async function getCliStatus( } } -export function resolveDesktopWindowStatus( - status: RuntimeStatus -): CliStatusResult['app']['desktopWindowStatus'] { - if (status.desktopWindowStatus) { - return status.desktopWindowStatus - } - // Why: older desktop runtimes predate the explicit status but a positive - // Electron id still proves that a real window owns the graph. - return status.authoritativeWindowId !== null && status.authoritativeWindowId > 0 - ? 'available' - : undefined -} - function buildCliStatusResponse(result: CliStatusResult): RuntimeRpcSuccess { return { id: 'local-status', ok: true, - result, + result: { target: { kind: 'local' }, ...result }, _meta: { runtimeId: result.runtime.runtimeId ?? 'none' } diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 714115680f6..7aa503fad36 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -197,17 +197,23 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ { path: ['terminal', 'read'], summary: 'Read bounded terminal output', - usage: 'orca terminal read [--terminal ] [--cursor ] [--limit ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'cursor', 'limit'], + usage: + 'orca terminal read [--terminal ] [--cursor ] [--limit ] [--screen] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'cursor', 'limit', 'screen'], notes: [ 'Omit --terminal to target the active terminal in the current worktree.', + 'By default this returns accumulated terminal output with escape sequences stripped, not the rendered screen. Any program that repaints a line — shells, progress bars, TUIs — comes back as stacked fragments, so one `clear` keystroke by keystroke reads as `cclclecleaclear`, and spaces a prompt draws by moving the cursor are absent.', + 'Use --screen to read what the terminal actually renders. Prefer it whenever the answer depends on how output looks rather than what was emitted over time; the default is unsuitable for verifying rendered output.', + 'The result reports source: stream or screen, so a caller can tell which question was answered. A --screen read falls back to source: stream when no rendered state exists rather than passing the stream off as a screen.', + '--screen and --cursor are mutually exclusive: a screen read is the current frame and has no history to page.', 'Use --cursor with the nextCursor value from a previous read to get only new output since that read.', 'Use --limit to request more retained lines for long agent responses; output reports oldestCursor when older lines were dropped.', 'Useful for capturing the response to a command: read before sending, then read --cursor after waiting.' ], examples: [ 'orca terminal read --json', - 'orca terminal read --terminal term_abc123 --cursor 42 --limit 1000 --json' + 'orca terminal read --terminal term_abc123 --cursor 42 --limit 1000 --json', + 'orca terminal read --terminal term_abc123 --screen --json' ] }, { diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index 97da0e890c6..806a39cb588 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -140,11 +140,19 @@ export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): s const header = [ `handle: ${terminal.handle}`, `status: ${terminal.status}`, + ...(terminal.source ? [`source: ${terminal.source}`] : []), ...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : []), ...oldestCursor, ...latestCursor, ...(terminal.truncated ? ['warning: older output is no longer retained'] : []), - ...(limitedWarning ? [limitedWarning] : []) + ...(limitedWarning ? [limitedWarning] : []), + // Why: the caller asked for the rendered screen; say plainly that this is not it rather + // than let repaint fragments be read as what the terminal displayed. + ...(terminal.source === 'screen-unavailable' + ? [ + 'warning: no rendered screen was available, so this is accumulated output; repainted lines may appear as stacked fragments' + ] + : []) ] return [...header, '', ...terminal.tail].join('\n') } diff --git a/src/cli/terminal-read-screen.test.ts b/src/cli/terminal-read-screen.test.ts new file mode 100644 index 00000000000..b3aba57c6bf --- /dev/null +++ b/src/cli/terminal-read-screen.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest' + +const { + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock +} = vi.hoisted(() => ({ + callMock: vi.fn(), + runtimeClientConstructorMock: vi.fn(), + serveOrcaAppMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), + addEnvironmentFromPairingCodeMock: vi.fn(), + listEnvironmentsMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('./runtime-client', async () => { + const { createRuntimeClientModuleMock } = await import('./index-test-harness.js') + return createRuntimeClientModuleMock({ + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock + }) +}) + +vi.mock('./runtime/environments', () => ({ + addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock, + listEnvironments: listEnvironmentsMock, + removeEnvironment: vi.fn(), + resolveEnvironment: vi.fn() +})) + +vi.mock('child_process', async () => { + const { createChildProcessModuleMock } = await import('./index-test-harness.js') + return createChildProcessModuleMock(spawnMock) +}) + +import { main } from './index' +import { okFixture, queueFixtures } from './test-fixtures' +import { useWorktreeAwarenessEnvironment } from './index-test-harness' + +function readFixture(overrides: Record = {}) { + return okFixture('req_terminal_read', { + terminal: { + handle: 'term_abc', + status: 'running', + tail: ['clear'], + truncated: false, + nextCursor: null, + ...overrides + } + }) +} + +describe('orca terminal read --screen', () => { + useWorktreeAwarenessEnvironment({ + callMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock + }) + + it('does not ask for a screen unless requested', async () => { + queueFixtures(callMock, readFixture({ source: 'stream' })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'read', '--terminal', 'term_abc', '--json'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith( + 'terminal.read', + expect.not.objectContaining({ screen: true }) + ) + }) + + it('requests the rendered screen with --screen', async () => { + queueFixtures(callMock, readFixture({ source: 'screen' })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'read', '--terminal', 'term_abc', '--screen', '--json'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith( + 'terminal.read', + expect.objectContaining({ terminal: 'term_abc', screen: true }) + ) + }) + + it('reports which question was answered in human output', async () => { + queueFixtures(callMock, readFixture({ source: 'screen' })) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'read', '--terminal', 'term_abc', '--screen'], '/tmp/repo') + + expect(String(logSpy.mock.calls[0]?.[0])).toContain('source: screen') + }) + + // Why: the whole defect is a stream being read as if it were the screen. A fallback has to + // announce itself rather than look like a successful screen read. + it('warns when a screen was asked for but only the stream was available', async () => { + queueFixtures( + callMock, + readFixture({ source: 'screen-unavailable', tail: ['cclclecleaclear'] }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'read', '--terminal', 'term_abc', '--screen'], '/tmp/repo') + + const printed = String(logSpy.mock.calls[0]?.[0]) + expect(printed).toContain('no rendered screen was available') + expect(printed).toContain('stacked fragments') + }) + + // Why: zod strips the unknown `screen` key on an older host, so it answers with an ordinary + // stream read. Handing that back would be the original defect wearing the new flag's name. + it("refuses to pass an older host's stream off as a screen read", async () => { + queueFixtures(callMock, readFixture({ tail: ['cclclecleaclear'] })) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['terminal', 'read', '--terminal', 'term_abc', '--screen', '--json'], '/tmp/repo') + + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'does not support --screen reads' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects --screen with --cursor instead of implying history that is not there', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['terminal', 'read', '--terminal', 'term_abc', '--screen', '--cursor', '42', '--json'], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'has no cursor to page from' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) +}) diff --git a/src/cli/worktree-project-target.ts b/src/cli/worktree-project-target.ts index 766b0bf710f..66d94aa0b1a 100644 --- a/src/cli/worktree-project-target.ts +++ b/src/cli/worktree-project-target.ts @@ -77,7 +77,20 @@ export async function resolveProjectCreateTarget( if (!projectHostSetupId && !projectId && !host) { return undefined } - const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list') + let result: Awaited>> + try { + result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list') + } catch (error) { + // Why: --host runtime: routes here, so an older server is reachable without the caller + // meaning to; name the version gap rather than surfacing a raw method_not_found. + if (error instanceof RuntimeClientError && error.code === 'method_not_found') { + throw new RuntimeClientError( + 'incompatible_runtime', + 'This Orca server does not support project host setup yet. Update Orca on the server and try again.' + ) + } + throw error + } const ready = result.result.setups.filter((candidate) => candidate.setupState === 'ready') const setup = projectHostSetupId ? ready.find((candidate) => candidate.id === projectHostSetupId) diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/README.md b/src/main/__fixtures__/shell-wrapper-snapshots/README.md index b3c5e66d20d..6674985fd43 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/README.md +++ b/src/main/__fixtures__/shell-wrapper-snapshots/README.md @@ -5,11 +5,11 @@ Generated test fixtures. **Do not edit by hand.** Each `.txt` here is the byte-exact content of one shell startup file Orca writes into a pane's wrapper `ZDOTDIR`, captured per transport: -| | | -| :--- | :--- | -| `local-*` | local PTY | +| | | +| :--------- | :---------------- | +| `local-*` | local PTY | | `daemon-*` | daemon / SSH host | -| `relay-*` | relay overlay | +| `relay-*` | relay overlay | Owned by [`../../shell-wrapper-generated-file-snapshot.test.ts`](../../shell-wrapper-generated-file-snapshot.test.ts), which drives the real wrapper entry points, reads the files back off disk and diff --git a/src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts index 73216dcb0f5..94d544121f2 100644 --- a/src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts +++ b/src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts @@ -27,10 +27,10 @@ import { } from './session-scanner-kimi-paths' import { readJsonObjectIfExists } from './session-scanner-values' import { + resetWslTranscriptFsGateForTests, WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS, WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, - WslTranscriptFsError, - resetWslTranscriptFsGateForTests + WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' // Identity must not change between the refused and the recovered read, or the diff --git a/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts index accc60d008f..42ce4f247db 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts @@ -20,7 +20,10 @@ vi.mock('./session-scanner-opencode-sqlite-worker-spawn', () => ({ })) import { opencodeDiscoveries } from './session-scanner-opencode-sources' -import { WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS } from '../native-chat/wsl-transcript-fs-gate' +import { + resetWslTranscriptFsGateForTests, + WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS +} from '../native-chat/wsl-transcript-fs-gate' let releaseStall: (() => void) | undefined @@ -39,6 +42,7 @@ function stalls(): Promise { } beforeEach(() => { + resetWslTranscriptFsGateForTests() mocks.readdir.mockReset() mocks.stat.mockReset() releaseStall = undefined diff --git a/src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts index f41c36a2b05..1e0162b1748 100644 --- a/src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts +++ b/src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts @@ -20,10 +20,10 @@ import { resetSessionParseCacheForTests } from './session-scanner-parse-cache' import { + resetWslTranscriptFsGateForTests, WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS, WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, - WslTranscriptFsError, - resetWslTranscriptFsGateForTests + WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' type ReadResult = { bytesRead: number; buffer: Buffer } diff --git a/src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts b/src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts index 337c2064d8a..3fad1803ea1 100644 --- a/src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts +++ b/src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts @@ -24,7 +24,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader' import { resetSessionParseCacheForTests } from './session-scanner-parse-cache' -import { WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS } from '../native-chat/wsl-transcript-fs-gate' +import { + resetWslTranscriptFsGateForTests, + WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS +} from '../native-chat/wsl-transcript-fs-gate' let releaseStall: (() => void) | undefined let tempRoot: string | undefined @@ -47,6 +50,7 @@ function userRecord(sessionId: string, text: string): string { } beforeEach(async () => { + resetWslTranscriptFsGateForTests() resetSessionParseCacheForTests() mocks.lstat.mockReset() releaseStall = undefined diff --git a/src/main/browser/anti-detection.test.ts b/src/main/browser/anti-detection.test.ts index 1dfdd31b654..ddb904cb436 100644 --- a/src/main/browser/anti-detection.test.ts +++ b/src/main/browser/anti-detection.test.ts @@ -101,7 +101,7 @@ describe('ANTI_DETECTION_SCRIPT', () => { }) it.each(['geolocation', 'idle-detection', 'midi', 'storage-access'])( - 'preserves the native denied state for %s', + 'passes non-intercepted permission queries through to the native state for %s', async (name) => { const context = createContext({ nativeNotificationPermission: 'denied', diff --git a/src/main/browser/browser-cookie-clear-preserve.test.ts b/src/main/browser/browser-cookie-clear-preserve.test.ts index 9c93e783c79..061289b86dd 100644 --- a/src/main/browser/browser-cookie-clear-preserve.test.ts +++ b/src/main/browser/browser-cookie-clear-preserve.test.ts @@ -5,6 +5,7 @@ import { type CookieClearIdentity, type CookieClearSession } from './browser-cookie-import-clear' +import { importedDomainScope } from './browser-cookie-import-policy' /** * A jar that actually holds cookies. @@ -16,36 +17,33 @@ import { */ function jar(initial: Cookie[]) { let cookies = [...initial] - const clearDataMock = vi.fn(async () => { - // Why: the real bulk clear removes everything it is not told to exclude. - cookies = [] - }) const removeMock = vi.fn(async (url: string, name: string) => { const host = new URL(url).hostname cookies = cookies.filter( (c) => !(c.name === name && (c.domain ?? '').replace(/^\./, '') === host.replace(/^\./, '')) ) }) + const snapshotMock = vi.fn(async (items: readonly { cookie: Cookie; url: string }[]) => + items.map(({ cookie, url }) => ({ + url, + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path + })) + ) const session: CookieClearSession = { cookies: { get: async () => [...cookies], remove: removeMock }, - clearData: clearDataMock, - snapshotClearIdentities: async (items) => - items.map(({ cookie, url }) => ({ - url, - name: cookie.name, - value: cookie.value, - domain: cookie.domain, - path: cookie.path - })) as CookieClearIdentity[], + snapshotClearIdentities: snapshotMock, restoreClearIdentities: async () => undefined } as unknown as CookieClearSession return { session, - clearDataMock, removeMock, + snapshotMock, names: () => cookies.map((c) => c.name).sort(), get: (name: string) => cookies.find((c) => c.name === name) } @@ -54,18 +52,29 @@ function jar(initial: Cookie[]) { const cookie = (domain: string, name: string, value = `${name}-live`): Cookie => ({ domain, name, value, path: '/', secure: true }) as Cookie +const coordinatesOf = (calls: readonly [string, string][]): string[] => + calls.map(([url, name]) => `${url}|${name}`) + describe('removeTransplantableCookies — preserved families on a POPULATED jar', () => { - it('clears everything when nothing is preserved (unchanged behaviour)', async () => { + it('clears every in-scope domain through per-coordinate removals when nothing is preserved', async () => { const target = jar([ cookie('.mixed.example', 'live-session'), cookie('.other.example', 'stale') ]) - await removeTransplantableCookies(target.session) + await removeTransplantableCookies( + target.session, + new Set(), + importedDomainScope(['mixed.example', 'other.example']) + ) expect(target.names()).toEqual([]) - // Why: the ordinary import must keep using the single bulk call. - expect(target.clearDataMock).toHaveBeenCalledOnce() + // Why (STA-4797): the bulk clearData shortcut is gone — an import with nothing preserved still + // clears one coordinate at a time, drawn from the frozen plan the snapshot is taken from. + expect(coordinatesOf(target.removeMock.mock.calls)).toEqual([ + 'https://mixed.example/|live-session', + 'https://other.example/|stale' + ]) }) it('leaves a preserved family untouched while clearing everything else', async () => { @@ -75,7 +84,11 @@ describe('removeTransplantableCookies — preserved families on a POPULATED jar' cookie('.other.example', 'stale') ]) - await removeTransplantableCookies(target.session, new Set(['mixed.example'])) + await removeTransplantableCookies( + target.session, + new Set(['mixed.example']), + importedDomainScope(['mixed.example', 'sub.mixed.example', 'other.example']) + ) // The whole family survives — apex AND subdomain — and it survives byte-identically. expect(target.names()).toEqual(['apex-session', 'sub-session']) @@ -83,20 +96,29 @@ describe('removeTransplantableCookies — preserved families on a POPULATED jar' expect(target.get('sub-session')?.value).toBe('sub-session-live') }) - it('never calls bulk clearData when a family is preserved', async () => { - // Why (§4.3a): clearData removes everything outside excludeOrigins and its own contract admits - // a rejection may already have emptied part of the jar. A preserved family is deliberately - // absent from the snapshot, so a partial delete then a rejection would destroy it with nothing - // able to restore it. The only safe answer is not to call it. + it('submits no coordinate to a removal the snapshot cannot restore', async () => { + // Why (§4.3a): the removed set must equal the restorable set. The bulk clearData path could not + // honour that — it deletes by exclusion, and its own contract admits a rejection may already + // have emptied part of the jar, so a preserved family absent from the snapshot would be + // destroyed with nothing able to put it back. Removal is per-coordinate for that reason, and + // every coordinate it submits comes from the same frozen plan the snapshot was taken from. const target = jar([ cookie('.mixed.example', 'live-session'), cookie('.other.example', 'stale') ]) - await removeTransplantableCookies(target.session, new Set(['mixed.example'])) + await removeTransplantableCookies( + target.session, + new Set(['mixed.example']), + importedDomainScope(['mixed.example', 'other.example']) + ) - expect(target.clearDataMock).not.toHaveBeenCalled() - expect(target.removeMock).toHaveBeenCalled() + const removed = coordinatesOf(target.removeMock.mock.calls) + const snapshotted = target.snapshotMock.mock.calls.flatMap(([items]) => + items.map(({ cookie: c, url }) => `${url}|${c.name}`) + ) + expect(removed).toEqual(['https://other.example/|stale']) + expect(snapshotted).toEqual(removed) }) it('never submits a preserved coordinate to a removal', async () => { @@ -106,7 +128,11 @@ describe('removeTransplantableCookies — preserved families on a POPULATED jar' cookie('.other.example', 'stale') ]) - await removeTransplantableCookies(target.session, new Set(['mixed.example'])) + await removeTransplantableCookies( + target.session, + new Set(['mixed.example']), + importedDomainScope(['mixed.example', 'sub.mixed.example', 'other.example']) + ) const removedNames = target.removeMock.mock.calls.map((call) => call[1]) expect(removedNames).toEqual(['stale']) @@ -135,7 +161,11 @@ describe('removeTransplantableCookies — preserved families on a POPULATED jar' } } as unknown as CookieClearSession - await removeTransplantableCookies(session, new Set(['mixed.example'])) + await removeTransplantableCookies( + session, + new Set(['mixed.example']), + importedDomainScope(['mixed.example', 'other.example']) + ) expect(snapshotted).toEqual(['stale']) }) @@ -145,7 +175,11 @@ describe('removeTransplantableCookies — preserved families on a POPULATED jar' // the live 127.0.0.1 session would not match the preserve set and would be erased. const target = jar([cookie('127.0.0.1', 'loopback-session'), cookie('.other.example', 'stale')]) - await removeTransplantableCookies(target.session, new Set(['127.0.0.1'])) + await removeTransplantableCookies( + target.session, + new Set(['127.0.0.1']), + importedDomainScope(['127.0.0.1', 'other.example']) + ) expect(target.names()).toEqual(['loopback-session']) }) @@ -153,7 +187,11 @@ describe('removeTransplantableCookies — preserved families on a POPULATED jar' it('preserves a single-label host family', async () => { const target = jar([cookie('localhost', 'dev-session'), cookie('.other.example', 'stale')]) - await removeTransplantableCookies(target.session, new Set(['localhost'])) + await removeTransplantableCookies( + target.session, + new Set(['localhost']), + importedDomainScope(['localhost', 'other.example']) + ) expect(target.names()).toEqual(['dev-session']) }) @@ -161,7 +199,11 @@ describe('removeTransplantableCookies — preserved families on a POPULATED jar' it('does not preserve a different family that merely shares a suffix', async () => { const target = jar([cookie('.kept.example', 'kept'), cookie('.other.example', 'stale')]) - await removeTransplantableCookies(target.session, new Set(['kept.example'])) + await removeTransplantableCookies( + target.session, + new Set(['kept.example']), + importedDomainScope(['kept.example', 'other.example']) + ) expect(target.names()).toEqual(['kept']) }) @@ -183,19 +225,20 @@ describe('removeTransplantableCookies — preserved families on a POPULATED jar' cookie('.preserved.example', 'live-session'), ...Array.from({ length: 12 }, (_, index) => cookie('.other.example', `stale-${index}`)) ] - const clearData = vi.fn() const session = { cookies: { get: async () => cookies, remove }, - clearData, snapshotClearIdentities: async (items: { cookie: Cookie; url: string }[]) => items.map(({ cookie: entry, url }) => ({ url, ...entry })), restoreClearIdentities: async () => undefined } as unknown as CookieClearSession - const clearing = removeTransplantableCookies(session, new Set(['preserved.example'])) + const clearing = removeTransplantableCookies( + session, + new Set(['preserved.example']), + importedDomainScope(['preserved.example', 'other.example']) + ) await vi.waitFor(() => expect(remove).toHaveBeenCalledTimes(8)) expect(maxActive).toBe(8) - expect(clearData).not.toHaveBeenCalled() releaseRemovals?.() await clearing diff --git a/src/main/browser/browser-cookie-import-clear-atomicity.test.ts b/src/main/browser/browser-cookie-import-clear-atomicity.test.ts index cc3f67e5117..d7991b3be5a 100644 --- a/src/main/browser/browser-cookie-import-clear-atomicity.test.ts +++ b/src/main/browser/browser-cookie-import-clear-atomicity.test.ts @@ -6,6 +6,7 @@ import { type CookieClearIdentity, type CookieClearSession } from './browser-cookie-import-clear' +import { importedDomainScope } from './browser-cookie-import-policy' function cookie(domain: string, name: string, path = '/', secure = true): Cookie { return { @@ -49,15 +50,15 @@ function createJarSession( jar = jar.filter((entry) => entry.name !== name) } }, - clearData: async () => { - // Why: a cookie the user creates mid-clear lands before the rejection surfaces. + // Why: the snapshot runs after the plan is frozen and before the first removal, which is + // exactly where a cookie the user creates mid-clear lands. Injecting the arrival here is what + // makes a re-read of the jar visible: a widened plan would sweep it up. + snapshotClearIdentities: async (items) => { if (options.arrivalDuringClear) { jar.push(options.arrivalDuringClear) } - throw new Error('storage busy') + return options.snapshot ? await options.snapshot(items) : identitiesFromClearCookies(items) }, - snapshotClearIdentities: - options.snapshot ?? (async (items) => identitiesFromClearCookies(items)), restoreClearIdentities: async (identities) => { restoredNames.push(...identities.map((identity) => identity.name)) if (options.restoreError) { @@ -85,9 +86,15 @@ describe('STA-4090 failed full cookie clear', () => { cookie('.other.test', 'stale', '/two') ]) - await expect(removeTransplantableCookies(session)).rejects.toThrow( - /existing cookies were restored/ - ) + await expect( + removeTransplantableCookies( + session, + new Set(), + // google.com is in the import scope on purpose: the non-transplantable exemption, not the + // scope, has to be what keeps SID out of the removal plan. + importedDomainScope(['google.com', 'example.com', 'other.test']) + ) + ).rejects.toThrow(/existing cookies were restored/) expect(session.removedNames()).toEqual(['removed-first']) expect(session.names()).toEqual(['SID', 'removed-first', 'stale']) @@ -102,9 +109,13 @@ describe('STA-4090 failed full cookie clear', () => { } ) - await expect(removeTransplantableCookies(session)).rejects.toThrow( - /the session was left unchanged/ - ) + await expect( + removeTransplantableCookies( + session, + new Set(), + importedDomainScope(['example.com', 'other.test']) + ) + ).rejects.toThrow(/the session was left unchanged/) expect(session.removedNames()).toEqual([]) expect(session.names()).toEqual(['removed-first', 'stale']) }) @@ -115,9 +126,13 @@ describe('STA-4090 failed full cookie clear', () => { { restoreError: new Error('restore rejected') } ) - await expect(removeTransplantableCookies(session)).rejects.toThrow( - /the session was left partially cleared/ - ) + await expect( + removeTransplantableCookies( + session, + new Set(), + importedDomainScope(['example.com', 'other.test']) + ) + ).rejects.toThrow(/the session was left partially cleared/) expect(session.names()).toEqual(['stale']) }) @@ -132,9 +147,6 @@ describe('STA-4090 failed full cookie clear', () => { } } }, - clearData: async () => { - throw new Error('storage busy') - }, snapshotClearIdentities: async (items) => identitiesFromClearCookies(items).map((identity) => identity.name === 'removed-first' @@ -149,9 +161,13 @@ describe('STA-4090 failed full cookie clear', () => { } } - await expect(removeTransplantableCookies(session)).rejects.toThrow( - /existing cookies were restored/ - ) + await expect( + removeTransplantableCookies( + session, + new Set(), + importedDomainScope(['example.com', 'other.test']) + ) + ).rejects.toThrow(/existing cookies were restored/) expect(identities).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -171,9 +187,15 @@ describe('STA-4090 failed full cookie clear', () => { { arrivalDuringClear: cookie('.arrived.test', 'fresh-login') } ) - await expect(removeTransplantableCookies(session)).rejects.toThrow( - /existing cookies were restored/ - ) + await expect( + removeTransplantableCookies( + session, + new Set(), + // arrived.test is inside the import scope, so only the frozen plan can be what spares the + // login — being out of scope must not stand in for it. + importedDomainScope(['example.com', 'other.test', 'arrived.test']) + ) + ).rejects.toThrow(/existing cookies were restored/) expect(session.removedNames()).toEqual(['removed-first']) expect(session.names()).toEqual(['fresh-login', 'removed-first', 'stale']) @@ -197,13 +219,14 @@ describe('STA-4090 failed full cookie clear', () => { jar = jar.filter((entry) => entry.name !== name) } }, - clearData: async () => { - // The site re-sets the same cookie while the bulk clear is failing. + snapshotClearIdentities: async (items) => { + const identities = identitiesFromClearCookies(items) + // The site re-sets the same cookie after the plan is frozen but before its coordinate is + // emptied. jar = jar.filter((entry) => entry.name !== 'session') jar.push(valueCookie('.example.com', 'session', 'mid-clear')) - throw new Error('storage busy') + return identities }, - snapshotClearIdentities: async (items) => identitiesFromClearCookies(items), restoreClearIdentities: async (identities) => { restored.push(...identities) for (const identity of identities) { @@ -215,9 +238,13 @@ describe('STA-4090 failed full cookie clear', () => { } } - await expect(removeTransplantableCookies(session)).rejects.toThrow( - /existing cookies were restored/ - ) + await expect( + removeTransplantableCookies( + session, + new Set(), + importedDomainScope(['example.com', 'other.test']) + ) + ).rejects.toThrow(/existing cookies were restored/) expect(jar.map((entry) => [entry.name, entry.value])).toEqual([ ['stale', 'stale-value'], @@ -239,14 +266,15 @@ describe('STA-4090 failed full cookie clear', () => { inClear -= 1 } }, - clearData: async () => { - throw new Error('storage busy') - }, snapshotClearIdentities: async (items) => identitiesFromClearCookies(items), restoreClearIdentities: async () => undefined } - await Promise.all([removeTransplantableCookies(session), removeTransplantableCookies(session)]) + const scope = importedDomainScope(['example.com']) + await Promise.all([ + removeTransplantableCookies(session, new Set(), scope), + removeTransplantableCookies(session, new Set(), scope) + ]) expect(activeClears).toEqual([1, 1]) }) diff --git a/src/main/browser/browser-cookie-import-clear.ts b/src/main/browser/browser-cookie-import-clear.ts index 9f364d097d0..af79c4249ed 100644 --- a/src/main/browser/browser-cookie-import-clear.ts +++ b/src/main/browser/browser-cookie-import-clear.ts @@ -1,9 +1,10 @@ -import type { Cookie, Cookies, Session } from 'electron' +import type { Cookie, Cookies } from 'electron' import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' +import type { ImportedDomainScope } from './browser-cookie-import-policy' import { cookieRemovalUrl, + domainIsInImportedScope, isNonTransplantableCookieDomain, - NON_TRANSPLANTABLE_CLEAR_EXCLUDED_ORIGINS, normalizeCookieDomain, registrableFamily } from './browser-cookie-import-policy' @@ -44,9 +45,11 @@ export type CookieImportWriteStore = Pick & { } // Why (STA-4061): 'set' stays out so the lossy partition-dropping reconstruction cannot return. +// Why (STA-4797): 'clearData' stays out for the same structural reason. It can only express +// "everything except these origins", never "only the domains this import replaces", so any route +// back to it is a route back to wiping the whole partition. export type CookieClearSession = { cookies: Pick - clearData: Session['clearData'] snapshotClearIdentities: CookieClearStore['snapshotClearIdentities'] restoreClearIdentities: CookieClearStore['restoreClearIdentities'] } @@ -107,13 +110,25 @@ export async function withCookieMutationLock(owner: object, run: () => Promis function removableCookieEntries( cookies: readonly Cookie[], - preserveFamilies: ReadonlySet + preserveFamilies: ReadonlySet, + importScope: ImportedDomainScope ): { cookie: Cookie; url: string }[] { const removable: { cookie: Cookie; url: string }[] = [] for (const cookie of cookies) { if (isNonTransplantableCookieDomain(cookie.domain ?? '')) { continue } + // Why (STA-4797): a cookie for a site this import never mentions is not stale — it is the + // user's live session, and signing them out of it buys the import nothing. The scope test + // comes before the removal-URL derivation below so an unaddressable cookie parked in some + // unrelated corner of the jar cannot fail an import that was never going to touch it. + const scopedDomain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null + if ( + scopedDomain === null || + !domainIsInImportedScope(importScope, scopedDomain, cookie.hostOnly === true) + ) { + continue + } // Why (STA-4300 I2): a family whose partition could not be read faithfully is neither written // nor removed. Filtering HERE keeps it out of the removal plan and — because the CDP snapshot // is taken from this same list — out of the restore set too, so it is never submitted to any @@ -124,12 +139,7 @@ function removableCookieEntries( continue } } - const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null - const url = domain ? cookieRemovalUrl(cookie, domain) : null - if (!url) { - throw new Error('Could not clear existing cookies; the session was left unchanged') - } - removable.push({ cookie, url }) + removable.push({ cookie, url: cookieRemovalUrl(cookie, scopedDomain) }) } return removable } @@ -179,7 +189,7 @@ async function restoreClearedCookies( } /** - * Clears the transplantable cookies from a jar. + * Clears the cookies an import is about to replace, for the domains it is importing. * * Why (STA-4601): this takes the mutation lock on the object it is PASSED, which serialises direct * callers that hand it a real Session — pinned by "serializes concurrent clears on the same @@ -188,19 +198,27 @@ async function restoreClearedCookies( * them. That is deliberate and safe: the importer holds the real per-partition lock, keyed on the * Electron Session, across its whole clear-and-write transaction — a scope this function cannot * see. Do not remove the importer's outer lock on the assumption that this one covers it. + * + * Why (STA-4797): importScope is required, not defaulted. A default would be the whole jar again, + * and the whole-jar clear is the defect — an import of three sites signed the user out of every + * other site in the partition. Every caller has to name what it is about to replace. */ export async function removeTransplantableCookies( targetSession: CookieClearSession, - preserveFamilies: ReadonlySet = new Set() + preserveFamilies: ReadonlySet, + importScope: ImportedDomainScope ): Promise { return withCookieMutationLock(targetSession, async () => { const store = targetSession.cookies + if (importScope.exact.size === 0) { + return + } const initialCookies = await store.get({}) if (initialCookies.length === 0) { return } - const initialRemovable = removableCookieEntries(initialCookies, preserveFamilies) + const initialRemovable = removableCookieEntries(initialCookies, preserveFamilies, importScope) if (initialRemovable.length === 0) { return } @@ -213,28 +231,13 @@ export async function removeTransplantableCookies( // no-op, so the stale plan costs nothing; only its narrowness matters. const removalGroups = [...groupRemovableCookies(initialRemovable).values()] - // Why (STA-4300 §4.3a): bulk clearData removes everything outside excludeOrigins, and its own - // contract admits a rejection may already have emptied part of the jar. Handing it a - // dynamically derived preserve list cannot be made safe: a partial delete followed by a - // rejection would destroy a preserved family with no identity to restore it from, because the - // preserved coordinates are deliberately absent from the snapshot. So when anything is - // preserved we do not use the bulk path at all — the frozen per-coordinate plan, which already - // excludes those families, becomes the primary path. Nothing is preserved on the ordinary - // import, so that path keeps today's single clearData call unchanged. - if (preserveFamilies.size === 0) { - try { - // Why (STA-4065): excludeOrigins keeps the google.com family, including partitioned - // cookies, so one call replaces a remove() per cookie on the ordinary import path. - await targetSession.clearData({ - dataTypes: ['cookies'], - excludeOrigins: NON_TRANSPLANTABLE_CLEAR_EXCLUDED_ORIGINS - }) - return - } catch { - // Why: a rejected bulk clear can still have emptied part of the jar. - } - } - + // Why (STA-4797): the bulk clearData shortcut is gone. It clears by exclusion, so the only + // scope it could express was "everything except google.com" — the defect itself. Narrowing it + // to an include list would not help either: clearData matches at the registrable-domain + // boundary, so it would still take host-only siblings this import does not replace, and a + // partial delete followed by a rejection would destroy them with no identity to restore from. + // The frozen per-coordinate plan is now the only path, and it is a small one — it covers the + // imported domains rather than the jar. const results = await mapSettledWithConcurrency( removalGroups, COOKIE_CLEAR_CONCURRENCY, diff --git a/src/main/browser/browser-cookie-import-concurrency.test.ts b/src/main/browser/browser-cookie-import-concurrency.test.ts index 0da6242cf8c..776ded2f7e7 100644 --- a/src/main/browser/browser-cookie-import-concurrency.test.ts +++ b/src/main/browser/browser-cookie-import-concurrency.test.ts @@ -277,7 +277,9 @@ describe('two concurrent imports into one partition', () => { describe('two concurrent NATIVE imports into one partition', () => { let tmpDir: string let events: string[] + let cookiesRemoveMock: ReturnType let cookiesSetMock: ReturnType + let clearDataMock: ReturnType let releaseFirstWrite: () => void let platformSpy: { mockRestore: () => void } let stagingCopyCount: number @@ -309,7 +311,11 @@ describe('two concurrent NATIVE imports into one partition', () => { copyFileSync(source, destination) }) // Why: an EMPTY jar makes removeTransplantableCookies return before it clears, so the clear - // would never run and a clearData assertion would pass vacuously. Seed it. + // would never run and the clear assertions would pass vacuously. Seed it. + // Why (STA-4797): the seed has to sit on the domains these two imports actually bring over. + // The clear is scoped to the imported domains now, so a jar holding only an unrelated site is + // the empty-jar case wearing a disguise — nothing would be removed and the detector, which + // reads "did B clear yet?" off the removals, would measure nothing. snapshotClearIdentitiesMock .mockReset() .mockImplementation(async (items: { cookie: Record; url: string }[]) => @@ -328,22 +334,25 @@ describe('two concurrent NATIVE imports into one partition', () => { }) // Why (STA-4300): same guard as the file path — the native writes go through CDP identities. cookiesSetMock = vi.fn() + cookiesRemoveMock = vi.fn(async (_url: string, name: string) => { + events.push(`remove:${name}`) + }) + clearDataMock = vi.fn(async () => { + events.push('clearData') + }) const stableSession = { cookies: { - get: vi - .fn() - .mockResolvedValue([ - { domain: '.stale.example', name: 'stale', value: 'v', path: '/', secure: true } - ]), - remove: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockResolvedValue([ + { domain: '.a.example', name: 'old-a', value: 'v', path: '/', secure: true }, + { domain: '.b.example', name: 'old-b', value: 'v', path: '/', secure: true } + ]), + remove: cookiesRemoveMock, set: cookiesSetMock, flushStore: vi.fn(async () => { events.push('flushStore') }) }, - clearData: vi.fn(async () => { - events.push('clearData') - }), + clearData: clearDataMock, // Why (STA-4300): the importer asks the Session where its storage lives instead of rebuilding // the path from the partition string, so the stub has to answer. getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'native-conc'), @@ -386,10 +395,13 @@ describe('two concurrent NATIVE imports into one partition', () => { // The first import is parked inside its write. The second must not stage a stale image while // that transaction is active — this assertion fails if the staging copy sits outside the lock. expect(events).not.toContain('copy:second') - // Nor may it flush, clear, or write the live jar while that transaction is active. + // The first import clears only its imported domain, while the second may not flush, remove, + // or write the live jar until that transaction finishes. + expect(events).toContain('remove:old-a') + expect(events).not.toContain('remove:old-b') expect(events.filter((e) => e === 'flushStore')).toHaveLength(1) - expect(events.filter((e) => e === 'clearData')).toHaveLength(1) expect(events).not.toContain('set:second') + expect(clearDataMock).not.toHaveBeenCalled() releaseFirstWrite() const [resultA, resultB] = await Promise.all([first, second]) @@ -401,7 +413,8 @@ describe('two concurrent NATIVE imports into one partition', () => { // once per import, not just that the end state looks right. expect(writeCookieIdentityMock).toHaveBeenCalledTimes(2) expect(cookiesSetMock).not.toHaveBeenCalled() - expect(events.indexOf('set:first')).toBeLessThan(events.lastIndexOf('clearData')) + expect(cookiesRemoveMock.mock.calls.map(([, name]) => name)).toEqual(['old-a', 'old-b']) + expect(events.indexOf('set:first')).toBeLessThan(events.indexOf('remove:old-b')) expect(events.indexOf('copy:second')).toBeGreaterThan(events.indexOf('set:first')) }) }) diff --git a/src/main/browser/browser-cookie-import-google-exclusion.test.ts b/src/main/browser/browser-cookie-import-google-exclusion.test.ts index 48af8c4329b..1cc13450cff 100644 --- a/src/main/browser/browser-cookie-import-google-exclusion.test.ts +++ b/src/main/browser/browser-cookie-import-google-exclusion.test.ts @@ -243,7 +243,7 @@ describe('native Chromium import excludes the Google cookie family', () => { createChromiumCookieTestDatabase(targetCookiesPath, rows).close() } - it('bulk clears while excluding live Google cookies before importing', async () => { + it('clears the imported domain coordinate by coordinate and never a Google one', async () => { const sourceCookiesPath = seedSource([ { domain: '.google.com', name: 'SID', value: 'transplanted-sid' }, { domain: '.example.com', name: 'session', value: 'new' } @@ -263,10 +263,10 @@ describe('native Chromium import excludes the Google cookie family', () => { googleCookiesSkipped: 1, domains: ['example.com'] }) - expect(clearDataMock.mock.calls).toEqual([ - [{ dataTypes: ['cookies'], excludeOrigins: ['https://google.com'] }] - ]) - expect(cookiesRemoveMock).not.toHaveBeenCalled() + // Why (STA-4797): the bulk clear is gone, so the live Google session survives by never being + // named as a removal coordinate rather than by riding an excludeOrigins exemption. + expect(clearDataMock).not.toHaveBeenCalled() + expect(cookiesRemoveMock.mock.calls).toEqual([['https://example.com/', 'stale']]) expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual(['.example.com']) }) @@ -352,10 +352,13 @@ describe('native Chromium import excludes the Google cookie family', () => { { domain: '.example.com', name: 'session', value: 'new' } ]) seedTarget([{ domain: '.example.com', name: 'stale', value: 'stale' }]) + // Why (STA-4797): the rejecting cookie has to sit on a domain this import actually replaces. + // Parked on an unrelated site it is out of the import scope, never enters the removal plan, + // and the rejection the case exists to exercise never happens. let jar = [ existingCookie('.google.com', 'SID'), existingCookie('.example.com', 'removed-first'), - existingCookie('.other.test', 'stale') + existingCookie('.example.com', 'stale') ] cookiesGetMock.mockImplementation(async () => [...jar]) cookiesRemoveMock.mockImplementation(async (_url: string, name: string) => { @@ -369,13 +372,12 @@ describe('native Chromium import excludes the Google cookie family', () => { jar.push(existingCookie(details.domain ?? '.example.com', details.name)) } }) - clearDataMock.mockRejectedValue(new Error('storage busy')) const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') expect(result).toMatchObject({ ok: false }) expect(result.ok || result.reason).toContain('Could not clear existing cookies') - expect(clearDataMock).toHaveBeenCalledOnce() + expect(clearDataMock).not.toHaveBeenCalled() expect(cookiesRemoveMock.mock.calls.map(([, name]) => name)).toEqual(['removed-first', 'stale']) expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual( expect.arrayContaining(['removed-first']) diff --git a/src/main/browser/browser-cookie-import-partition-rollback.electron.test.ts b/src/main/browser/browser-cookie-import-partition-rollback.electron.test.ts index 9237d0dfbdb..b573cfbd116 100644 --- a/src/main/browser/browser-cookie-import-partition-rollback.electron.test.ts +++ b/src/main/browser/browser-cookie-import-partition-rollback.electron.test.ts @@ -20,7 +20,6 @@ type CdpCookie = { name: string; value: string; partitionKey?: Record | undefined clearError: string | null - bulkClearCalls: number remainingChips: CdpCookie[] remainingPlain: CdpCookie[] remainingExcluded: CdpCookie[] @@ -35,7 +34,7 @@ function buildFixtureMain(bundlePath: string, resultPath: string): string { return ` const { app, BrowserWindow, session } = require('electron') const { writeFileSync } = require('node:fs') -const { openCookieClearStore, removeTransplantableCookies } = require(${JSON.stringify(bundlePath)}) +const { openCookieClearStore, removeTransplantableCookies, importedDomainScope } = require(${JSON.stringify(bundlePath)}) const resultPath = ${JSON.stringify(resultPath)} let currentStep = 'starting' const mark = (step) => { @@ -81,9 +80,8 @@ async function run() { await targetSession.cookies.set({ url: 'https://accounts.google.com/', name: 'SID', value: 'live', secure: true }) mark('removable cookies set') - // Why: the bulk clear is the ordinary path now, so rejecting it routes this fixture onto - // the per-cookie fallback where a later removal can fail after earlier ones succeeded. - let bulkClearCalls = 0 + // Why (STA-4797): the per-coordinate plan is the only removal path, so a rejection partway + // through it is exactly the failure this fixture needs — no bulk clear to force off first. const cookieClearStore = openCookieClearStore(targetSession) const clearSession = { cookies: { @@ -93,17 +91,21 @@ async function run() { return targetSession.cookies.remove(url, name) } }, - clearData: async () => { - bulkClearCalls++ - throw new Error('forced bulk clear failure') - }, snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies), restoreClearIdentities: (identities) => cookieClearStore.restoreClearIdentities(identities) } + // Why: the import these removals stand in for writes all three fixture domains, so all three + // are in scope. accounts.google.com stays out by policy, not by scope. + const importScope = importedDomainScope([ + 'app.acme-chips.test', + 'plain.example', + 'victim.example', + 'accounts.google.com' + ]) let clearError = null try { - await removeTransplantableCookies(clearSession) + await removeTransplantableCookies(clearSession, new Set(), importScope) } catch (error) { clearError = String(error?.message || error) } finally { @@ -119,7 +121,6 @@ async function run() { writeFileSync(resultPath, JSON.stringify({ beforePartitionKey: beforeChips.partitionKey, clearError, - bulkClearCalls, remainingChips: project('chips-auth'), remainingPlain: project('plain'), remainingExcluded: project('SID') @@ -147,7 +148,8 @@ async function runFixture(): Promise { bundleEntryPath, [ `export { openCookieClearStore } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-clear-store.ts'))}`, - `export { removeTransplantableCookies } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import-clear.ts'))}` + `export { removeTransplantableCookies } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import-clear.ts'))}`, + `export { importedDomainScope } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import-policy.ts'))}` ].join('\n') ) await buildVite({ @@ -191,7 +193,6 @@ describe('non-Google partitioned cookie under a failed Electron cookie clear', ( const result = await runFixture() expect(result.beforePartitionKey).toEqual(EXPECTED_PARTITION_KEY) - expect(result.bulkClearCalls).toBe(1) expect(result.remainingExcluded.map(({ name }) => name)).toEqual(['SID']) expect(result.clearError).toContain('Could not clear existing cookies') expect(result.remainingChips).toEqual([ diff --git a/src/main/browser/browser-cookie-import-partition.electron.test.ts b/src/main/browser/browser-cookie-import-partition.electron.test.ts index e844341e12f..c992421871e 100644 --- a/src/main/browser/browser-cookie-import-partition.electron.test.ts +++ b/src/main/browser/browser-cookie-import-partition.electron.test.ts @@ -68,10 +68,20 @@ async function run() { partitionKey: { topLevelSite: 'https://example.com', hasCrossSiteAncestor: true } }) mark('partitioned cookie set') + // Why (STA-4797): a stale cookie on a domain the import DOES bring over. Narrowing the clear + // must not stop it removing this one, or imported sites inherit a session the site rejects. + await targetSession.cookies.set({ + url: 'https://example.com/', + name: 'superseded', + value: 'remove-me', + secure: true + }) + // Why (STA-4797): a live session for a site the import never mentions. It must still be here + // afterwards — a real Electron jar proving the import no longer signs the user out of it. await targetSession.cookies.set({ url: 'https://stale.example/', name: 'stale', - value: 'remove-me', + value: 'keep-me', secure: true }) // Why: the excluded origin is HTTPS, so prove registrable-domain matching also keeps HTTP. @@ -207,7 +217,8 @@ describe('native Chromium excluded partition cookie under Electron', () => { 'domain-google', 'http-google', 'imported', - 'partitioned-google' + 'partitioned-google', + 'stale' ]) }, 90_000) }) diff --git a/src/main/browser/browser-cookie-import-policy.test.ts b/src/main/browser/browser-cookie-import-policy.test.ts index 25204fcd1c7..c1ea2ef2ccd 100644 --- a/src/main/browser/browser-cookie-import-policy.test.ts +++ b/src/main/browser/browser-cookie-import-policy.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi, type Mock } from 'vitest' -import { DatabaseSync } from 'node:sqlite' import type { Cookie } from 'electron' import { identitiesFromClearCookies, @@ -7,9 +6,9 @@ import { type CookieClearIdentity } from './browser-cookie-import-clear' import { + importedDomainScope, isGoogleSourceBoundCookie, isNonTransplantableCookieDomain, - NON_TRANSPLANTABLE_HOST_KEY_SQL, normalizeCookieDomain, replaceCookiesForImportedDomains } from './browser-cookie-import-policy' @@ -233,47 +232,13 @@ describe('isNonTransplantableCookieDomain', () => { }) }) -describe('NON_TRANSPLANTABLE_HOST_KEY_SQL', () => { - it('selects the google.com family and nothing that merely looks like it', () => { - const db = new DatabaseSync(':memory:') - db.exec('CREATE TABLE cookies (host_key TEXT)') - for (const hostKey of [ - 'google.com', - '.google.com', - 'accounts.google.com', - 'withgoogle.com', - 'google.com.evil.example', - '.youtube.com', - '.linear.app' - ]) { - db.prepare('INSERT INTO cookies (host_key) VALUES (?)').run(hostKey) - } - - const matched = db - .prepare( - `SELECT host_key FROM cookies WHERE ${NON_TRANSPLANTABLE_HOST_KEY_SQL} ORDER BY host_key` - ) - .all() as { host_key: string }[] - db.close() - - expect(matched.map((row) => row.host_key)).toEqual([ - '.google.com', - 'accounts.google.com', - 'google.com' - ]) - }) -}) - type CookieClearMocks = { get: Mock remove: Mock set: Mock - clearData: Mock } describe('removeTransplantableCookies', () => { - const rejectingBulkClear = () => vi.fn().mockRejectedValue(new Error('storage busy')) - function clearSession(cookies: Cookie[], overrides: Partial = {}) { const store = { get: vi.fn().mockResolvedValue(cookies), @@ -281,7 +246,6 @@ describe('removeTransplantableCookies', () => { set: vi.fn().mockResolvedValue(undefined), ...overrides } - const clearData = overrides.clearData ?? vi.fn().mockResolvedValue(undefined) const restoreClearIdentities = vi.fn().mockResolvedValue(undefined) const snapshotClearIdentities = vi.fn( async (items: Parameters[0]) => @@ -290,109 +254,105 @@ describe('removeTransplantableCookies', () => { return { session: { cookies: store, - clearData, snapshotClearIdentities, restoreClearIdentities }, get: store.get, remove: store.remove, set: store.set, - clearData, snapshotClearIdentities, restoreClearIdentities } } - // Why (STA-4065): the bulk call is the ordinary path even when the jar holds cookies to keep — - // excludeOrigins preserves the whole google.com family, verified against real Electron. - it('clears a jar holding Google cookies in one call that excludes them', async () => { - const { session, remove, set, clearData } = clearSession([ + // Why (STA-4065/STA-4797): google.com is in the import scope in these fixtures on purpose — the + // non-transplantable exemption, not the scope, has to be what keeps the family out of the plan. + it('removes the imported coordinates one by one and leaves the Google family alone', async () => { + const { session, remove, set } = clearSession([ cookie('.google.com', 'SID'), cookie('accounts.google.com', 'ACCOUNT'), cookie('.example.com', 'session'), cookie('other.test', 'tracker', '/scoped') ]) - await removeTransplantableCookies(session) + await removeTransplantableCookies( + session, + new Set(), + importedDomainScope(['google.com', 'example.com', 'other.test']) + ) - expect(clearData.mock.calls).toEqual([ - [{ dataTypes: ['cookies'], excludeOrigins: ['https://google.com'] }] + expect(remove.mock.calls).toEqual([ + ['https://example.com/', 'session'], + ['https://other.test/scoped', 'tracker'] ]) - expect(remove).not.toHaveBeenCalled() expect(set).not.toHaveBeenCalled() }) - it('bulk clears in one call when the jar holds nothing to preserve', async () => { - const { session, remove, set, clearData } = clearSession([ + it('removes every in-scope coordinate when the jar holds nothing to preserve', async () => { + const { session, remove, set } = clearSession([ cookie('.example.com', 'session'), cookie('other.test', 'tracker', '/scoped'), cookie('notgoogle.com', 'lookalike') ]) - await removeTransplantableCookies(session) + await removeTransplantableCookies( + session, + new Set(), + importedDomainScope(['example.com', 'other.test', 'notgoogle.com']) + ) - expect(clearData.mock.calls).toEqual([ - [{ dataTypes: ['cookies'], excludeOrigins: ['https://google.com'] }] + expect(remove.mock.calls).toEqual([ + ['https://example.com/', 'session'], + ['https://other.test/scoped', 'tracker'], + ['https://notgoogle.com/', 'lookalike'] ]) - expect(remove).not.toHaveBeenCalled() expect(set).not.toHaveBeenCalled() }) it('touches nothing when the jar is already empty', async () => { - const { session, remove, clearData } = clearSession([]) + const { session, remove, snapshotClearIdentities } = clearSession([]) - await removeTransplantableCookies(session) + await removeTransplantableCookies(session, new Set(), importedDomainScope(['example.com'])) - expect(clearData).not.toHaveBeenCalled() + expect(snapshotClearIdentities).not.toHaveBeenCalled() expect(remove).not.toHaveBeenCalled() }) - it('does not attach or clear when the jar contains only excluded cookies', async () => { - const { session, snapshotClearIdentities, clearData, remove } = clearSession([ + it('does not attach or remove when the jar contains only excluded cookies', async () => { + const { session, snapshotClearIdentities, remove } = clearSession([ cookie('.google.com', 'SID'), cookie('accounts.google.com', 'ACCOUNT') ]) - await removeTransplantableCookies(session) + await removeTransplantableCookies(session, new Set(), importedDomainScope(['google.com'])) expect(snapshotClearIdentities).not.toHaveBeenCalled() - expect(clearData).not.toHaveBeenCalled() expect(remove).not.toHaveBeenCalled() }) - it('does not mutate when a transplantable cookie cannot be represented for rollback', async () => { - const { session, clearData, remove, restoreClearIdentities } = clearSession([ + // Why (STA-4797): a cookie with no usable domain cannot be in the scope of any import, so it is + // skipped rather than failing the clear — the scope test runs before the removal-URL derivation + // precisely so an unaddressable cookie in an unrelated corner of the jar cannot abort an import + // that was never going to touch it. Either way it is never mutated. + it('skips a cookie that cannot be represented for rollback without mutating anything', async () => { + const { session, remove, snapshotClearIdentities, restoreClearIdentities } = clearSession([ { ...cookie('.example.com', 'session'), domain: '' } ]) - await expect(removeTransplantableCookies(session)).rejects.toThrow(/session was left unchanged/) + await expect( + removeTransplantableCookies(session, new Set(), importedDomainScope(['example.com'])) + ).resolves.toBeUndefined() - expect(clearData).not.toHaveBeenCalled() + expect(snapshotClearIdentities).not.toHaveBeenCalled() expect(remove).not.toHaveBeenCalled() expect(restoreClearIdentities).not.toHaveBeenCalled() }) - it('falls back to per-cookie removal when the bulk clear rejects', async () => { - const { session, remove, clearData } = clearSession( - [cookie('.example.com', 'session'), cookie('other.test', 'tracker')], - { clearData: rejectingBulkClear() } - ) - - await removeTransplantableCookies(session) - - expect(clearData).toHaveBeenCalledOnce() - expect(remove.mock.calls).toEqual([ - ['https://example.com/', 'session'], - ['https://other.test/', 'tracker'] - ]) - }) - - // Why (STA-4170): the fallback may only mutate what the identity snapshot can undo. Re-reading - // the jar here widened the removal set past the restore set. Re-removing a cookie the partial - // bulk clear already deleted is a harmless no-op, so the narrower stale plan costs nothing. - it('removes only the pre-clear snapshot after a rejected bulk clear', async () => { + // Why (STA-4170): the clear may only mutate what the identity snapshot can undo, so the plan is + // frozen from a single read. Re-reading the jar widened the removal set past the restore set. + it('removes only the frozen pre-clear plan and never re-reads the jar', async () => { const beforeAttempt = [ - cookie('.removed.test', 'gone-before-fallback'), + cookie('.removed.test', 'gone-before-clear'), cookie('.survivor.test', 'survived') ] const get = vi @@ -403,24 +363,25 @@ describe('removeTransplantableCookies', () => { cookie('.arrived.test', 'arrived-during-clear') ]) .mockResolvedValueOnce(beforeAttempt) - const { session, remove, clearData } = clearSession(beforeAttempt, { - get, - clearData: rejectingBulkClear() - }) + const { session, remove } = clearSession(beforeAttempt, { get }) - await removeTransplantableCookies(session) + await removeTransplantableCookies( + session, + new Set(), + // arrived.test is in scope: only the frozen plan may be what spares it. + importedDomainScope(['removed.test', 'survivor.test', 'arrived.test', 'google.com']) + ) - expect(clearData).toHaveBeenCalledOnce() expect(get).toHaveBeenCalledOnce() expect(remove.mock.calls).toEqual([ - ['https://removed.test/', 'gone-before-fallback'], + ['https://removed.test/', 'gone-before-clear'], ['https://survivor.test/', 'survived'] ]) }) // Why (STA-4170): arrival plus a later removal failure is the exact shape that deleted a login // the user had just completed and still reported restoration. Mutated set must equal restore set. - it('never touches a cookie that arrives while a rejected clear falls back', async () => { + it('never touches a cookie that arrives after the removal plan is frozen', async () => { const beforeAttempt = [ cookie('.example.com', 'first', '/one'), cookie('.example.com', 'second', '/two') @@ -431,7 +392,6 @@ describe('removeTransplantableCookies', () => { .mockResolvedValueOnce(beforeAttempt) const { session, remove, restoreClearIdentities } = clearSession(beforeAttempt, { get, - clearData: rejectingBulkClear(), remove: vi.fn().mockImplementation(async (_url: string, name: string) => { if (name === 'second') { throw new Error('store unavailable') @@ -439,9 +399,13 @@ describe('removeTransplantableCookies', () => { }) }) - await expect(removeTransplantableCookies(session)).rejects.toThrow( - 'existing cookies were restored' - ) + await expect( + removeTransplantableCookies( + session, + new Set(), + importedDomainScope(['example.com', 'arrived.test']) + ) + ).rejects.toThrow('existing cookies were restored') expect(remove.mock.calls).toEqual([ ['https://example.com/one', 'first'], @@ -454,25 +418,26 @@ describe('removeTransplantableCookies', () => { expect([...restored].sort()).toEqual(['first', 'second']) }) - // Why: the fallback carries the same exclusion as the bulk call, so a rejected clearData must - // not become the path that finally deletes a live Google session. - it('still preserves Google cookies on the per-cookie fallback', async () => { - const { session, remove, set } = clearSession( - [ - cookie('.google.com', 'SID'), - cookie('accounts.google.com', 'ACCOUNT'), - cookie('.example.com', 'session') - ], - { clearData: rejectingBulkClear() } - ) + // Why: the exemption is enforced per coordinate, so no removal may ever be the path that finally + // deletes a live Google session. + it('preserves Google cookies on the per-coordinate removal path', async () => { + const { session, remove, set } = clearSession([ + cookie('.google.com', 'SID'), + cookie('accounts.google.com', 'ACCOUNT'), + cookie('.example.com', 'session') + ]) - await removeTransplantableCookies(session) + await removeTransplantableCookies( + session, + new Set(), + importedDomainScope(['google.com', 'example.com']) + ) expect(remove.mock.calls).toEqual([['https://example.com/', 'session']]) expect(set).not.toHaveBeenCalled() }) - // Why (STA-4090): a failed fallback must restore through captured identities, never cookies.set. + // Why (STA-4090): a failed clear must restore through captured identities, never cookies.set. it('restores removed cookies through captured identities when another removal fails', async () => { const { session, remove, set, restoreClearIdentities } = clearSession( [ @@ -482,7 +447,6 @@ describe('removeTransplantableCookies', () => { cookie('.example.com', 'third', '/three') ], { - clearData: rejectingBulkClear(), remove: vi.fn().mockImplementation(async (_url: string, name: string) => { if (name === 'second') { throw new Error('store unavailable') @@ -491,9 +455,13 @@ describe('removeTransplantableCookies', () => { } ) - await expect(removeTransplantableCookies(session)).rejects.toThrow( - 'existing cookies were restored' - ) + await expect( + removeTransplantableCookies( + session, + new Set(), + importedDomainScope(['google.com', 'example.com']) + ) + ).rejects.toThrow('existing cookies were restored') expect(remove).toHaveBeenCalledTimes(3) expect(set).not.toHaveBeenCalled() expect(restoreClearIdentities).toHaveBeenCalledOnce() @@ -515,7 +483,6 @@ describe('removeTransplantableCookies', () => { ...Array.from({ length: 12 }, (_, index) => cookie('.example.com', `${index}`)) ], { - clearData: rejectingBulkClear(), remove: vi.fn().mockImplementation(async () => { active++ maxActive = Math.max(maxActive, active) @@ -525,7 +492,11 @@ describe('removeTransplantableCookies', () => { } ) - const clearing = removeTransplantableCookies(session) + const clearing = removeTransplantableCookies( + session, + new Set(), + importedDomainScope(['google.com', 'example.com']) + ) await vi.waitFor(() => expect(remove).toHaveBeenCalledTimes(8)) expect(maxActive).toBe(8) releaseRemovals?.() @@ -547,7 +518,6 @@ describe('removeTransplantableCookies', () => { { ...cookie('example.com', 'session'), hostOnly: true } ], { - clearData: rejectingBulkClear(), remove: vi .fn() .mockImplementationOnce(() => firstReleased) @@ -555,7 +525,11 @@ describe('removeTransplantableCookies', () => { } ) - const clearing = removeTransplantableCookies(session) + const clearing = removeTransplantableCookies( + session, + new Set(), + importedDomainScope(['google.com', 'example.com']) + ) await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce()) releaseFirst?.() await clearing diff --git a/src/main/browser/browser-cookie-import-policy.ts b/src/main/browser/browser-cookie-import-policy.ts index 29c5e97ee5e..04a54a47b15 100644 --- a/src/main/browser/browser-cookie-import-policy.ts +++ b/src/main/browser/browser-cookie-import-policy.ts @@ -96,15 +96,12 @@ export function normalizeCookieImportDomain(domain: string): string | null { // session, so an import must never write these cookies and never remove them either — the // live session is always more valuable than anything an import could put in its place. // Entries must be canonical lowercase ASCII (punycode) registrable domains, never subdomains or -// public suffixes, because clearData derives one excluded origin and matches at that boundary. -// Adding a site is one entry here. +// public suffixes: isNonTransplantableCookieDomain exempts an entry and everything under it, and +// the staged image's clear matches the same way. Adding a site is one entry here. // youtube.com is deliberately NOT listed: YouTube accepts a transplanted session and re-issues // its cookies via the accounts.youtube.com relay, so excluding it would silently drop imports // users actually asked for. const NON_TRANSPLANTABLE_DOMAINS = ['google.com'] as const -export const NON_TRANSPLANTABLE_CLEAR_EXCLUDED_ORIGINS = NON_TRANSPLANTABLE_DOMAINS.map( - (root) => `https://${root}` -) export function isNonTransplantableCookieDomain(domain: string): boolean { const normalized = normalizeCookieDomain(domain) @@ -116,12 +113,6 @@ export function isNonTransplantableCookieDomain(domain: string): boolean { ) } -// Why: Chromium stores host_key lowercase as 'google.com', '.google.com' or 'sub.google.com'; -// the LIKE pattern covers the leading-dot row and cannot match lookalikes ('withgoogle.com'). -export const NON_TRANSPLANTABLE_HOST_KEY_SQL = NON_TRANSPLANTABLE_DOMAINS.map( - (root) => `host_key = '${root}' OR host_key LIKE '%.${root}'` -).join(' OR ') - // Why: subsumed by the domain exclusion above for google.com — kept because it is the general // rule for rotation-only cookies and applies to any family added without a full exclusion. export function isGoogleSourceBoundCookie(name: string, domain: string): boolean { @@ -150,11 +141,17 @@ function importDomainAncestors(domain: string): string[] { return ancestors } -function importedDomainScopes(domains: readonly string[]): { +// Why (STA-4797): one scope object, shared by every clear that precedes an import — path B's +// replacement, the native path's live-jar clear, and the staged image's delete. Each one used to +// name its own scope (or, on the native path, none at all), which is how the two import paths came +// to disagree about what an import is allowed to destroy. +export type ImportedDomainScope = { exact: Set ancestors: Set descendantRoots: Set -} { +} + +export function importedDomainScope(domains: readonly string[]): ImportedDomainScope { const exact = new Set() const ancestors = new Set() const descendantRoots = new Set() @@ -180,28 +177,30 @@ function importedDomainScopes(domains: readonly string[]): { return { exact, ancestors, descendantRoots } } -function overlapsImportedDomain( - cookie: Cookie, +// Why: hostOnly is passed rather than read off a Cookie because the staged image scopes the same +// way from a raw Chromium host_key, where the leading dot is the only host-only marker there is. +export function domainIsInImportedScope( + scope: ImportedDomainScope, domain: string, - scopes: ReturnType + hostOnly: boolean ): boolean { - if (scopes.exact.has(domain)) { + if (scope.exact.has(domain)) { return true } - if (cookie.hostOnly !== true && scopes.ancestors.has(domain)) { + if (!hostOnly && scope.ancestors.has(domain)) { return true } - return domainSuffixes(domain).some((suffix) => scopes.descendantRoots.has(suffix)) + return domainSuffixes(domain).some((suffix) => scope.descendantRoots.has(suffix)) } -export function cookieRemovalUrl(cookie: Cookie, domain: string): string | null { - try { - const url = new URL(`${cookie.secure ? 'https' : 'http'}://${domain}/`) - url.pathname = cookie.path?.startsWith('/') ? cookie.path : '/' - return url.toString() - } catch { - return null - } +// Why: takes a normalizeCookieDomain output, never a raw cookie domain. That host already parsed +// as a URL hostname, and assigning pathname cannot throw, so this always builds — both callers +// used to carry a branch for a null that could not happen, one of them a throw that failed an +// import for a reason it could never actually hit. +export function cookieRemovalUrl(cookie: Cookie, normalizedDomain: string): string { + const url = new URL(`${cookie.secure ? 'https' : 'http'}://${normalizedDomain}/`) + url.pathname = cookie.path?.startsWith('/') ? cookie.path : '/' + return url.toString() } // Why (STA-4097): 'set' stays out so the partition-dropping reconstruction cannot return. @@ -240,8 +239,8 @@ export async function replaceCookiesForImportedDomains( store: ImportedDomainReplaceStore, importedDomains: readonly string[] ): Promise { - const scopes = importedDomainScopes(importedDomains) - if (scopes.exact.size === 0) { + const scope = importedDomainScope(importedDomains) + if (scope.exact.size === 0) { return { removed: [], identities: [] } } @@ -252,13 +251,10 @@ export async function replaceCookiesForImportedDomains( const removable: { cookie: Cookie; url: string }[] = [] for (const cookie of existingCookies) { const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null - if (!domain || !overlapsImportedDomain(cookie, domain, scopes)) { + if (!domain || !domainIsInImportedScope(scope, domain, cookie.hostOnly === true)) { continue } - const url = cookieRemovalUrl(cookie, domain) - if (url) { - removable.push({ cookie, url }) - } + removable.push({ cookie, url: cookieRemovalUrl(cookie, domain) }) } if (removable.length === 0) { return { removed: [], identities: [] } diff --git a/src/main/browser/browser-cookie-import-scope.test.ts b/src/main/browser/browser-cookie-import-scope.test.ts new file mode 100644 index 00000000000..33381f915d6 --- /dev/null +++ b/src/main/browser/browser-cookie-import-scope.test.ts @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' + +const { + appGetPathMock, + execFileSyncMock, + sessionFromPartitionMock, + setPendingCookieImportMock, + clearPendingCookieImportMock, + writeCookieIdentityMock +} = vi.hoisted(() => ({ + appGetPathMock: vi.fn(), + execFileSyncMock: vi.fn(), + sessionFromPartitionMock: vi.fn(), + setPendingCookieImportMock: vi.fn(), + clearPendingCookieImportMock: vi.fn(), + writeCookieIdentityMock: vi.fn() +})) + +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock + } +})) +vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +vi.mock('node:fs', async (importOriginal) => await importOriginal()) +vi.mock('electron', () => ({ + app: { getPath: appGetPathMock }, + BrowserWindow: { fromWebContents: vi.fn() }, + dialog: { showOpenDialog: vi.fn() }, + session: { fromPartition: sessionFromPartitionMock } +})) +vi.mock('./browser-cookie-clear-store', () => ({ + openCookieClearStore: (targetSession: { + cookies: { + get: (filter: object) => Promise + remove: (url: string, name: string) => Promise + } + }) => ({ + get: (filter: object) => targetSession.cookies.get(filter), + remove: (url: string, name: string) => targetSession.cookies.remove(url, name), + snapshotClearIdentities: async (items: { cookie: Record; url: string }[]) => + items.map(({ cookie, url }) => ({ url, ...cookie })), + restoreClearIdentities: async () => undefined, + writeCookieIdentity: writeCookieIdentityMock, + dispose: () => undefined + }) +})) + +import { DatabaseSync } from 'node:sqlite' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { importCookiesFromBrowser, type DetectedBrowser } from './browser-cookie-import' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' + +// Why (STA-4797): every other end-to-end fixture in this module starts with an empty target jar, +// which is precisely why four gates — review, readiness audit, Electron QA and CI — passed an +// import that signed the user out of every site in the partition. A jar with sessions for domains +// OUTSIDE the import set is the only fixture that can observe the defect at all. +function populatedTargetJar(): Record[] { + return [ + // The site from the Electron QA report: a working login the import never mentions. + { + name: 'rack.session', + value: 'live-login', + domain: 'the-internet.herokuapp.com', + hostOnly: true, + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax' + }, + // An unrelated site whose cookie has no derivable removal URL. Out of scope, so it must not + // fail the import either. + { + name: 'orphan', + value: 'no-domain', + domain: '', + hostOnly: true, + path: '/', + secure: false, + httpOnly: false, + sameSite: 'no_restriction' + }, + // Google is exempt by policy (STA-3811) and stays exempt. + { + name: 'SID', + value: 'google-live', + domain: '.google.com', + hostOnly: false, + path: '/', + secure: true, + httpOnly: true, + sameSite: 'no_restriction' + }, + // In scope: the import brings github.com, so this stale cookie is what the clear is FOR. + { + name: 'user_session', + value: 'stale-github', + domain: '.github.com', + hostOnly: false, + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax' + } + ] +} + +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('native cookie import clear scope', () => { + let tmpDir: string + let cookiesRemoveMock: ReturnType + let sourceCookiesPath: string + let targetCookiesPath: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-import-scope-')) + sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) + writeCookieIdentityMock.mockReset() + writeCookieIdentityMock.mockResolvedValue(undefined) + appGetPathMock.mockReset() + appGetPathMock.mockReturnValue(join(tmpDir, 'userData')) + setPendingCookieImportMock.mockClear() + clearPendingCookieImportMock.mockClear() + execFileSyncMock.mockReset() + execFileSyncMock.mockImplementation(() => { + throw new Error('OS credential commands are unavailable in this test') + }) + sessionFromPartitionMock.mockReset() + sessionFromPartitionMock.mockReturnValue({ + cookies: { + get: vi.fn().mockResolvedValue(populatedTargetJar()), + set: vi.fn(async () => undefined), + remove: cookiesRemoveMock, + flushStore: vi.fn().mockResolvedValue(undefined) + }, + clearData: vi.fn().mockResolvedValue(undefined), + setUserAgent: vi.fn(), + getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test') + }) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('leaves sessions for sites outside the import set signed in', async () => { + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { domain: '.github.com', name: 'user_session', value: 'imported-github' } + ]).close() + createChromiumCookieTestDatabase(targetCookiesPath, []).close() + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + const removedNames = cookiesRemoveMock.mock.calls.map(([, name]) => name) + expect(removedNames).toEqual(['user_session']) + expect(cookiesRemoveMock).toHaveBeenCalledWith('https://github.com/', 'user_session') + // The three assertions the whole ticket is about. + expect(removedNames).not.toContain('rack.session') + expect(removedNames).not.toContain('SID') + expect(removedNames).not.toContain('orphan') + }) + + it('clears a subdomain of an imported domain, and nothing under a sibling registrable domain', async () => { + sessionFromPartitionMock.mockReturnValue({ + cookies: { + get: vi.fn().mockResolvedValue([ + { + name: 'gist-session', + value: 'stale', + domain: 'gist.github.com', + hostOnly: true, + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax' + }, + { + name: 'lookalike', + value: 'live', + domain: 'notgithub.com', + hostOnly: true, + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax' + } + ]), + set: vi.fn(async () => undefined), + remove: cookiesRemoveMock, + flushStore: vi.fn().mockResolvedValue(undefined) + }, + clearData: vi.fn().mockResolvedValue(undefined), + setUserAgent: vi.fn(), + getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test') + }) + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { domain: '.github.com', name: 'user_session', value: 'imported-github' } + ]).close() + createChromiumCookieTestDatabase(targetCookiesPath, []).close() + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + expect(cookiesRemoveMock.mock.calls.map(([, name]) => name)).toEqual(['gist-session']) + }) + + // Why: cold-start replay uses the staged image after the live import has returned. An in-memory + // assertion cannot prove its rows and recorded merge scope preserve unrelated sessions. + it('keeps out-of-scope rows in the staged cold-start image', async () => { + // Why: no unaddressable cookie in this jar — the staged image is what this case is about, so + // the live clear must run to completion rather than aborting on something else. + sessionFromPartitionMock.mockReturnValue({ + cookies: { + get: vi + .fn() + .mockResolvedValue(populatedTargetJar().filter((cookie) => cookie.domain !== '')), + set: vi.fn(async () => undefined), + remove: cookiesRemoveMock, + flushStore: vi.fn().mockResolvedValue(undefined) + }, + clearData: vi.fn().mockResolvedValue(undefined), + setUserAgent: vi.fn(), + getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test') + }) + writeCookieIdentityMock.mockRejectedValue(new Error('rejected so the staged image is kept')) + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { domain: '.github.com', name: 'user_session', value: 'imported-github' } + ]).close() + createChromiumCookieTestDatabase( + targetCookiesPath, + [ + { domain: 'the-internet.herokuapp.com', name: 'rack.session', value: 'live-login' }, + { domain: '.google.com', name: 'SID', value: 'google-live' }, + { domain: '.github.com', name: 'user_session', value: 'stale-github' } + ], + { journalMode: 'wal' } + ).close() + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + expect(setPendingCookieImportMock).toHaveBeenCalledTimes(1) + const stagedPath = setPendingCookieImportMock.mock.calls[0][1] as string + expect(existsSync(`${stagedPath}-wal`)).toBe(false) + const staged = new DatabaseSync(stagedPath, { readOnly: true }) + try { + expect( + staged.prepare('SELECT domain, format_version FROM orca_cookie_import_scope').all() + ).toEqual([{ domain: 'github.com', format_version: 1 }]) + const rows = ( + staged + .prepare('SELECT host_key, name, value FROM cookies ORDER BY host_key, name') + .all() as { host_key: string; name: string; value: string | Uint8Array }[] + ).map((row) => ({ + ...row, + // Why: a staged row copies the source value column verbatim, which node:sqlite hands back + // as bytes when it was written from a buffer. + value: typeof row.value === 'string' ? row.value : Buffer.from(row.value).toString('latin1') + })) + expect(rows).toEqual([ + // The stale github.com row was replaced by the imported one — the clear that is justified. + { host_key: '.github.com', name: 'user_session', value: 'imported-github' }, + { host_key: '.google.com', name: 'SID', value: 'google-live' }, + // Still signed in, one restart later. + { host_key: 'the-internet.herokuapp.com', name: 'rack.session', value: 'live-login' } + ]) + } finally { + staged.close() + } + }) +}) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index c39a831a6bb..22d4dc5e270 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -77,9 +77,9 @@ import { browserSessionRegistry } from './browser-session-registry' import { isGoogleSourceBoundCookie, isNonTransplantableCookieDomain, - NON_TRANSPLANTABLE_HOST_KEY_SQL, normalizeCookieDomain, normalizeCookieImportDomain, + importedDomainScope, replaceCookiesForImportedDomains, type CookieImportMode, type ReplacedImportedDomainCookies @@ -111,6 +111,7 @@ import { type ChromiumCookieSnapshot } from './chromium-cookie-snapshot' import { resolveChromiumCookiesPath } from './chromium-cookie-path' +import { prepareStagedCookiesForImport } from './browser-cookie-staged-import' import { copyFileWithWindowsRetry } from '../codex-accounts/fs-utils' // --------------------------------------------------------------------------- @@ -1587,7 +1588,8 @@ export async function importCookiesFromBrowser( // Why: cookies.set() rejects many valid values (bytes > 0x7F); instead write plaintext to the `value` column, which CookieMonster reads raw when `encrypted_value` is empty and re-encrypts on flush in packaged builds. - // Why: CookieMonster overwrites the live DB on flush, so stage a populated copy and swap it in at next cold start. + // Why: CookieMonster can reject otherwise valid imported bytes, so stage a populated copy whose + // imported-domain rows can be merged into the live DB on the next cold start. const targetSession = session.fromPartition(targetPartition) // Why (STA-4601): native imports mutate the live jar and their staged image before the old // clear/write lock was reached. Hold the per-partition lock from the first flush through staging, @@ -1705,16 +1707,16 @@ export async function importCookiesFromBrowser( // 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: string[] = targetColumnInfo.map((r) => r.name) colList = targetCols.join(', ') placeholders = targetCols.map(() => '?').join(', ') - // Why: the staged DB replaces the whole live DB at cold start, so it is a clear step - // like any other — keep the live non-transplantable rows in it rather than replaying - // a wipe the in-memory path was not allowed to perform. - stagingDb.exec(`DELETE FROM cookies WHERE NOT (${NON_TRANSPLANTABLE_HOST_KEY_SQL})`) } catch (err) { diag(` staging database unusable, restart fallback disabled: ${String(err)}`) stagingAvailable = false @@ -1846,8 +1848,8 @@ export async function importCookiesFromBrowser( disableStaging('staged database exposed no cookies columns') } - // Why (§4.3b): a staged image is a whole-DB replacement on next start, so it cannot represent - // "preserve this family". When anything is preserved, this import gets no cold-start fallback. + // 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 (nativePlan.skippedFamilies.size > 0) { disableStaging( `${nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` @@ -1955,12 +1957,29 @@ export async function importCookiesFromBrowser( }) } + for (const { entry } of scanned) { + domainSet.add(entry.domain.startsWith('.') ? entry.domain.slice(1) : entry.domain) + } + // Why (STA-4797): the import may only destroy what it is replacing. Naming the scope from the + // plan — the same rows the writes come from — is what keeps the removal set from drifting past + // the write set, and it is derived here rather than at the clear because the staged image below + // has to be cleared to the identical scope. + const importScope = importedDomainScope([...domainSet]) + + // Why (STA-4797): the staged image must carry the same imported-domain scope as the live clear. + // Cold-start replay uses it to replace only those rows and preserve newer unrelated sessions. + if (stagingDb && insertStmt) { + try { + prepareStagedCookiesForImport(stagingDb, importScope) + } catch (err) { + disableStaging(String(err)) + } + } + // EMIT: everything downstream derives from the plan, so there is no second place a row can // leak in. for (const { entry, sourceRow } of scanned) { decryptedCookies.push(entry) - const cleanDomain = entry.domain.startsWith('.') ? entry.domain.slice(1) : entry.domain - domainSet.add(cleanDomain) if (insertStmt && targetColumnInfo) { try { const params = buildChromiumCookieInsertParams( @@ -2036,9 +2055,12 @@ export async function importCookiesFromBrowser( diag(` staging skipped: ${imported} cookies will load in-memory only`) } - // Why: clear stale cookies first; mixing them with the imported set makes sites reject the - // session. Non-transplantable families are exempt — nothing was imported for them, and their - // live session is the only one that works. + // Why: clear stale cookies for the domains being imported first; mixing them with the imported + // set makes sites reject the session. Non-transplantable families are exempt — nothing was + // imported for them, and their live session is the only one that works. + // Why (STA-4797): every other site in the partition is exempt too. The rationale above reaches + // only as far as the domains this import writes; beyond them a clear has nothing to reconcile + // and only signs the user out of sessions the import was never about. // Why (STA-4300): one store spans the clear and the writes, so both halves of the import speak // the same CDP identities — cookies.set() cannot express the partition either one reads. const cookieClearStore = openCookieClearStore(targetSession) @@ -2048,7 +2070,6 @@ export async function importCookiesFromBrowser( await removeTransplantableCookies( { cookies: cookieClearStore, - clearData: (options) => targetSession.clearData(options), snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies), restoreClearIdentities: (identities) => cookieClearStore.restoreClearIdentities(identities) @@ -2056,10 +2077,11 @@ export async function importCookiesFromBrowser( // Why (STA-4300): the families this import declined to write must not be removed either. // Passing them here keeps their coordinates out of the removal plan AND out of the CDP // snapshot taken from it, so they are never submitted to any mutation. - nativePlan.skippedFamilies + nativePlan.skippedFamilies, + importScope ) diag( - ` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies` + ` cleared existing cookies for ${domainSet.size} imported domains before loading ${decryptedCookies.length} imported cookies` ) const writable: SourceCookieToWrite[] = [] diff --git a/src/main/browser/browser-cookie-staged-import.ts b/src/main/browser/browser-cookie-staged-import.ts new file mode 100644 index 00000000000..c013ee58934 --- /dev/null +++ b/src/main/browser/browser-cookie-staged-import.ts @@ -0,0 +1,208 @@ +import { DatabaseSync } from 'node:sqlite' +import type { ImportedDomainScope } from './browser-cookie-import-policy' +import { + domainIsInImportedScope, + importedDomainScope, + isNonTransplantableCookieDomain, + normalizeCookieDomain, + normalizeCookieImportDomain +} from './browser-cookie-import-policy' + +const IMPORT_SCOPE_TABLE = 'orca_cookie_import_scope' +const IMPORT_SCOPE_FORMAT_VERSION = 1 +export const SCOPED_COOKIE_IMPORT_FORMAT = `scoped-v${IMPORT_SCOPE_FORMAT_VERSION}` + +type StagedHostKeyRow = { host_key: unknown } +type StagedScopeRow = { domain: unknown; format_version: unknown } +type SqliteColumnRow = { name: unknown } +type SqliteTableRow = { sql: unknown } + +function quotedIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"` +} + +function cookieColumns(database: DatabaseSync, schema: 'main' | 'staged_import'): string[] { + return (database.prepare(`PRAGMA ${schema}.table_info(cookies)`).all() as SqliteColumnRow[]) + .map((row) => row.name) + .filter((name): name is string => typeof name === 'string') +} + +function cookieTableSql(database: DatabaseSync, schema: 'main' | 'staged_import'): string | null { + const row = database + .prepare(`SELECT sql FROM ${schema}.sqlite_master WHERE type = 'table' AND name = 'cookies'`) + .get() as SqliteTableRow | undefined + return typeof row?.sql === 'string' ? row.sql : null +} + +function isHostKeyInScope(hostKey: unknown, scope: ImportedDomainScope): hostKey is string { + if (typeof hostKey !== 'string' || isNonTransplantableCookieDomain(hostKey)) { + return false + } + const domain = normalizeCookieDomain(hostKey) + return domain !== null && domainIsInImportedScope(scope, domain, !hostKey.startsWith('.')) +} + +function readImportedScope( + database: DatabaseSync, + schema: 'main' | 'staged_import' +): ImportedDomainScope | null { + const marker = database + .prepare(`SELECT 1 AS present FROM ${schema}.sqlite_master WHERE type = 'table' AND name = ?`) + .get(IMPORT_SCOPE_TABLE) + if (!marker) { + return null + } + const scopeRows = database + .prepare( + `SELECT domain, format_version FROM ${schema}.${quotedIdentifier( + IMPORT_SCOPE_TABLE + )} ORDER BY domain` + ) + .all() as StagedScopeRow[] + const scopeDomains: string[] = [] + for (const row of scopeRows) { + if ( + row.format_version !== IMPORT_SCOPE_FORMAT_VERSION || + typeof row.domain !== 'string' || + normalizeCookieImportDomain(row.domain) !== row.domain + ) { + throw new Error('Staged cookie import has an invalid domain scope') + } + scopeDomains.push(row.domain) + } + if (scopeDomains.length === 0) { + throw new Error('Staged cookie import has an invalid domain scope') + } + return importedDomainScope(scopeDomains) +} + +// Why (STA-4797): the staged image is a copy of the live jar that may be replayed on the next cold +// start. Record the exact normalized import scope inside it so replay can merge only these rows into +// the then-current jar instead of replacing unrelated sessions with an older snapshot. +export function prepareStagedCookiesForImport( + stagingDb: DatabaseSync, + importScope: ImportedDomainScope +): void { + if (importScope.exact.size === 0) { + return + } + stagingDb.exec( + `CREATE TABLE IF NOT EXISTS ${quotedIdentifier(IMPORT_SCOPE_TABLE)} (` + + 'domain TEXT PRIMARY KEY, format_version INTEGER NOT NULL)' + ) + stagingDb.exec(`DELETE FROM ${quotedIdentifier(IMPORT_SCOPE_TABLE)}`) + const insertScope = stagingDb.prepare( + `INSERT INTO ${quotedIdentifier(IMPORT_SCOPE_TABLE)} (domain, format_version) VALUES (?, ?)` + ) + for (const domain of importScope.exact) { + insertScope.run(domain, IMPORT_SCOPE_FORMAT_VERSION) + } + + const hostKeys = ( + stagingDb.prepare('SELECT DISTINCT host_key FROM cookies').all() as StagedHostKeyRow[] + ).map((row) => row.host_key) + const deleteByHostKey = stagingDb.prepare('DELETE FROM cookies WHERE host_key = ?') + for (const hostKey of hostKeys) { + if (isHostKeyInScope(hostKey, importScope)) { + deleteByHostKey.run(hostKey) + } + } +} + +export function isScopedStagedCookieImport(stagedCookiesPath: string): boolean { + const database = new DatabaseSync(stagedCookiesPath, { readOnly: true }) + try { + return readImportedScope(database, 'main') !== null + } finally { + database.close() + } +} + +export function removeCookieImportScopeMarker(liveCookiesPath: string): void { + const database = new DatabaseSync(liveCookiesPath, { timeout: 1_000 }) + try { + database.exec(`DROP TABLE IF EXISTS ${quotedIdentifier(IMPORT_SCOPE_TABLE)}`) + } finally { + database.close() + } +} + +/** + * Applies a scoped staged import and returns false for a legacy unmarked whole-image replay. + * + * Why: one transaction covers every scoped delete and insert. A failure or crash therefore leaves + * the current live jar unchanged; after a committed crash, replay is idempotent and still cannot + * touch an unrelated host key. + */ +export function applyScopedStagedCookieImport( + liveCookiesPath: string, + stagedCookiesPath: string +): boolean { + const database = new DatabaseSync(liveCookiesPath, { timeout: 1_000 }) + let attached = false + let transactionOpen = false + try { + database.prepare('ATTACH DATABASE ? AS staged_import').run(stagedCookiesPath) + attached = true + const scope = readImportedScope(database, 'staged_import') + if (!scope) { + return false + } + + const liveColumns = cookieColumns(database, 'main') + const stagedColumns = cookieColumns(database, 'staged_import') + if ( + !liveColumns.includes('host_key') || + cookieTableSql(database, 'main') !== cookieTableSql(database, 'staged_import') || + liveColumns.length !== stagedColumns.length || + liveColumns.some((column, index) => column !== stagedColumns[index]) + ) { + throw new Error('Staged cookie import has an incompatible cookie schema') + } + // Why: partial column intersections can silently erase a partition key or fill a newly required + // column with the wrong default after an app update. Exact schema parity is the safe boundary. + const columnList = liveColumns.map(quotedIdentifier).join(', ') + database.exec('BEGIN IMMEDIATE') + transactionOpen = true + const hostKeys = database + .prepare('SELECT host_key FROM main.cookies UNION SELECT host_key FROM staged_import.cookies') + .all() as StagedHostKeyRow[] + const deleteLiveHost = database.prepare('DELETE FROM main.cookies WHERE host_key = ?') + const insertStagedHost = database.prepare( + `INSERT OR REPLACE INTO main.cookies (${columnList}) ` + + `SELECT ${columnList} FROM staged_import.cookies WHERE host_key = ?` + ) + + for (const { host_key: hostKey } of hostKeys) { + if (!isHostKeyInScope(hostKey, scope)) { + continue + } + deleteLiveHost.run(hostKey) + insertStagedHost.run(hostKey) + } + // A missing-live replay may have copied a full marked image on an earlier interrupted start. + // Never leave that marker in Chromium's live DB where a downgrade could propagate it. + database.exec(`DROP TABLE IF EXISTS main.${quotedIdentifier(IMPORT_SCOPE_TABLE)}`) + database.exec('COMMIT') + transactionOpen = false + return true + } catch (error) { + if (transactionOpen) { + try { + database.exec('ROLLBACK') + } catch { + /* closing the database rolls back any transaction SQLite still owns */ + } + } + throw error + } finally { + if (attached) { + try { + database.exec('DETACH DATABASE staged_import') + } catch { + /* close releases the attached file even after a failed transaction */ + } + } + database.close() + } +} diff --git a/src/main/browser/browser-manager-auth-user-agent.test.ts b/src/main/browser/browser-manager-auth-user-agent.test.ts index 79f24c83de8..405b689e850 100644 --- a/src/main/browser/browser-manager-auth-user-agent.test.ts +++ b/src/main/browser/browser-manager-auth-user-agent.test.ts @@ -48,6 +48,11 @@ import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness' +import { + createViewportGuestFactory, + flushViewportOps, + GUEST_CLEAN_UA +} from './browser-manager-viewport-test-fixtures' const { guestOffMock, @@ -57,6 +62,7 @@ const { guestOpenDevToolsMock, webContentsFromIdMock } = browserMocks +const makeViewportGuest = createViewportGuestFactory(browserMocks) describe('browserManager', () => { beforeEach(() => { @@ -68,8 +74,9 @@ describe('browserManager', () => { vi.useRealTimers() }) - it('presents the Firefox UA on Google auth hosts and restores the base UA off them', () => { + it('presents the Firefox UA on Google auth hosts and restores the base UA off them', async () => { let currentUa = guestBaseUserAgent + const sendCommand = vi.fn().mockResolvedValue(undefined) const setUserAgent = vi.fn((ua: string) => { currentUa = ua }) @@ -85,6 +92,7 @@ describe('browserManager', () => { getURL: vi.fn(() => 'https://accounts.google.com/'), getUserAgent: vi.fn(() => currentUa), setUserAgent, + debugger: { isAttached: vi.fn(() => true), sendCommand }, session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } } webContentsFromIdMock.mockReturnValue(guest) @@ -98,14 +106,26 @@ describe('browserManager', () => { const didStartNavigation = guestOnMock.mock.calls.find( ([event]) => event === 'did-start-navigation' )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const willRedirect = guestOnMock.mock.calls.find( + ([event]) => event === 'will-redirect' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void setUserAgent.mockClear() didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) expect(setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent()) - // Off the auth host, the guest's base identity is restored. - didStartNavigation(null, 'https://myaccount.google.com/', false, true) - expect(setUserAgent).toHaveBeenLastCalledWith(guestBaseUserAgent) + // A redirect off the auth host must derive the CDP write from the session UA, not the stale + // Firefox WebContents UA installed by the direct navigation. + sendCommand.mockClear() + willRedirect(null, 'https://myaccount.google.com/', false, true) + const uaOverrideIndex = sendCommand.mock.calls.findIndex( + ([method]) => method === 'Emulation.setUserAgentOverride' + ) + await expect(sendCommand.mock.results[uaOverrideIndex]?.value).resolves.toBeUndefined() + expect(sendCommand.mock.calls[uaOverrideIndex]).toEqual([ + 'Emulation.setUserAgentOverride', + { userAgent: guestBaseUserAgent } + ]) // A navigation that doesn't change the required UA must not thrash setUserAgent. setUserAgent.mockClear() @@ -233,4 +253,297 @@ describe('browserManager', () => { popupDidStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) expect(popupSetUserAgent).not.toHaveBeenCalled() }) + + // Why: WebContents.setUserAgent() from will-redirect makes Chromium cancel the in-flight navigation + // (ERR_ABORTED) and replay the original request. A "Sign in with Google" button POSTs to the + // provider and lands on accounts.google.com only by redirect, so the replay never reproduces it and + // the tab is left blank. Verified against Electron 43.1.0: the server sees the original request twice. + it('retargets the auth-host UA over CDP during a redirect instead of cancelling it', async () => { + const debuggerHandlers = new Map void>() + let holdNextUserAgentOverride = false + let releaseHeldUserAgentOverride = (): void => {} + let rejectNextUserAgentOverride = false + const sendCommand = vi.fn((method: string) => { + if (method === 'Emulation.setUserAgentOverride' && holdNextUserAgentOverride) { + holdNextUserAgentOverride = false + return new Promise((resolve) => { + releaseHeldUserAgentOverride = resolve + }) + } + if (method === 'Emulation.setUserAgentOverride' && rejectNextUserAgentOverride) { + rejectNextUserAgentOverride = false + return Promise.reject(new Error('debugger detached')) + } + return Promise.resolve(undefined) + }) + const guest = { + id: 418, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://mail.google.com/'), + getUserAgent: vi.fn(() => guestBaseUserAgent), + setUserAgent: vi.fn(), + debugger: { + isAttached: vi.fn(() => true), + sendCommand, + on: vi.fn((event: string, handler: () => void) => debuggerHandlers.set(event, handler)), + off: vi.fn((event: string) => debuggerHandlers.delete(event)) + }, + session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-redirect-ua', + webContentsId: guest.id, + rendererWebContentsId + }) + const willRedirect = guestOnMock.mock.calls.find( + ([event]) => event === 'will-redirect' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + sendCommand.mockClear() + + rejectNextUserAgentOverride = true + willRedirect( + { preventDefault: vi.fn() }, + 'https://accounts.google.com/v3/signin/identifier', + false, + true + ) + await expect(sendCommand.mock.results.at(-1)?.value).rejects.toThrow('debugger detached') + + // A rejected write must not be recorded as installed; the next navigation retries it. + willRedirect( + { preventDefault: vi.fn() }, + 'https://accounts.google.com/v3/signin/identifier', + false, + true + ) + await expect(sendCommand.mock.results.at(-1)?.value).resolves.toBeUndefined() + + expect(guest.setUserAgent).not.toHaveBeenCalled() + expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: googleAuthUserAgent() + }) + + // Detaching clears Chromium's CDP overrides, so the confirmed identity must be invalidated too. + sendCommand.mockClear() + debuggerHandlers.get('detach')?.() + willRedirect( + { preventDefault: vi.fn() }, + 'https://accounts.google.com/v3/signin/identifier', + false, + true + ) + expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: googleAuthUserAgent() + }) + + // Why: the CDP override outranks the WebContents UA, so getUserAgent() still reports the base + // identity. Leaving the auth host must read the override, not that stale value, or the guest + // keeps presenting Firefox on every later host. + sendCommand.mockClear() + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + rejectNextUserAgentOverride = true + didStartNavigation(null, 'https://example.com/', false, true) + await expect(sendCommand.mock.results.at(-1)?.value).rejects.toThrow('debugger detached') + didStartNavigation(null, 'https://example.com/', false, true) + + expect(guest.setUserAgent).not.toHaveBeenCalled() + expect(sendCommand.mock.calls).toEqual([ + ['Emulation.setUserAgentOverride', { userAgent: guestBaseUserAgent }], + ['Emulation.setUserAgentOverride', { userAgent: guestBaseUserAgent }] + ]) + + // A newer confirmed write outranks an older write that remains in flight. + debuggerHandlers.get('detach')?.() + holdNextUserAgentOverride = true + willRedirect( + { preventDefault: vi.fn() }, + 'https://accounts.google.com/v3/signin/identifier', + false, + true + ) + const olderHeldWrite = sendCommand.mock.results.at(-1)?.value as Promise + didStartNavigation(null, 'https://example.com/', false, true) + await expect(sendCommand.mock.results.at(-1)?.value).resolves.toBeUndefined() + + sendCommand.mockClear() + willRedirect( + { preventDefault: vi.fn() }, + 'https://accounts.google.com/v3/signin/identifier', + false, + true + ) + expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: googleAuthUserAgent() + }) + releaseHeldUserAgentOverride() + await olderHeldWrite + + // A newer failure must not discard an older write that is still in flight. + debuggerHandlers.get('detach')?.() + holdNextUserAgentOverride = true + willRedirect( + { preventDefault: vi.fn() }, + 'https://accounts.google.com/v3/signin/identifier', + false, + true + ) + const heldWrite = sendCommand.mock.results.at(-1)?.value as Promise + rejectNextUserAgentOverride = true + didStartNavigation(null, 'https://example.com/', false, true) + await expect(sendCommand.mock.results.at(-1)?.value).rejects.toThrow('debugger detached') + releaseHeldUserAgentOverride() + await heldWrite + + sendCommand.mockClear() + didStartNavigation(null, 'https://example.com/', false, true) + expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: guestBaseUserAgent + }) + browserManager.unregisterGuest('browser-redirect-ua') + }) + + // Why: a redirect must never be cancelled to keep the identity in sync. Without a debugger there is + // no cancel-free write available, and a stale navigator.userAgent is recoverable where a dead + // navigation is not. + it('leaves a redirect alone when no debugger is attached to write the UA', () => { + const guest = { + id: 419, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://mail.google.com/'), + getUserAgent: vi.fn(() => guestBaseUserAgent), + setUserAgent: vi.fn(), + debugger: { isAttached: vi.fn(() => false), sendCommand: vi.fn() }, + session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-redirect-no-debugger', + webContentsId: guest.id, + rendererWebContentsId + }) + const willRedirect = guestOnMock.mock.calls.find( + ([event]) => event === 'will-redirect' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + willRedirect( + { preventDefault: vi.fn() }, + 'https://accounts.google.com/v3/signin/identifier', + false, + true + ) + + expect(guest.setUserAgent).not.toHaveBeenCalled() + expect(guest.debugger.sendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.anything() + ) + }) + + // Why: a direct load reaches did-start-navigation before the request is dispatched, so the + // WebContents write is safe there and must stay — CDP is the redirect-path mechanism only. + it('still uses the WebContents UA write for navigations that are not redirects', () => { + const guest = { + id: 420, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://example.com/'), + getUserAgent: vi.fn(() => guestBaseUserAgent), + setUserAgent: vi.fn(), + debugger: { isAttached: vi.fn(() => true), sendCommand: vi.fn(async () => undefined) }, + session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-direct-ua', + webContentsId: guest.id, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + + expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent()) + expect(guest.debugger.sendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.anything() + ) + }) + + // Why: the direct-navigation branch still writes the Firefox UA through WebContents.setUserAgent, + // and nothing ever restores it once the guest switches to the CDP override. A viewport preset that + // read getUserAgent() back as its base identity would therefore republish Firefox on every ordinary + // host — the wire UA saying Firefox while sec-ch-ua still says Chrome, the exact cross-layer tell + // this scope exists to remove. + it('keeps a viewport preset on the session identity after an auth-host visit', async () => { + const { guest, debuggerSendCommand } = makeViewportGuest(9001) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-auth-then-preset', + webContentsId: guest.id as number, + rendererWebContentsId + }) + await browserManager.setViewportOverride('tab-auth-then-preset', { + width: 1280, + height: 800, + deviceScaleFactor: 1, + mobile: false + }) + await flushViewportOps() + + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const willRedirect = guestOnMock.mock.calls.find( + ([event]) => event === 'will-redirect' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + await flushViewportOps() + // The direct branch pins the WebContents UA to Firefox and never restores it. + expect((guest.getUserAgent as () => string)()).toBe(googleAuthUserAgent()) + + willRedirect({ preventDefault: vi.fn() }, 'https://myaccount.google.com/', false, true) + await flushViewportOps() + + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://github.com/', false, true) + await flushViewportOps() + + const uaWrites = debuggerSendCommand.mock.calls.filter( + ([method]) => method === 'Emulation.setUserAgentOverride' + ) + expect(uaWrites.length).toBeGreaterThan(0) + for (const [, params] of uaWrites) { + expect((params as { userAgent: string }).userAgent).toBe(GUEST_CLEAN_UA) + } + }) }) diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts index f4d09a913a3..c72936cf6a3 100644 --- a/src/main/browser/browser-manager-viewport-override.test.ts +++ b/src/main/browser/browser-manager-viewport-override.test.ts @@ -55,6 +55,12 @@ import { const { guestOnMock, webContentsFromIdMock } = browserMocks const makeGuest = createViewportGuestFactory(browserMocks) +const MOBILE_VIEWPORT_OVERRIDE = { + width: 375, + height: 667, + deviceScaleFactor: 2, + mobile: true +} as const describe('browserManager', () => { beforeEach(() => { @@ -68,12 +74,7 @@ describe('browserManager', () => { describe('setViewportOverride', () => { it('returns false when the tab is not registered', async () => { - const result = await browserManager.setViewportOverride('missing', { - width: 375, - height: 667, - deviceScaleFactor: 2, - mobile: true - }) + const result = await browserManager.setViewportOverride('missing', MOBILE_VIEWPORT_OVERRIDE) expect(result).toBe(false) }) @@ -89,12 +90,7 @@ describe('browserManager', () => { webContentsFromIdMock.mockReset() webContentsFromIdMock.mockReturnValue(guest) - const ok = await browserManager.setViewportOverride('tab-mobile', { - width: 375, - height: 667, - deviceScaleFactor: 2, - mobile: true - }) + const ok = await browserManager.setViewportOverride('tab-mobile', MOBILE_VIEWPORT_OVERRIDE) expect(ok).toBe(true) expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setDeviceMetricsOverride', { @@ -194,6 +190,14 @@ describe('browserManager', () => { const didStartNavigation = guestOnMock.mock.calls.find( ([event]) => event === 'did-start-navigation' )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const willRedirect = guestOnMock.mock.calls.find( + ([event]) => event === 'will-redirect' + )?.[1] as ( + event: { preventDefault: () => void }, + url: string, + isInPlace: boolean, + isMainFrame: boolean + ) => void // Case A: the desktop preset lands first, while the tab is still off the auth host. await browserManager.setViewportOverride('tab-auth-nav', { @@ -216,11 +220,9 @@ describe('browserManager', () => { // Leaving the auth host restores the clean Chrome-shaped preset UA. debuggerSendCommand.mockClear() - didStartNavigation(null, 'https://example.com/', false, true) + willRedirect({ preventDefault: vi.fn() }, 'https://example.com/', false, true) await flushViewportOps() - expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { - userAgent: GUEST_CLEAN_UA - }) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) }) // Why: not an ordering race — debugger.sendCommand dispatches in call order over one channel, so @@ -263,12 +265,10 @@ describe('browserManager', () => { ([event]) => event === 'did-start-navigation' )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - const presetDone = browserManager.setViewportOverride('tab-race-onto-auth', { - width: 375, - height: 667, - deviceScaleFactor: 2, - mobile: true - }) + const presetDone = browserManager.setViewportOverride( + 'tab-race-onto-auth', + MOBILE_VIEWPORT_OVERRIDE + ) await flushViewportOps() // The tab navigates to the auth host while the preset is still awaiting its first command. @@ -382,12 +382,7 @@ describe('browserManager', () => { // A later preset must also resolve the committed, non-auth URL. debuggerSendCommand.mockClear() - await browserManager.setViewportOverride('tab-pending-cleared', { - width: 375, - height: 667, - deviceScaleFactor: 2, - mobile: true - }) + await browserManager.setViewportOverride('tab-pending-cleared', MOBILE_VIEWPORT_OVERRIDE) await flushViewportOps() expect(lastUserAgentOverride(debuggerSendCommand)?.userAgent).toContain('iPhone') @@ -450,12 +445,16 @@ describe('browserManager', () => { willRedirect({ preventDefault: vi.fn() }, 'https://accounts.google.com/same', false, true) didStartNavigation(null, 'https://accounts.google.com/same', false, true) await flushViewportOps() + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ + userAgent: googleAuthUserAgent() + }) debuggerSendCommand.mockClear() didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/same', true) await flushViewportOps() - expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent()) + // Why: the redirect path never writes the WebContents UA — that write cancels the navigation. + expect(guest.setUserAgent).not.toHaveBeenCalled() expect(debuggerSendCommand).not.toHaveBeenCalledWith( 'Emulation.setUserAgentOverride', expect.objectContaining({ userAgent: GUEST_CLEAN_UA }) @@ -507,14 +506,92 @@ describe('browserManager', () => { ) await flushViewportOps() - expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent()) + // Why: the identity switch for a redirect goes over CDP only. WebContents.setUserAgent() here + // makes Chromium cancel the in-flight navigation and replay the original request, which is what + // left Google sign-in on a blank tab. + expect(guest.setUserAgent).not.toHaveBeenCalled() expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: googleAuthUserAgent() }) didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/redirected', true) await flushViewportOps() - expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA) + expect(guest.setUserAgent).not.toHaveBeenCalled() + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) + }) + + it('preserves the auth identity when a viewport preset is cleared after a redirect', async () => { + const { guest, debuggerSendCommand } = makeGuest(4262, 'https://example.com/') + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-clear-on-auth', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const willRedirect = guestOnMock.mock.calls.find( + ([event]) => event === 'will-redirect' + )?.[1] as ( + event: { preventDefault: () => void }, + url: string, + isInPlace: boolean, + isMainFrame: boolean + ) => void + + await browserManager.setViewportOverride('tab-clear-on-auth', MOBILE_VIEWPORT_OVERRIDE) + didStartNavigation(null, 'https://example.com/start', false, true) + willRedirect({ preventDefault: vi.fn() }, 'https://accounts.google.com/', false, true) + await flushViewportOps() + + debuggerSendCommand.mockClear() + await expect(browserManager.setViewportOverride('tab-clear-on-auth', null)).resolves.toBe( + true + ) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ + userAgent: googleAuthUserAgent() + }) + expect(debuggerSendCommand).not.toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: '' + }) + }) + + it('does not inherit a mobile owner UA in a desktop popup', async () => { + const { guest: ownerGuest } = makeGuest(4263) + webContentsFromIdMock.mockReturnValue(ownerGuest) + browserManager.attachGuestPolicies(ownerGuest as never) + browserManager.registerGuest({ + browserPageId: 'tab-mobile-popup-owner', + webContentsId: ownerGuest.id as number, + rendererWebContentsId + }) + await browserManager.setViewportOverride('tab-mobile-popup-owner', MOBILE_VIEWPORT_OVERRIDE) + + const { guest: popupGuest, debuggerSendCommand } = makeGuest(4264, 'https://mail.google.com/') + const popupOn = vi.fn() + popupGuest.on = popupOn + browserManager.attachGuestPolicies(popupGuest as never, { + browserTabId: 'tab-mobile-popup-owner', + rootGuestWebContentsId: ownerGuest.id as number + }) + const willRedirect = popupOn.mock.calls.find(([event]) => event === 'will-redirect')?.[1] as ( + event: { preventDefault: () => void }, + url: string, + isInPlace: boolean, + isMainFrame: boolean + ) => void + const didStartNavigation = popupOn.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + willRedirect({ preventDefault: vi.fn() }, 'https://accounts.google.com/', false, true) + await flushViewportOps() + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://example.com/', false, true) + await flushViewportOps() + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) }) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index abbd79bcf4f..2a8360af946 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -153,6 +153,15 @@ type PendingMainFrameNavigation = { currentUrl: string supersededUrls: string[] } +type AuthUserAgentOverrideOperation = { + sequence: number + userAgent: string +} +type AuthUserAgentOverrideState = { + confirmed: AuthUserAgentOverrideOperation | null + nextSequence: number + pending: AuthUserAgentOverrideOperation[] +} const SAFE_POPUP_WINDOW_OPTIONS = { alwaysOnTop: false, closable: true, @@ -229,6 +238,12 @@ export class BrowserManager { // Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation // can re-issue it against the target URL's identity. private readonly viewportUaOverrideMobileByTabId = new Map() + // Why: the confirmed CDP identity outranks getUserAgent; pending intent keeps rapid navigations + // ordered without claiming a failed write was installed. + private readonly authUserAgentOverrideStateByGuestId = new Map< + number, + AuthUserAgentOverrideState + >() // Why: the in-flight main-frame navigation target, held only until commit or failure — getURL() // still reports the outgoing page until then. See resolveTabNavigationUrl. private readonly pendingNavigationByGuestId = new Map() @@ -315,6 +330,7 @@ export class BrowserManager { // Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides. const onDetach = (): void => { + this.authUserAgentOverrideStateByGuestId.delete(guest.id) if (!disposed && !guest.isDestroyed() && reattachTimer === null) { reattachTimer = setTimeout(() => { reattachTimer = null @@ -810,7 +826,7 @@ export class BrowserManager { return } this.updatePendingNavigationForRedirect(guest.id, url) - this.applyGoogleAuthUserAgent(guest, url) + this.applyGoogleAuthUserAgent(guest, url, { duringRedirect: true }) } const didFailLoadHandler = ( @@ -946,7 +962,11 @@ export class BrowserManager { // must be matched here per navigation or the two layers disagree — itself a bot tell. // Restores the session's base identity off the auth hosts. Native-UA profiles opt out // of the whole clean-UA path, so they keep their untouched identity everywhere. - private applyGoogleAuthUserAgent(guest: Electron.WebContents, url: string): void { + private applyGoogleAuthUserAgent( + guest: Electron.WebContents, + url: string, + options: { duringRedirect?: boolean } = {} + ): void { const browserPageId = this.tabIdByWebContentsId.get(guest.id) // Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct // lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA. @@ -961,23 +981,113 @@ export class BrowserManager { return } const firefoxUa = googleAuthUserAgent() - const currentUa = guest.getUserAgent() - if (isGoogleAuthUrl(url)) { - if (currentUa !== firefoxUa) { - guest.setUserAgent(firefoxUa) + const overrideState = this.authUserAgentOverrideStateByGuestId.get(guest.id) + const latestPendingOverride = overrideState?.pending.at(-1) + const confirmedOverride = overrideState?.confirmed + const currentOverride = + latestPendingOverride && latestPendingOverride.sequence > (confirmedOverride?.sequence ?? -1) + ? latestPendingOverride + : confirmedOverride + const currentUa = currentOverride?.userAgent ?? guest.getUserAgent() + const nextUa = isGoogleAuthUrl(url) + ? firefoxUa + : // Only restore when the auth-host override is actually in place, so normal + // navigation never touches the session UA. + currentUa === firefoxUa + ? guest.session.getUserAgent() + : null + let authOverrideIssuedOverCdp = false + if (nextUa !== null && nextUa !== currentUa) { + // Why: WebContents.setUserAgent() during a redirect makes Chromium cancel the in-flight + // navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain + // cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without + // touching the navigation, and it outranks the WebContents UA from then on, so a guest that + // switches to it stays on it. The wire UA never depended on this write: setupClientHintsOverride + // rewrites User-Agent per request for auth-host URLs on its own. + if (options.duringRedirect === true || overrideState !== undefined) { + if (this.canOverrideUserAgentOverCdp(guest)) { + authOverrideIssuedOverCdp = true + // Why: go through the viewport builder rather than writing nextUa raw, so both CDP writers + // resolve one identity for this URL — Firefox on auth hosts, the profile's clean base off + // them, any mobile preset preserved. Writing the session UA directly would put the + // unlaundered Electron token back on the wire. + void this.applyAuthUserAgentOverrideOverCdp( + guest, + (browserPageId ? this.viewportUaOverrideMobileByTabId.get(browserPageId) : undefined) ?? + false, + url, + nextUa + ) + } + // Why: with no debugger there is no way to retarget the identity without cancelling the + // redirect. A stale navigator.userAgent is recoverable; a dead navigation is not. + } else { + guest.setUserAgent(nextUa) } - } else if (currentUa === firefoxUa) { - // Only restore when the auth-host override is actually in place, so normal - // navigation never touches the session UA. - guest.setUserAgent(guest.session.getUserAgent()) } // Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of // its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport. - if (browserPageId) { + if (browserPageId && !authOverrideIssuedOverCdp) { this.reapplyViewportUserAgentOverride(guest, browserPageId, url) } } + private canOverrideUserAgentOverCdp(guest: Electron.WebContents): boolean { + try { + return !guest.isDestroyed() && guest.debugger.isAttached() + } catch { + return false + } + } + + private applyAuthUserAgentOverrideOverCdp( + guest: Electron.WebContents, + mobile: boolean, + url: string, + userAgent: string + ): Promise { + if (!this.canOverrideUserAgentOverCdp(guest)) { + return Promise.resolve(false) + } + const state = this.authUserAgentOverrideStateByGuestId.get(guest.id) ?? { + confirmed: null, + nextSequence: 0, + pending: [] + } + const operation = { sequence: ++state.nextSequence, userAgent } + state.pending.push(operation) + this.authUserAgentOverrideStateByGuestId.set(guest.id, state) + return this.sendViewportUserAgentOverride(guest, mobile, url, userAgent).then( + () => this.settleAuthUserAgentOverride(guest.id, state, operation, true), + () => { + this.settleAuthUserAgentOverride(guest.id, state, operation, false) + return false + } + ) + } + + private settleAuthUserAgentOverride( + guestId: number, + state: AuthUserAgentOverrideState, + operation: AuthUserAgentOverrideOperation, + succeeded: boolean + ): boolean { + if (this.authUserAgentOverrideStateByGuestId.get(guestId) !== state) { + return false + } + if (succeeded && (state.confirmed?.sequence ?? -1) < operation.sequence) { + state.confirmed = operation + } + const pendingIndex = state.pending.indexOf(operation) + if (pendingIndex !== -1) { + state.pending.splice(pendingIndex, 1) + } + if (state.confirmed === null && state.pending.length === 0) { + this.authUserAgentOverrideStateByGuestId.delete(guestId) + } + return true + } + private startPendingNavigation(guestId: number, url: string): void { const pending = this.pendingNavigationByGuestId.get(guestId) this.pendingNavigationByGuestId.set(guestId, { @@ -1044,7 +1154,8 @@ export class BrowserManager { private async sendViewportUserAgentOverride( guest: Electron.WebContents, mobile: boolean, - url?: string + url?: string, + baseUserAgent?: string ): Promise { if (guest.isDestroyed() || !guest.debugger.isAttached()) { return @@ -1054,7 +1165,10 @@ export class BrowserManager { buildViewportUserAgentOverride({ url: url ?? this.resolveTabNavigationUrl(guest), mobile, - baseUserAgent: cleanElectronUserAgent(guest.getUserAgent()) + // Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not: + // applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to + // the CDP override, so reading it back here would republish that identity on ordinary hosts. + baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent()) }) ) } @@ -1107,6 +1221,7 @@ export class BrowserManager { this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) this.offscreenGuestIds.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) + this.authUserAgentOverrideStateByGuestId.delete(guestWebContentsId) this.pendingNavigationByGuestId.delete(guestWebContentsId) // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. if (isPrimaryGuest) { @@ -1311,6 +1426,7 @@ export class BrowserManager { this.sessionProfileIdByPageId.clear() this.userAgentModeByPageId.clear() this.viewportUaOverrideMobileByTabId.clear() + this.authUserAgentOverrideStateByGuestId.clear() this.pendingNavigationByGuestId.clear() this.pendingLoadFailuresByGuestId.clear() this.loadErrorsByGuestId.clear() @@ -1730,9 +1846,22 @@ export class BrowserManager { const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) // A navigation after this point must not re-install the override behind the clear. this.viewportUaOverrideMobileByTabId.delete(browserTabId) - // Why: passing an empty string restores the session default UA. try { - await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) + if (this.authUserAgentOverrideStateByGuestId.has(guest.id)) { + const url = this.resolveTabNavigationUrl(guest) + const restored = await this.applyAuthUserAgentOverrideOverCdp( + guest, + false, + url, + isGoogleAuthUrl(url) ? googleAuthUserAgent() : guest.session.getUserAgent() + ) + if (!restored) { + throw new Error('Failed to preserve auth user agent') + } + } else { + // Why: passing an empty string restores the session default UA. + await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) + } } catch (error) { if (trackedMobile !== undefined) { this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile) diff --git a/src/main/browser/browser-session-cookie-staging.scoped.test.ts b/src/main/browser/browser-session-cookie-staging.scoped.test.ts new file mode 100644 index 00000000000..c5eba68c6d4 --- /dev/null +++ b/src/main/browser/browser-session-cookie-staging.scoped.test.ts @@ -0,0 +1,270 @@ +import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { appGetPathMock } = vi.hoisted(() => ({ appGetPathMock: vi.fn() })) + +vi.mock('electron', () => ({ app: { getPath: appGetPathMock } })) + +import { importedDomainScope } from './browser-cookie-import-policy' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' +import { prepareStagedCookiesForImport } from './browser-cookie-staged-import' +import { + applyPendingBrowserCookieImports, + setPendingBrowserCookieImport +} from './browser-session-cookie-staging' +import { loadBrowserSessionMeta, persistBrowserSessionMeta } from './browser-session-meta-store' + +const PARTITION = 'persist:test' + +type CookieRow = { host_key: string; name: string; value: string } + +function insertCookie(database: DatabaseSync, domain: string, name: string, value: string): void { + database + .prepare( + `INSERT INTO cookies ( + creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path, + expires_utc, is_secure, is_httponly, samesite, source_scheme, source_port, + last_update_utc, has_cross_site_ancestor + ) VALUES (1, ?, '', ?, ?, X'', '/', 0, 0, 0, 0, 0, -1, 0, 0)` + ) + .run(domain, name, value) +} + +function readCookies(databasePath: string): CookieRow[] { + const database = new DatabaseSync(databasePath, { readOnly: true }) + try { + return database + .prepare('SELECT host_key, name, value FROM cookies ORDER BY host_key, name') + .all() as CookieRow[] + } finally { + database.close() + } +} + +describe('scoped pending browser cookie imports', () => { + let tmpDir: string + let livePath: string + let metaPath: string + let stagedPath: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-scoped-cookie-replay-')) + livePath = join(tmpDir, 'Partitions', 'test', 'Network', 'Cookies') + metaPath = join(tmpDir, 'browser-session-meta.json') + stagedPath = join(tmpDir, 'staged-cookies') + appGetPathMock.mockReset() + appGetPathMock.mockReturnValue(tmpDir) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function markPending(): void { + setPendingBrowserCookieImport({ + resolveMetadataPath: () => metaPath, + defaultPartition: PARTITION, + partition: PARTITION, + stagingDbPath: stagedPath + }) + } + + function applyPending(): void { + applyPendingBrowserCookieImports({ + resolveMetadataPath: () => metaPath, + defaultPartition: PARTITION, + activeOrcaProfileId: 'test-profile' + }) + } + + function createScopedStage(): void { + copyFileSync(livePath, stagedPath) + const staged = new DatabaseSync(stagedPath) + try { + prepareStagedCookiesForImport(staged, importedDomainScope(['a.example.com'])) + insertCookie(staged, '.a.example.com', 'new-a', 'imported') + } finally { + staged.close() + } + } + + it('preserves unrelated cookies changed after staging while merging imported domains', () => { + createChromiumCookieTestDatabase( + livePath, + [ + { domain: '.example.com', name: 'parent', value: 'stale-parent' }, + { domain: '.a.example.com', name: 'old-a', value: 'stale' }, + { domain: 'child.a.example.com', name: 'child', value: 'stale-child' }, + { domain: '.unrelated.example.com', name: 'session', value: 'before-stage' } + ], + { journalMode: 'wal' } + ).close() + createScopedStage() + expect(existsSync(`${stagedPath}-wal`)).toBe(false) + + const changedLive = new DatabaseSync(livePath) + changedLive + .prepare("UPDATE cookies SET value = 'rotated-after-stage' WHERE name = 'session'") + .run() + insertCookie(changedLive, '.another.example.com', 'new-login', 'created-after-stage') + changedLive.close() + markPending() + + applyPending() + + expect(readCookies(livePath)).toEqual([ + { host_key: '.a.example.com', name: 'new-a', value: 'imported' }, + { host_key: '.another.example.com', name: 'new-login', value: 'created-after-stage' }, + { host_key: '.unrelated.example.com', name: 'session', value: 'rotated-after-stage' } + ]) + expect(existsSync(stagedPath)).toBe(false) + expect(loadBrowserSessionMeta(() => metaPath, PARTITION).pendingCookieImports).toEqual({}) + }) + + it('rolls back the merge and keeps the staged retry when an insert fails', () => { + createChromiumCookieTestDatabase(livePath, [ + { domain: '.a.example.com', name: 'old-a', value: 'keep-on-failure' }, + { domain: '.unrelated.example.com', name: 'session', value: 'live' } + ]).close() + createScopedStage() + const rejectingLive = new DatabaseSync(livePath) + rejectingLive.exec(` + CREATE TRIGGER reject_staged_cookie BEFORE INSERT ON cookies + WHEN NEW.name = 'new-a' + BEGIN + SELECT RAISE(ABORT, 'forced staged insert failure'); + END + `) + rejectingLive.close() + markPending() + + applyPending() + + expect(readCookies(livePath)).toEqual([ + { host_key: '.a.example.com', name: 'old-a', value: 'keep-on-failure' }, + { host_key: '.unrelated.example.com', name: 'session', value: 'live' } + ]) + expect(existsSync(stagedPath)).toBe(true) + expect(loadBrowserSessionMeta(() => metaPath, PARTITION).pendingCookieImports).toEqual({ + [PARTITION]: { format: 'scoped-v1', path: stagedPath } + }) + // Why: both SQLite handles must be closed after failure so Windows can read the retry file. + expect(() => copyFileSync(stagedPath, `${stagedPath}.copy`)).not.toThrow() + }) + + it('copies a scoped image when the live cookie database no longer exists', () => { + createChromiumCookieTestDatabase(livePath, [ + { domain: '.a.example.com', name: 'old-a', value: 'stale' }, + { domain: '.unrelated.example.com', name: 'session', value: 'preserved' } + ]).close() + createScopedStage() + rmSync(livePath) + markPending() + + applyPending() + + // With neither modern nor legacy DB present, replay uses Chromium's legacy fallback path. + const replayedPath = join(tmpDir, 'Partitions', 'test', 'Cookies') + expect(readCookies(replayedPath)).toEqual([ + { host_key: '.a.example.com', name: 'new-a', value: 'imported' }, + { host_key: '.unrelated.example.com', name: 'session', value: 'preserved' } + ]) + const replayed = new DatabaseSync(replayedPath, { readOnly: true }) + expect( + replayed + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'orca_%'") + .all() + ).toEqual([]) + replayed.close() + expect(existsSync(stagedPath)).toBe(false) + }) + + it('keeps whole-image replay for legacy staged databases without a scope marker', () => { + createChromiumCookieTestDatabase(livePath, [ + { domain: '.live.example', name: 'live', value: 'replaced' } + ]).close() + createChromiumCookieTestDatabase(stagedPath, [ + { domain: '.legacy.example', name: 'legacy', value: 'pending' } + ]).close() + persistBrowserSessionMeta(() => metaPath, PARTITION, { + pendingCookieImports: { [PARTITION]: stagedPath }, + pendingCookieDbPath: stagedPath + }) + + applyPending() + + expect(readCookies(livePath)).toEqual([ + { host_key: '.legacy.example', name: 'legacy', value: 'pending' } + ]) + expect(existsSync(stagedPath)).toBe(false) + expect(loadBrowserSessionMeta(() => metaPath, PARTITION).pendingCookieImports).toEqual({}) + }) + + it('fails closed across cookie schema drift without mutating the live jar', () => { + createChromiumCookieTestDatabase(livePath, [ + { domain: '.a.example.com', name: 'old-a', value: 'keep-on-mismatch' } + ]).close() + createScopedStage() + const changedSchema = new DatabaseSync(livePath) + changedSchema.exec("ALTER TABLE cookies ADD COLUMN future_required TEXT NOT NULL DEFAULT 'v'") + changedSchema.close() + markPending() + + applyPending() + + expect(readCookies(livePath)).toEqual([ + { host_key: '.a.example.com', name: 'old-a', value: 'keep-on-mismatch' } + ]) + expect(existsSync(stagedPath)).toBe(true) + expect(loadBrowserSessionMeta(() => metaPath, PARTITION).pendingCookieImports).toEqual({ + [PARTITION]: { format: 'scoped-v1', path: stagedPath } + }) + }) + + it('fails closed on an unknown staged scope format', () => { + createChromiumCookieTestDatabase(livePath, [ + { domain: '.a.example.com', name: 'old-a', value: 'keep-on-unknown-format' } + ]).close() + createScopedStage() + const invalidStage = new DatabaseSync(stagedPath) + invalidStage.exec('UPDATE orca_cookie_import_scope SET format_version = 999') + invalidStage.close() + markPending() + + applyPending() + + expect(readCookies(livePath)).toEqual([ + { host_key: '.a.example.com', name: 'old-a', value: 'keep-on-unknown-format' } + ]) + expect(existsSync(stagedPath)).toBe(true) + expect(loadBrowserSessionMeta(() => metaPath, PARTITION).pendingCookieImports).toEqual({ + [PARTITION]: { format: 'scoped-v1', path: stagedPath } + }) + }) + + it('leaves a future pending-import format untouched', () => { + createChromiumCookieTestDatabase(livePath, [ + { domain: '.a.example.com', name: 'old-a', value: 'keep-for-newer-build' } + ]).close() + createScopedStage() + persistBrowserSessionMeta(() => metaPath, PARTITION, { + pendingCookieImports: { + [PARTITION]: { format: 'scoped-v2', path: stagedPath } + }, + pendingCookieDbPath: null + }) + + applyPending() + + expect(readCookies(livePath)).toEqual([ + { host_key: '.a.example.com', name: 'old-a', value: 'keep-for-newer-build' } + ]) + expect(existsSync(stagedPath)).toBe(true) + expect(loadBrowserSessionMeta(() => metaPath, PARTITION).pendingCookieImports).toEqual({ + [PARTITION]: { format: 'scoped-v2', path: stagedPath } + }) + }) +}) diff --git a/src/main/browser/browser-session-cookie-staging.ts b/src/main/browser/browser-session-cookie-staging.ts index 70ba92f8ec4..192c55a178a 100644 --- a/src/main/browser/browser-session-cookie-staging.ts +++ b/src/main/browser/browser-session-cookie-staging.ts @@ -1,8 +1,19 @@ import { app } from 'electron' import { copyFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs' import { join } from 'node:path' -import { loadBrowserSessionMeta, persistBrowserSessionMeta } from './browser-session-meta-store' +import { + loadBrowserSessionMeta, + persistBrowserSessionMeta, + type PendingBrowserCookieImport +} from './browser-session-meta-store' import { isValidPersistedBrowserSessionProfile } from './browser-session-persisted-profile-validation' +import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' +import { + applyScopedStagedCookieImport, + isScopedStagedCookieImport, + removeCookieImportScopeMarker, + SCOPED_COOKIE_IMPORT_FORMAT +} from './browser-cookie-staged-import' import { resolveChromiumCookiesPath } from './chromium-cookie-path' type PendingCookieImportTarget = { @@ -11,6 +22,16 @@ type PendingCookieImportTarget = { defaultPartition: string } +function legacyPendingPath(entry: PendingBrowserCookieImport | undefined): string | null { + return typeof entry === 'string' ? entry : null +} + +function scopedPendingPath(entry: PendingBrowserCookieImport): string | null { + return typeof entry !== 'string' && entry.format === SCOPED_COOKIE_IMPORT_FORMAT + ? entry.path + : null +} + function partitionCookiesPath(partition: string): string { const partitionName = partition.replace('persist:', '') const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName) @@ -18,6 +39,20 @@ function partitionCookiesPath(partition: string): string { return resolveChromiumCookiesPath(partitionDir) ?? join(partitionDir, 'Cookies') } +function consumeStagedCookieImport(stagedPath: string): void { + // Why: metadata persistence is best-effort. Move the replay source out of its recorded path + // before updating metadata so a simultaneous metadata-write failure cannot replay it next start. + const consumedPath = `${stagedPath}.consumed` + renameFileWithWindowsRetry(stagedPath, consumedPath) + for (const path of [consumedPath, `${stagedPath}-wal`, `${stagedPath}-shm`]) { + try { + unlinkSync(path) + } catch { + /* the recorded replay path is already absent */ + } + } +} + // Why: must run before any session.fromPartition() so CookieMonster reads the staged cookies instead of overwriting them from its in-memory DB. export function applyPendingBrowserCookieImports({ resolveMetadataPath, @@ -39,11 +74,18 @@ export function applyPendingBrowserCookieImports({ } const remainingEntries = { ...meta.pendingCookieImports } - for (const [partition, stagedPath] of pendingEntries) { + for (const [partition, pendingEntry] of pendingEntries) { if (!knownPartitions.has(partition)) { delete remainingEntries[partition] continue } + const scopedPath = scopedPendingPath(pendingEntry) + const stagedPath = typeof pendingEntry === 'string' ? pendingEntry : scopedPath + // Why: future formats must remain pending for a newer build instead of being replayed as a + // legacy whole image or silently discarded by an older one. + if (!stagedPath) { + continue + } if (!existsSync(stagedPath)) { delete remainingEntries[partition] continue @@ -52,36 +94,50 @@ export function applyPendingBrowserCookieImports({ const liveCookiesPath = partitionCookiesPath(partition) try { mkdirSync(join(liveCookiesPath, '..'), { recursive: true }) - copyFileSync(stagedPath, liveCookiesPath) - // Why: stale WAL/SHM sidecars would corrupt CookieMonster's read of the freshly swapped DB. - let sidecarCopyFailed = false - for (const suffix of ['-wal', '-shm']) { - try { - unlinkSync(liveCookiesPath + suffix) - } catch { - /* may not exist */ + // Why: a scoped stage is still a complete valid image. If the live DB was removed, copying + // it cannot overwrite a newer unrelated cookie and avoids creating an empty DB that can + // never satisfy the scoped merge's schema check. + const liveCookiesExist = existsSync(liveCookiesPath) + const markedScopedImage = isScopedStagedCookieImport(stagedPath) + if (scopedPath && !markedScopedImage) { + throw new Error('Scoped cookie import is missing its scope marker') + } + const copiedScopedImage = !liveCookiesExist && markedScopedImage + const appliedScopedImport = + liveCookiesExist && markedScopedImage + ? applyScopedStagedCookieImport(liveCookiesPath, stagedPath) + : false + if (!appliedScopedImport) { + // Why: staged imports written before scoped replay existed have no marker. Preserve their + // existing whole-image behavior so an update does not strand an already pending import. + copyFileSync(stagedPath, liveCookiesPath) + // Why: stale WAL/SHM sidecars would corrupt CookieMonster's read of the freshly swapped DB. + let sidecarCopyFailed = false + for (const suffix of ['-wal', '-shm']) { + try { + unlinkSync(liveCookiesPath + suffix) + } catch { + /* may not exist */ + } + const stagingSidecar = stagedPath + suffix + if (!existsSync(stagingSidecar)) { + continue + } + try { + copyFileSync(stagingSidecar, liveCookiesPath + suffix) + } catch { + sidecarCopyFailed = true + } } - const stagingSidecar = stagedPath + suffix - if (!existsSync(stagingSidecar)) { + if (sidecarCopyFailed) { + // Why: sidecar copy failed → inconsistent replay; keep this entry for retry. continue } - try { - copyFileSync(stagingSidecar, liveCookiesPath + suffix) - } catch { - sidecarCopyFailed = true - } - } - if (sidecarCopyFailed) { - // Why: sidecar copy failed → inconsistent replay; keep this entry for retry. - continue - } - for (const ext of ['', '-wal', '-shm']) { - try { - unlinkSync(`${stagedPath}${ext}`) - } catch { - /* best-effort */ + if (copiedScopedImage) { + removeCookieImportScopeMarker(liveCookiesPath) } } + consumeStagedCookieImport(stagedPath) delete remainingEntries[partition] } catch { // Why: keep this entry for retry — one partition's failed replay shouldn't drop unrelated entries. @@ -89,7 +145,7 @@ export function applyPendingBrowserCookieImports({ } persistBrowserSessionMeta(resolveMetadataPath, defaultPartition, { pendingCookieImports: remainingEntries, - pendingCookieDbPath: remainingEntries[defaultPartition] ?? null + pendingCookieDbPath: legacyPendingPath(remainingEntries[defaultPartition]) }) } catch { // best-effort — if this fails, CookieMonster loads the old DB @@ -103,10 +159,13 @@ export function setPendingBrowserCookieImport({ stagingDbPath }: PendingCookieImportTarget & { partition: string; stagingDbPath: string }): void { const meta = loadBrowserSessionMeta(resolveMetadataPath, defaultPartition) - const pendingCookieImports = { ...meta.pendingCookieImports, [partition]: stagingDbPath } + const pendingCookieImports = { + ...meta.pendingCookieImports, + [partition]: { format: SCOPED_COOKIE_IMPORT_FORMAT, path: stagingDbPath } + } persistBrowserSessionMeta(resolveMetadataPath, defaultPartition, { pendingCookieImports, - pendingCookieDbPath: pendingCookieImports[defaultPartition] ?? null + pendingCookieDbPath: legacyPendingPath(pendingCookieImports[defaultPartition]) }) } @@ -121,11 +180,21 @@ export function clearPendingBrowserCookieImport({ return } const pendingCookieImports = { ...meta.pendingCookieImports } - const stagedPath = pendingCookieImports[partition] + const pendingEntry = pendingCookieImports[partition] + const stagedPath = typeof pendingEntry === 'string' ? pendingEntry : pendingEntry.path + // Why: metadata writes and file removal are both best-effort. Consuming the recorded path first + // and then persisting its removal gives either operation a chance to prevent a stale replay. + if (existsSync(stagedPath)) { + try { + consumeStagedCookieImport(stagedPath) + } catch { + /* metadata removal below is the fallback */ + } + } delete pendingCookieImports[partition] persistBrowserSessionMeta(resolveMetadataPath, defaultPartition, { pendingCookieImports, - pendingCookieDbPath: pendingCookieImports[defaultPartition] ?? null + pendingCookieDbPath: legacyPendingPath(pendingCookieImports[defaultPartition]) }) for (const suffix of ['', '-wal', '-shm']) { try { diff --git a/src/main/browser/browser-session-meta-store.ts b/src/main/browser/browser-session-meta-store.ts index 6fb8eeb3a15..8aeb7c36422 100644 --- a/src/main/browser/browser-session-meta-store.ts +++ b/src/main/browser/browser-session-meta-store.ts @@ -2,6 +2,13 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' import type { BrowserSessionProfile } from '../../shared/browser-workspace-types' +export type PendingBrowserCookieImport = + | string + | { + format: string + path: string + } + // Why: no userAgent fields — the session UA is always derived from the running // engine at startup (clean or native), never persisted. Imports before Aug 2026 // stored a synthesized source-browser UA here; persistMeta drops those legacy @@ -9,7 +16,7 @@ import type { BrowserSessionProfile } from '../../shared/browser-workspace-types export type BrowserSessionMeta = { defaultSource: BrowserSessionProfile['source'] pendingCookieDbPath: string | null - pendingCookieImports: Record + pendingCookieImports: Record profiles: BrowserSessionProfile[] } @@ -25,10 +32,20 @@ export function loadBrowserSessionMeta( const data = JSON.parse(raw) const legacyPendingCookieDbPath = typeof data?.pendingCookieDbPath === 'string' ? data.pendingCookieDbPath : null - const pendingCookieImports: Record = - data && typeof data.pendingCookieImports === 'object' && data.pendingCookieImports - ? { ...data.pendingCookieImports } - : {} + const pendingCookieImports: Record = {} + if (data && typeof data.pendingCookieImports === 'object' && data.pendingCookieImports) { + for (const [partition, entry] of Object.entries(data.pendingCookieImports)) { + if ( + typeof entry === 'string' || + (entry && + typeof entry === 'object' && + typeof (entry as { format?: unknown }).format === 'string' && + typeof (entry as { path?: unknown }).path === 'string') + ) { + pendingCookieImports[partition] = entry as PendingBrowserCookieImport + } + } + } if (legacyPendingCookieDbPath && !pendingCookieImports[defaultPartition]) { pendingCookieImports[defaultPartition] = legacyPendingCookieDbPath } diff --git a/src/main/browser/browser-session-permission-policy.ts b/src/main/browser/browser-session-permission-policy.ts index 895161db9e3..7dacf067dc7 100644 --- a/src/main/browser/browser-session-permission-policy.ts +++ b/src/main/browser/browser-session-permission-policy.ts @@ -12,8 +12,22 @@ const AUTO_GRANTED_BROWSER_PERMISSIONS = new Set([ 'persistent-storage', // Chromium still requires user activation, so this only removes Orca's // otherwise unactionable denial for immersive browser apps. - 'pointerLock' + 'pointerLock', + // Orca allows unpartitioned third-party cookies, so cross-site frames already send them; denying + // this only rejected the API and burned the caller's user gesture. Not cookies-only: the same + // permission also hands requestStorageAccess({localStorage: true}) a handle onto unpartitioned + // localStorage/IndexedDB, though ambient globals stay partitioned. Chrome grants the same + // permission under the same cookie policy, so this is parity. Electron has no auto-grant, so + // the embedder must answer, and check must agree with request or compliant sites fall back to + // the gesture path. Revisit if Orca ever gains a cookie or storage-partitioning control: the + // handle is gated separately and would survive, but Electron never writes the STORAGE_ACCESS + // content setting, so cookie access would stay blocked while the promise still resolved — and + // sites that reload on success would loop. + 'storage-access' ]) +// 'top-level-storage-access' is deliberately absent: Chromium gates requestStorageAccessFor() on +// Related Website Sets rather than cookie policy, and Orca has no such source, so the rationale +// above does not transfer to it. export function isAutoGrantedBrowserSessionPermission(permission: string): boolean { return AUTO_GRANTED_BROWSER_PERMISSIONS.has(permission) diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index f27a861a45c..cb9229174ed 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -122,6 +122,30 @@ function installModuleMocks( cleanElectronUserAgent: vi.fn((ua: string) => ua.replace(/\s*Electron\/\S+/, '')), setupClientHintsOverride: setupClientHintsOverrideMock })) + // This suite models replay with an in-memory filesystem. The real file-backed SQLite merge has + // dedicated coverage; these fixtures are legacy unmarked images and keep the copy path. + vi.doMock('./browser-cookie-staged-import', () => ({ + SCOPED_COOKIE_IMPORT_FORMAT: 'scoped-v1', + applyScopedStagedCookieImport: vi.fn(() => false), + isScopedStagedCookieImport: vi.fn(() => false), + removeCookieImportScopeMarker: vi.fn() + })) + vi.doMock('../codex-accounts/fs-utils', () => ({ + renameFileWithWindowsRetry: vi.fn((source: string, target: string) => { + const sourceKey = fsKey(source) + const targetKey = fsKey(target) + if (!fsState.present.has(sourceKey)) { + throw new Error('ENOENT') + } + const value = fsState.files.get(sourceKey) + fsState.present.delete(sourceKey) + fsState.files.delete(sourceKey) + fsState.present.add(targetKey) + if (value !== undefined) { + fsState.files.set(targetKey, value) + } + }) + })) return { sessionFromPartitionMock, @@ -257,10 +281,13 @@ describe('BrowserSessionRegistry persistence', () => { ) const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}') - expect(written.pendingCookieDbPath).toBe('/staged/default') + expect(written.pendingCookieDbPath).toBeNull() expect(written.pendingCookieImports).toEqual({ - 'persist:orca-browser': '/staged/default', - 'persist:orca-browser-session-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa': '/staged/imported' + 'persist:orca-browser': { format: 'scoped-v1', path: '/staged/default' }, + 'persist:orca-browser-session-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa': { + format: 'scoped-v1', + path: '/staged/imported' + } }) }) @@ -547,6 +574,26 @@ describe('BrowserSessionRegistry persistence', () => { expect(checkHandler(null, 'persistent-storage', '')).toBe(true) expect(checkHandler(null, 'geolocation', '')).toBe(false) expect(checkHandler(null, 'media', '', { mediaType: 'video' })).toBe(true) + + // Why: this session allows unpartitioned third-party cookies, so a cross-site frame already has + // the access requestStorageAccess() would grant. Denying it protected nothing and only broke + // sites taking the API's failure path. Red before the grant landed. + requestHandler(guestWc, 'storage-access', permissionCallback) + expect(permissionCallback).toHaveBeenLastCalledWith(true) + expect(checkHandler(null, 'storage-access', '')).toBe(true) + + // Why: requestStorageAccessFor() is a different platform decision — Chromium consults Related + // Website Sets and has no third-party-cookie auto-grant, and Orca has no such data source. This + // pins the deliberate denial so a future blanket widening of the allow-set fails loudly. + requestHandler(guestWc, 'top-level-storage-access', permissionCallback) + expect(permissionCallback).toHaveBeenLastCalledWith(false) + expect(checkHandler(null, 'top-level-storage-access', '')).toBe(false) + + // Why: the reported symptom was a user-visible denial notice, so pin the notified list here — + // storage-access must no longer raise one, while the deliberate top-level denial still does. + expect( + browserManagerNotifyPermissionDeniedMock.mock.calls.map(([args]) => args.permission) + ).toEqual(['geolocation', 'top-level-storage-access']) expect(defaultSession.setDisplayMediaRequestHandler).toHaveBeenCalled() const displayMediaHandler = defaultSession.setDisplayMediaRequestHandler.mock.calls[0][0] const displayMediaCallback = vi.fn() diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 9f32e4f379d..d91307d732f 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -249,6 +249,23 @@ describe('BrowserSessionRegistry', () => { expect(checkHandler(null, 'pointerLock', '', {})).toBe(true) }) + it('auto-grants storage-access for isolated partitions', () => { + // Why: mirrors the pointerLock precedent directly above — the default-partition suite does not + // reach this install path. + browserSessionRegistry.createProfile('isolated', 'Storage Access Test') + const mockSession = sessionFromPartitionMock.mock.results[0]?.value + const requestHandler = mockSession.setPermissionRequestHandler.mock.calls[0][0] + const checkHandler = mockSession.setPermissionCheckHandler.mock.calls[0][0] + const callback = vi.fn() + const guestWc = { id: 7, getURL: vi.fn(() => 'https://example.com/') } + + requestHandler(guestWc, 'storage-access', callback, {}) + + expect(callback).toHaveBeenCalledWith(true) + expect(checkHandler(null, 'storage-access', '', {})).toBe(true) + expect(checkHandler(null, 'top-level-storage-access', '', {})).toBe(false) + }) + it('routes media permission requests through macOS TCC for isolated partitions', async () => { // Why: verify the parallel fix to the default partition — isolated/imported // profiles must also defer media permission checks to macOS instead of diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index f8db8ab54a0..b5e1ff7dc50 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -236,9 +236,10 @@ class BrowserSessionRegistry { const meta = this.loadPersistedMeta() const pendingCookieImports = { ...meta.pendingCookieImports } delete pendingCookieImports[profile.partition] + const defaultPendingImport = pendingCookieImports[this.defaultPartition] this.persistMeta({ pendingCookieImports, - pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null + pendingCookieDbPath: typeof defaultPendingImport === 'string' ? defaultPendingImport : null }) // Why: clear the partition's storage so deleting a profile doesn't leave orphaned cookies/cache behind. diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index b93acc1213e..2137bcda780 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -16,6 +16,7 @@ vi.mock('electron', () => ({ import type { SFTPWrapper } from 'ssh2' import { createManagedCommandMatcher } from '../agent-hooks/installer-utils' +import { WINDOWS_HOOK_STDIN_DRAIN_LABEL } from '../agent-hooks/hook-stdin-contract' import { ClaudeHookService } from './hook-service' import { getWindowsManagedLifecycleHook, OPENCLAUDE_HOOK_SETTINGS } from './hook-settings' @@ -426,6 +427,83 @@ describe('ClaudeHookService.install', () => { ) }) +describe('backgrounded-session pane guard (#9236)', () => { + // Why: a `--bg` / `/background` worker runs under the shared daemon and inherits the + // env of whichever pane started that daemon, so ORCA_PANE_KEY names a pane the session + // does not run in. CLAUDE_JOB_DIR is set only in those workers, so it is the signal to + // decline rather than post a pane identity the worker cannot prove is current. + it('declines to post from a daemon worker, before spawning curl', async () => { + const { sftp, fs } = createFakeSftp() + expect((await new ClaudeHookService().installRemote(sftp, '/home/dev')).state).toBe('installed') + const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')! + + expect(script).toContain('if [ -n "$CLAUDE_JOB_DIR" ]; then') + // Why: the guard is worthless if it runs after the post it is meant to prevent. + expect(script.indexOf('CLAUDE_JOB_DIR')).toBeLessThan(script.indexOf('curl')) + // Why: neutral JSON still has to reach a permission hook that fails closed (#14818). + expect(script.indexOf('printf "{}\\n"')).toBeLessThan(script.indexOf('CLAUDE_JOB_DIR')) + }) + + // Why not skipped as unreachable: a backgrounded session's statusline IS invoked, inside the + // worker (ancestry terminates at the daemon, never at a pane), carrying the same stale key. + it('guards the statusline too, on both branches', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + for (const target of ['darwin', 'win32'] as const) { + const tmpHome = mkdtempSync(join(tmpdir(), `orca-claude-sl-${target}-`)) + Object.defineProperty(process, 'platform', { value: target, configurable: true }) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + expect(new ClaudeHookService().install().state).toBe('installed') + const script = readFileSync( + join( + tmpHome, + '.orca', + 'agent-hooks', + target === 'win32' ? 'claude-statusline.cmd' : 'claude-statusline.sh' + ), + 'utf-8' + ) + const guard = script + .split(target === 'win32' ? '\r\n' : '\n') + .find((line) => line.includes('CLAUDE_JOB_DIR')) + expect(guard).toBeDefined() + // Why: the guard is worthless if it runs after the post it is meant to prevent. + expect(script.indexOf('CLAUDE_JOB_DIR')).toBeLessThan(script.indexOf('curl')) + if (target === 'win32') { + // Why: a worker is outside an Orca pane, where reading stdin to EOF never returns (#11549). + expect(guard).not.toContain(WINDOWS_HOOK_STDIN_DRAIN_LABEL) + } + } finally { + Object.defineProperty(process, 'platform', platform) + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + } + }) + + it('exits rather than draining stdin on Windows, where a worker has no Orca pane', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-bg-')) + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + expect(new ClaudeHookService().install().state).toBe('installed') + const script = readFileSync(join(tmpHome, '.orca', 'agent-hooks', 'claude-hook.cmd'), 'utf-8') + const guard = script.split('\r\n').find((line) => line.includes('CLAUDE_JOB_DIR')) + expect(guard).toBe('if not "%CLAUDE_JOB_DIR%"=="" exit /b 0') + // Why: the drain parks in more.com, and a daemon worker is exactly the + // abandoned-stdin case #11549 guards against — it must never route there. + expect(guard).not.toContain(WINDOWS_HOOK_STDIN_DRAIN_LABEL) + } finally { + Object.defineProperty(process, 'platform', platform) + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) +}) + describe('ClaudeHookService.installRemote', () => { it('writes Claude settings + managed script under the remote $HOME', async () => { const svc = new ClaudeHookService() diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 57270575c5c..4de81077ab2 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -72,6 +72,11 @@ function getManagedScript( // Why (#11549): the env guards must outrank the Devin skip — the Devin skip parks in more.com, // and outside an Orca pane the caller can abandon stdin, so more.com never returns. ...buildWindowsHookEnvironmentGuardLines(), + // Why: a backgrounded session runs in a daemon worker that inherited the dispatching + // pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236). + // Why exit, not the drain label: the drain parks in more.com and a worker is outside + // an Orca pane — the abandoned-stdin hang #11549 guards against. + 'if not "%CLAUDE_JOB_DIR%"=="" exit /b 0', ...(options.skipWhenDevinImportsClaude ? [ // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. @@ -99,6 +104,11 @@ function getManagedScript( 'fi' ] : []), + // Why: a backgrounded session runs in a daemon worker that inherited the dispatching + // pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236). + 'if [ -n "$CLAUDE_JOB_DIR" ]; then', + ' exit 0', + 'fi', // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. // Why: suppress parse errors so they neither leak nor trip outer set -e. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', diff --git a/src/main/claude/statusline-script.ts b/src/main/claude/statusline-script.ts index 65830af6407..ef5615d23e2 100644 --- a/src/main/claude/statusline-script.ts +++ b/src/main/claude/statusline-script.ts @@ -19,6 +19,11 @@ export function getManagedStatusLineScript(target: 'local' | 'posix' = 'local'): return [ '@echo off', 'setlocal', + // Why: a backgrounded session's statusline runs in a daemon worker that inherited the + // dispatching pane's env, so ORCA_PANE_KEY names a pane it does not run in (#9236). + // Why exit, not the drain label: a worker is outside an Orca pane, so reading stdin to + // EOF can block forever (#11549). This gates before stdin is owned, per that contract. + 'if not "%CLAUDE_JOB_DIR%"=="" exit /b 0', // Why: pane key is static PTY env (the endpoint file never sets it), so it can gate before stdin is consumed. `if "%ORCA_PANE_KEY%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`, // Why: current keys end in a UUID; replacing the legacy delimiter also keeps surviving numeric-pane keys filename-safe. @@ -88,6 +93,12 @@ export function getManagedStatusLineScript(target: 'local' | 'posix' = 'local'): 'if [ -z "$payload" ]; then', ' exit 0', 'fi', + // Why: a backgrounded session's statusline runs in a daemon worker that inherited the + // dispatching pane's env, so ORCA_PANE_KEY names a pane it does not run in (#9236). + // Placed after capture: POSIX hooks own stdin first, or the agent sees EPIPE (#8110). + 'if [ -n "$CLAUDE_JOB_DIR" ]; then', + ' exit 0', + 'fi', // Why: rate_limits appears only for Claude.ai-subscriber sessions after the first API response; skip the post (and its curl spawn) otherwise. 'case "$payload" in', ' *\'"rate_limits"\'*) ;;', diff --git a/src/main/codex/codex-trust-grant-host.test.ts b/src/main/codex/codex-trust-grant-host.test.ts index 24aa8328ca7..5e23add099e 100644 --- a/src/main/codex/codex-trust-grant-host.test.ts +++ b/src/main/codex/codex-trust-grant-host.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' - const { execFileSyncMock, resolveCodexCommandMock } = vi.hoisted(() => ({ execFileSyncMock: vi.fn(), resolveCodexCommandMock: vi.fn() diff --git a/src/main/local-worktree-filesystem-wsl-banner.wsl.test.ts b/src/main/local-worktree-filesystem-wsl-banner.wsl.test.ts index 3aa4fe41d1c..a4e7e4a40f9 100644 --- a/src/main/local-worktree-filesystem-wsl-banner.wsl.test.ts +++ b/src/main/local-worktree-filesystem-wsl-banner.wsl.test.ts @@ -27,11 +27,7 @@ async function wsl(command: string, ...args: string[]): Promise { async function readThroughRawLoginShell(linuxPath: string): Promise { const { stdout } = await execFileAsync( 'wsl.exe', - buildWslExecArgs(DISTRO, [ - 'sh', - '-lc', - buildWslLoginShellCommand(`cat -- '${linuxPath}'`) - ]), + buildWslExecArgs(DISTRO, ['sh', '-lc', buildWslLoginShellCommand(`cat -- '${linuxPath}'`)]), { encoding: 'utf-8', timeout: 30000 } ) return stdout @@ -71,13 +67,17 @@ describe.skipIf(!runRealWsl)('WSL worktree reads carry no shell chatter', () => it.each([ ['dir', 'directory'], ['file.txt', 'file'] - ])('stats %s as a usable type, not shell chatter', async (entry, expectedType) => { - const { statPath } = getLocalWorktreePathAccess({ wslDistro: DISTRO }) + ])( + 'stats %s as a usable type, not shell chatter', + async (entry, expectedType) => { + const { statPath } = getLocalWorktreePathAccess({ wslDistro: DISTRO }) - const stat = (await statPath(unc(`${fixtureRoot}/${entry}`))) as { type: string } + const stat = (await statPath(unc(`${fixtureRoot}/${entry}`))) as { type: string } - expect(stat.type).toBe(expectedType) - }, 60_000) + expect(stat.type).toBe(expectedType) + }, + 60_000 + ) it('still reports a missing path as ENOENT', async () => { const { statPath } = getLocalWorktreePathAccess({ wslDistro: DISTRO }) diff --git a/src/main/local-worktree-filesystem.test.ts b/src/main/local-worktree-filesystem.test.ts index d6bdb289161..3ad23caeb80 100644 --- a/src/main/local-worktree-filesystem.test.ts +++ b/src/main/local-worktree-filesystem.test.ts @@ -167,9 +167,7 @@ describe('local worktree filesystem runtime access', () => { ) const removeArgs = execFileMock.mock.calls[2]?.[1] as string[] expect(removeArgs.at(-1)).toContain('rm -rf --') - expect(removeArgs.at(-1)).toContain( - String.raw`rm -rf -- '/mnt/c/Users/me/repo feature'` - ) + expect(removeArgs.at(-1)).toContain(String.raw`rm -rf -- '/mnt/c/Users/me/repo feature'`) expect(rmMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/transcript-read-cache-wsl-stall.test.ts b/src/main/native-chat/transcript-read-cache-wsl-stall.test.ts index 1ce0a22e455..dabcf58a0a2 100644 --- a/src/main/native-chat/transcript-read-cache-wsl-stall.test.ts +++ b/src/main/native-chat/transcript-read-cache-wsl-stall.test.ts @@ -24,12 +24,16 @@ import { clearNativeChatTranscriptCache, readNativeChatTranscriptCached } from './transcript-read-cache' -import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate' +import { + resetWslTranscriptFsGateForTests, + WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS +} from './wsl-transcript-fs-gate' const SLOW_MESSAGE = 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' beforeEach(() => { + resetWslTranscriptFsGateForTests() mocks.resolve.mockReset() mocks.stat.mockReset() mocks.read.mockReset() diff --git a/src/main/native-chat/transcript-reader-wsl-stall.test.ts b/src/main/native-chat/transcript-reader-wsl-stall.test.ts index 47d50ab74bc..c7f271b4f39 100644 --- a/src/main/native-chat/transcript-reader-wsl-stall.test.ts +++ b/src/main/native-chat/transcript-reader-wsl-stall.test.ts @@ -11,7 +11,10 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ })) import { readNativeChatTranscript } from './transcript-reader' -import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate' +import { + resetWslTranscriptFsGateForTests, + WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS +} from './wsl-transcript-fs-gate' const SLOW_MESSAGE = 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' @@ -43,6 +46,7 @@ function handleServing(body: Buffer, delayMs = 0) { } beforeEach(() => { + resetWslTranscriptFsGateForTests() mocks.open.mockReset() }) diff --git a/src/main/providers/filesystem-provider-contract.ts b/src/main/providers/filesystem-provider-contract.ts index 4031584b5ff..27dc5a6ec33 100644 --- a/src/main/providers/filesystem-provider-contract.ts +++ b/src/main/providers/filesystem-provider-contract.ts @@ -24,9 +24,47 @@ export type FileReadLimits = { maxTextBytes?: number } +export type FileRangeReadResult = { + /** Raw bytes for `[position, position + bytesRead)`. `bytesRead < length` + * always means end of file: the host loops until the window is filled, and + * an over-cap request is rejected rather than clamped, so a short read is + * never a partial syscall or a silently narrowed window. A `position` at or + * past EOF yields `bytesRead === 0`. */ + bytes: Buffer + bytesRead: number +} + +/** Thrown by `readFileRange` when the host cannot serve a positional read. + * Callers that tail a file should probe `supportsFileRangeRead()` once and + * fall back to a single whole-file snapshot, NOT to a whole-file read per + * chunk -- that is quadratic on a growing file. */ +export class FileRangeReadUnsupportedError extends Error { + constructor(message = 'Positional file reads are unavailable on this host') { + super(message) + this.name = 'FileRangeReadUnsupportedError' + } +} + export type IFilesystemProvider = { readDir(dirPath: string): Promise readFile(filePath: string, limits?: FileReadLimits): Promise + /** Positional read. Optional because an older remote host cannot serve one. + * Strict by design: it throws `FileRangeReadUnsupportedError` rather than + * silently degrading, so a caller cannot accidentally pay a whole-file + * transfer per chunk while tailing. + * + * `position` must be a non-negative safe integer and `length` a positive one + * no larger than `MAX_FILE_RANGE_READ_BYTES`; anything else throws + * `FileRangeReadRequestError` without reaching the host. The final byte + * offset must also remain a safe integer. */ + readFileRange?( + filePath: string, + position: number, + length: number, + options?: { signal?: AbortSignal } + ): Promise + /** Cached capability probe for `readFileRange`. */ + supportsFileRangeRead?(options?: { signal?: AbortSignal }): Promise readTerminalArtifact?( filePath: string, options: TerminalArtifactAccessOptions diff --git a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts index 1d389a34c4c..5f95812d5ae 100644 --- a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts +++ b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts @@ -519,7 +519,14 @@ describe('LocalPtyProvider', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--exec', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo/subdir'")], + [ + '-d', + 'Ubuntu', + '--exec', + 'sh', + '-c', + expect.stringContaining("cd '/home/jin/repo/subdir'") + ], expect.objectContaining({ cwd: expect.any(String) }) ) }) diff --git a/src/main/providers/ssh-filesystem-provider-capabilities.test.ts b/src/main/providers/ssh-filesystem-provider-capabilities.test.ts index 426c89f3054..66691119419 100644 --- a/src/main/providers/ssh-filesystem-provider-capabilities.test.ts +++ b/src/main/providers/ssh-filesystem-provider-capabilities.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import { probeSshQuickOpenSearchCapability } from './ssh-filesystem-provider-capabilities' +import { + probeSshQuickOpenSearchCapability, + probeSshRangedReadCapability +} from './ssh-filesystem-provider-capabilities' import { JsonRpcErrorCode } from '../ssh/relay-protocol' describe('SSH Quick Open capability probe', () => { @@ -34,3 +37,96 @@ describe('SSH Quick Open capability probe', () => { ) }) }) + +describe('SSH filesystem capability document', () => { + // fs.getCapabilities answers every feature at once. Probing per feature would + // spend an extra round trip per connection for a document already in hand. + it('is fetched once for every feature probe on a connection', async () => { + const mux = { + request: vi.fn().mockResolvedValue({ quickOpenSearchVersion: 1, rangedReadVersion: 1 }) + } + await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(true) + await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(true) + expect(mux.request).toHaveBeenCalledTimes(1) + }) + + it('reads each feature independently off the shared document', async () => { + const mux = { request: vi.fn().mockResolvedValue({ quickOpenSearchVersion: 1 }) } + await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(true) + await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(false) + }) + + // A transport failure is not a verdict about the host, so it must not be the + // cached answer for the rest of the connection's life. + it('retries after a failed fetch instead of caching the failure', async () => { + const mux = { + request: vi + .fn() + .mockRejectedValueOnce(new Error('connection closed')) + .mockResolvedValue({ rangedReadVersion: 1 }) + } + await expect(probeSshRangedReadCapability(mux as never)).rejects.toThrow('connection closed') + await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(true) + expect(mux.request).toHaveBeenCalledTimes(2) + }) + + // One document now backs every feature, so a caller walking away from its own + // probe must not evict the fetch a second feature is still waiting on -- + // otherwise an aborted quick-open probe costs the ranged-read probe a round + // trip, and a caller that keeps aborting re-fetches forever. + it('keeps the in-flight document when one caller aborts its own probe', async () => { + let settle: (value: Record) => void = () => {} + const mux = { + request: vi + .fn() + .mockImplementationOnce( + () => + new Promise>((resolve) => { + settle = resolve + }) + ) + .mockResolvedValue({ rangedReadVersion: 1 }) + } + const controller = new AbortController() + const abandoned = probeSshQuickOpenSearchCapability(mux as never, controller.signal) + const patient = probeSshRangedReadCapability(mux as never) + controller.abort() + await expect(abandoned).rejects.toThrow('client_disconnected') + + settle({ rangedReadVersion: 1 }) + await expect(patient).resolves.toBe(true) + await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(true) + expect(mux.request).toHaveBeenCalledTimes(1) + }) + + it('retries when the fetch fails after its only waiter aborts', async () => { + let rejectFetch: (error: Error) => void = () => {} + const mux = { + request: vi + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFetch = reject + }) + ) + .mockResolvedValue({ rangedReadVersion: 1 }) + } + const controller = new AbortController() + const abandoned = probeSshRangedReadCapability(mux as never, controller.signal) + controller.abort() + await expect(abandoned).rejects.toThrow('client_disconnected') + + rejectFetch(new Error('connection closed')) + await new Promise((resolve) => setImmediate(resolve)) + + await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(true) + expect(mux.request).toHaveBeenCalledTimes(2) + }) + + it('treats a non-object response as no capabilities', async () => { + const mux = { request: vi.fn().mockResolvedValue('yes') } + await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(false) + await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(false) + }) +}) diff --git a/src/main/providers/ssh-filesystem-provider-capabilities.ts b/src/main/providers/ssh-filesystem-provider-capabilities.ts index 25799bb4e16..2703c4d16c7 100644 --- a/src/main/providers/ssh-filesystem-provider-capabilities.ts +++ b/src/main/providers/ssh-filesystem-provider-capabilities.ts @@ -2,38 +2,63 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' import { waitForSshCapabilityProbe } from './ssh-capability-probe-waiter' -const quickOpenSearchSupport = new WeakMap>() +/** `null` means the host has no capability document at all: it predates + * `fs.getCapabilities`, or answered with something that is not an object. + * Either way every feature reads as unsupported. */ +type RelayFsCapabilities = Record | null + +const capabilitiesByMux = new WeakMap>() + +/** One `fs.getCapabilities` per multiplexer, shared by every feature probe -- + * the response is a single document, so fetching it once per feature would + * spend a round trip for nothing. Keyed by the multiplexer, which is replaced + * on reconnect, so a host upgraded behind a reconnect is re-probed. + * + * A failed fetch is evicted so the next probe retries; a resolved one is not, + * because a relay does not gain methods without a new connection. */ +function readSshFsCapabilities( + mux: SshChannelMultiplexer, + signal?: AbortSignal +): Promise { + const cached = capabilitiesByMux.get(mux) + const probe = + cached ?? + mux + .request('fs.getCapabilities', undefined, { timeoutMs: 5_000 }) + .then((result) => + typeof result === 'object' && result !== null ? (result as Record) : null + ) + .catch((error) => { + if (isMethodNotFoundError(error)) { + return null + } + throw error + }) + if (!cached) { + capabilitiesByMux.set(mux, probe) + void probe.catch(() => { + if (capabilitiesByMux.get(mux) === probe) { + capabilitiesByMux.delete(mux) + } + }) + } + return waitForSshCapabilityProbe(probe, signal) +} export function probeSshQuickOpenSearchCapability( mux: SshChannelMultiplexer, signal?: AbortSignal ): Promise { - const cached = quickOpenSearchSupport.get(mux) - const probe = - cached ?? - mux - .request('fs.getCapabilities', undefined, { timeoutMs: 5_000 }) - .then((result) => { - const version = (result as { quickOpenSearchVersion?: unknown } | null) - ?.quickOpenSearchVersion - return version === 1 - }) - .catch((error) => { - if (isMethodNotFoundError(error)) { - return false - } - throw error - }) - if (!cached) { - quickOpenSearchSupport.set(mux, probe) - } - return waitForSshCapabilityProbe(probe, signal).then( - (supported) => supported, - (error) => { - if (!signal?.aborted && quickOpenSearchSupport.get(mux) === probe) { - quickOpenSearchSupport.delete(mux) - } - throw error - } + return readSshFsCapabilities(mux, signal).then( + (capabilities) => capabilities?.quickOpenSearchVersion === 1 + ) +} + +export function probeSshRangedReadCapability( + mux: SshChannelMultiplexer, + signal?: AbortSignal +): Promise { + return readSshFsCapabilities(mux, signal).then( + (capabilities) => capabilities?.rangedReadVersion === 1 ) } diff --git a/src/main/providers/ssh-filesystem-provider-range.test.ts b/src/main/providers/ssh-filesystem-provider-range.test.ts new file mode 100644 index 00000000000..3dee006d59d --- /dev/null +++ b/src/main/providers/ssh-filesystem-provider-range.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest' +import { SshFilesystemProvider } from './ssh-filesystem-provider' +import { FileRangeReadUnsupportedError } from './filesystem-provider-contract' +import { FileRangeReadRequestError, MAX_FILE_RANGE_READ_BYTES } from '../../shared/file-range-read' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' + +function methodNotFound(): Error { + const err = new Error('Method not found: fs.readFileRange') as Error & { code?: number } + err.code = -32601 + return err +} + +function providerWith(request: ReturnType): SshFilesystemProvider { + const mux = { request, onNotification: () => () => {} } as unknown as SshChannelMultiplexer + return new SshFilesystemProvider('conn-1', mux) +} + +describe('SshFilesystemProvider.readFileRange', () => { + it('decodes the base64 window the relay returned', async () => { + const payload = Buffer.from('hello') + const request = vi.fn().mockResolvedValue({ + base64: payload.toString('base64'), + bytesRead: payload.length + }) + const result = await providerWith(request).readFileRange('/x.jsonl', 7, 5) + expect(result.bytes).toEqual(payload) + expect(result.bytesRead).toBe(5) + expect(request).toHaveBeenCalledWith( + 'fs.readFileRange', + { filePath: '/x.jsonl', position: 7, length: 5 }, + undefined + ) + }) + + it('reports a short window as read without complaint', async () => { + const payload = Buffer.from('tail') + const request = vi.fn().mockResolvedValue({ + base64: payload.toString('base64'), + bytesRead: payload.length + }) + const result = await providerWith(request).readFileRange('/x.jsonl', 0, 64) + expect(result.bytesRead).toBe(4) + expect(result.bytes).toEqual(payload) + }) + + it('forwards an abort signal to the multiplexer', async () => { + const controller = new AbortController() + const request = vi.fn().mockResolvedValue({ base64: '', bytesRead: 0 }) + await providerWith(request).readFileRange('/x.jsonl', 0, 4, { signal: controller.signal }) + expect(request).toHaveBeenCalledWith('fs.readFileRange', expect.anything(), { + signal: controller.signal + }) + }) + + // Throwing beats a whole-file fallback here: a tailing caller issues several + // reads per snapshot, so falling back per call is quadratic on a growing file. + it('throws a typed unsupported error against a relay without the method', async () => { + const request = vi.fn().mockRejectedValue(methodNotFound()) + await expect(providerWith(request).readFileRange('/x.jsonl', 0, 4)).rejects.toBeInstanceOf( + FileRangeReadUnsupportedError + ) + }) + + it('propagates a non-capability error unchanged', async () => { + const request = vi.fn().mockRejectedValue(new Error('EACCES: permission denied')) + await expect(providerWith(request).readFileRange('/x.jsonl', 0, 4)).rejects.toThrow(/EACCES/) + }) + + // The client runs the host's own validator, so a request the host would + // refuse fails as a typed error instead of an opaque -32000 a round trip later. + describe('request validation', () => { + it.each([ + ['a negative position', -1, 4], + ['a fractional position', 0.5, 4], + ['a zero length', 0, 0], + ['a negative length', 0, -1], + ['an over-cap length', 0, MAX_FILE_RANGE_READ_BYTES + 1], + ['a window past safe-integer offsets', Number.MAX_SAFE_INTEGER, 2] + ])('rejects %s without a round trip', async (_label, position, length) => { + const request = vi.fn() + await expect( + providerWith(request).readFileRange('/x.jsonl', position, length) + ).rejects.toBeInstanceOf(FileRangeReadRequestError) + expect(request).not.toHaveBeenCalled() + }) + }) + + // The remote is untrusted for framing: a count disagreeing with the payload + // would shift every downstream offset while looking like a success. + it('rejects a byte count that disagrees with the payload', async () => { + const request = vi.fn().mockResolvedValue({ + base64: Buffer.from('ab').toString('base64'), + bytesRead: 9 + }) + await expect(providerWith(request).readFileRange('/x.jsonl', 0, 10)).rejects.toThrow( + /inconsistent byte count/ + ) + }) + + it('rejects a byte count larger than the requested length', async () => { + const payload = Buffer.from('abcdef') + const request = vi.fn().mockResolvedValue({ + base64: payload.toString('base64'), + bytesRead: payload.length + }) + await expect(providerWith(request).readFileRange('/x.jsonl', 0, 2)).rejects.toThrow( + /inconsistent byte count/ + ) + }) + + it('rejects a malformed response', async () => { + const request = vi.fn().mockResolvedValue({ bytesRead: 3 }) + await expect(providerWith(request).readFileRange('/x.jsonl', 0, 3)).rejects.toThrow( + /malformed response/ + ) + }) +}) + +describe('SshFilesystemProvider.supportsFileRangeRead', () => { + it('is true when the relay advertises the capability', async () => { + const request = vi.fn().mockResolvedValue({ rangedReadVersion: 1 }) + await expect(providerWith(request).supportsFileRangeRead()).resolves.toBe(true) + }) + + it('is false when the relay omits it', async () => { + const request = vi.fn().mockResolvedValue({ quickOpenSearchVersion: 1 }) + await expect(providerWith(request).supportsFileRangeRead()).resolves.toBe(false) + }) + + it('is false when the relay predates fs.getCapabilities', async () => { + const request = vi.fn().mockRejectedValue(methodNotFound()) + await expect(providerWith(request).supportsFileRangeRead()).resolves.toBe(false) + }) + + it('probes once per connection and caches the answer', async () => { + const request = vi.fn().mockResolvedValue({ rangedReadVersion: 1 }) + const provider = providerWith(request) + await provider.supportsFileRangeRead() + await provider.supportsFileRangeRead() + expect(request).toHaveBeenCalledTimes(1) + }) + + // The cache is keyed by the multiplexer, which is replaced on reconnect, so a + // second connection must not inherit the first host's answer. + it('does not share the answer across connections', async () => { + const legacy = vi.fn().mockResolvedValue({ quickOpenSearchVersion: 1 }) + const current = vi.fn().mockResolvedValue({ rangedReadVersion: 1 }) + await expect(providerWith(legacy).supportsFileRangeRead()).resolves.toBe(false) + await expect(providerWith(current).supportsFileRangeRead()).resolves.toBe(true) + }) +}) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 4a3195e65f5..c21d42f65b8 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -16,6 +16,7 @@ import { } from './ssh-filesystem-provider-watch' import type { IFilesystemProvider, + FileRangeReadResult, FileReadLimits, FileStat, FileReadResult, @@ -27,7 +28,15 @@ import type { DirEntry, FsChangeEvent } from '../../shared/filesystem-entry-type import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-notifications' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' import { isWindowsRemoteHost, type RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { probeSshQuickOpenSearchCapability } from './ssh-filesystem-provider-capabilities' +import { + probeSshQuickOpenSearchCapability, + probeSshRangedReadCapability +} from './ssh-filesystem-provider-capabilities' +import { readSshFileRange } from './ssh-filesystem-range-read' +import { + readSshTerminalArtifact, + writeSshTerminalArtifact +} from './ssh-filesystem-terminal-artifact' const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000 export class SshFilesystemProvider implements IFilesystemProvider { private connectionId: string @@ -111,25 +120,32 @@ export class SshFilesystemProvider implements IFilesystemProvider { } } - async readTerminalArtifact( + readFileRange( + filePath: string, + position: number, + length: number, + options?: { signal?: AbortSignal } + ): Promise { + return readSshFileRange(this.mux, filePath, position, length, options?.signal) + } + + supportsFileRangeRead(options?: { signal?: AbortSignal }): Promise { + return probeSshRangedReadCapability(this.mux, options?.signal) + } + + readTerminalArtifact( filePath: string, options: TerminalArtifactAccessOptions ): Promise { - try { - return (await this.mux.request('fs.readTerminalArtifact', { - filePath, - expectedRealPath: options.expectedRealPath, - expectedStatIdentity: options.expectedStatIdentity, - maxBytes: options.maxBytes - })) as FileReadResult - } catch (err) { - if (isMethodNotFoundError(err)) { - throw new Error( - 'Remote terminal artifact access is unavailable. Reconnect the SSH target before retrying.' - ) - } - throw err - } + return readSshTerminalArtifact(this.mux, filePath, options) + } + + writeTerminalArtifact( + filePath: string, + content: string, + options: TerminalArtifactAccessOptions + ): Promise { + return writeSshTerminalArtifact(this.mux, filePath, content, options) } async downloadFile(sourcePath: string, destinationPath: string): Promise { @@ -163,34 +179,6 @@ export class SshFilesystemProvider implements IFilesystemProvider { await this.mux.request('fs.writeFile', { filePath, content }) } - async writeTerminalArtifact( - filePath: string, - content: string, - options: TerminalArtifactAccessOptions - ): Promise { - let result: { stat?: FileStat } - try { - result = (await this.mux.request('fs.writeTerminalArtifact', { - filePath, - content, - expectedRealPath: options.expectedRealPath, - expectedStatIdentity: options.expectedStatIdentity, - maxBytes: options.maxBytes - })) as { stat?: FileStat } - } catch (err) { - if (isMethodNotFoundError(err)) { - throw new Error( - 'Remote terminal artifact access is unavailable. Reconnect the SSH target before retrying.' - ) - } - throw err - } - if (!result.stat) { - throw new Error('terminal_file_grant_stale') - } - return result.stat - } - async writeFileBase64(filePath: string, contentBase64: string): Promise { await this.writeFileBase64Chunk(filePath, contentBase64, false) } diff --git a/src/main/providers/ssh-filesystem-range-read.ts b/src/main/providers/ssh-filesystem-range-read.ts new file mode 100644 index 00000000000..77ec0872363 --- /dev/null +++ b/src/main/providers/ssh-filesystem-range-read.ts @@ -0,0 +1,55 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' +import { validateFileRangeRequest } from '../../shared/file-range-read' +import { + FileRangeReadUnsupportedError, + type FileRangeReadResult +} from './filesystem-provider-contract' + +export async function readSshFileRange( + mux: SshChannelMultiplexer, + filePath: string, + rawPosition: number, + rawLength: number, + signal?: AbortSignal +): Promise { + // Same validator the host runs, so an out-of-contract request fails locally + // as a typed error instead of costing a round trip and coming back as an + // opaque -32000. + const { position, length } = validateFileRangeRequest(rawPosition, rawLength) + let result: unknown + try { + // No timeoutMs: the multiplexer's 30s default is right for a bounded + // MAX_FILE_RANGE_READ_BYTES window on a slow link. The capability probe + // pins a shorter one because it gates a UI decision, not a data read. + result = await mux.request( + 'fs.readFileRange', + { filePath, position, length }, + signal ? { signal } : undefined + ) + } catch (err) { + // Why throw rather than read the whole file here: a tailing caller issues + // several reads per snapshot, so a per-call fallback is quadratic on a + // growing transcript. Callers probe once and snapshot instead. + if (isMethodNotFoundError(err)) { + throw new FileRangeReadUnsupportedError() + } + throw err + } + const payload = result as { base64?: unknown; bytesRead?: unknown } | null + if (typeof payload?.base64 !== 'string' || typeof payload.bytesRead !== 'number') { + throw new Error('fs.readFileRange returned a malformed response') + } + const bytes = Buffer.from(payload.base64, 'base64') + // The remote is untrusted for framing: a count that disagrees with the + // payload would silently shift every downstream offset. + if ( + !Number.isSafeInteger(payload.bytesRead) || + payload.bytesRead < 0 || + payload.bytesRead > length || + payload.bytesRead !== bytes.length + ) { + throw new Error('fs.readFileRange returned an inconsistent byte count') + } + return { bytes, bytesRead: payload.bytesRead } +} diff --git a/src/main/providers/ssh-filesystem-terminal-artifact.ts b/src/main/providers/ssh-filesystem-terminal-artifact.ts new file mode 100644 index 00000000000..f4088df46a9 --- /dev/null +++ b/src/main/providers/ssh-filesystem-terminal-artifact.ts @@ -0,0 +1,54 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' +import type { FileReadResult, FileStat, TerminalArtifactAccessOptions } from './types' + +export async function readSshTerminalArtifact( + mux: SshChannelMultiplexer, + filePath: string, + options: TerminalArtifactAccessOptions +): Promise { + try { + return (await mux.request('fs.readTerminalArtifact', { + filePath, + expectedRealPath: options.expectedRealPath, + expectedStatIdentity: options.expectedStatIdentity, + maxBytes: options.maxBytes + })) as FileReadResult + } catch (err) { + if (isMethodNotFoundError(err)) { + throw new Error( + 'Remote terminal artifact access is unavailable. Reconnect the SSH target before retrying.' + ) + } + throw err + } +} + +export async function writeSshTerminalArtifact( + mux: SshChannelMultiplexer, + filePath: string, + content: string, + options: TerminalArtifactAccessOptions +): Promise { + let result: { stat?: FileStat } + try { + result = (await mux.request('fs.writeTerminalArtifact', { + filePath, + content, + expectedRealPath: options.expectedRealPath, + expectedStatIdentity: options.expectedStatIdentity, + maxBytes: options.maxBytes + })) as { stat?: FileStat } + } catch (err) { + if (isMethodNotFoundError(err)) { + throw new Error( + 'Remote terminal artifact access is unavailable. Reconnect the SSH target before retrying.' + ) + } + throw err + } + if (!result.stat) { + throw new Error('terminal_file_grant_stale') + } + return result.stat +} diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index aae7266ecaa..56bced04c65 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -18,6 +18,7 @@ export type { // ─── Filesystem Provider ──────────────────────────────────────────── export type { + FileRangeReadResult, FileReadLimits, FileReadResult, FileStat, @@ -26,6 +27,8 @@ export type { TerminalArtifactAccessOptions } from './filesystem-provider-contract' +export { FileRangeReadUnsupportedError } from './filesystem-provider-contract' + // ─── Git Provider ─────────────────────────────────────────────────── export type { GitProviderStatusOptions, IGitProvider } from './git-provider-contract' diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index b9a36e273aa..7cf24f40534 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -817,3 +817,42 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence', }) }) }) + +// Why: reported against local Mac terminals — "Take back all terminals" looked like +// a no-op. The phone stays subscribed, and its passive viewport report (forced on +// iOS app resume and on every reconnect) silently re-took the floor. +describe('mobile presence lock — desktop take-back survives passive viewport reports', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('a still-subscribed phone reporting its viewport does not undo a desktop take-back', async () => { + const { runtime, ptySizes, driverEvents } = createRuntime(null) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 40, rows: 20 }) + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + const driverEventsBefore = driverEvents.length + + await vi.advanceTimersByTimeAsync(10) + await expect( + runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 40, rows: 20 }) + ).resolves.toEqual({ updated: true, applied: false }) + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(driverEvents.slice(driverEventsBefore)).toEqual([]) + }) + + it('a deliberate mobile gesture still re-takes the floor after a desktop take-back', async () => { + const { runtime, ptySizes } = createRuntime(null) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 40, rows: 20 }) + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + + await vi.advanceTimersByTimeAsync(10) + await runtime.mobileTookFloor('pty-1', 'phone-A') + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 40, rows: 20 }) + }) +}) diff --git a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts index 5d683dc77dd..a8bcfa8d5ae 100644 --- a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts +++ b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts @@ -2,21 +2,31 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import WebSocket from 'ws' import { parsePairingCode } from '../../shared/pairing' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import type { PersistedMobileClientTabSelections } from '../../shared/persisted-state-types' import { OrcaRuntimeService } from './orca-runtime' -import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' import { OrcaRuntimeRpcServer } from './runtime-rpc' - -const REPO_ID = 'repo-1' -const worktreeId = (name: string): string => `${REPO_ID}::/tmp/${name}` -const HOST_WORKTREE_ID = worktreeId('host') -const CLIENT_A_WORKTREE_ID = worktreeId('client-a') -const CLIENT_A2_WORKTREE_ID = worktreeId('client-a2') -const CLIENT_B_WORKTREE_ID = worktreeId('client-b') -const SESSION_WORKTREE_ID = worktreeId('session') +import { + activeTabId, + authenticate, + CLIENT_A2_WORKTREE_ID, + CLIENT_A_WORKTREE_ID, + CLIENT_B_WORKTREE_ID, + createReader, + FOLDER_REPO_ID, + HOST_WORKTREE_ID, + makeStore, + REPO_ID, + resultType, + seedSessionTabs, + send, + SESSION_WORKTREE_ID, + snapshotVersion, + worktreeId, + type PairedSession, + type ResponseReader +} from './paired-client-navigation-test-harness' vi.mock('../git/worktree', () => { const worktrees = ['host', 'client-a', 'client-a2', 'client-b', 'session'].map((name) => ({ @@ -32,206 +42,6 @@ vi.mock('../git/worktree', () => { } }) -type PairedSession = { - ws: WebSocket - sharedKey: Uint8Array -} - -type ResponseReader = { - next: ( - id: string, - predicate?: (response: Record) => boolean - ) => Promise> - dispose: () => void -} - -function makeStore() { - const worktreeMeta = Object.fromEntries( - [ - HOST_WORKTREE_ID, - CLIENT_A_WORKTREE_ID, - CLIENT_A2_WORKTREE_ID, - CLIENT_B_WORKTREE_ID, - SESSION_WORKTREE_ID - ].map((id) => [ - id, - { - displayName: id.split('/').at(-1) ?? id, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - instanceId: id - } - ]) - ) - return { - getRepo: (id: string) => (id === REPO_ID ? makeStore().getRepos()[0] : undefined), - getRepos: () => [ - { - id: REPO_ID, - path: '/tmp/repo', - displayName: 'repo', - badgeColor: 'blue', - addedAt: 1 - } - ], - addRepo: () => {}, - updateRepo: () => undefined as never, - getAllWorktreeMeta: () => worktreeMeta, - getWorktreeMeta: (id: string) => worktreeMeta[id], - setWorktreeMeta: () => undefined as never, - removeWorktreeMeta: () => {}, - getSettings: () => ({ - workspaceDir: '/tmp/workspaces', - nestWorkspaces: false, - refreshLocalBaseRefOnWorktreeCreate: false, - branchPrefix: 'none', - branchPrefixCustom: '' - }) - } -} - -function connect(endpoint: string): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(endpoint) - ws.once('open', () => resolve(ws)) - ws.once('error', reject) - }) -} - -function nextMessage(ws: WebSocket): Promise { - return new Promise((resolve) => { - ws.once('message', (data) => resolve(typeof data === 'string' ? data : data.toString('utf-8'))) - }) -} - -async function authenticate(pairingUrl: string): Promise { - const pairing = parsePairingCode(pairingUrl) - if (!pairing) { - throw new Error('invalid_pairing_url') - } - const ws = await connect(pairing.endpoint) - const keys = generateKeyPair() - const serverPublicKey = Uint8Array.from(Buffer.from(pairing.publicKeyB64, 'base64')) - const sharedKey = deriveSharedKey(keys.secretKey, serverPublicKey) - ws.send( - JSON.stringify({ - type: 'e2ee_hello', - publicKeyB64: Buffer.from(keys.publicKey).toString('base64') - }) - ) - expect(JSON.parse(await nextMessage(ws))).toEqual({ type: 'e2ee_ready' }) - ws.send( - encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), sharedKey) - ) - expect(JSON.parse(decrypt(await nextMessage(ws), sharedKey)!)).toEqual({ - type: 'e2ee_authenticated' - }) - return { ws, sharedKey } -} - -function send(session: PairedSession, request: Record): void { - session.ws.send(encrypt(JSON.stringify(request), session.sharedKey)) -} - -function createReader(session: PairedSession): ResponseReader { - type Waiter = { - id: string - predicate: (response: Record) => boolean - resolve: (response: Record) => void - } - const queued: Record[] = [] - const waiters: Waiter[] = [] - const onMessage = (data: WebSocket.RawData): void => { - const plaintext = decrypt( - typeof data === 'string' ? data : data.toString('utf-8'), - session.sharedKey - ) - if (!plaintext) { - return - } - const response = JSON.parse(plaintext) as Record - const waiterIndex = waiters.findIndex( - (waiter) => response.id === waiter.id && waiter.predicate(response) - ) - if (waiterIndex === -1) { - queued.push(response) - return - } - waiters.splice(waiterIndex, 1)[0]?.resolve(response) - } - session.ws.on('message', onMessage) - return { - next: (id, predicate = () => true) => { - const queuedIndex = queued.findIndex((response) => response.id === id && predicate(response)) - if (queuedIndex !== -1) { - return Promise.resolve(queued.splice(queuedIndex, 1)[0]!) - } - return new Promise((resolve) => waiters.push({ id, predicate, resolve })) - }, - dispose: () => { - session.ws.off('message', onMessage) - queued.length = 0 - waiters.length = 0 - } - } -} - -function resultType(response: Record): string | undefined { - return (response.result as { type?: string } | undefined)?.type -} - -function activeTabId(response: Record): string | null { - return (response.result as RuntimeMobileSessionTabsResult | undefined)?.activeTabId ?? null -} - -function snapshotVersion(response: Record): number { - return (response.result as RuntimeMobileSessionTabsResult | undefined)?.snapshotVersion ?? -1 -} - -function seedSessionTabs(runtime: OrcaRuntimeService): void { - const tabs = ['host-tab', 'client-a-tab', 'client-a2-tab', 'client-b-tab'].map((id, index) => ({ - type: 'terminal' as const, - id, - parentTabId: id, - leafId: `pane:${index + 1}`, - ptyId: `pty-${index + 1}`, - title: id, - isActive: id === 'host-tab' - })) - runtime.syncWindowGraph(1, { - tabs: [], - leaves: [], - mobileSessionTabs: [ - { - worktree: SESSION_WORKTREE_ID, - publicationEpoch: 'renderer:host', - snapshotVersion: 1, - activeGroupId: 'group-1', - activeTabId: 'host-tab', - activeTabType: 'terminal', - tabGroups: [ - { - id: 'group-1', - activeTabId: 'host-tab', - tabOrder: tabs.map((tab) => tab.parentTabId) - } - ], - tabs - } - ] - }) - for (let index = 0; index < tabs.length; index += 1) { - runtime.registerPty(`pty-${index + 1}`, SESSION_WORKTREE_ID) - } -} - describe('paired runtime navigation isolation', () => { const servers: OrcaRuntimeRpcServer[] = [] const sessions: PairedSession[] = [] @@ -247,7 +57,8 @@ describe('paired runtime navigation isolation', () => { await Promise.all(servers.splice(0).map((server) => server.stop())) }) - async function startHarness() { + /** `headless: true` models `orca serve` — no renderer notifier and no attached window. */ + async function startHarness(options: { headless?: boolean } = {}) { const hostSelections = { worktreeId: HOST_WORKTREE_ID, tabId: 'host-tab' } const activateWorktree = vi.fn((_repoId: string, nextWorktreeId: string) => { hostSelections.worktreeId = nextWorktreeId @@ -256,23 +67,25 @@ describe('paired runtime navigation isolation', () => { hostSelections.tabId = nextTabId }) const runtime = new OrcaRuntimeService(makeStore() as never) - runtime.setNotifier({ - worktreesChanged: vi.fn(), - reposChanged: vi.fn(), - activateWorktree, - createTerminal: vi.fn(), - revealTerminalSession: vi.fn(), - splitTerminal: vi.fn(), - renameTerminal: vi.fn(), - focusTerminal, - closeTerminal: vi.fn(), - sleepWorktree: vi.fn(), - terminalFitOverrideChanged: vi.fn(), - terminalDriverChanged: vi.fn() - }) - runtime.attachWindow(1) - runtime.markGraphReady(1) - seedSessionTabs(runtime) + if (!options.headless) { + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal, + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + seedSessionTabs(runtime) + } const server = new OrcaRuntimeRpcServer({ runtime, @@ -323,6 +136,7 @@ describe('paired runtime navigation isolation', () => { deviceIdA: offerA.deviceId, deviceIdB: offerB.deviceId, pairingUrlA: offerA.pairingUrl, + pairingUrlB: offerB.pairingUrl, readerA, readerB } @@ -448,6 +262,232 @@ describe('paired runtime navigation isolation', () => { expect(harness.activateWorktree).not.toHaveBeenCalled() }) + async function subscribeBothClientEventStreams(harness: { + clientA: PairedSession + clientB: PairedSession + readerA: ResponseReader + readerB: ResponseReader + }): Promise { + for (const [session, id] of [ + [harness.clientA, 'events-a'], + [harness.clientB, 'events-b'] + ] as const) { + send(session, { id, method: 'runtime.clientEvents.subscribe' }) + } + await Promise.all([ + harness.readerA.next('events-a', (response) => resultType(response) === 'ready'), + harness.readerB.next('events-b', (response) => resultType(response) === 'ready') + ]) + } + + it('keeps a paired client workspace create-with-activate off every other client and the host', async () => { + const harness = await startHarness() + await subscribeBothClientEventStreams(harness) + + // Negative control: repeated ordinary navigation by A never reaches B or the host. + for (const [index, target] of [ + CLIENT_A2_WORKTREE_ID, + CLIENT_A_WORKTREE_ID, + CLIENT_A2_WORKTREE_ID + ].entries()) { + send(harness.clientA, { + id: `nav-${index}`, + method: 'worktree.activate', + params: { worktree: `id:${target}` } + }) + await expect(harness.readerA.next(`nav-${index}`)).resolves.toMatchObject({ ok: true }) + } + + send(harness.clientA, { + id: 'create-with-agent', + method: 'worktree.create', + params: { + repo: FOLDER_REPO_ID, + name: 'teammate-agent-workspace', + activate: true + } + }) + await expect(harness.readerA.next('create-with-agent')).resolves.toMatchObject({ ok: true }) + + // Fence: a shared-catalog event emitted after the create must be the next frame each + // client sees. An activation frame would be delivered ahead of it on the ordered stream. + harness.runtime.notifyReposChangedForRemoteClients() + const observed: Record<'a' | 'b', string[]> = { a: [], b: [] } + for (const [key, reader, id] of [ + ['a', harness.readerA, 'events-a'], + ['b', harness.readerB, 'events-b'] + ] as const) { + for (;;) { + const type = resultType(await reader.next(id)) + observed[key].push(type ?? 'unknown') + if (type === 'reposChanged' || type === 'activateWorktree') { + break + } + } + } + + // The observer must never be navigated by the creator... + expect(observed.b).not.toContain('activateWorktree') + expect(observed.b.at(-1)).toBe('reposChanged') + expect(harness.activateWorktree).not.toHaveBeenCalled() + expect(harness.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID) + // ...nor is the creator's own view driven from the host stream (it navigates from its RPC result)... + expect(observed.a).not.toContain('activateWorktree') + expect(observed.a.at(-1)).toBe('reposChanged') + // ...while shared catalog state still reaches both clients. + expect(observed.a).toContain('worktreesChanged') + expect(observed.b).toContain('worktreesChanged') + }) + + it('does not replay a missed create activation to a reconnecting observer', async () => { + const harness = await startHarness() + await subscribeBothClientEventStreams(harness) + + // The observer is offline while the creator works. + harness.readerB.dispose() + await new Promise((resolve) => { + harness.clientB.ws.once('close', () => resolve()) + harness.clientB.ws.close() + }) + + // Why navigation 'all': a caller-scoped create emits no activation at all, so the + // reconnect assertion below would pass vacuously — there must be a real frame to miss. + send(harness.clientA, { + id: 'create-while-b-offline', + method: 'worktree.create', + params: { + repo: FOLDER_REPO_ID, + name: 'offline-observer-workspace', + activate: true, + navigation: 'all' + } + }) + await expect(harness.readerA.next('create-while-b-offline')).resolves.toMatchObject({ + ok: true + }) + // The still-connected client proves the activation really was emitted while B was down. + expect( + resultType( + await harness.readerA.next( + 'events-a', + (response) => resultType(response) === 'activateWorktree' + ) + ) + ).toBe('activateWorktree') + + const reconnectedB = await authenticate(harness.pairingUrlB) + sessions.push(reconnectedB) + const reconnectedReaderB = createReader(reconnectedB) + readers.push(reconnectedReaderB) + send(reconnectedB, { id: 'events-b2', method: 'runtime.clientEvents.subscribe' }) + await reconnectedReaderB.next('events-b2', (response) => resultType(response) === 'ready') + + // Why: the ready snapshot must not carry navigation intent forward; only live, + // addressed activation is legitimate, so a reconnect can never inherit it. + harness.runtime.notifyReposChangedForRemoteClients() + const observed: string[] = [] + for (;;) { + const type = resultType(await reconnectedReaderB.next('events-b2')) + observed.push(type ?? 'unknown') + if (type === 'reposChanged' || type === 'activateWorktree') { + break + } + } + expect(observed).not.toContain('activateWorktree') + expect(observed.at(-1)).toBe('reposChanged') + }) + + it('still reveals to every client when a paired caller asks for all-surface navigation', async () => { + // Why: the CLI pairs as a runtime device but has no viewer of its own, so + // `orca worktree create --activate` against a remote runtime sends navigation 'all'. + const harness = await startHarness() + await subscribeBothClientEventStreams(harness) + + send(harness.clientA, { + id: 'cli-shaped-create', + method: 'worktree.create', + params: { + repo: FOLDER_REPO_ID, + name: 'cli-shaped-workspace', + activate: true, + navigation: 'all' + } + }) + await expect(harness.readerA.next('cli-shaped-create')).resolves.toMatchObject({ ok: true }) + + const [eventA, eventB] = await Promise.all([ + harness.readerA.next('events-a', (response) => resultType(response) === 'activateWorktree'), + harness.readerB.next('events-b', (response) => resultType(response) === 'activateWorktree') + ]) + expect([resultType(eventA), resultType(eventB)]).toEqual([ + 'activateWorktree', + 'activateWorktree' + ]) + expect(harness.activateWorktree).toHaveBeenCalled() + }) + + it('keeps create activation caller-scoped on a headless orca serve host', async () => { + const harness = await startHarness({ headless: true }) + await subscribeBothClientEventStreams(harness) + + send(harness.clientA, { + id: 'headless-create', + method: 'worktree.create', + params: { repo: FOLDER_REPO_ID, name: 'headless-agent-workspace', activate: true } + }) + await expect(harness.readerA.next('headless-create')).resolves.toMatchObject({ ok: true }) + + harness.runtime.notifyReposChangedForRemoteClients() + const observed: string[] = [] + for (;;) { + const type = resultType(await harness.readerB.next('events-b')) + observed.push(type ?? 'unknown') + if (type === 'reposChanged' || type === 'activateWorktree') { + break + } + } + expect(observed).not.toContain('activateWorktree') + expect(observed).toContain('worktreesChanged') + + // A headless host with no viewer of its own still reveals an in-process/CLI create. + await harness.runtime.createManagedWorktree({ + repoSelector: `id:${FOLDER_REPO_ID}`, + name: 'headless-cli-workspace', + activate: true + }) + expect( + resultType( + await harness.readerB.next( + 'events-b', + (response) => resultType(response) === 'activateWorktree' + ) + ) + ).toBe('activateWorktree') + }) + + it('still reveals a host-originated create-with-activate on the host and every client', async () => { + const harness = await startHarness() + await subscribeBothClientEventStreams(harness) + + // Why: a headless server's only viewer is a remote client, so an in-process/CLI + // create must keep reaching clients; only paired-client callers are scoped. + await harness.runtime.createManagedWorktree({ + repoSelector: `id:${FOLDER_REPO_ID}`, + name: 'cli-created-workspace', + activate: true + }) + + const [eventA, eventB] = await Promise.all([ + harness.readerA.next('events-a', (response) => resultType(response) === 'activateWorktree'), + harness.readerB.next('events-b', (response) => resultType(response) === 'activateWorktree') + ]) + expect([resultType(eventA), resultType(eventB)]).toEqual([ + 'activateWorktree', + 'activateWorktree' + ]) + expect(harness.activateWorktree).toHaveBeenCalled() + }) + it('projects session-tab activation only to the paired caller across fanout and reconnect', async () => { const harness = await startHarness() diff --git a/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts b/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts new file mode 100644 index 00000000000..5780b353944 --- /dev/null +++ b/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts @@ -0,0 +1,517 @@ +/** + * STA-4593 incident: closing paired-remote tabs "worked briefly" but the tabs + * returned seconds later and after workspace switches, on a host whose PTYs + * outlive their tabs. + * + * Invariant under test: a user close committed through closeMobileSessionTab + * stays closed — no later host publication may re-add the tab while its PTY + * lingers connected. + * + * Causal boundary: shouldPreserveHeadlessMobileSessionTab preserves any + * renderer-omitted tab whose PTY is connected && runtimeSessionOwned. Every + * paired-created terminal is runtimeSessionOwned, and the close path clears + * that flag only AFTER the renderer relay acknowledges — but the renderer's + * own prune publication normally lands BEFORE the ack (retire, publish, then + * reply). That publication runs the preserved-merge while the flag is still + * set and the PTY exit has not fired yet, so the merge resurrects the tab the + * renderer just retired, and nothing prunes it again until some future + * renderer publication. + * + * The harness is the close-continuity pattern: a real OrcaRuntimeService with + * injected store/notifier/pty controller. Interleavings are controlled with a + * deferred relay ack; time is never the oracle. + */ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { makePaneKey } from '../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { OrcaRuntimeService } from './orca-runtime' + +const REPO_ID = 'repo-preserved-resurrection' +const WORKTREE_PATH = '/tmp/preserved-resurrection' +const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` + +const TAB_A = 'tab-reviewer-a' +const TAB_B = 'tab-main-b' +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const PTY_A = 'pty-reviewer-a' +const PTY_B = 'pty-main-b' +const INC_A = '33333333-3333-4333-8333-333333333333' +const INC_B = '44444444-4444-4444-8444-444444444444' + +const TAB_P = 'tab-split-parent' +// LEAF_C: the persisted session's alternative leafId for PTY_A's surface — +// renderer and headless sources can derive different leafIds for one surface. +const LEAF_C = '55555555-5555-4555-8555-555555555555' +const PTY_BY_LEAF: Record = { [LEAF_A]: PTY_A, [LEAF_B]: PTY_B, [LEAF_C]: PTY_A } +const INC_BY_LEAF: Record = { [LEAF_A]: INC_A, [LEAF_B]: INC_B, [LEAF_C]: INC_A } + +type TabSpec = { tabId: string; leafId: string; ptyId: string } +const TAB_SPECS: Record = { + [TAB_A]: { tabId: TAB_A, leafId: LEAF_A, ptyId: PTY_A }, + [TAB_B]: { tabId: TAB_B, leafId: LEAF_B, ptyId: PTY_B } +} + +function makeSession(tabIds: readonly string[]): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: tabIds.map((tabId, index) => ({ + id: tabId, + ptyId: TAB_SPECS[tabId]!.ptyId, + worktreeId: WORKTREE_ID, + title: tabId, + customTitle: null, + color: null, + sortOrder: index, + createdAt: index + 1 + })) + }, + terminalLayoutsByTabId: Object.fromEntries( + tabIds.map((tabId) => [ + tabId, + { + root: { type: 'leaf' as const, leafId: TAB_SPECS[tabId]!.leafId }, + activeLeafId: TAB_SPECS[tabId]!.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [TAB_SPECS[tabId]!.leafId]: TAB_SPECS[tabId]!.ptyId } + } + ]) + ), + terminalPtyIncarnationsByPaneKey: { + [makePaneKey(TAB_A, LEAF_A)]: INC_A, + [makePaneKey(TAB_B, LEAF_B)]: INC_B + } + } +} + +function makeDeferred() { + let resolve!: () => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +function createHarness() { + let session = makeSession([TAB_A, TAB_B]) + const repo = { + id: REPO_ID, + path: WORKTREE_PATH, + displayName: 'preserved-resurrection', + badgeColor: '#000000', + addedAt: 1 + } + const store = { + getRepos: () => [repo], + getRepo: (id: string) => (id === REPO_ID ? repo : undefined), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), + getProjects: () => [], + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + flushOrThrow: () => {} + } + const relayAck = makeDeferred() + const closeTerminal = vi.fn() + const closeTerminalTab = vi.fn(() => relayAck.promise) + const kill = vi.fn(() => true) + const runtime = new OrcaRuntimeService(store as never) + runtime.setNotifier({ closeTerminal, closeTerminalTab } as never) + runtime.setPtyController({ + write: () => true, + kill, + stopAndWait: vi.fn(async () => true), + listProcesses: vi.fn(async () => [ + { id: PTY_A, incarnationId: INC_A, cwd: WORKTREE_PATH, title: 'reviewer shell' }, + { id: PTY_B, incarnationId: INC_B, cwd: WORKTREE_PATH, title: 'main shell' } + ]), + getForegroundProcess: async () => null + } as never) + runtime.attachWindow(1) + + const rendererGraph = (tabIds: readonly string[]) => ({ + tabs: tabIds.map((tabId) => ({ + tabId, + worktreeId: WORKTREE_ID, + title: tabId, + activeLeafId: TAB_SPECS[tabId]!.leafId, + layout: { type: 'leaf' as const, leafId: TAB_SPECS[tabId]!.leafId } + })), + leaves: tabIds.map((tabId, index) => ({ + tabId, + worktreeId: WORKTREE_ID, + leafId: TAB_SPECS[tabId]!.leafId, + paneRuntimeId: index + 7, + ptyId: TAB_SPECS[tabId]!.ptyId + })) + }) + + const publishRendererSnapshot = (tabIds: readonly string[], snapshotVersion: number) => { + runtime.syncWindowGraph(1, { + ...rendererGraph(tabIds), + mobileSessionTabs: [ + { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer:preserved-resurrection', + snapshotVersion, + activeGroupId: 'group-1', + activeTabId: tabIds.length ? `${tabIds[0]}::${TAB_SPECS[tabIds[0]!]!.leafId}` : null, + activeTabType: tabIds.length ? ('terminal' as const) : null, + tabs: tabIds.map((tabId, index) => ({ + type: 'terminal' as const, + id: `${tabId}::${TAB_SPECS[tabId]!.leafId}`, + parentTabId: tabId, + leafId: TAB_SPECS[tabId]!.leafId, + ptyId: TAB_SPECS[tabId]!.ptyId, + title: tabId, + isActive: index === 0 + })) + } + ] + }) + } + + for (const spec of Object.values(TAB_SPECS)) { + runtime.registerPty(spec.ptyId, WORKTREE_ID, null, { + tabId: spec.tabId, + leafId: spec.leafId, + incarnationId: spec.tabId === TAB_A ? INC_A : INC_B + }) + // Why: byte-identical to a paired create — ensurePtyBackedMobileSurfaceForRendererTab + // marks every paired-created PTY runtimeSessionOwned + paired-session-owned, + // and nothing clears either flag until close/exit. + const record = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get(spec.ptyId)! + record.runtimeSessionOwned = true + ;( + runtime as unknown as { pairedRendererSessionOwnedPtyIds: Set } + ).pairedRendererSessionOwnedPtyIds.add(spec.ptyId) + } + publishRendererSnapshot([TAB_A, TAB_B], 1) + + return { + runtime, + relayAck, + closeTerminalTab, + kill, + publishRendererSnapshot, + retirePersistedTab: (tabId: string) => { + session = makeSession([TAB_A, TAB_B].filter((id) => id !== tabId)) + }, + isRuntimeSessionOwned: (ptyId: string) => + ( + runtime as unknown as { ptysById: Map } + ).ptysById.get(ptyId)?.runtimeSessionOwned === true, + markCreatePending: (tabId: string) => { + ;( + runtime as unknown as { + pendingMobileTerminalCreatesByKey: Map + } + ).pendingMobileTerminalCreatesByKey.set(`${WORKTREE_ID}::${tabId}`, { + activate: true, + paired: true, + selectIfNoActiveTab: true + }) + } + } +} + +function makeSplitLayout(leafIds: readonly string[]) { + const root = + leafIds.length > 1 + ? { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: leafIds[0]! }, + second: { type: 'leaf' as const, leafId: leafIds[1]! }, + ratio: 0.5 + } + : { type: 'leaf' as const, leafId: leafIds[0]! } + return { + root, + activeLeafId: leafIds[0]!, + expandedLeafId: null, + ptyIdsByLeafId: Object.fromEntries(leafIds.map((leafId) => [leafId, PTY_BY_LEAF[leafId]!])) + } +} + +/** One parent tab split across `leafIds` — closing a leaf rewrites the layout, not the tab. */ +function makeSplitSession(leafIds: readonly string[]): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: TAB_P, + ptyId: PTY_BY_LEAF[leafIds[0]!]!, + worktreeId: WORKTREE_ID, + title: TAB_P, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { [TAB_P]: makeSplitLayout(leafIds) }, + terminalPtyIncarnationsByPaneKey: Object.fromEntries( + leafIds.map((leafId) => [makePaneKey(TAB_P, leafId), INC_BY_LEAF[leafId]!]) + ) + } +} + +function createSplitHarness() { + let session = makeSplitSession([LEAF_A, LEAF_B]) + const repo = { + id: REPO_ID, + path: WORKTREE_PATH, + displayName: 'preserved-resurrection', + badgeColor: '#000000', + addedAt: 1 + } + const store = { + getRepos: () => [repo], + getRepo: (id: string) => (id === REPO_ID ? repo : undefined), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), + getProjects: () => [], + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + flushOrThrow: () => {} + } + const runtime = new OrcaRuntimeService(store as never) + runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab: vi.fn(async () => {}) } as never) + runtime.setPtyController({ + write: () => true, + kill: vi.fn(() => true), + stopAndWait: vi.fn(async () => true), + listProcesses: vi.fn(async () => [ + { id: PTY_A, incarnationId: INC_A, cwd: WORKTREE_PATH, title: 'left pane' }, + { id: PTY_B, incarnationId: INC_B, cwd: WORKTREE_PATH, title: 'right pane' } + ]), + getForegroundProcess: async () => null + } as never) + runtime.attachWindow(1) + + const publishSplitSnapshot = (leafIds: readonly string[], snapshotVersion: number) => { + const layout = makeSplitLayout(leafIds) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_P, + worktreeId: WORKTREE_ID, + title: TAB_P, + activeLeafId: layout.activeLeafId, + layout: layout.root + } + ], + leaves: leafIds.map((leafId, index) => ({ + tabId: TAB_P, + worktreeId: WORKTREE_ID, + leafId, + paneRuntimeId: index + 7, + ptyId: PTY_BY_LEAF[leafId]! + })), + mobileSessionTabs: [ + { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer:preserved-resurrection', + snapshotVersion, + activeGroupId: 'group-1', + activeTabId: `${TAB_P}::${layout.activeLeafId}`, + activeTabType: 'terminal' as const, + tabs: leafIds.map((leafId, index) => ({ + type: 'terminal' as const, + id: `${TAB_P}::${leafId}`, + parentTabId: TAB_P, + leafId, + ptyId: PTY_BY_LEAF[leafId]!, + parentLayout: layout, + title: `${TAB_P} ${leafId}`, + isActive: index === 0 + })) + } + ] + }) + } + + for (const leafId of [LEAF_A, LEAF_B]) { + runtime.registerPty(PTY_BY_LEAF[leafId]!, WORKTREE_ID, null, { + tabId: TAB_P, + leafId, + incarnationId: INC_BY_LEAF[leafId]! + }) + const record = ( + runtime as unknown as { ptysById: Map } + ).ptysById.get(PTY_BY_LEAF[leafId]!)! + record.runtimeSessionOwned = true + ;( + runtime as unknown as { pairedRendererSessionOwnedPtyIds: Set } + ).pairedRendererSessionOwnedPtyIds.add(PTY_BY_LEAF[leafId]!) + } + publishSplitSnapshot([LEAF_A, LEAF_B], 1) + + return { + runtime, + publishSplitSnapshot, + isRuntimeSessionOwned: (ptyId: string) => + ( + runtime as unknown as { ptysById: Map } + ).ptysById.get(ptyId)?.runtimeSessionOwned === true, + retirePersistedLeaf: (leafId: string) => { + session = makeSplitSession([LEAF_A, LEAF_B].filter((id) => id !== leafId)) + }, + relabelPersistedLeftLeaf: () => { + session = makeSplitSession([LEAF_C, LEAF_B]) + } + } +} + +async function listTerminalSurfaceIds(runtime: OrcaRuntimeService): Promise { + const listed = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + return listed.tabs + .filter((tab) => tab.type === 'terminal') + .map((tab) => tab.id) + .sort() +} + +async function listParentTabIds(runtime: OrcaRuntimeService): Promise { + const listed = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + return [ + ...new Set( + listed.tabs.map((tab) => (tab.type === 'terminal' ? tab.parentTabId : tab.id)).sort() + ) + ] +} + +describe('a committed paired close stays closed while its PTY lingers', () => { + it('control: a renderer prune that lands after the relay ack retires the tab for good', async () => { + const harness = createHarness() + + const closing = harness.runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, TAB_A, { + reason: 'user' + }) + await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalledWith(TAB_A)) + harness.retirePersistedTab(TAB_A) + harness.relayAck.resolve() + await expect(closing).resolves.toMatchObject({ closed: true }) + // The renderer's prune publication arrives only after the ack (and after + // the close cleared the ownership flags). + harness.publishRendererSnapshot([TAB_B], 2) + + expect(await listParentTabIds(harness.runtime)).toEqual([TAB_B]) + }) + + it('red: the preserved-merge resurrects a pre-ack renderer prune only until the close cleans up', async () => { + const harness = createHarness() + + const closing = harness.runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, TAB_A, { + reason: 'user' + }) + await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalledWith(TAB_A)) + // Production ordering: the renderer durably retires the tab and publishes + // the pruned graph BEFORE replying to the relay. The kill it dispatched has + // not produced an exit yet, so the PTY record is still connected. + harness.retirePersistedTab(TAB_A) + harness.publishRendererSnapshot([TAB_B], 2) + harness.relayAck.resolve() + await expect(closing).resolves.toMatchObject({ closed: true }) + + const republished = await listParentTabIds(harness.runtime) + expect( + republished, + 'the preserved-merge resurrected a tab whose user close was committed' + ).toEqual([TAB_B]) + }) + + it('red: a host-side close (no paired close RPC) must not be resurrected by the preserved-merge', async () => { + const harness = createHarness() + + // The host closes the tab itself — host user gesture or `orca terminal` + // lifecycle driven by an agent on the host. No closeMobileSessionTab runs, + // so no post-relay cleanup exists. The renderer durably retires the tab and + // publishes the pruned graph; the PTY it killed has not exited yet (or the + // kill failed — WSL/Windows), so its record is still connected and still + // carries the paired-create ownership flags. + harness.retirePersistedTab(TAB_A) + harness.publishRendererSnapshot([TAB_B], 2) + + const republished = await listParentTabIds(harness.runtime) + expect(republished, 'the preserved-merge resurrected a tab the host renderer closed').toEqual([ + TAB_B + ]) + }) + + it('control: a create still in flight is not retirement — its tab and ownership survive the omission', async () => { + const harness = createHarness() + + // The renderer's stale publication omits TAB_A while its paired create has + // not settled: it is missing from persistence AND from the publication, the + // exact input pattern durable retirement keys on. The pending-create mark + // is the only discriminator, and losing it would strand the create-recovery + // rescue: the freshly created remote tab would vanish. + harness.markCreatePending(TAB_A) + harness.retirePersistedTab(TAB_A) + harness.publishRendererSnapshot([TAB_B], 2) + + expect( + await listParentTabIds(harness.runtime), + 'a create-in-flight tab was retired by a stale publication' + ).toEqual([TAB_B, TAB_A]) + expect(harness.isRuntimeSessionOwned(PTY_A)).toBe(true) + }) +}) + +describe('a durably closed split leaf stays closed while its sibling survives', () => { + it('red: the preserved-merge must not resurrect a retired leaf of a persisted parent', async () => { + const harness = createSplitHarness() + expect(await listTerminalSurfaceIds(harness.runtime)).toEqual([ + `${TAB_P}::${LEAF_A}`, + `${TAB_P}::${LEAF_B}` + ]) + + // The host closes the left pane only: the parent tab persists with the + // surviving leaf, and the killed PTY has not exited yet (kill lag, or a + // failed kill on WSL/Windows), so it stays connected + paired-create owned. + harness.retirePersistedLeaf(LEAF_A) + harness.publishSplitSnapshot([LEAF_B], 2) + + expect( + await listTerminalSurfaceIds(harness.runtime), + 'the preserved-merge resurrected a split leaf the host renderer closed' + ).toEqual([`${TAB_P}::${LEAF_B}`]) + expect(harness.isRuntimeSessionOwned(PTY_A)).toBe(false) + // The surviving sibling keeps its ownership: the release must be leaf-scoped. + expect(harness.isRuntimeSessionOwned(PTY_B)).toBe(true) + }) + + it('control: a persisted layout that still binds the PTY under a different leafId is not retirement', async () => { + const harness = createSplitHarness() + + // The persisted session relabels PTY_A's surface to another leafId (renderer + // and headless sources can derive different leafIds for one surface), and a + // stale publication omits it. The still-bound PTY id is the only signal that + // the leaf lives on; releasing on the leafId mismatch alone would retire a + // live pane and let the merge drop it. + harness.relabelPersistedLeftLeaf() + harness.publishSplitSnapshot([LEAF_B], 2) + + expect( + await listTerminalSurfaceIds(harness.runtime), + 'a live relabeled leaf was retired by a stale publication' + ).toEqual([`${TAB_P}::${LEAF_A}`, `${TAB_P}::${LEAF_B}`]) + expect(harness.isRuntimeSessionOwned(PTY_A)).toBe(true) + expect(harness.isRuntimeSessionOwned(PTY_B)).toBe(true) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c9fe7ba79ca..0f21177b63e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1124,6 +1124,7 @@ import { } from '../../shared/worktree/retired-name-registry' import { normalizeSparseDirectories } from '../ipc/sparse-checkout-directories' import type { PtyBindingSourceExpectation, Store } from '../persistence' +import { collectLayoutLeafIdsInOrder } from '../persistence/restoring-sessions/terminal-layout-normalization' import type { StatsCollector } from '../stats/collector' import { computeValidatedBranchName, @@ -3171,6 +3172,7 @@ export class OrcaRuntimeService { private detachedPreAllocatedLeaves = new Map() private graphSyncCallbacks: (() => void)[] = [] private waitersByHandle = new Map>() + private ptyExitListenersByPtyId = new Map void>>() private ptyController: RuntimePtyController | null = null private notifier: RuntimeNotifier | null = null private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() @@ -6597,15 +6599,28 @@ export class OrcaRuntimeService { this.emitClientEvent({ type: 'reposChanged' }) } + /** Why an object with a required `navigationTarget`: 'caller' means the requester already owns + * the navigation, so a site that forgets to forward it silently reverts to broadcasting at + * every viewer (STA-2802). A required key makes omission a compile error, and naming it makes + * a hardcoded target visible at the call site instead of hiding in a positional tail. */ private notifyActivateWorktree( repoId: string, worktreeId: string, - setup?: CreateWorktreeResult['setup'], - startup?: WorktreeStartupLaunch, - defaultTabs?: CreateWorktreeResult['defaultTabs'] + launch: { + setup?: CreateWorktreeResult['setup'] + startup?: WorktreeStartupLaunch + defaultTabs?: CreateWorktreeResult['defaultTabs'] + navigationTarget: RuntimeNavigationTarget | undefined + } ): void { - this.notifyHostActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) - this.notifyClientsActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + const { setup, startup, defaultTabs } = launch + const navigation = launch.navigationTarget ?? 'all' + if (navigationTargetsHost(navigation)) { + this.notifyHostActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + } + if (navigationTargetsClients(navigation)) { + this.notifyClientsActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + } } private notifyHostActivateWorktree( @@ -7657,6 +7672,99 @@ export class OrcaRuntimeService { } } + private getMobileTerminalLeafPtyIds(tab: RuntimeMobileSessionTerminalTab): string[] { + return [tab.ptyId, tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId]].filter( + (ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0 + ) + } + + private clearRuntimeSessionOwnershipForMobileTerminalLeaf( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): void { + for (const ptyId of this.getMobileTerminalLeafPtyIds(tab)) { + const pty = this.ptysById.get(ptyId) + if (pty?.worktreeId === worktreeId && pty.tabId === tab.parentTabId) { + pty.runtimeSessionOwned = false + this.setPairedRendererSessionOwnership(pty.ptyId, false) + } + } + } + + // Why: only positive evidence that the persisted parent dropped this leaf may + // release it — a parent with no persisted layout is no evidence of a split. + private persistedParentStillBindsMobileTerminalLeaf( + session: WorkspaceSessionState, + persistedParent: TerminalTab, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + const layout = session.terminalLayoutsByTabId?.[tab.parentTabId] + if (!layout) { + return true + } + if ( + typeof layout.ptyIdsByLeafId?.[tab.leafId] === 'string' || + collectLayoutLeafIdsInOrder(layout.root).includes(tab.leafId) + ) { + return true + } + // Why: renderer and headless sources can derive different leafIds for one + // surface, so a still-bound PTY id outranks a leafId that no longer matches. + const leafPtyIds = new Set(this.getMobileTerminalLeafPtyIds(tab)) + if (leafPtyIds.size === 0) { + return true + } + return [persistedParent.ptyId, ...Object.values(layout.ptyIdsByLeafId ?? {})].some( + (ptyId) => typeof ptyId === 'string' && leafPtyIds.has(ptyId) + ) + } + + // Why: omitted from the publication AND from persistence = durably closed, so release + // ownership — else a lagging or failed kill preserves the tab back into every merge. + // A create still in flight has not been retired, only not published yet. + private releaseRuntimeSessionOwnershipForRendererRetiredTabs( + incoming: RuntimeMobileSessionTabsSnapshot, + existing: RuntimeMobileSessionTabsSnapshot | undefined + ): void { + if (!existing || this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch)) { + return + } + const worktreeId = existing.worktree + const session = this.getWorkspaceSessionForWorktree(worktreeId) + const persistedTabs = session?.tabsByWorktree?.[worktreeId] + if (!session || !persistedTabs) { + return + } + const persistedTabsById = new Map(persistedTabs.map((tab) => [tab.id, tab])) + const incomingIdentityKeys = new Set( + incoming.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) + ) + for (const tab of existing.tabs) { + if (tab.type !== 'terminal') { + continue + } + if ( + this.pendingMobileTerminalCreatesByKey.has(`${worktreeId}::${tab.parentTabId}`) || + this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => + incomingIdentityKeys.has(id) + ) || + !this.hasLiveRuntimeSessionOwnedPtyBinding(worktreeId, tab) + ) { + continue + } + const persistedParent = persistedTabsById.get(tab.parentTabId) + if (!persistedParent) { + this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, existing, tab.parentTabId) + continue + } + // Why: a split parent outlives its retired leaf, so releasing the parent's + // PTYs would retire the surviving sibling with it. + if (!this.persistedParentStillBindsMobileTerminalLeaf(session, persistedParent, tab)) { + this.clearRuntimeSessionOwnershipForMobileTerminalLeaf(worktreeId, tab) + } + } + } + // Why: a tab needs authoritative runtime teardown (kill + de-persist + prune) // only when the renderer can't durably tear it down: either it's serve/SSH // (preserved + re-hydrated, would resurrect) or the renderer graph never @@ -15996,6 +16104,7 @@ export class OrcaRuntimeService { pty?.incarnationId ?? `runtime:${this.runtimeId}:${this.getPtyLifecycleGeneration(ptyId)}` this.advancePtyLifecycleGeneration(ptyId) + this.notifyPtyExitListeners(ptyId) const exactSurfaceByKey = new Map< string, Pick @@ -16681,6 +16790,13 @@ export class OrcaRuntimeService { // Watching at desktop dims — viewport is informational only. return { updated: true, applied: false } } + // Why: a desktop take-back is released only by a deliberate mobile gesture + // (mobileTookFloor / setDisplayMode / fresh subscribe). A passive viewport report + // — iOS resume and every reconnect force one — must not re-phone-fit and re-take + // the floor, or the take-back looks like a no-op to the desktop user. + if (this.getDriver(ptyId).kind === 'desktop') { + return { updated: true, applied: false } + } // Drive PTY dims by the most-recent-actor (just updated to this client). const winner = this.pickMostRecentActor(inner!) if (!winner) { @@ -19244,14 +19360,16 @@ export class OrcaRuntimeService { async readTerminal( handle: string, - opts: { cursor?: number; limit?: number } = {} + opts: { cursor?: number; limit?: number; screen?: boolean } = {} ): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { const read = this.readPtyTerminal(handle, pty.pty, opts) - const visibleRead = await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts) + const visibleRead = opts.screen + ? await this.readRenderedScreen(pty.pty.ptyId, read, opts) + : await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts) this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) - return visibleRead + return labelTerminalReadSource(visibleRead) } const { leaf } = this.getLiveLeafForHandle(handle) @@ -19267,11 +19385,33 @@ export class OrcaRuntimeService { limit: opts.limit }) if (!leaf.ptyId) { - return read + return { ...read, source: opts.screen ? 'screen-unavailable' : 'stream' } } - const visibleRead = await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts) + const visibleRead = opts.screen + ? await this.readRenderedScreen(leaf.ptyId, read, opts) + : await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts) this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId) - return visibleRead + return labelTerminalReadSource(visibleRead) + } + + // Why: the default read is the accumulated pty stream, which stacks every repaint of a line + // ("cclclecleaclear" for one `clear`) and drops spaces a prompt draws with cursor-forward. + // That is the right answer for "what happened over time" and the wrong one for "what is on + // screen", so an explicit screen read goes to the emulator state instead. When no rendered + // state exists the stream is still returned, but labelled `stream` rather than passed off as + // a screen — silently answering the other question is the defect this exists to stop. + private async readRenderedScreen( + ptyId: string, + read: RuntimeTerminalRead, + opts: { limit?: number } = {} + ): Promise { + const visibleState = await this.readVisibleTerminalState(ptyId) + const lines = + visibleState?.lines ?? (await this.readProviderTerminalTailLines(ptyId, opts.limit)) + if (lines.length === 0) { + return { ...read, source: 'screen-unavailable' } + } + return buildVisibleSnapshotReadFallback(read, lines, opts.limit) } // Why a cache: leaf-branch sends may arrive per keystroke; one proven-absent @@ -20616,6 +20756,39 @@ export class OrcaRuntimeService { }) } + subscribeToPtyExit(ptyId: string, listener: () => void): () => void { + const lifecycleGeneration = this.getPtyLifecycleGeneration(ptyId) + if (this.isPtyKnownExited(ptyId)) { + listener() + return () => {} + } + let listeners = this.ptyExitListenersByPtyId.get(ptyId) + if (!listeners) { + listeners = new Set() + this.ptyExitListenersByPtyId.set(ptyId, listeners) + } + let active = true + const unsubscribe = (): void => { + if (!active) { + return + } + active = false + listeners.delete(listener) + if (listeners.size === 0 && this.ptyExitListenersByPtyId.get(ptyId) === listeners) { + this.ptyExitListenersByPtyId.delete(ptyId) + } + } + listeners.add(listener) + if ( + this.getPtyLifecycleGeneration(ptyId) !== lifecycleGeneration || + this.isPtyKnownExited(ptyId) + ) { + unsubscribe() + listener() + } + return unsubscribe + } + async waitForSetupTerminalCompletion(handle: string): Promise<{ exitCode: number | null }> { const ptyId = this.getLivePtyForHandle(handle)?.pty.ptyId if (!ptyId) { @@ -24976,6 +25149,9 @@ export class OrcaRuntimeService { pushTarget?: GitPushTarget runHooks?: boolean activate?: boolean + /** Who the create's activation is addressed to. Defaults to 'all' so host/CLI callers keep + * revealing on every surface; the RPC layer narrows it to 'caller' for paired clients. */ + navigation?: RuntimeNavigationTarget setupDecision?: 'run' | 'skip' | 'inherit' awaitTerminalProvisioning?: boolean observeSetupCompletion?: boolean @@ -25139,9 +25315,14 @@ export class OrcaRuntimeService { } if (shouldActivate) { if (effectiveStartup && !didSpawnStartup) { - this.notifyActivateWorktree(repo.id, worktree.id, undefined, effectiveStartup) + this.notifyActivateWorktree(repo.id, worktree.id, { + startup: effectiveStartup, + navigationTarget: args.navigation + }) } else { - this.notifyActivateWorktree(repo.id, worktree.id) + this.notifyActivateWorktree(repo.id, worktree.id, { + navigationTarget: args.navigation + }) } } else if (this.ptyController?.spawn && !didSpawnStartup) { try { @@ -25967,21 +26148,18 @@ export class OrcaRuntimeService { : undefined const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : defaultTabs if (effectiveStartup && !didSpawnStartup) { - this.notifyActivateWorktree( - repo.id, - worktree.id, - activationSetup, - effectiveStartup, - activationDefaultTabs - ) + this.notifyActivateWorktree(repo.id, worktree.id, { + setup: activationSetup, + startup: effectiveStartup, + defaultTabs: activationDefaultTabs, + navigationTarget: args.navigation + }) } else { - this.notifyActivateWorktree( - repo.id, - worktree.id, - activationSetup, - undefined, - activationDefaultTabs - ) + this.notifyActivateWorktree(repo.id, worktree.id, { + setup: activationSetup, + defaultTabs: activationDefaultTabs, + navigationTarget: args.navigation + }) } } else if (this.ptyController?.spawn && (setup || defaultTabs || didSpawnStartup)) { // Why: inactive terminal materialization matches normal worktree creation, @@ -26121,6 +26299,7 @@ export class OrcaRuntimeService { pushTarget?: GitPushTarget runHooks?: boolean activate?: boolean + navigation?: RuntimeNavigationTarget setupDecision?: 'run' | 'skip' | 'inherit' awaitTerminalProvisioning?: boolean observeSetupCompletion?: boolean @@ -26311,21 +26490,18 @@ export class OrcaRuntimeService { : undefined const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : result.defaultTabs if (args.startup && !didSpawnStartup) { - this.notifyActivateWorktree( - repo.id, - result.worktree.id, - activationSetup, - args.startup, - activationDefaultTabs - ) + this.notifyActivateWorktree(repo.id, result.worktree.id, { + setup: activationSetup, + startup: args.startup, + defaultTabs: activationDefaultTabs, + navigationTarget: args.navigation + }) } else { - this.notifyActivateWorktree( - repo.id, - result.worktree.id, - activationSetup, - undefined, - activationDefaultTabs - ) + this.notifyActivateWorktree(repo.id, result.worktree.id, { + setup: activationSetup, + defaultTabs: activationDefaultTabs, + navigationTarget: args.navigation + }) } } @@ -33919,6 +34095,7 @@ export class OrcaRuntimeService { this.reconcileNativeChatLaunchDraftResolutionTombstones(snapshot) const launchDraftFencedSnapshot = this.applyNativeChatLaunchDraftResolutionFence(snapshot) const fencedSnapshot = this.applyMobileSessionRetirementFences(launchDraftFencedSnapshot) + this.releaseRuntimeSessionOwnershipForRendererRetiredTabs(fencedSnapshot, existing) const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(fencedSnapshot, existing) // Why: clients drop same-epoch frames whose version isn't strictly newer, // and main-local touches may already have emitted a higher version than @@ -36206,6 +36383,23 @@ export class OrcaRuntimeService { } } + private isPtyKnownExited(ptyId: string): boolean { + const pty = this.ptysById.get(ptyId) + if (pty) { + return !pty.connected + } + return this.getLeavesForPty(ptyId).some((leaf) => getTerminalState(leaf) === 'exited') + } + + private notifyPtyExitListeners(ptyId: string): void { + const listeners = this.ptyExitListenersByPtyId.get(ptyId) + if (!listeners) { + return + } + this.ptyExitListenersByPtyId.delete(ptyId) + notifyRuntimeListeners(listeners, (listener) => listener(), 'pty-exit') + } + private resolvePtyTuiIdleWaiters(pty: RuntimePtyWorktreeRecord, ptyId: string): void { const handle = this.handleByPtyId.get(ptyId) if (!handle) { @@ -40602,6 +40796,15 @@ function visibleNonBlankTerminalLines(lines: string[]): string[] { return lines.map((line) => line.trimEnd()).filter((line) => line.trim().length > 0) } +// Why: every read carries its source, so a caller that asked for a screen and got a response +// with no source at all knows it reached a host that predates screen reads — rather than +// mistaking the stream for the screen. Rendered lines only ever enter a read through +// buildVisibleSnapshotReadFallback, which stamps `screen` itself, so anything still unlabelled +// here is the accumulated stream. +function labelTerminalReadSource(resolved: RuntimeTerminalRead): RuntimeTerminalRead { + return resolved.source ? resolved : { ...resolved, source: 'stream' } +} + function buildVisibleSnapshotReadFallback( read: RuntimeTerminalRead, visibleLines: string[], @@ -40618,7 +40821,8 @@ function buildVisibleSnapshotReadFallback( tail: charBoundedTail.tail, limited: read.limited || lineBoundedTail.length < visibleLines.length || charBoundedTail.limited, - returnedLineCount: charBoundedTail.tail.length + returnedLineCount: charBoundedTail.tail.length, + source: 'screen' } } diff --git a/src/main/runtime/paired-client-navigation-test-harness.ts b/src/main/runtime/paired-client-navigation-test-harness.ts new file mode 100644 index 00000000000..acc330dc3fe --- /dev/null +++ b/src/main/runtime/paired-client-navigation-test-harness.ts @@ -0,0 +1,229 @@ +import { expect } from 'vitest' +import WebSocket from 'ws' +import { parsePairingCode } from '../../shared/pairing' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import type { OrcaRuntimeService } from './orca-runtime' +import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' + +export const REPO_ID = 'repo-1' +export const FOLDER_REPO_ID = 'folder-repo-1' +export const worktreeId = (name: string): string => `${REPO_ID}::/tmp/${name}` +export const HOST_WORKTREE_ID = worktreeId('host') +export const CLIENT_A_WORKTREE_ID = worktreeId('client-a') +export const CLIENT_A2_WORKTREE_ID = worktreeId('client-a2') +export const CLIENT_B_WORKTREE_ID = worktreeId('client-b') +export const SESSION_WORKTREE_ID = worktreeId('session') + +export type PairedSession = { + ws: WebSocket + sharedKey: Uint8Array +} + +export type ResponseReader = { + next: ( + id: string, + predicate?: (response: Record) => boolean + ) => Promise> + dispose: () => void +} + +export function makeStore() { + const worktreeMeta = Object.fromEntries( + [ + HOST_WORKTREE_ID, + CLIENT_A_WORKTREE_ID, + CLIENT_A2_WORKTREE_ID, + CLIENT_B_WORKTREE_ID, + SESSION_WORKTREE_ID + ].map((id) => [ + id, + { + displayName: id.split('/').at(-1) ?? id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + instanceId: id + } + ]) + ) + const repos = [ + { + id: REPO_ID, + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }, + // Why: a folder project reaches the same create-activation notifier without a real git worktree add. + { + id: FOLDER_REPO_ID, + path: '/tmp/folder-project', + displayName: 'folder-project', + kind: 'folder', + badgeColor: 'blue', + addedAt: 2 + } + ] + return { + getRepo: (id: string) => repos.find((repo) => repo.id === id), + getRepos: () => repos, + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => worktreeMeta, + getWorktreeMeta: (id: string) => worktreeMeta[id], + setWorktreeMeta: (id: string, patch: Record) => { + const next = { ...worktreeMeta[id], ...patch } + worktreeMeta[id] = next as (typeof worktreeMeta)[string] + return next as never + }, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) + } +} + +function connect(endpoint: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(endpoint) + ws.once('open', () => resolve(ws)) + ws.once('error', reject) + }) +} + +function nextMessage(ws: WebSocket): Promise { + return new Promise((resolve) => { + ws.once('message', (data) => resolve(typeof data === 'string' ? data : data.toString('utf-8'))) + }) +} + +export async function authenticate(pairingUrl: string): Promise { + const pairing = parsePairingCode(pairingUrl) + if (!pairing) { + throw new Error('invalid_pairing_url') + } + const ws = await connect(pairing.endpoint) + const keys = generateKeyPair() + const serverPublicKey = Uint8Array.from(Buffer.from(pairing.publicKeyB64, 'base64')) + const sharedKey = deriveSharedKey(keys.secretKey, serverPublicKey) + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from(keys.publicKey).toString('base64') + }) + ) + expect(JSON.parse(await nextMessage(ws))).toEqual({ type: 'e2ee_ready' }) + ws.send( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), sharedKey) + ) + expect(JSON.parse(decrypt(await nextMessage(ws), sharedKey)!)).toEqual({ + type: 'e2ee_authenticated' + }) + return { ws, sharedKey } +} + +export function send(session: PairedSession, request: Record): void { + session.ws.send(encrypt(JSON.stringify(request), session.sharedKey)) +} + +export function createReader(session: PairedSession): ResponseReader { + type Waiter = { + id: string + predicate: (response: Record) => boolean + resolve: (response: Record) => void + } + const queued: Record[] = [] + const waiters: Waiter[] = [] + const onMessage = (data: WebSocket.RawData): void => { + const plaintext = decrypt( + typeof data === 'string' ? data : data.toString('utf-8'), + session.sharedKey + ) + if (!plaintext) { + return + } + const response = JSON.parse(plaintext) as Record + const waiterIndex = waiters.findIndex( + (waiter) => response.id === waiter.id && waiter.predicate(response) + ) + if (waiterIndex === -1) { + queued.push(response) + return + } + waiters.splice(waiterIndex, 1)[0]?.resolve(response) + } + session.ws.on('message', onMessage) + return { + next: (id, predicate = () => true) => { + const queuedIndex = queued.findIndex((response) => response.id === id && predicate(response)) + if (queuedIndex !== -1) { + return Promise.resolve(queued.splice(queuedIndex, 1)[0]!) + } + return new Promise((resolve) => waiters.push({ id, predicate, resolve })) + }, + dispose: () => { + session.ws.off('message', onMessage) + queued.length = 0 + waiters.length = 0 + } + } +} + +export function resultType(response: Record): string | undefined { + return (response.result as { type?: string } | undefined)?.type +} + +export function activeTabId(response: Record): string | null { + return (response.result as RuntimeMobileSessionTabsResult | undefined)?.activeTabId ?? null +} + +export function snapshotVersion(response: Record): number { + return (response.result as RuntimeMobileSessionTabsResult | undefined)?.snapshotVersion ?? -1 +} + +export function seedSessionTabs(runtime: OrcaRuntimeService): void { + const tabs = ['host-tab', 'client-a-tab', 'client-a2-tab', 'client-b-tab'].map((id, index) => ({ + type: 'terminal' as const, + id, + parentTabId: id, + leafId: `pane:${index + 1}`, + ptyId: `pty-${index + 1}`, + title: id, + isActive: id === 'host-tab' + })) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: SESSION_WORKTREE_ID, + publicationEpoch: 'renderer:host', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'host-tab', + activeTabType: 'terminal', + tabGroups: [ + { + id: 'group-1', + activeTabId: 'host-tab', + tabOrder: tabs.map((tab) => tab.parentTabId) + } + ], + tabs + } + ] + }) + for (let index = 0; index < tabs.length; index += 1) { + runtime.registerPty(`pty-${index + 1}`, SESSION_WORKTREE_ID) + } +} diff --git a/src/main/runtime/rpc/methods/project-host-setup-self-host-stamp.test.ts b/src/main/runtime/rpc/methods/project-host-setup-self-host-stamp.test.ts new file mode 100644 index 00000000000..38a26c49561 --- /dev/null +++ b/src/main/runtime/rpc/methods/project-host-setup-self-host-stamp.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods' + +// Why: a paired client addresses this runtime as `runtime:`. Persisting that +// verbatim hides the row from every other client and defeats the (projectId, hostId) duplicate +// check, so the request's host id has to be re-spelled as this machine's own `local`. +function parseParams(methodName: string, params: unknown): { hostId: string } { + const method = PROJECT_RUNTIME_METHODS.find((candidate) => candidate.name === methodName) + if (!method?.params) { + throw new Error(`Missing params schema for ${methodName}`) + } + return method.params.parse(params) as { hostId: string } +} + +const CREATING_METHODS = [ + { + name: 'projectHostSetup.setupExistingFolder', + base: { projectId: 'github:stablyai/orca', path: '/srv/orca' } + }, + { + name: 'projectHostSetup.clone', + base: { + projectId: 'github:stablyai/orca', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + } + }, + { name: 'projectHostSetup.create', base: { projectId: 'github:stablyai/orca' } } +] as const + +describe('project host setup self-host stamp', () => { + it.each(CREATING_METHODS)('$name stores a caller runtime id as local', ({ name, base }) => { + const parsed = parseParams(name, { ...base, hostId: 'runtime:c0ffee-env-id' }) + + expect(parsed.hostId).toBe('local') + }) + + it.each(CREATING_METHODS)('$name leaves an explicit local host alone', ({ name, base }) => { + expect(parseParams(name, { ...base, hostId: 'local' }).hostId).toBe('local') + }) + + // Why: ssh targets are a different machine from the runtime handling the call, so they must + // keep their own identity — only `runtime:` means "you". + it.each(CREATING_METHODS)('$name preserves an ssh host', ({ name, base }) => { + expect(parseParams(name, { ...base, hostId: 'ssh:box-1' }).hostId).toBe('ssh:box-1') + }) + + it.each(CREATING_METHODS)('$name still rejects an unparseable host', ({ name, base }) => { + expect(() => parseParams(name, { ...base, hostId: 'nonsense' })).toThrow() + expect(() => parseParams(name, { ...base, hostId: 'runtime:' })).toThrow() + }) + + // Two clients paired with the same server now converge on one row instead of one each. + it('collapses two different clients onto the same host id', () => { + const fromClientA = parseParams('projectHostSetup.create', { + projectId: 'github:stablyai/orca', + hostId: 'runtime:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + }) + const fromClientB = parseParams('projectHostSetup.create', { + projectId: 'github:stablyai/orca', + hostId: 'runtime:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + }) + + expect(fromClientA.hostId).toBe(fromClientB.hostId) + }) +}) diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts index db613d7992b..c25671d5bed 100644 --- a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { normalizeExecutionHostId } from '../../../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + parseExecutionHostId +} from '../../../../shared/execution-host' import { defineMethod, type RpcMethod } from '../core' import { OptionalString, requiredString } from '../schemas' import { projectRepoResultVisibilityForClient } from '../repo-visibility-projection' @@ -11,17 +15,27 @@ const ProjectProviderIdentity = z.object({ host: OptionalString }) +// Why: `runtime:` ids are minted by the calling client's own pairing store +// (addEnvironmentFromPairingCode -> randomUUID), so they name a machine only relative to that +// client. A client sending one to this runtime is addressing *us*, and runtimes do not proxy +// these calls onward, so the host it names is this machine. Persisting the caller's id verbatim +// makes one machine look like a different host to every other client, hides its rows from them, +// and defeats the (projectId, hostId) duplicate check. Store our own spelling instead: `local`. +// Rows written before this normalization keep their client-minted stamp; readers still project +// `local` back to `runtime:`, so the client-visible model is unchanged. +const RequestedHostId = requiredString('Missing host ID').transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host ID' }) + return z.NEVER + } + return parseExecutionHostId(hostId)?.kind === 'runtime' ? LOCAL_EXECUTION_HOST_ID : hostId +}) + const ProjectHostSetupExistingFolder = z.object({ projectId: requiredString('Missing project ID'), projectProviderIdentity: ProjectProviderIdentity.optional(), - hostId: requiredString('Missing host ID').transform((value, ctx) => { - const hostId = normalizeExecutionHostId(value) - if (!hostId) { - ctx.addIssue({ code: 'custom', message: 'Invalid host ID' }) - return z.NEVER - } - return hostId - }), + hostId: RequestedHostId, path: requiredString('Missing project path'), kind: z.enum(['git', 'folder']).optional(), displayName: OptionalString, @@ -31,14 +45,7 @@ const ProjectHostSetupExistingFolder = z.object({ const ProjectHostSetupClone = z.object({ projectId: requiredString('Missing project ID'), projectProviderIdentity: ProjectProviderIdentity.optional(), - hostId: requiredString('Missing host ID').transform((value, ctx) => { - const hostId = normalizeExecutionHostId(value) - if (!hostId) { - ctx.addIssue({ code: 'custom', message: 'Invalid host ID' }) - return z.NEVER - } - return hostId - }), + hostId: RequestedHostId, url: requiredString('Missing clone URL'), destination: requiredString('Missing clone destination'), displayName: OptionalString @@ -59,14 +66,7 @@ const ProjectUpdate = z.object({ const ProjectHostSetupCreate = z.object({ projectId: requiredString('Missing project ID'), - hostId: requiredString('Missing host ID').transform((value, ctx) => { - const hostId = normalizeExecutionHostId(value) - if (!hostId) { - ctx.addIssue({ code: 'custom', message: 'Invalid host ID' }) - return z.NEVER - } - return hostId - }), + hostId: RequestedHostId, setupId: OptionalString, path: OptionalString, kind: z.enum(['git', 'folder']).optional(), diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index d20daa9407c..9eee5feedf5 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -8,7 +8,7 @@ import { type RpcAnyMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' -import type { DriverState, OrcaRuntimeService } from '../../orca-runtime' +import type { DriverState, OrcaRuntimeService, SubscriptionRegistration } from '../../orca-runtime' import { TerminalStreamOpcode, decodeTerminalStreamJson, @@ -323,6 +323,45 @@ function resolveMobileFloorClientId( type TerminalStreamInputOutcome = 'delivered' | 'rejected' | 'failed' +function watchSubscriptionLifetime( + runtime: OrcaRuntimeService, + ptyId: string, + signal: AbortSignal | undefined, + registration: SubscriptionRegistration +): () => void { + let unsubscribeExit: (() => void) | null = null + let removeAbort: (() => void) | null = null + let stopped = false + const stop = (): void => { + stopped = true + unsubscribeExit?.() + removeAbort?.() + } + const release = (): void => { + registration.releaseIfCurrent() + stop() + } + unsubscribeExit = runtime.subscribeToPtyExit(ptyId, release) + if (stopped) { + unsubscribeExit() + return stop + } + if (!signal) { + return stop + } + if (signal.aborted) { + release() + return stop + } + const onAbort = (): void => release() + removeAbort = () => signal.removeEventListener('abort', onAbort) + signal.addEventListener('abort', onAbort, { once: true }) + if (stopped) { + removeAbort() + } + return stop +} + function isTerminalStreamInputRejection(error: unknown): boolean { // Why: a lease refusal is a deliberate rejection, not a transport failure, so the stream reports // it through the WriteUnavailable frame old clients already decode rather than a new opcode. @@ -889,7 +928,10 @@ const TerminalRead = TerminalHandle.extend({ }) ) .optional(), - limit: OptionalFiniteNumber + limit: OptionalFiniteNumber, + // Why: optional so an older host that does not understand it simply drops the key and answers + // with its usual stream read; the response's `source` is what tells the caller which it got. + screen: z.literal(true).optional() }) // Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct. @@ -1191,7 +1233,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ handler: async (params, { runtime }) => ({ terminal: await runtime.readTerminal(params.terminal, { cursor: params.cursor, - limit: params.limit + limit: params.limit, + screen: params.screen }) }) }), @@ -2983,6 +3026,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const supportsWriteUnavailable = params.capabilities?.writeUnavailable === 1 if (mobileInputLeaseOnly && clientId) { let closed = false + let stopWatchingLifetime = (): void => {} let resolveStream = (): void => {} const streamClosed = new Promise((resolve) => { resolveStream = resolve @@ -2992,6 +3036,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const registration = runtime.registerOwnedSubscriptionCleanup( subscriptionId, () => { + stopWatchingLifetime() closed = true runtime.handleMobileUnsubscribe(ptyId, clientId) emit({ type: 'end' }) @@ -2999,10 +3044,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }, connectionId ) - void runtime - .waitForTerminal(params.terminal, { condition: 'exit', signal }) - .then(() => registration.releaseIfCurrent()) - .catch(() => registration.releaseIfCurrent()) + stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) + if (closed) { + // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + return + } try { // Why: a lease-only subscriber has no terminal view, so its cached viewport must never phone-fit the PTY. await runtime.handleMobileSubscribe(ptyId, clientId, undefined) @@ -3037,6 +3083,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let outputBatcher: ReturnType | null = null let unsubscribeData = (): void => {} let unsubscribeFit = (): void => {} + let stopWatchingLifetime = (): void => {} let resolveStream = (): void => {} const streamClosed = new Promise((resolve) => { resolveStream = resolve @@ -3045,6 +3092,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const registration = runtime.registerOwnedSubscriptionCleanup( subscriptionId, () => { + stopWatchingLifetime() closed = true outputBatcher?.flush() outputBatcher?.dispose() @@ -3058,6 +3106,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }, connectionId ) + stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) + if (closed) { + // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + return + } try { if (clientId && params.client && params.viewport) { registeredRemoteDesktopDriver = true @@ -3125,11 +3178,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ rows: event.rows }) }) - // Why: bind the exit-waiter to the connection signal so socket close/error removes it instead of leaking until real exit. - void runtime - .waitForTerminal(params.terminal, { condition: 'exit', signal }) - .then(() => registration.releaseIfCurrent()) - .catch(() => registration.releaseIfCurrent()) await streamClosed } catch (error) { registration.releaseIfCurrent() @@ -3160,6 +3208,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let unsubscribeFit = (): void => {} let unregisterBinaryHandler = (): void => {} let abortRendererMountWait = (): void => {} + let stopWatchingLifetime = (): void => {} let lateRendererReadyPromise: Promise | null = null let outputBatcher: ReturnType | null = null let resolveStream = (): void => {} @@ -3171,6 +3220,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const registration = runtime.registerOwnedSubscriptionCleanup( subscriptionId, () => { + stopWatchingLifetime() outputBatcher?.flush() outputBatcher?.dispose() closed = true @@ -3189,13 +3239,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }, connectionId ) - // Why: bind the exit-waiter to the connection signal so socket close/error removes it instead of leaking until real exit. - // Why releaseIfCurrent: the signal aborts per socket, so after a reconnect rebinds - // this id a keyed teardown here would kill the replacement stream (STA-4510). - void runtime - .waitForTerminal(params.terminal, { condition: 'exit', signal }) - .then(() => registration.releaseIfCurrent()) - .catch(() => registration.releaseIfCurrent()) + stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) + if (closed) { + // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + return + } const sendFrame = ( opcode: TerminalStreamOpcode, payload: Uint8Array = new Uint8Array(), diff --git a/src/main/runtime/rpc/methods/worktree-create-args.test.ts b/src/main/runtime/rpc/methods/worktree-create-args.test.ts index c630f6170af..fa2b773560b 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.test.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { buildManagedWorktreeCreateArgs } from './worktree-create-args' -import { WorktreeCreate } from './worktree-schemas' +import { WorktreeCreate } from './worktree-create-schemas' const PROVENANCE = { automationProvenance: undefined, diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index 8b0d3ac49eb..2b86b12ee05 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -1,6 +1,7 @@ import type { z } from 'zod' +import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import type { OrcaRuntimeService } from '../../orca-runtime' -import type { WorktreeCreate } from './worktree-schemas' +import type { WorktreeCreate } from './worktree-create-schemas' type WorktreeCreateParams = z.infer type ManagedWorktreeCreateArgs = Parameters[0] @@ -13,7 +14,8 @@ type CreateProvenance = Pick< * the schema without the table becoming unreadable. */ export function buildManagedWorktreeCreateArgs( params: WorktreeCreateParams, - provenance: CreateProvenance + provenance: CreateProvenance, + origin: { clientKind?: 'mobile' | 'runtime' } = {} ): ManagedWorktreeCreateArgs { return { repoSelector: params.repo, @@ -44,6 +46,22 @@ export function buildManagedWorktreeCreateArgs( pushTarget: params.pushTarget, runHooks: params.runHooks === true, activate: params.activate === true, + // Why: create-activation is the caller's own view intent; without this a paired + // client's create dragged every other connected client and the host with it. + // Why 'runtime' only: a phone has no terminal-provisioning renderer, so when it + // creates without a startup command the host renderer is what runs the repo's + // setup/default tabs off this activation. Scoping mobile would drop that work. + // Why the CLI is excluded by payload and not by version: the CLI also pairs as a + // 'runtime' device but has no viewer of its own, so scoping it makes --activate + // reveal nothing. Current CLIs say so explicitly with `navigation`, but an older + // CLI against an updated host cannot; `cliProvenanceRequest` is the marker every + // CLI has always sent, and no renderer or phone sends it. + navigation: resolveRuntimeNavigationTarget({ + ...(params.navigation ? { navigation: params.navigation } : {}), + ...(origin.clientKind === 'runtime' && params.cliProvenanceRequest === undefined + ? { clientKind: origin.clientKind } + : {}) + }), setupDecision: params.setupDecision, createdWithAgent: params.createdWithAgent ?? params.startupAgent, ...provenance, diff --git a/src/main/runtime/rpc/methods/worktree-create-navigation.test.ts b/src/main/runtime/rpc/methods/worktree-create-navigation.test.ts new file mode 100644 index 00000000000..39f826986e6 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-create-navigation.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { WORKTREE_METHODS } from './worktree' + +const repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 1, + kind: 'git' as const, + executionHostId: 'ssh:ssh-target-1' as const +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +const passthroughDedupe = (_repo: string, _id: string | undefined, run: () => Promise) => + run() + +describe('worktree.create navigation authority', () => { + it.each([ + ['runtime', 'caller'], + // Why: no phone renderer provisions the host's setup/default tabs off the activation, + // so mobile creates keep the all-surface reveal until that work moves to the runtime. + ['mobile', 'all'] + ] as const)( + 'resolves create activation from the paired %s client kind', + async (clientKind, expected) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, + showRepo: vi.fn().mockResolvedValue(repo), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('worktree.create', { repo: 'repo-1', name: 'feature', activate: true }), + () => {}, + { clientKind, pairedDeviceId: 'device-1', connectionId: 'conn-1' } + ) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ activate: true, navigation: expected }) + ) + } + ) + + it('keeps an older CLI reveal working against an updated host', async () => { + // Why: an old CLI cannot send `navigation`, but it pairs as a runtime device. Without the + // cliProvenanceRequest marker it would resolve to 'caller' and `--activate` would reveal + // nothing anywhere — strictly worse than the pre-fix behavior for that version skew. + const runtime = { + getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, + showRepo: vi.fn().mockResolvedValue(repo), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'feature', + activate: true, + cliProvenanceRequest: {} + }), + () => {}, + { clientKind: 'runtime', pairedDeviceId: 'device-1', connectionId: 'conn-1' } + ) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ navigation: 'all' }) + ) + }) + + it('still scopes a desktop create that carries no CLI marker', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, + showRepo: vi.fn().mockResolvedValue(repo), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('worktree.create', { repo: 'repo-1', name: 'feature', activate: true }), + () => {}, + { clientKind: 'runtime', pairedDeviceId: 'device-1', connectionId: 'conn-1' } + ) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ navigation: 'caller' }) + ) + }) + + it('honors an explicit follow navigation on create', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, + showRepo: vi.fn().mockResolvedValue(repo), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'feature', + activate: true, + navigation: 'clients' + }), + () => {}, + { clientKind: 'runtime', pairedDeviceId: 'device-1', connectionId: 'conn-1' } + ) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ navigation: 'clients' }) + ) + }) + + it('keeps an explicit all-surface reveal from a paired caller', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, + showRepo: vi.fn().mockResolvedValue(repo), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'feature', + activate: true, + navigation: 'all' + }), + () => {}, + { clientKind: 'runtime', pairedDeviceId: 'device-1', connectionId: 'conn-1' } + ) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ navigation: 'all' }) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-create-schemas.ts b/src/main/runtime/rpc/methods/worktree-create-schemas.ts new file mode 100644 index 00000000000..507f44b325b --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-create-schemas.ts @@ -0,0 +1,150 @@ +import { z } from 'zod' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { workspaceSourceSchema } from '../../../../shared/telemetry-events' +import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' +import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' +import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema' +import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema' +import { + OptionalBoolean, + OptionalFiniteNumber, + OptionalString, + TriStateLinkedIssue +} from '../schemas' +import { + assertLinkedWorkItemSourceContextMatch, + AutomationWorkspaceProvenanceRequest, + CliWorkspaceProvenanceRequest, + OptionalTuiAgent +} from './worktree-schemas' + +export const WorktreeCreate = z + .object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + name: OptionalString, + /** Set by clients that fell back to a generated creature name. Absent means user-typed, so the + * host neither skips a retired candidate nor retires the name it lands on. */ + nameWasGenerated: z.boolean().optional(), + baseBranch: OptionalString, + compareBaseRef: OptionalString, + branchNameOverride: OptionalString, + linkedIssue: TriStateLinkedIssue, + linkedPR: TriStateLinkedIssue, + linkedLinearIssue: z.string().optional(), + linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(), + linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(), + linkedGitLabMR: TriStateLinkedIssue, + linkedGitLabIssue: TriStateLinkedIssue, + linkedBitbucketPR: TriStateLinkedIssue, + linkedAzureDevOpsPR: TriStateLinkedIssue, + linkedGiteaPR: TriStateLinkedIssue, + linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(), + linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), + comment: OptionalString, + displayName: OptionalString, + telemetrySource: z + .unknown() + .transform((value) => { + const parsed = workspaceSourceSchema.safeParse(value) + return parsed.success ? parsed.data : undefined + }) + .optional(), + workspaceStatus: OptionalString, + manualOrder: OptionalFiniteNumber, + sparseCheckout: z + .object({ + directories: z.array(z.string()), + presetId: OptionalString + }) + .optional(), + pushTarget: z + .object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: OptionalString + }) + .optional(), + runHooks: OptionalBoolean, + activate: OptionalBoolean, + // Why: activation on create is view intent, so it is addressed like worktree.activate. + // Contract: a paired desktop/web caller resolves to 'caller' and therefore receives NO + // activateWorktree event — it must reveal from this call's result, which carries setup, + // startup and defaultTabs. Pass an explicit target to opt into an all-surface reveal. + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), + parentWorkspace: OptionalString, + envParentWorkspace: OptionalString, + parentWorktree: OptionalString, + cwdParentWorktree: OptionalString, + noParent: OptionalBoolean, + callerTerminalHandle: OptionalString, + orchestrationContext: z + .object({ + parentWorktreeId: OptionalString, + orchestrationRunId: OptionalString, + taskId: OptionalString, + coordinatorHandle: OptionalString + }) + .optional(), + setupDecision: z + .unknown() + .transform((v) => + typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined + ) + .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) + .optional(), + // Why: some clients (e.g. desktop) pass a pre-built launch command so the + // first terminal pane launches the selected agent instead of an idle shell. + // Clients that can't quote for the host shell send `startupAgent` instead. + startupCommand: OptionalString, + startupEnv: z.record(z.string(), z.string()).optional(), + startupLaunchConfig: sleepingAgentLaunchConfigSchema, + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), + // Why: CLI clients should not hardcode agent launch quoting because SSH + // workspaces execute in a different shell than the client process. + startupAgent: OptionalTuiAgent, + startupPrompt: OptionalString, + // Why: task-driven mobile creates need desktop parity: the host chooses + // the same default/detected agent and drafts the linked issue/PR URL into it. + startupDraft: OptionalString, + createdWithAgent: z + .unknown() + .transform((value) => (isTuiAgent(value) ? value : undefined)) + .optional(), + // Why: mobile retries a create interrupted by a connection migration with the + // same key so the host dedupes instead of spawning a duplicate worktree. + clientMutationId: z.string().min(1).max(128).optional(), + automationProvenanceRequest: AutomationWorkspaceProvenanceRequest.optional(), + cliProvenanceRequest: CliWorkspaceProvenanceRequest.optional() + }) + .superRefine((params, ctx) => { + assertLinkedWorkItemSourceContextMatch(params, ctx) + if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either one parent selector or --no-parent.' + }) + } + if (params.parentWorkspace && params.parentWorktree) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either one parent selector or --no-parent.' + }) + } + if (params.startupPrompt !== undefined && params.startupAgent === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'startupPrompt requires startupAgent' + }) + } + }) + +export const WorktreePrefetchCreateBase = z.object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + baseBranch: OptionalString +}) diff --git a/src/main/runtime/rpc/methods/worktree-schemas.test.ts b/src/main/runtime/rpc/methods/worktree-schemas.test.ts index 5737e2c4c8d..ce86c740962 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.test.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { WorktreeActivate, WorktreeCreate, WorktreeSet } from './worktree-schemas' +import { WorktreeCreate } from './worktree-create-schemas' +import { WorktreeActivate, WorktreeSet } from './worktree-schemas' describe('worktree RPC schemas', () => { it('validates additive navigation intent', () => { diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index dfed9c2e44a..ce02c032daf 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -1,8 +1,6 @@ import { z } from 'zod' import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../shared/tui-agent' -import { workspaceSourceSchema } from '../../../../shared/telemetry-events' -import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' import { OptionalBoolean, @@ -28,7 +26,7 @@ const OptionalExecutionHostId = z }) .optional() -const OptionalTuiAgent = z +export const OptionalTuiAgent = z .unknown() .superRefine((value, ctx) => { if (value !== undefined && !isTuiAgent(value)) { @@ -38,7 +36,7 @@ const OptionalTuiAgent = z .transform((value): TuiAgent | undefined => (isTuiAgent(value) ? value : undefined)) .optional() -const AutomationWorkspaceProvenanceRequest = z.object({ +export const AutomationWorkspaceProvenanceRequest = z.object({ automationId: z.string(), automationRunId: z.string(), dispatchToken: z.string(), @@ -48,7 +46,7 @@ const AutomationWorkspaceProvenanceRequest = z.object({ // Why no dispatch token (unlike automation provenance): this is a descriptive // origin marker for sidebar filtering, not an authority grant. The host stamps // createdAt itself so a client clock can't skew sort order. -const CliWorkspaceProvenanceRequest = z.object({ +export const CliWorkspaceProvenanceRequest = z.object({ callerTerminalHandle: OptionalString }) @@ -92,7 +90,7 @@ export const WorktreeActivate = WorktreeSelector.extend({ }) /** Shared by WorktreeCreate and WorktreeSet so the two error messages cannot drift. */ -function assertLinkedWorkItemSourceContextMatch( +export function assertLinkedWorkItemSourceContextMatch( params: { linkedWorkItem?: z.infer | null linkedTaskSourceContext?: z.infer | null @@ -111,132 +109,6 @@ function assertLinkedWorkItemSourceContextMatch( } } -export const WorktreeCreate = z - .object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - name: OptionalString, - /** Set by clients that fell back to a generated creature name. Absent means user-typed, so the - * host neither skips a retired candidate nor retires the name it lands on. */ - nameWasGenerated: z.boolean().optional(), - baseBranch: OptionalString, - compareBaseRef: OptionalString, - branchNameOverride: OptionalString, - linkedIssue: TriStateLinkedIssue, - linkedPR: TriStateLinkedIssue, - linkedLinearIssue: z.string().optional(), - linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(), - linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(), - linkedGitLabMR: TriStateLinkedIssue, - linkedGitLabIssue: TriStateLinkedIssue, - linkedBitbucketPR: TriStateLinkedIssue, - linkedAzureDevOpsPR: TriStateLinkedIssue, - linkedGiteaPR: TriStateLinkedIssue, - linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(), - linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), - comment: OptionalString, - displayName: OptionalString, - telemetrySource: z - .unknown() - .transform((value) => { - const parsed = workspaceSourceSchema.safeParse(value) - return parsed.success ? parsed.data : undefined - }) - .optional(), - workspaceStatus: OptionalString, - manualOrder: OptionalFiniteNumber, - sparseCheckout: z - .object({ - directories: z.array(z.string()), - presetId: OptionalString - }) - .optional(), - pushTarget: z - .object({ - remoteName: z.string(), - branchName: z.string(), - remoteUrl: OptionalString - }) - .optional(), - runHooks: OptionalBoolean, - activate: OptionalBoolean, - parentWorkspace: OptionalString, - envParentWorkspace: OptionalString, - parentWorktree: OptionalString, - cwdParentWorktree: OptionalString, - noParent: OptionalBoolean, - callerTerminalHandle: OptionalString, - orchestrationContext: z - .object({ - parentWorktreeId: OptionalString, - orchestrationRunId: OptionalString, - taskId: OptionalString, - coordinatorHandle: OptionalString - }) - .optional(), - setupDecision: z - .unknown() - .transform((v) => - typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined - ) - .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) - .optional(), - // Why: some clients (e.g. desktop) pass a pre-built launch command so the - // first terminal pane launches the selected agent instead of an idle shell. - // Clients that can't quote for the host shell send `startupAgent` instead. - startupCommand: OptionalString, - startupEnv: z.record(z.string(), z.string()).optional(), - startupLaunchConfig: sleepingAgentLaunchConfigSchema, - startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), - // Why: CLI clients should not hardcode agent launch quoting because SSH - // workspaces execute in a different shell than the client process. - startupAgent: OptionalTuiAgent, - startupPrompt: OptionalString, - // Why: task-driven mobile creates need desktop parity: the host chooses - // the same default/detected agent and drafts the linked issue/PR URL into it. - startupDraft: OptionalString, - createdWithAgent: z - .unknown() - .transform((value) => (isTuiAgent(value) ? value : undefined)) - .optional(), - // Why: mobile retries a create interrupted by a connection migration with the - // same key so the host dedupes instead of spawning a duplicate worktree. - clientMutationId: z.string().min(1).max(128).optional(), - automationProvenanceRequest: AutomationWorkspaceProvenanceRequest.optional(), - cliProvenanceRequest: CliWorkspaceProvenanceRequest.optional() - }) - .superRefine((params, ctx) => { - assertLinkedWorkItemSourceContextMatch(params, ctx) - if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose either one parent selector or --no-parent.' - }) - } - if (params.parentWorkspace && params.parentWorktree) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose either one parent selector or --no-parent.' - }) - } - if (params.startupPrompt !== undefined && params.startupAgent === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'startupPrompt requires startupAgent' - }) - } - }) - -export const WorktreePrefetchCreateBase = z.object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - baseBranch: OptionalString -}) - export const WorktreeSet = WorktreeSelector.extend({ // Why: '' is the blanking contract — "fall back to the branch/folder name". // OptionalString coerced it to undefined, so on remote/SSH hosts clearing the diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 1be08978ea9..79171b927a3 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -131,6 +131,7 @@ describe('worktree RPC methods', () => { pushTarget: { remoteName: 'fork', branchName: 'feature' }, runHooks: false, activate: false, + navigation: 'all', setupDecision: 'skip', createdWithAgent: undefined, automationProvenance: undefined, diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 2dd7ef3f46f..7503d65d623 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -8,11 +8,10 @@ import { defineMethod, type RpcMethod } from '../core' import { buildManagedWorktreeCreateArgs } from './worktree-create-args' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' +import { WorktreeCreate, WorktreePrefetchCreateBase } from './worktree-create-schemas' import { - WorktreeCreate, WorktreeActivate, WorktreeForceDeleteBranch, - WorktreePrefetchCreateBase, WorktreeRemove, WorktreeResolveMrBase, WorktreeResolvePrBase, @@ -91,14 +90,18 @@ export const WORKTREE_METHODS: RpcMethod[] = [ // but failed create attempts must release the reservation for a safe retry. try { const result = await runtime.createManagedWorktree( - buildManagedWorktreeCreateArgs(params, { - automationProvenance, - cliProvenance: buildCliWorkspaceProvenance(params.cliProvenanceRequest, { - startupAgent: params.startupAgent ?? params.createdWithAgent, - createdAt: Date.now() - }), - creatorProvenance: resolveRpcWorkspaceCreatorProvenance(context) - }) + buildManagedWorktreeCreateArgs( + params, + { + automationProvenance, + cliProvenance: buildCliWorkspaceProvenance(params.cliProvenanceRequest, { + startupAgent: params.startupAgent ?? params.createdWithAgent, + createdAt: Date.now() + }), + creatorProvenance: resolveRpcWorkspaceCreatorProvenance(context) + }, + context.clientKind ? { clientKind: context.clientKind } : {} + ) ) finishAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) // Why: agent callers need a stable dispatch target without traversing diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index 502614f3e87..a5a132ae0cd 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -5,7 +5,6 @@ import { defineMethod, defineStreamingMethod, type RpcRequest } from './core' import type { OrcaRuntimeService } from '../orca-runtime' import { TERMINAL_METHODS } from './methods/terminal' import { createSubscriptionRegistryDouble } from './subscription-registry-test-double' -import type { RuntimeTerminalWait } from '../../../shared/runtime-types' function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { @@ -313,19 +312,10 @@ describe('RpcDispatcher streaming', () => { cleanupSubscriptionIfOwnedByConnection: vi.fn( registry.cleanupSubscriptionIfOwnedByConnection ), - waitForTerminal: vi.fn( - () => - new Promise((resolve) => { - resolveExit = () => - resolve({ - handle: 'terminal-1', - condition: 'exit', - satisfied: true, - status: 'exited', - exitCode: 0 - }) - }) - ) + subscribeToPtyExit: vi.fn((_ptyId: string, listener: () => void) => { + resolveExit = listener + return vi.fn() + }) }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) @@ -340,7 +330,7 @@ describe('RpcDispatcher streaming', () => { await vi.waitFor(() => expect(registry.peekCleanup('terminal-1:desktop-1')).toBeDefined()) // Cleanup now registers before snapshot work so a disconnect cannot orphan // a desktop width floor; wait for the actual exit waiter before resolving it. - await vi.waitFor(() => expect(runtime.waitForTerminal).toHaveBeenCalled()) + await vi.waitFor(() => expect(runtime.subscribeToPtyExit).toHaveBeenCalled()) resolveExit() await dispatchPromise diff --git a/src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts b/src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts new file mode 100644 index 00000000000..d7921f13135 --- /dev/null +++ b/src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' +import { createSubscriptionRegistryDouble } from './subscription-registry-test-double' + +type ExitListener = () => void + +const subscriptionCases = [ + { + name: 'lease-only', + params: { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 } + } + }, + { + name: 'legacy JSON', + params: { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' } + } + }, + { + name: 'binary', + params: { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } + } +] as const + +function createRuntime(exitListeners: ExitListener[]) { + const registry = createSubscriptionRegistryDouble() + const runtime = { + getRuntimeId: () => 'test-runtime', + requestRendererTerminalTabMount: () => false, + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + subscribeToTerminalData: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'snapshot', cols: 80, rows: 24, seq: 1 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn(() => vi.fn()), + subscribeToFitOverrideChanges: vi.fn(() => vi.fn()), + registerSubscriptionCleanup: vi.fn(registry.registerSubscriptionCleanup), + registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), + cleanupSubscription: vi.fn(registry.cleanupSubscription), + waitForTerminal: vi.fn(() => Promise.reject(new Error('unexpected handle waiter'))), + subscribeToPtyExit: vi.fn((_ptyId: string, listener: ExitListener) => { + exitListeners.push(listener) + return vi.fn() + }) + } as unknown as OrcaRuntimeService + return { registry, runtime } +} + +function makeRequest(params: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method: 'terminal.subscribe', params } +} + +describe('terminal subscribe after a handle becomes stale', () => { + it.each(subscriptionCases)( + 'keeps $name alive until the backing PTY exits', + async ({ params }) => { + const exitListeners: ExitListener[] = [] + const { registry, runtime } = createRuntime(exitListeners) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const messages: string[] = [] + + void dispatcher + .dispatchStreaming(makeRequest(params), (message) => messages.push(message), { + connectionId: 'conn-1', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + .catch(() => undefined) + + await vi.waitFor(() => expect(exitListeners).toHaveLength(1)) + const subscriptionId = `terminal-1:${params.client.id}` + await vi.waitFor(() => expect(registry.peekCleanup(subscriptionId)).toBeDefined()) + + vi.mocked(runtime.resolveLeafForHandle).mockImplementation(() => { + throw new Error('terminal_handle_stale') + }) + await Promise.resolve() + + expect(registry.peekCleanup(subscriptionId)).toBeDefined() + expect(messages.map((message) => JSON.parse(message).result?.type)).not.toContain('end') + expect(runtime.waitForTerminal).not.toHaveBeenCalled() + + exitListeners[0]!() + + await vi.waitFor(() => expect(registry.peekCleanup(subscriptionId)).toBeUndefined()) + expect(messages.map((message) => JSON.parse(message).result?.type)).toContain('end') + } + ) +}) diff --git a/src/main/runtime/rpc/terminal-multiplex-test-harness.ts b/src/main/runtime/rpc/terminal-multiplex-test-harness.ts index 39b3e3e2f66..13368bd680d 100644 --- a/src/main/runtime/rpc/terminal-multiplex-test-harness.ts +++ b/src/main/runtime/rpc/terminal-multiplex-test-harness.ts @@ -22,6 +22,7 @@ export function stubRuntime(overrides: Partial = {}): OrcaRu overrides.serializeTerminalBuffer?.(ptyId, opts)) return { getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), // Why: every multiplex stream registers as a remote view subscriber for // Phase-5 query-authority suppression (terminal-query-authority.md). registerRemoteTerminalViewSubscriber: () => () => {}, diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index 5493fee0fd2..e597f5e5b13 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -16,6 +16,7 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), // Why: subscribe streams register as remote view subscribers for Phase-5 // query-authority suppression (terminal-query-authority.md). registerRemoteTerminalViewSubscriber: () => () => {}, diff --git a/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts b/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts index be0df0a1775..726141c536d 100644 --- a/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts +++ b/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts @@ -33,6 +33,7 @@ it('replays post-capture output in the provider snapshot sequence domain', async | undefined const runtime = { getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), registerRemoteTerminalViewSubscriber: () => () => {}, resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-restored' }), handleMobileSubscribe: vi.fn().mockResolvedValue(true), @@ -108,6 +109,7 @@ it('keeps provider-backed alternate-screen resizes geometry-only', async () => { .mockResolvedValue({ data: 'restored tui', cols: 80, rows: 24 }) const runtime = { getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), registerRemoteTerminalViewSubscriber: () => () => {}, resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-tui' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), diff --git a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts index 87fedfd51e9..3562f9cc3c4 100644 --- a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts @@ -10,6 +10,7 @@ import type { RuntimeTerminalWait } from '../../../shared/runtime-types' function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), registerRemoteTerminalViewSubscriber: () => () => {}, requestRendererTerminalTabMount: () => false, getRendererTerminalSerializerGenerationForHandle: () => 0, diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index ad29d8a2643..f7381c5665c 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -15,6 +15,7 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), // Why: subscribe streams register as remote view subscribers for Phase-5 // query-authority suppression (terminal-query-authority.md). registerRemoteTerminalViewSubscriber: () => () => {}, diff --git a/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts b/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts index 31425defeb9..1ee273d1ca2 100644 --- a/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import type { RuntimeTerminalWait } from '../../../shared/runtime-types' import type { OrcaRuntimeService } from '../orca-runtime' import type { RpcRequest } from './core' import { RpcDispatcher } from './dispatcher' @@ -22,6 +21,7 @@ describe('terminal lease-only subscription', () => { it('keeps mobile input ownership without viewport resize or output delivery', async () => { const messages: string[] = [] const registry = createSubscriptionRegistryDouble() + const unsubscribeExit = vi.fn() const runtime = { getRuntimeId: () => 'test-runtime', resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), @@ -36,7 +36,7 @@ describe('terminal lease-only subscription', () => { registerSubscriptionCleanup: vi.fn(registry.registerSubscriptionCleanup), registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), cleanupSubscription: vi.fn(registry.cleanupSubscription), - waitForTerminal: vi.fn(() => new Promise(() => {})) + subscribeToPtyExit: vi.fn(() => unsubscribeExit) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) @@ -65,6 +65,7 @@ describe('terminal lease-only subscription', () => { runtime.cleanupSubscription('terminal-1:phone-1') await dispatchPromise + expect(unsubscribeExit).toHaveBeenCalledOnce() expect(runtime.handleMobileUnsubscribe).toHaveBeenCalledWith('pty-1', 'phone-1') }) }) diff --git a/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts index 7533881f903..c64747616cf 100644 --- a/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts @@ -29,6 +29,7 @@ describe('terminal subscribe mount replay', () => { let mounted = false const runtime = { getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), hasHeadlessTerminalState: vi.fn(() => mounted), requestRendererTerminalTabMount: vi.fn(() => true), @@ -98,6 +99,7 @@ describe('terminal subscribe mount replay', () => { let serializeCalls = 0 const runtime = { getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), hasHeadlessTerminalState: vi.fn(() => headlessPresent), requestRendererTerminalTabMount: vi.fn(() => true), @@ -189,6 +191,7 @@ describe('terminal subscribe mount replay', () => { }) const runtime = { getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-delayed' }), hasHeadlessTerminalState: vi.fn(() => false), requestRendererTerminalTabMount: vi.fn(() => true), @@ -289,6 +292,7 @@ describe('terminal subscribe mount replay', () => { }) const runtime = { getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), resolveLeafForHandle: vi.fn().mockReturnValue(null), waitForLeafPtyId: vi.fn(async () => { // A redraw may reach main before the PTY wait completes, but it does @@ -368,6 +372,7 @@ describe('terminal subscribe mount replay', () => { let waitSignal: AbortSignal | undefined const runtime = { getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), hasHeadlessTerminalState: vi.fn(() => false), requestRendererTerminalTabMount: vi.fn(() => true), diff --git a/src/main/runtime/rpc/terminal-subscribe-ownership.test.ts b/src/main/runtime/rpc/terminal-subscribe-ownership.test.ts index cba7b181f2a..9e036b879ce 100644 --- a/src/main/runtime/rpc/terminal-subscribe-ownership.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-ownership.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import type { RuntimeTerminalWait } from '../../../shared/runtime-types' import type { OrcaRuntimeService } from '../orca-runtime' import type { RpcRequest } from './core' import { RpcDispatcher } from './dispatcher' @@ -8,7 +7,7 @@ import { createSubscriptionRegistryDouble } from './subscription-registry-test-d const SUBSCRIPTION_ID = 'terminal-1:phone-1' -type Waiter = { resolve: (value: RuntimeTerminalWait) => void; reject: (reason: unknown) => void } +type Waiter = { resolve: () => void } function stubRuntime( registry: ReturnType, @@ -37,16 +36,10 @@ function stubRuntime( registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), cleanupSubscription: vi.fn(registry.cleanupSubscription), cleanupSubscriptionIfOwnedByConnection: vi.fn(registry.cleanupSubscriptionIfOwnedByConnection), - // Mirrors bindTerminalWaiterAbort: abort rejects with request_aborted. - waitForTerminal: vi.fn( - (_handle: string, options?: { signal?: AbortSignal }) => - new Promise((resolve, reject) => { - waiters.push({ resolve, reject }) - options?.signal?.addEventListener('abort', () => reject(new Error('request_aborted')), { - once: true - }) - }) - ), + subscribeToPtyExit: vi.fn((_ptyId: string, listener: () => void) => { + waiters.push({ resolve: listener }) + return vi.fn() + }), ...overrides } as unknown as OrcaRuntimeService } @@ -107,8 +100,8 @@ describe('terminal.subscribe teardown ownership', () => { expect(runtime.handleMobileUnsubscribe).toHaveBeenCalledWith('pty-1', 'phone-1') }) - // T4(c): a genuine terminal-gone rejection still tears down. - it('tears down the current owner when the terminal handle goes stale', async () => { + // T4(c): a genuine terminal exit still tears down. + it('tears down the current owner when the PTY exits', async () => { const registry = createSubscriptionRegistryDouble() const waiters: Waiter[] = [] const runtime = stubRuntime(registry, waiters) @@ -117,12 +110,80 @@ describe('terminal.subscribe teardown ownership', () => { void dispatcher.dispatchStreaming(makeRequest(binaryParams), vi.fn(), streamOptions('conn-a')) await vi.waitFor(() => expect(waiters).toHaveLength(1)) - waiters[0]!.reject(new Error('terminal_handle_stale')) + waiters[0]!.resolve() await flush() expect(registry.peekCleanup(SUBSCRIPTION_ID)).toBeUndefined() }) + it('disposes an already-exited PTY observer before binding socket abort', async () => { + const registry = createSubscriptionRegistryDouble() + const unsubscribeExit = vi.fn() + const runtime = stubRuntime(registry, [], { + subscribeToPtyExit: vi.fn((_ptyId: string, listener: () => void) => { + listener() + return unsubscribeExit + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const conn = new AbortController() + const addAbort = vi.spyOn(conn.signal, 'addEventListener') + const options = streamOptions('conn-a', conn.signal) + + await dispatcher.dispatchStreaming(makeRequest(binaryParams), vi.fn(), options) + + expect(unsubscribeExit).toHaveBeenCalledOnce() + expect(addAbort).not.toHaveBeenCalled() + }) + + // Why: the synchronous release runs cleanup before setup, so anything registered after it never gets torn down. + it('registers nothing after an already-exited PTY releases the subscription', async () => { + const registry = createSubscriptionRegistryDouble() + const runtime = stubRuntime(registry, [], { + subscribeToPtyExit: vi.fn((_ptyId: string, listener: () => void) => { + listener() + return vi.fn() + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const options = streamOptions('conn-a') + + await dispatcher.dispatchStreaming(makeRequest(binaryParams), vi.fn(), options) + await flush() + + expect(options.registerBinaryStreamHandler).not.toHaveBeenCalled() + expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled() + expect(runtime.subscribeToTerminalResize).not.toHaveBeenCalled() + expect(runtime.handleMobileSubscribe).not.toHaveBeenCalled() + }) + + it('registers no remote-desktop viewer when an already-exited PTY releases the legacy JSON stream', async () => { + const registry = createSubscriptionRegistryDouble() + const updateRemoteDesktopViewer = vi.fn().mockResolvedValue(true) + const runtime = stubRuntime(registry, [], { + updateRemoteDesktopViewer, + subscribeToPtyExit: vi.fn((_ptyId: string, listener: () => void) => { + listener() + return vi.fn() + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest({ + terminal: 'terminal-1', + client: { id: 'desk-1', type: 'desktop' }, + viewport: { cols: 80, rows: 24 } + }), + vi.fn(), + { connectionId: 'conn-a' } + ) + await flush() + + expect(updateRemoteDesktopViewer).not.toHaveBeenCalled() + expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled() + }) + // T8: the exit-waiter is only half the story — stale async continuations must be owned too. it('does not let a lease-only subscribe rejecting after a rebind retire the replacement', async () => { const registry = createSubscriptionRegistryDouble() diff --git a/src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts b/src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts index 1d0e3e7456d..df3bd9bcece 100644 --- a/src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts @@ -31,6 +31,7 @@ describe('terminal.subscribe reconnect rebind (STA-4510)', () => { const runtime = { getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), registerRemoteTerminalViewSubscriber: () => () => {}, requestRendererTerminalTabMount: () => false, resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), diff --git a/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts b/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts index 550a95b5c36..65701e2a80a 100644 --- a/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts @@ -33,6 +33,7 @@ describe('terminal subscribe renderer recovery output ordering', () => { | undefined const runtime = { getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-output-race' }), hasHeadlessTerminalState: vi.fn(() => false), requestRendererTerminalTabMount: vi.fn(() => true), diff --git a/src/main/runtime/terminal-pty-exit-waiter.test.ts b/src/main/runtime/terminal-pty-exit-waiter.test.ts new file mode 100644 index 00000000000..5f10449f34d --- /dev/null +++ b/src/main/runtime/terminal-pty-exit-waiter.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +type RuntimeInternals = { + recordPtyWorktree: (ptyId: string, worktreeId: string, state?: { connected?: boolean }) => unknown + ptyExitListenersByPtyId: Map> +} + +function internals(runtime: OrcaRuntimeService): RuntimeInternals { + return runtime as unknown as RuntimeInternals +} + +function registerLivePty(runtime: OrcaRuntimeService): void { + internals(runtime).recordPtyWorktree('pty-1', 'wt-1', { connected: true }) +} + +describe('PTY exit subscription', () => { + it('fires on the backing PTY exit', () => { + const runtime = new OrcaRuntimeService() + registerLivePty(runtime) + const listener = vi.fn() + + runtime.subscribeToPtyExit('pty-1', listener) + + expect(internals(runtime).ptyExitListenersByPtyId.get('pty-1')).toHaveLength(1) + + runtime.onPtyExit('pty-1', 0) + + expect(listener).toHaveBeenCalledOnce() + expect(internals(runtime).ptyExitListenersByPtyId.has('pty-1')).toBe(false) + }) + + it('does not retain listeners across subscription churn', () => { + const runtime = new OrcaRuntimeService() + registerLivePty(runtime) + const listener = vi.fn() + + for (let index = 0; index < 25; index += 1) { + runtime.subscribeToPtyExit('pty-1', listener)() + } + runtime.onPtyExit('pty-1', 0) + + expect(listener).not.toHaveBeenCalled() + expect(internals(runtime).ptyExitListenersByPtyId.has('pty-1')).toBe(false) + }) + + it('fires immediately when the PTY already exited', () => { + const runtime = new OrcaRuntimeService() + registerLivePty(runtime) + runtime.onPtyExit('pty-1', 0) + const listener = vi.fn() + + runtime.subscribeToPtyExit('pty-1', listener) + + expect(listener).toHaveBeenCalledOnce() + expect(internals(runtime).ptyExitListenersByPtyId.has('pty-1')).toBe(false) + }) +}) diff --git a/src/main/runtime/terminal-subscribe-exit-waiter-leak.test.ts b/src/main/runtime/terminal-subscribe-exit-waiter-leak.test.ts index c981ad96e0e..b0b3c116af5 100644 --- a/src/main/runtime/terminal-subscribe-exit-waiter-leak.test.ts +++ b/src/main/runtime/terminal-subscribe-exit-waiter-leak.test.ts @@ -4,8 +4,10 @@ * Without an AbortSignal that waiter is only removed on real PTY exit, so for a * never-exiting agent terminal every remote/mobile reconnect and tab-switch * re-subscribe leaked a waiter (and the closed-connection handler context it - * captures). The subscribe paths now pass a signal — this pins that a signalled - * exit-waiter is released when the signal aborts, and an unsignalled one is not. + * captures). The subscribe paths now observe pty exit directly instead, so this + * pins the runtime contract the other waitForTerminal callers still rely on: a + * signalled exit-waiter is released when the signal aborts, an unsignalled one + * is not. */ import { describe, expect, it } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 602ca674271..9019a10e9b5 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -1,4 +1,5 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' +import { projectRemoteAppStatus } from '../../shared/cli-app-status-projection' import { randomUUID } from 'node:crypto' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' import { readOrchestrationCompatibilityEvidence } from '../../shared/orchestration-compatibility-evidence' @@ -172,11 +173,12 @@ async function dispatchRemoteCli( } const status = response.result as RuntimeStatus const cliStatus: CliStatusResult = { - app: { - running: true, - pid: null, - ...(status.desktopWindowStatus ? { desktopWindowStatus: status.desktopWindowStatus } : {}) - }, + target: { kind: 'environment', environment: 'ssh' }, + // Why: this answers for the Orca host the caller reached over SSH, not for the caller's + // machine. It used to report running:true unconditionally, which claimed a desktop app + // even for a headless `serve`; share the same projection the paired-server path uses so + // both transports answer the question the same way (STA-4792 defect 4). + app: projectRemoteAppStatus(status), runtime: { state: status.graphStatus === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, diff --git a/src/relay/fs-handler-file-range-dispatch.test.ts b/src/relay/fs-handler-file-range-dispatch.test.ts new file mode 100644 index 00000000000..3f1de4693a4 --- /dev/null +++ b/src/relay/fs-handler-file-range-dispatch.test.ts @@ -0,0 +1,157 @@ +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 { FsHandler } from './fs-handler' +import { RelayContext } from './context' +import { RelayDispatcher } from './dispatcher' +import { encodeJsonRpcFrame, RelayErrorCode } from './protocol' +import { FileRangeReadRequestError, MAX_FILE_RANGE_READ_BYTES } from '../shared/file-range-read' + +/** Minimal dispatcher: this suite only needs the registered request handlers. + * The full harness lives in fs-handler.test.ts, which is at its line budget. */ +function createHandlerUnderTest() { + const requests = new Map) => Promise>() + const dispatcher = { + onRequest: (method: string, handler: (params: Record) => Promise) => { + requests.set(method, handler) + }, + onNotification: vi.fn(), + notify: vi.fn(), + notifyClient: vi.fn(), + onClientDetached: vi.fn(() => () => {}) + } + const handler = new FsHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) + const call = (method: string, params: Record): Promise => { + const registered = requests.get(method) + if (!registered) { + throw new Error(`No handler for ${method}`) + } + return registered(params) + } + return { handler, call } +} + +let root: string +let filePath: string +let underTest: ReturnType + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-relay-range-dispatch-')) + filePath = join(root, 'data.jsonl') + await writeFile(filePath, '0123456789') + underTest = createHandlerUnderTest() +}) + +afterEach(async () => { + underTest.handler.dispose() + await rm(root, { recursive: true, force: true }) +}) + +describe('fs.readFileRange dispatch', () => { + it('serves the requested window', async () => { + const result = (await underTest.call('fs.readFileRange', { + filePath, + position: 2, + length: 3 + })) as { base64: string; bytesRead: number } + expect(Buffer.from(result.base64, 'base64').toString('utf8')).toBe('234') + expect(result.bytesRead).toBe(3) + }) + + // Params reach the handler as an unschema-d bag, so a missing or wrong-typed + // path must be a stated refusal rather than a TypeError from expandTilde. + it.each([ + ['a missing filePath', {}], + ['a non-string filePath', { filePath: 42 }], + ['an empty filePath', { filePath: '' }] + ])('rejects %s', async (_label, params) => { + await expect( + underTest.call('fs.readFileRange', { position: 0, length: 4, ...params }) + ).rejects.toBeInstanceOf(FileRangeReadRequestError) + }) + + it('rejects an out-of-contract offset before touching the file', async () => { + await expect( + underTest.call('fs.readFileRange', { filePath, position: -1, length: 4 }) + ).rejects.toBeInstanceOf(FileRangeReadRequestError) + }) +}) + +// A direct call to readRelayFileRange never meets the writer, which is where an +// over-wide window actually dies: a response frame past the control lane's +// budget is demoted to `legacy-response` and refused as ResponseOverCapacity. +// This drives a full-cap read through the real dispatcher instead. +describe('fs.readFileRange over the real dispatcher', () => { + function decodePayload(frame: Buffer): Record { + const length = frame.readUInt32BE(9) + return JSON.parse(frame.subarray(13, 13 + length).toString('utf-8')) + } + + it('delivers a full-cap window as a result, not a capacity error', async () => { + const contents = Buffer.allocUnsafe(MAX_FILE_RANGE_READ_BYTES) + for (let i = 0; i < contents.length; i++) { + contents[i] = (i * 37) % 256 + } + const capPath = join(root, 'full-cap.bin') + await writeFile(capPath, contents) + + const frames: Buffer[] = [] + let closes = 0 + const dispatcher = new RelayDispatcher( + (data: Buffer) => { + frames.push(Buffer.from(data)) + return true + }, + { + writableHighWaterMark: () => 64 * 1024, + writableLength: () => 0, + close: () => { + closes++ + } + } + ) + const handler = new FsHandler(dispatcher, new RelayContext()) + try { + dispatcher.feed( + encodeJsonRpcFrame( + { + jsonrpc: '2.0', + id: 91, + method: 'fs.readFileRange', + params: { filePath: capPath, position: 0, length: MAX_FILE_RANGE_READ_BYTES } + }, + 1, + 0 + ) + ) + await vi.waitFor(() => expect(frames).toHaveLength(1), { timeout: 4_000 }) + const response = decodePayload(frames[0]) as { + id: number + error?: { code: number } + result?: { base64: string; bytesRead: number } + } + expect(response.id).toBe(91) + expect(response.error?.code).not.toBe(RelayErrorCode.ResponseOverCapacity) + expect(response.error).toBeUndefined() + expect(response.result?.bytesRead).toBe(MAX_FILE_RANGE_READ_BYTES) + expect(Buffer.from(response.result?.base64 ?? '', 'base64').equals(contents)).toBe(true) + expect(closes).toBe(0) + } finally { + handler.dispose() + dispatcher.dispose() + } + }) +}) + +describe('fs.getCapabilities', () => { + // Rule 1 of docs/reference/remote-wire-compatibility.md: the ranged-read flag + // is additive. Dropping the pre-existing key would strand an older desktop's + // quick-open probe on a host that still serves it. + it('advertises ranged reads without dropping the existing capability', async () => { + await expect(underTest.call('fs.getCapabilities', {})).resolves.toEqual({ + quickOpenSearchVersion: 1, + rangedReadVersion: 1 + }) + }) +}) diff --git a/src/relay/fs-handler-file-range.test.ts b/src/relay/fs-handler-file-range.test.ts new file mode 100644 index 00000000000..3ecca25ec4c --- /dev/null +++ b/src/relay/fs-handler-file-range.test.ts @@ -0,0 +1,237 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { readRelayFileRange } from './fs-handler-file-range' +import { readFullStreamChunk } from './fs-handler-file-read' +import { + FileRangeReadRequestError, + MAX_FILE_RANGE_READ_BYTES, + validateFileRangeRequest +} from '../shared/file-range-read' +import { HEADER_LENGTH, prepareJsonRpcPayload } from './protocol' +import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from './dispatcher-writer-admission' + +let roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))) + roots = [] +}) + +async function fileWith(contents: Buffer | string): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-relay-range-')) + roots.push(root) + const path = join(root, 'data.bin') + await writeFile(path, contents) + return path +} + +function decode(base64: string): Buffer { + return Buffer.from(base64, 'base64') +} + +describe('readRelayFileRange', () => { + it('returns exactly the requested window', async () => { + const path = await fileWith('0123456789') + const result = await readRelayFileRange(path, 3, 4) + expect(decode(result.base64).toString('utf8')).toBe('3456') + expect(result.bytesRead).toBe(4) + }) + + it('reads from a non-zero position to the end', async () => { + const path = await fileWith('abcdef') + const result = await readRelayFileRange(path, 4, 2) + expect(decode(result.base64).toString('utf8')).toBe('ef') + }) + + // A short result must mean EOF and nothing else, or a caller advancing a + // cursor by bytesRead would silently skip data. + it('short-reads only at end of file', async () => { + const path = await fileWith('abc') + const result = await readRelayFileRange(path, 1, 100) + expect(result.bytesRead).toBe(2) + expect(decode(result.base64).toString('utf8')).toBe('bc') + }) + + it('returns zero bytes when the position is past the end', async () => { + const path = await fileWith('abc') + const result = await readRelayFileRange(path, 99, 10) + expect(result.bytesRead).toBe(0) + expect(decode(result.base64)).toHaveLength(0) + }) + + it('returns zero bytes when the position is exactly at the end', async () => { + const path = await fileWith('abc') + const result = await readRelayFileRange(path, 3, 10) + expect(result.bytesRead).toBe(0) + }) + + // A regular file answers a full-cap read in one syscall, so this pins offset + // arithmetic over a many-page window, not the fill loop -- that is covered + // against a stub reader in `readFullStreamChunk` below. + it('assembles a many-page window without misplacing any byte', async () => { + const size = MAX_FILE_RANGE_READ_BYTES + const contents = Buffer.allocUnsafe(size) + for (let i = 0; i < size; i++) { + contents[i] = i % 251 + } + const path = await fileWith(contents) + const result = await readRelayFileRange(path, 1024, size - 1024) + expect(result.bytesRead).toBe(size - 1024) + // .equals(), not toEqual(): a byte-wise diff of a 256 KiB buffer costs + // minutes when it passes. + expect(decode(result.base64).equals(contents.subarray(1024))).toBe(true) + }) + + // Base64 rather than utf-8: a range boundary can split a multi-byte sequence, + // and a utf-8 round trip would substitute U+FFFD and shift every later offset. + it('preserves bytes that split a multi-byte character at both edges', async () => { + const text = Buffer.from('aé漢b', 'utf8') + const path = await fileWith(text) + const middle = await readRelayFileRange(path, 1, 3) + expect(decode(middle.base64)).toEqual(text.subarray(1, 4)) + }) + + it('round-trips arbitrary binary bytes', async () => { + const bytes = Buffer.from([0x00, 0xff, 0x0a, 0x80, 0x7f]) + const path = await fileWith(bytes) + const result = await readRelayFileRange(path, 0, bytes.length) + expect(decode(result.base64)).toEqual(bytes) + }) + + // The cap is a TRANSPORT budget, not just a memory one. A response frame over + // DISPATCHER_CONTROL_QUEUE_MAX_BYTES is demoted to the `legacy-response` lane, + // which the writer refuses once the producer queue is busy -- so an over-wide + // cap fails with ResponseOverCapacity depending on unrelated load. + // + // Fitting the lane once is not enough: the control queue is a SHARED budget + // and overflowing it closes the client, so a full-cap frame has to leave room + // for a second one. Anything wider lets two pipelined tail reads -- or one + // read racing an unrelated response -- kill the connection. + it('leaves control-queue headroom for a second full-cap window', async () => { + const contents = Buffer.allocUnsafe(MAX_FILE_RANGE_READ_BYTES) + for (let i = 0; i < contents.length; i++) { + contents[i] = (i * 37) % 256 + } + const path = await fileWith(contents) + const result = await readRelayFileRange(path, 0, MAX_FILE_RANGE_READ_BYTES) + expect(result.bytesRead).toBe(MAX_FILE_RANGE_READ_BYTES) + const frameBytes = + HEADER_LENGTH + prepareJsonRpcPayload({ jsonrpc: '2.0', id: 4294967295, result }).byteLength + expect(frameBytes).toBeLessThanOrEqual(DISPATCHER_CONTROL_QUEUE_MAX_BYTES) + expect(frameBytes * 2).toBeLessThanOrEqual(DISPATCHER_CONTROL_QUEUE_MAX_BYTES) + }) + + it('rejects a missing file', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-relay-range-missing-')) + roots.push(root) + await expect(readRelayFileRange(join(root, 'nope.bin'), 0, 4)).rejects.toThrow() + }) + + // Every rejection below happens before the file is opened: these offsets go + // straight into a read syscall and the relay has no request schema. + describe('request validation', () => { + // Rejecting beats clamping: a clamped read is indistinguishable from EOF. + it('rejects an over-cap length instead of silently clamping', async () => { + const path = await fileWith('abc') + await expect( + readRelayFileRange(path, 0, MAX_FILE_RANGE_READ_BYTES + 1) + ).rejects.toBeInstanceOf(FileRangeReadRequestError) + }) + + it('accepts a length exactly at the cap', async () => { + const path = await fileWith('abc') + const result = await readRelayFileRange(path, 0, MAX_FILE_RANGE_READ_BYTES) + expect(result.bytesRead).toBe(3) + }) + + it.each([ + ['a negative position', -1, 4], + ['a fractional position', 1.5, 4], + ['a NaN position', Number.NaN, 4], + ['an infinite position', Number.POSITIVE_INFINITY, 4], + ['a position past the safe-integer range', Number.MAX_SAFE_INTEGER + 2, 4], + ['a window past the safe-integer range', Number.MAX_SAFE_INTEGER - 1, 3], + ['a zero length', 0, 0], + ['a negative length', 0, -4], + ['a fractional length', 0, 4.5], + ['a NaN length', 0, Number.NaN] + ])('rejects %s', async (_label, position, length) => { + const path = await fileWith('abc') + await expect(readRelayFileRange(path, position, length)).rejects.toBeInstanceOf( + FileRangeReadRequestError + ) + }) + + it.each([ + ['a missing position', undefined, 4], + ['a string position', '0', 4], + ['a missing length', 0, undefined], + ['a string length', 0, '4'] + ])('rejects %s from an unschema-d param bag', async (_label, position, length) => { + const path = await fileWith('abc') + await expect(readRelayFileRange(path, position, length)).rejects.toBeInstanceOf( + FileRangeReadRequestError + ) + }) + }) +}) + +// The fill loop is what makes "short result == EOF" true. A regular file hands +// back a whole window in one syscall, so the loop only shows up against a +// reader that answers partially -- a pipe, a network filesystem, a large read +// interrupted by a signal. +describe('readFullStreamChunk', () => { + function partialReader(source: Buffer, perCall: number) { + const calls: { offset: number; length: number; position: number }[] = [] + return { + calls, + read(buffer: Buffer, offset: number, length: number, position: number) { + calls.push({ offset, length, position }) + const slice = source.subarray(position, position + Math.min(length, perCall)) + slice.copy(buffer, offset) + return Promise.resolve({ bytesRead: slice.length }) + } + } + } + + it('fills the window across partial reads, each landing at its own offset', async () => { + const source = Buffer.from('abcdefghij') + const reader = partialReader(source, 3) + const buffer = Buffer.alloc(6) + const bytesRead = await readFullStreamChunk(reader, buffer, 6, 2) + expect(bytesRead).toBe(6) + expect(buffer.toString('utf8')).toBe('cdefgh') + expect(reader.calls).toEqual([ + { offset: 0, length: 6, position: 2 }, + { offset: 3, length: 3, position: 5 } + ]) + }) + + it('keeps every partial-read offset safe at the upper boundary', async () => { + const { position: start, length } = validateFileRangeRequest(Number.MAX_SAFE_INTEGER - 2, 3) + const calls: number[] = [] + const reader = { + read(buffer: Buffer, offset: number, _length: number, position: number) { + calls.push(position) + buffer[offset] = 1 + return Promise.resolve({ bytesRead: 1 }) + } + } + const bytesRead = await readFullStreamChunk(reader, Buffer.alloc(length), length, start) + expect(bytesRead).toBe(length) + expect(calls).toEqual([start, start + 1, Number.MAX_SAFE_INTEGER]) + expect(calls.every(Number.isSafeInteger)).toBe(true) + }) + + // Without the zero check the loop would spin forever on a reader at EOF. + it('stops at the first empty read and reports only what it filled', async () => { + const source = Buffer.from('abcd') + const reader = partialReader(source, 3) + const buffer = Buffer.alloc(16) + const bytesRead = await readFullStreamChunk(reader, buffer, 16, 0) + expect(bytesRead).toBe(4) + expect(buffer.subarray(0, bytesRead).toString('utf8')).toBe('abcd') + }) +}) diff --git a/src/relay/fs-handler-file-range.ts b/src/relay/fs-handler-file-range.ts new file mode 100644 index 00000000000..cba1ed9f594 --- /dev/null +++ b/src/relay/fs-handler-file-range.ts @@ -0,0 +1,30 @@ +import { open } from 'node:fs/promises' +import { validateFileRangeRequest } from '../shared/file-range-read' +import { readFullStreamChunk } from './fs-handler-file-read' + +/** Positional read for tailing an append-only file. Fills the window until + * `length` is satisfied or the file genuinely ends, so a short result always + * means EOF and never a partial syscall. Base64 because the payload is + * arbitrary bytes and may split a UTF-8 sequence at either edge. + * + * Validates here rather than at the dispatcher: this is the function that puts + * a caller-supplied offset into a read syscall, so it is the boundary that has + * to hold even if a future call site forgets. */ +export async function readRelayFileRange( + filePath: string, + rawPosition: unknown, + rawLength: unknown +): Promise<{ base64: string; bytesRead: number }> { + const { position, length } = validateFileRangeRequest(rawPosition, rawLength) + const handle = await open(filePath, 'r') + try { + // allocUnsafe over alloc: only `subarray(0, bytesRead)` is ever read back, so + // uninitialised bytes cannot escape, and a tailing poll with a generous + // `length` should not pay a memset over the whole window per call. + const buffer = Buffer.allocUnsafe(length) + const bytesRead = await readFullStreamChunk(handle, buffer, length, position) + return { base64: buffer.subarray(0, bytesRead).toString('base64'), bytesRead } + } finally { + await handle.close() + } +} diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index 4a6f663484d..e8b7993fbcd 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -334,7 +334,9 @@ async function pumpChunks( // Why: fs.read() may return fewer bytes than requested before EOF. Fill each // protocol chunk so strict clients reject corruption, not valid short reads. -async function readFullStreamChunk( +// Shared with fs.readFileRange, where the same rule makes a short result mean +// EOF and nothing else. +export async function readFullStreamChunk( handle: StreamChunkReader, buffer: Buffer, length: number, diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 0e13b4e0e95..480f0da52c7 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -26,6 +26,8 @@ import { } from './fs-path-mutation-requests' import { buildExcludePathPrefixes } from '../shared/quick-open-filter' import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-file-read' +import { readRelayFileRange } from './fs-handler-file-range' +import { FileRangeReadRequestError } from '../shared/file-range-read' import { readVerifiedTerminalArtifact, writeVerifiedTerminalArtifact @@ -66,6 +68,7 @@ export class FsHandler { this.dispatcher.onRequest('fs.readDir', (p) => readRelayDir(p)) this.dispatcher.onRequest('fs.readFile', (p) => this.readFile(p)) this.dispatcher.onRequest('fs.readFileStream', (p, c) => this.readFileStream(p, c)) + this.dispatcher.onRequest('fs.readFileRange', (p) => this.readFileRange(p)) this.dispatcher.onRequest('fs.readTerminalArtifact', (p) => this.readTerminalArtifact(p)) this.dispatcher.onRequest('fs.tempDir', () => this.tempDir()) this.dispatcher.onRequest('fs.writeFile', (p) => writeRelayFile(p)) @@ -82,7 +85,8 @@ export class FsHandler { this.dispatcher.onRequest('fs.realpath', (p) => realpathRelayPath(p)) this.dispatcher.onRequest('fs.search', (p) => this.search(p)) this.dispatcher.onRequest('fs.getCapabilities', async () => ({ - quickOpenSearchVersion: 1 + quickOpenSearchVersion: 1, + rangedReadVersion: 1 })) this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c)) this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c)) @@ -108,6 +112,15 @@ export class FsHandler { return readRelayFileContent(filePath) } + // Why hand-checked: relay params arrive as raw casts with no schema. The + // offsets are validated inside readRelayFileRange, next to the read syscall. + private async readFileRange(params: Record) { + if (typeof params.filePath !== 'string' || params.filePath.length === 0) { + throw new FileRangeReadRequestError('fs.readFileRange requires a filePath') + } + return readRelayFileRange(expandTilde(params.filePath), params.position, params.length) + } + private async readTerminalArtifact(params: Record) { return readVerifiedTerminalArtifact({ ...params, diff --git a/src/renderer/src/components/editor/check-annotation-open.test.ts b/src/renderer/src/components/editor/check-annotation-open.test.ts new file mode 100644 index 00000000000..97f0fbf09b2 --- /dev/null +++ b/src/renderer/src/components/editor/check-annotation-open.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + activateAndRevealWorktree: vi.fn(), + state: { + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo/wt' }] }, + openFile: vi.fn(), + setPendingEditorReveal: vi.fn() + } +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: mocks.activateAndRevealWorktree +})) +vi.mock('@/lib/language-detect', () => ({ detectLanguage: () => 'typescript' })) + +import { openAnnotationLocation } from './check-annotation-open' + +afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +describe('openAnnotationLocation', () => { + it('activates as a surface-providing caller before opening the editor', () => { + vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1)) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + + openAnnotationLocation({ + worktreeId: 'wt-1', + path: 'src/a.ts', + line: 3, + revealRafRef: { current: null }, + revealInnerRafRef: { current: null } + }) + + // Why: the annotation's editor file is the surface — the jump must not re-seed + // a shell into a workspace whose last terminal the user closed. + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + providesInitialSurface: true + }) + expect(mocks.state.openFile).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'wt-1', relativePath: 'src/a.ts' }), + { forceContentReload: true } + ) + }) + + it('does not activate when the worktree is unknown', () => { + openAnnotationLocation({ + worktreeId: 'missing', + path: 'src/a.ts', + line: 3, + revealRafRef: { current: null }, + revealInnerRafRef: { current: null } + }) + + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.state.openFile).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/check-annotation-open.ts b/src/renderer/src/components/editor/check-annotation-open.ts index 44dcc9983cf..afcb3600efa 100644 --- a/src/renderer/src/components/editor/check-annotation-open.ts +++ b/src/renderer/src/components/editor/check-annotation-open.ts @@ -30,7 +30,7 @@ export function openAnnotationLocation(params: { // Why: reuse the shared activation path so an annotation jump lands in the // same history stack as sidebar, palette, and terminal-link navigation. - activateAndRevealWorktree(worktreeId) + activateAndRevealWorktree(worktreeId, { providesInitialSurface: true }) store.openFile( { diff --git a/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx b/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx index b6d185e9092..4567f75932e 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx @@ -37,7 +37,7 @@ export function BrowserAction(props: { done: boolean }): React.JSX.Element { return } closeModal() - activateAndRevealWorktree(targetWorktree.id) + activateAndRevealWorktree(targetWorktree.id, { providesInitialSurface: true }) const state = useAppStore.getState() // Why: open the browser into the worktree's active group so it lands beside // the user's current work rather than spawning a detached surface. diff --git a/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx b/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx index 9cb86404dd8..ea1d85f15b8 100644 --- a/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx @@ -350,7 +350,10 @@ describe('PortsPanel runtime routing', () => { ).resolves.toEqual({ ok: true }) expect(activateAndRevealWorktreeMock).toHaveBeenCalledTimes(2) - expect(activateAndRevealWorktreeMock).toHaveBeenCalledWith('repo::/workspace/app') + // Why: the browser tab is the surface — port opens must not re-seed a shell. + expect(activateAndRevealWorktreeMock).toHaveBeenCalledWith('repo::/workspace/app', { + providesInitialSurface: true + }) expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ 'status.get', 'browser.tabCreate', diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx index 2bea76ee2b9..25b103138f2 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx @@ -1,3 +1,7 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { screen } from '@testing-library/react' import { renderToStaticMarkup } from 'react-dom/server' import type { ButtonHTMLAttributes, ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -20,6 +24,10 @@ const mocks = vi.hoisted(() => { openSettingsTarget: vi.fn(), openSettingsPage: vi.fn(), settings: null, + sshTargetLabels: new Map(), + sshConnectionStates: new Map(), + runtimeEnvironments: [] as { id: string; name: string }[], + runtimeStatusByEnvironmentId: new Map(), gitStatusByWorktree: {} as Record, setGitStatus: vi.fn(), deleteStateByWorktreeId: {} as Record< @@ -162,6 +170,11 @@ describe('DeleteWorktreeDialog lineage copy', () => { mocks.state.modalData = {} mocks.state.allWorktrees.mockReturnValue([]) mocks.state.repos = [] + mocks.state.sshTargetLabels = new Map() + mocks.state.sshConnectionStates = new Map() + mocks.state.runtimeEnvironments = [] + mocks.state.runtimeStatusByEnvironmentId = new Map() + document.body.innerHTML = '' mocks.state.worktreeLineageById = {} mocks.state.gitStatusByWorktree = {} mocks.state.deleteStateByWorktreeId = {} @@ -169,7 +182,7 @@ describe('DeleteWorktreeDialog lineage copy', () => { vi.mocked(runWorktreeDeletesInParallel).mockResolvedValue([]) }) - it('previews the confirmed host row when workspace ids collide', async () => { + it('labels the confirmed single-host target when workspace ids collide', async () => { const local = { ...makeWorktree('shared', '/workspaces/local'), instanceId: 'local-instance', @@ -186,13 +199,18 @@ describe('DeleteWorktreeDialog lineage copy', () => { worktreeId: ssh.id, worktreeDeleteIdentities: [{ id: ssh.id, instanceId: ssh.instanceId, hostId: ssh.hostId }] } + mocks.state.sshTargetLabels = new Map([['builder', 'Build server']]) mocks.state.allWorktrees.mockReturnValue([local, ssh]) const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog') const markup = renderToStaticMarkup() + document.body.innerHTML = markup expect(markup).toContain('SSH target') expect(markup).not.toContain('Local sibling') + expect(screen.getByRole('region')).toHaveAccessibleName( + 'SSH target /workspaces/ssh Build server' + ) }) it('keeps Space safety-checked confirmation non-force', async () => { diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index b742641c190..5dc53aa064a 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -35,6 +35,7 @@ import { useConfirmedWorktreeDeleteTargets } from './use-confirmed-worktree-dele import { runLineageDeleteAll } from './delete-worktree-lineage-delete-all' import { runDialogForceDelete } from './delete-worktree-dialog-force-delete' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' +import { useSidebarHostScopeOptions } from './use-sidebar-host-scope-options' const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -49,7 +50,11 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree) - + const { hostOptions } = useSidebarHostScopeOptions() + const hostLabelById = useMemo( + () => new Map(hostOptions.map((host) => [host.id, host.label])), + [hostOptions] + ) const isOpen = activeModal === 'delete-worktree' const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : '' const worktreeIds = useMemo( @@ -373,6 +378,8 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { isBatchDelete={isBatchDelete} worktree={worktree} worktrees={worktrees} + collisionWorktrees={allWorktrees} + hostLabelById={hostLabelById} deleteStateByWorktreeId={deleteStateByWorktreeId} dirtyChangeCountsByWorktreeId={dirtyChangeCountsByWorktreeId} /> diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.test.tsx new file mode 100644 index 00000000000..937d015f76c --- /dev/null +++ b/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { cleanup, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { DeleteWorktreeTargetPreview } from './DeleteWorktreeTargetPreview' +import { buildSidebarHostOptions } from './sidebar-host-options' +import type { Worktree } from '../../../../shared/worktree/types' +import type { ExecutionHostId } from '../../../../shared/execution-host' + +function buildHostLabels( + hostLabelOverrides?: ReadonlyMap +): ReadonlyMap { + return new Map( + buildSidebarHostOptions({ + repos: [ + { connectionId: 'qa-linux-42' }, + { connectionId: null, executionHostId: 'runtime:runtime-7' } + ], + sshTargetLabels: new Map([['qa-linux-42', 'QA Linux']]), + settings: { activeRuntimeEnvironmentId: null }, + runtimeEnvironments: [{ id: 'runtime-7', name: 'Build Mac' }], + hostLabelOverrides + }).map((host) => [host.id, host.label]) + ) +} + +const savedHostLabels = buildHostLabels() + +function makeWorktree(id: string, displayName: string, hostId?: Worktree['hostId']): Worktree { + return { + id, + repoId: 'repo1', + path: `/work/${displayName}`, + head: 'abc123', + branch: 'main', + isBare: false, + isMainWorktree: false, + displayName, + ...(hostId ? { hostId } : {}) + } as Worktree +} + +function renderPreview(args: { + worktrees: readonly Worktree[] + collisionWorktrees?: readonly Worktree[] + worktree?: Worktree | null + isBatchDelete?: boolean + hostLabelById?: ReadonlyMap +}): void { + render( + + ) +} + +afterEach(cleanup) + +describe('DeleteWorktreeTargetPreview host labels', () => { + it('binds saved SSH and runtime host names to their colliding batch rows', () => { + renderPreview({ + worktrees: [ + makeWorktree('shared', 'collide', 'ssh:qa-linux-42'), + makeWorktree('shared', 'collide', 'runtime:runtime-7') + ] + }) + + const sshRow = screen.getByRole('listitem', { name: /QA Linux/ }) + const runtimeRow = screen.getByRole('listitem', { name: /Build Mac/ }) + expect(sshRow).toHaveAccessibleName('collide /work/collide QA Linux') + expect(within(sshRow).getByText('QA Linux')).toBeVisible() + expect(within(sshRow).queryByText('Build Mac')).not.toBeInTheDocument() + expect(runtimeRow).toHaveAccessibleName('collide /work/collide Build Mac') + expect(within(runtimeRow).getByText('Build Mac')).toBeVisible() + expect(within(runtimeRow).queryByText('QA Linux')).not.toBeInTheDocument() + }) + + it('uses configured display-label overrides for colliding hosts', () => { + const worktrees = [ + makeWorktree('shared', 'collide', 'ssh:qa-linux-42'), + makeWorktree('shared', 'collide', 'runtime:runtime-7') + ] + renderPreview({ + worktrees, + hostLabelById: buildHostLabels( + new Map([ + ['ssh:qa-linux-42', 'SSH override'], + ['runtime:runtime-7', 'Runtime override'] + ]) + ) + }) + + expect(screen.getByRole('listitem', { name: /SSH override/ })).toHaveTextContent('SSH override') + expect(screen.getByRole('listitem', { name: /Runtime override/ })).toHaveTextContent( + 'Runtime override' + ) + }) + + it('keeps an unqualified colliding target distinct from local', () => { + renderPreview({ + worktrees: [makeWorktree('shared', 'collide'), makeWorktree('shared', 'collide', 'local')] + }) + + const unknownRow = screen.getByRole('listitem', { name: /Unknown host/ }) + expect(within(unknownRow).getByText('Unknown host')).toBeVisible() + expect(screen.getAllByRole('listitem')).toHaveLength(2) + }) + + it('omits host metadata from every ordinary batch row', () => { + renderPreview({ + worktrees: [ + makeWorktree('one', 'alpha', 'local'), + makeWorktree('two', 'beta', 'ssh:qa-linux-42') + ] + }) + + const alphaRow = screen.getByRole('listitem', { name: 'alpha /work/alpha' }) + const betaRow = screen.getByRole('listitem', { name: 'beta /work/beta' }) + expect(within(alphaRow).queryByText(savedHostLabels.get('local')!)).not.toBeInTheDocument() + expect(within(betaRow).queryByText('QA Linux')).not.toBeInTheDocument() + }) + + it('includes the host in a colliding single target region and its accessible name', () => { + const sshWorktree = makeWorktree('shared', 'collide', 'ssh:qa-linux-42') + const runtimeWorktree = makeWorktree('shared', 'unselected', 'runtime:runtime-7') + renderPreview({ + isBatchDelete: false, + worktree: sshWorktree, + worktrees: [sshWorktree], + collisionWorktrees: [sshWorktree, runtimeWorktree] + }) + + const target = screen.getByRole('region', { name: /QA Linux/ }) + expect(target).toHaveAccessibleName('collide /work/collide QA Linux') + expect(within(target).getByText('QA Linux')).toBeVisible() + expect(screen.queryByText('unselected')).not.toBeInTheDocument() + }) + + it('omits the host from an ordinary single target region', () => { + const sshWorktree = makeWorktree('one', 'alpha', 'ssh:qa-linux-42') + renderPreview({ isBatchDelete: false, worktree: sshWorktree, worktrees: [sshWorktree] }) + + const target = screen.getByRole('region', { name: 'alpha /work/alpha' }) + expect(target).toHaveAccessibleName('alpha /work/alpha') + expect(within(target).queryByText('QA Linux')).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx index 34a0b2bc781..fa5a47715ca 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx @@ -1,4 +1,4 @@ -import type { JSX } from 'react' +import { useId, type JSX } from 'react' import { LoaderCircle } from 'lucide-react' import { ScrollArea } from '@/components/ui/scroll-area' import type { Worktree } from '../../../../shared/worktree/types' @@ -6,35 +6,87 @@ import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualif import { DeleteWorktreeDirtyChangeHint } from './DeleteWorktreeDirtyChangeHint' import type { AppState } from '@/store/types' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' +import { + getExecutionHostLabel, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { translate } from '@/i18n/i18n' + +function getCollisionIds(worktrees: readonly Worktree[]): ReadonlySet { + const seen = new Set() + const collisions = new Set() + for (const item of worktrees) { + if (seen.has(item.id)) { + collisions.add(item.id) + } else { + seen.add(item.id) + } + } + return collisions +} + +function getTargetHostLabel( + worktree: Worktree, + hostLabelById: ReadonlyMap +): string { + const hostId = parseExecutionHostId(worktree.hostId)?.id + return hostId + ? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId)) + : translate('components.workspace.cleanup.host.unknown', 'Unknown host') +} export function DeleteWorktreeTargetPreview({ isBatchDelete, worktree, worktrees, + collisionWorktrees, + hostLabelById, deleteStateByWorktreeId, dirtyChangeCountsByWorktreeId }: { isBatchDelete: boolean worktree: Worktree | null worktrees: readonly Worktree[] + collisionWorktrees: readonly Worktree[] + hostLabelById: ReadonlyMap deleteStateByWorktreeId: AppState['deleteStateByWorktreeId'] dirtyChangeCountsByWorktreeId: ReadonlyMap }): JSX.Element | null { + const targetIdPrefix = useId() + const collisionIds = getCollisionIds(collisionWorktrees) if (isBatchDelete) { return ( -
- {worktrees.map((item) => { +
+ {worktrees.map((item, index) => { const itemDeleteState = getDeleteStateForWorktreeHost(item, deleteStateByWorktreeId) + const labelIds = { + name: `${targetIdPrefix}-${index}-name`, + path: `${targetIdPrefix}-${index}-path`, + host: `${targetIdPrefix}-${index}-host` + } + const showHost = collisionIds.has(item.id) return (
-
{item.displayName}
-
{item.path}
+
+ {item.displayName} +
+
+ {item.path} +
+ {showHost ? ( +
+ {getTargetHostLabel(item, hostLabelById)} +
+ ) : null} -
{worktree.displayName}
-
{worktree.path}
+ if (!worktree) { + return null + } + const labelIds = { + name: `${targetIdPrefix}-name`, + path: `${targetIdPrefix}-path`, + host: `${targetIdPrefix}-host` + } + const showHost = collisionIds.has(worktree.id) + return ( +
+
+ {worktree.displayName} +
+
+ {worktree.path} +
+ {showHost ? ( +
+ {getTargetHostLabel(worktree, hostLabelById)} +
+ ) : null}
- ) : null + ) } diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 77d2443b969..0d81932fb74 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -35,6 +35,7 @@ import { useSidebarWorktreeSelection } from './worktree-list/navigation/use-sele import { useSidebarWorktreeSortOrder } from './worktree-list/listing/use-sort-order' import { useVisibleSidebarWorktrees } from './worktree-list/listing/use-visible-worktrees' import { useWorktreeStatusMutations } from './worktree-list/drag/use-status-mutations' +import { shouldFiltersHideAllRows } from './sidebar-empty-state-gate' type WorktreeListProps = { scrollOffsetRef: React.MutableRefObject @@ -126,7 +127,9 @@ const WorktreeList = React.memo(function WorktreeList({ workspaceStatuses, settings, projectGroups, - projectGrouping + projectGrouping, + folderWorkspaces, + defaultHostId }) const visibleScope = useSidebarHostVisibleScope({ filterState, @@ -233,11 +236,13 @@ const WorktreeList = React.memo(function WorktreeList({ clearFilters }) - const filtersHideAllRows = - hasFilters && - visibleWorktrees.length === 0 && - rowModel.placeholderRepoIds.size === 0 && - externalWorktreeCards.importedWorktreesByRepo.size === 0 + const filtersHideAllRows = shouldFiltersHideAllRows({ + hasFilters, + visibleWorktreeCount: visibleWorktrees.length, + visibleFolderWorkspaceCount: visibleScope.visibleFolderWorkspacesForRows.length, + placeholderRepoCount: rowModel.placeholderRepoIds.size, + importedWorktreeCardCount: externalWorktreeCards.importedWorktreesByRepo.size + }) // Why: when active filters hide every row, the Clear Filters empty state must win over Project Group headers. if (rowModel.rows.length === 0 || filtersHideAllRows) { return diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts index ae8b265a19d..a84b8323d03 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts @@ -32,6 +32,8 @@ const mocks = vi.hoisted(() => { } }), openModal: vi.fn(), + setRightSidebarTab: vi.fn(), + setRightSidebarOpen: vi.fn(), removeWorktree: vi.fn().mockResolvedValue({ ok: true }), gitStatusByWorktree: {} as Record, deleteStateByWorktreeId: {} as Record< @@ -346,6 +348,27 @@ describe('delete worktree flow', () => { }) }) + it('opens the diff without re-seeding a shell when View changes is clicked', async () => { + mocks.state.settings = { skipDeleteWorktreeConfirm: true } + mocks.state.removeWorktree.mockResolvedValueOnce({ ok: false, error: 'changed files' }) + setWorktrees([{ id: 'wt-1', displayName: 'one' }]) + + expect(runWorktreeBatchDelete(['wt-1'])).toBe(true) + + await vi.waitFor(() => expect(showDeleteWorktreeFailureToast).toHaveBeenCalled()) + const toastOptions = vi.mocked(showDeleteWorktreeFailureToast).mock.calls[0]?.[0] + toastOptions?.onViewChanges() + + // Why: the Source Control panel is the surface; seeding a shell would repopulate a + // workspace the user is trying to delete and erase its closed-last-terminal tombstone. + const { activateAndRevealWorktree } = await import('@/lib/worktree-activation') + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + providesInitialSurface: true + }) + expect(mocks.state.setRightSidebarTab).toHaveBeenCalledWith('source-control') + expect(mocks.state.setRightSidebarOpen).toHaveBeenCalledWith(true) + }) + it('does not offer force delete for a locked worktree', async () => { mocks.state.settings = { skipDeleteWorktreeConfirm: true } mocks.state.removeWorktree diff --git a/src/renderer/src/components/sidebar/folder-workspace-host-id.ts b/src/renderer/src/components/sidebar/folder-workspace-host-id.ts new file mode 100644 index 00000000000..a29a0e9baf5 --- /dev/null +++ b/src/renderer/src/components/sidebar/folder-workspace-host-id.ts @@ -0,0 +1,21 @@ +import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' + +/** + * Which host section a folder workspace's row belongs to. + * + * Single source of truth: header counting, host bucketing and reveal all resolve + * the host through here. Two resolvers that drift is how folder workspaces went + * missing from the sidebar in the first place (#15362). Deliberately distinct + * from the host *filter* resolver, which also consults executionHostId — this + * one must match where the row actually renders. + */ +export function getFolderWorkspaceHostId( + folderWorkspace: Pick, + projectGroup: Pick, + defaultHostId: ExecutionHostId +): ExecutionHostId { + const connectionId = folderWorkspace.connectionId ?? projectGroup.connectionId + return connectionId ? toSshExecutionHostId(connectionId) : defaultHostId +} diff --git a/src/renderer/src/components/sidebar/host-section-folder-workspace-counts.test.ts b/src/renderer/src/components/sidebar/host-section-folder-workspace-counts.test.ts new file mode 100644 index 00000000000..1d96bd9f7b7 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-section-folder-workspace-counts.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { Row } from './worktree-list/grouping/row-types' +import { addHostSectionRows, type HostSectionOption } from './host-section-rows' + +const LOCAL_REPO: Repo = { + id: 'local-repo', + path: '/local-repo', + displayName: 'Local', + badgeColor: '#000000', + addedAt: 0 +} + +const LOCAL_WORKTREE: Worktree = { + id: 'local-worktree', + repoId: LOCAL_REPO.id, + path: '/local-repo/worktree', + branch: 'refs/heads/main', + head: 'abc123', + isBare: false, + isMainWorktree: false, + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + comment: '', + isUnread: false, + isPinned: false, + displayName: 'Local worktree', + sortOrder: 0, + lastActivityAt: 0 +} + +const HOSTS: HostSectionOption[] = [ + { id: 'local', kind: 'local', label: 'Local', detail: 'This computer', health: 'local' }, + { id: 'ssh:builder', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } +] + +const LOCAL_ROW: Extract = { + type: 'item', + rowKey: 'local-worktree', + sectionKey: 'all', + worktree: LOCAL_WORKTREE, + repo: LOCAL_REPO, + depth: 0, + groupDepth: 0, + lineageTrail: [], + isLastLineageChild: true, + lineageChildCount: 0 +} + +const PROJECT_GROUP: ProjectGroup = { + id: 'group-1', + name: 'Remote folder', + parentPath: '/srv/project', + connectionId: 'builder', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +const FOLDER_ROW: Extract = { + type: 'folder-workspace', + key: 'folder-workspace:folder-1', + folderWorkspace: { + id: 'folder-1', + projectGroupId: PROJECT_GROUP.id, + name: 'Remote folder workspace', + folderPath: '/srv/project', + connectionId: 'builder', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + }, + projectGroup: PROJECT_GROUP, + depth: 0, + groupDepth: 0 +} + +function hostCounts(rows: readonly Row[]) { + return addHostSectionRows({ + rows, + hostOptions: HOSTS, + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + .filter((row) => row.type === 'host-header') + .map(({ hostId, count }) => ({ hostId, count })) +} + +describe('folder workspace host counts', () => { + it('counts an expanded folder workspace row', () => { + expect(hostCounts([LOCAL_ROW, FOLDER_ROW])).toEqual([ + { hostId: 'local', count: 1 }, + { hostId: 'ssh:builder', count: 1 } + ]) + }) + + it('counts a collapsed folder-only lane from its header', () => { + const collapsedLane: Extract = { + type: 'header', + key: 'workspace-status:in-progress', + label: 'In progress', + count: 1, + tone: 'text-foreground', + hostWorktreeCounts: new Map([['ssh:builder', 1]]), + hostWorktreeIds: new Map([['ssh:builder', []]]) + } + + expect(hostCounts([collapsedLane, LOCAL_ROW])).toEqual([ + { hostId: 'local', count: 1 }, + { hostId: 'ssh:builder', count: 1 } + ]) + }) +}) diff --git a/src/renderer/src/components/sidebar/host-section-rows.ts b/src/renderer/src/components/sidebar/host-section-rows.ts index bbe9100bab1..a9b96eb4d3d 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.ts @@ -11,10 +11,9 @@ import { import type { ExecutionHostHealth } from '../../../../shared/execution-host-registry' import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' import type { SshConnectionStatus } from '../../../../shared/ssh-types' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' import type { Repo } from '../../../../shared/repo-types' import type { Row } from './worktree-list/grouping/row-types' +import { getFolderWorkspaceHostId } from './folder-workspace-host-id' export type HostHeaderRow = { type: 'host-header' @@ -56,19 +55,6 @@ function getRepoHostId( return defaultHostId } -function getSshHostId(connectionId: string): ExecutionHostId { - return `ssh:${encodeURIComponent(connectionId)}` as ExecutionHostId -} - -function getFolderWorkspaceHostId( - folderWorkspace: Pick, - projectGroup: Pick, - defaultHostId: ExecutionHostId -): ExecutionHostId { - const connectionId = folderWorkspace.connectionId ?? projectGroup.connectionId - return connectionId ? getSshHostId(connectionId) : defaultHostId -} - function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId | null { switch (row.type) { case 'item': @@ -95,29 +81,32 @@ function getFallbackHost(hostId: ExecutionHostId): HostSectionOption { } } -function countWorktreeRows(rows: readonly Row[]): number { +function countWorkspaceRows(rows: readonly Row[]): number { // Why: a collapsed repo group contributes a header row but no item rows; // fall back to the header's own count so the host badge doesn't read 0 // while a visibly populated project sits right under it. let count = 0 const seenWorktreeIds = new Set() let pendingHeader: Extract | null = null - let pendingHeaderHadItems = false + let pendingHeaderHadWorkspaces = false const flushHeader = (): void => { - if (pendingHeader && !pendingHeaderHadItems) { + if (pendingHeader && !pendingHeaderHadWorkspaces) { if (pendingHeader.worktreeIds) { + const headerWorktreeIds = new Set(pendingHeader.worktreeIds) for (const worktreeId of pendingHeader.worktreeIds) { if (!seenWorktreeIds.has(worktreeId)) { count += 1 seenWorktreeIds.add(worktreeId) } } + // Folder workspaces contribute to the header count but have no worktree id. + count += Math.max(0, pendingHeader.count - headerWorktreeIds.size) } else { count += pendingHeader.count } } pendingHeader = null - pendingHeaderHadItems = false + pendingHeaderHadWorkspaces = false } for (const row of rows) { if (row.type === 'header') { @@ -130,7 +119,12 @@ function countWorktreeRows(rows: readonly Row[]): number { count += 1 seenWorktreeIds.add(row.worktree.id) } - pendingHeaderHadItems = pendingHeader !== null + pendingHeaderHadWorkspaces = pendingHeader !== null + continue + } + if (row.type === 'folder-workspace') { + count += 1 + pendingHeaderHadWorkspaces = pendingHeader !== null } } flushHeader() @@ -303,7 +297,7 @@ export function addHostSectionRows(args: { compatibility: host.compatibility, connectionStatus: host.connectionStatus, collapsed, - count: countWorktreeRows(hostRows) + count: countWorkspaceRows(hostRows) }) if (!collapsed) { result.push(...hostRows) diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts index d8872490727..3f844d6f09d 100644 --- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts @@ -13,7 +13,12 @@ function viewWorktreeDiff( worktreeId: string, executionHostId: WorktreeRemovalTarget['executionHostId'] ): void { - activateAndRevealWorktree(worktreeId, executionHostId ? { executionHostId } : {}) + // The Source Control panel is the requested surface — don't re-seed a shell in a + // workspace the user is trying to delete. + activateAndRevealWorktree(worktreeId, { + providesInitialSurface: true, + ...(executionHostId ? { executionHostId } : {}) + }) const state = useAppStore.getState() state.setRightSidebarTab('source-control') state.setRightSidebarOpen(true) diff --git a/src/renderer/src/components/sidebar/sidebar-empty-state-gate.test.ts b/src/renderer/src/components/sidebar/sidebar-empty-state-gate.test.ts new file mode 100644 index 00000000000..bd7504c09ac --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-empty-state-gate.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { shouldFiltersHideAllRows } from './sidebar-empty-state-gate' + +const NOTHING_ELSE = { + visibleWorktreeCount: 0, + placeholderRepoCount: 0, + importedWorktreeCardCount: 0 +} + +describe('Clear Filters empty state', () => { + it('does not replace a folder-only sidebar when a filter is active', () => { + // The reported symptom reached by a second path: with any filter active + // (host scope counts), a folder-only account saw Clear Filters instead of + // its workspaces — under every Group by mode, including Project. + expect( + shouldFiltersHideAllRows({ + ...NOTHING_ELSE, + hasFilters: true, + visibleFolderWorkspaceCount: 1 + }) + ).toBe(false) + }) + + it('still wins when filters hid every row kind', () => { + expect( + shouldFiltersHideAllRows({ + ...NOTHING_ELSE, + hasFilters: true, + visibleFolderWorkspaceCount: 0 + }) + ).toBe(true) + }) + + it('never wins without active filters', () => { + expect( + shouldFiltersHideAllRows({ + ...NOTHING_ELSE, + hasFilters: false, + visibleFolderWorkspaceCount: 0 + }) + ).toBe(false) + }) + + it('defers to worktrees, placeholders and imported cards as before', () => { + for (const key of [ + 'visibleWorktreeCount', + 'placeholderRepoCount', + 'importedWorktreeCardCount' + ] as const) { + expect( + shouldFiltersHideAllRows({ + ...NOTHING_ELSE, + hasFilters: true, + visibleFolderWorkspaceCount: 0, + [key]: 1 + }) + ).toBe(false) + } + }) +}) diff --git a/src/renderer/src/components/sidebar/sidebar-empty-state-gate.ts b/src/renderer/src/components/sidebar/sidebar-empty-state-gate.ts new file mode 100644 index 00000000000..2939cc299a3 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-empty-state-gate.ts @@ -0,0 +1,24 @@ +/** + * Whether active filters have hidden every sidebar row, so the Clear Filters + * empty state should win over any remaining headers. + * + * Split out of WorktreeList so the row kinds it counts are testable: folder + * workspaces were missing from this gate, which meant an account whose only + * workspaces were folder workspaces lost them to the empty state as soon as any + * filter — including host scope — was active (#15362). + */ +export function shouldFiltersHideAllRows(counts: { + hasFilters: boolean + visibleWorktreeCount: number + visibleFolderWorkspaceCount: number + placeholderRepoCount: number + importedWorktreeCardCount: number +}): boolean { + return ( + counts.hasFilters && + counts.visibleWorktreeCount === 0 && + counts.visibleFolderWorkspaceCount === 0 && + counts.placeholderRepoCount === 0 && + counts.importedWorktreeCardCount === 0 + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts new file mode 100644 index 00000000000..292d6535832 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest' +import { buildRows } from './build-rows' +import { getFolderWorkspaceLaneKey } from './folder-workspace-lanes' +import { getPRGroupKey, getPRLaneKey } from './group-keys' +import type { Row, WorktreeGroupBy } from './row-types' +import { repo, worktree } from '../../worktree-list-groups-test-fixtures' +import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../../shared/project-group-types' +import type { Repo } from '../../../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' + +const GROUP: ProjectGroup = { + id: 'group-1', + name: 'Multi-repo Group', + parentPath: '/tmp/parent', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { + return { + id: 'fw-1', + projectGroupId: GROUP.id, + name: 'Folder workspace', + folderPath: '/tmp/parent', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1, + workspaceStatus: 'in-progress', + ...overrides + } +} + +const GROUPED_REPO: Repo = { ...repo, projectGroupId: GROUP.id } + +function buildSidebarRows(options: { + groupBy: WorktreeGroupBy + folderWorkspaces?: readonly FolderWorkspace[] + projectGroups?: readonly ProjectGroup[] + worktrees?: (typeof worktree)[] + collapsedGroups?: Set +}): Row[] { + const worktrees = options.worktrees ?? [worktree] + return buildRows( + options.groupBy, + worktrees, + new Map([[GROUPED_REPO.id, GROUPED_REPO]]), + null, + options.collapsedGroups ?? new Set(), + undefined, + undefined, + 'manual', + {}, + new Map(worktrees.map((entry) => [entry.id, entry])), + false, + undefined, + options.projectGroups ?? [GROUP], + new Set(), + new Map(), + new Map(), + [], + undefined, + options.folderWorkspaces ?? [makeFolderWorkspace()] + ) +} + +function folderRows(rows: Row[]): Extract[] { + return rows.filter( + (row): row is Extract => row.type === 'folder-workspace' + ) +} + +const ALL_GROUP_BY: WorktreeGroupBy[] = ['repo', 'workspace-status', 'pr-status', 'none'] + +describe('folder workspaces render under every Group by mode', () => { + // The three non-repo arms are the acceptance evidence; the repo arm is a + // deliberate no-regression guard that also passed before the fix. + for (const groupBy of ALL_GROUP_BY) { + it(`emits the folder-workspace row when groupBy is ${groupBy}`, () => { + expect(folderRows(buildSidebarRows({ groupBy }))).toHaveLength(1) + }) + } + + it('does not duplicate the row under repo grouping', () => { + const rows = buildSidebarRows({ groupBy: 'repo' }) + expect(folderRows(rows).map((row) => row.key)).toEqual(['folder-workspace:fw-1']) + }) +}) + +describe('a folder workspace can be the only member of a lane', () => { + it('creates its status lane with no worktrees present', () => { + const rows = buildSidebarRows({ groupBy: 'workspace-status', worktrees: [] }) + expect(folderRows(rows)).toHaveLength(1) + const header = rows.find((row) => row.type === 'header') + expect(header).toBeDefined() + expect(header && 'count' in header ? header.count : null).toBe(1) + }) + + it('renders in flat mode with no worktrees present', () => { + // Pre-fix the whole All section was gated on naturalWorktrees.length > 0, + // so a folder-only account saw nothing at all. + const rows = buildSidebarRows({ groupBy: 'none', worktrees: [] }) + expect(folderRows(rows)).toHaveLength(1) + }) +}) + +describe('lane assignment', () => { + it('routes to the same PR lane as a worktree with no PR', () => { + const laneKey = getFolderWorkspaceLaneKey( + { folderWorkspace: makeFolderWorkspace(), projectGroup: GROUP }, + 'pr-status', + [] + ) + const noPrWorktreeLane = getPRLaneKey( + getPRGroupKey(worktree, new Map([[GROUPED_REPO.id, GROUPED_REPO]]), null) + ) + expect(laneKey).toBe(noPrWorktreeLane) + }) +}) + +describe('ordering', () => { + const ordered = [ + makeFolderWorkspace({ id: 'fw-low', name: 'Low', sortOrder: 1 }), + makeFolderWorkspace({ id: 'fw-high', name: 'High', sortOrder: 9 }) + ] + + // Both emitters are covered: grouped lanes and the separate flat-mode path. + for (const groupBy of ['workspace-status', 'none'] as WorktreeGroupBy[]) { + it(`orders by manualOrder then sortOrder under ${groupBy}`, () => { + const rows = buildSidebarRows({ groupBy, folderWorkspaces: ordered }) + expect(folderRows(rows).map((row) => row.folderWorkspace.id)).toEqual(['fw-high', 'fw-low']) + }) + + it(`prefers manualOrder over sortOrder under ${groupBy}`, () => { + const rows = buildSidebarRows({ + groupBy, + folderWorkspaces: [ + makeFolderWorkspace({ id: 'fw-low', name: 'Low', sortOrder: 9, manualOrder: 1 }), + makeFolderWorkspace({ id: 'fw-high', name: 'High', sortOrder: 1, manualOrder: 9 }) + ] + }) + expect(folderRows(rows).map((row) => row.folderWorkspace.id)).toEqual(['fw-high', 'fw-low']) + }) + } +}) + +describe('membership is decided once, not per mode', () => { + // Negative arm alone is vacuous (zero rows either way pre-fix), so it is + // paired with an explicitly non-repo positive arm. + it('renders nothing when the owning project group is not visible', () => { + const rows = buildSidebarRows({ groupBy: 'workspace-status', projectGroups: [] }) + expect(folderRows(rows)).toHaveLength(0) + }) + + it('renders under a non-repo mode when the owning group is visible', () => { + const rows = buildSidebarRows({ groupBy: 'workspace-status' }) + expect(folderRows(rows)).toHaveLength(1) + }) + + it('keeps archived folder workspaces behaving identically in every mode', () => { + const archived = [makeFolderWorkspace({ isArchived: true })] + const counts = ALL_GROUP_BY.map( + (groupBy) => folderRows(buildSidebarRows({ groupBy, folderWorkspaces: archived })).length + ) + // Parity with today's behaviour: nothing filters folder workspaces by + // isArchived, so a mode must not be the thing that hides one. + expect(counts).toEqual([1, 1, 1, 1]) + }) +}) + +describe('host bookkeeping for lanes containing folder workspaces', () => { + const SSH_HOST = 'ssh:target-1' as ExecutionHostId + + it('scopes a collapsed folder-only lane header to its host', () => { + const sshGroup: ProjectGroup = { ...GROUP, connectionId: 'target-1' } + const rows = buildRows( + 'workspace-status', + [], + new Map(), + null, + new Set(['workspace-status:in-progress']), + undefined, + undefined, + 'manual', + {}, + new Map(), + false, + undefined, + [sshGroup], + new Set(), + new Map(), + new Map(), + [], + undefined, + [makeFolderWorkspace()] + ) + const header = rows.find((row) => row.type === 'header') + expect(header).toBeDefined() + const counts = header && 'hostWorktreeCounts' in header ? header.hostWorktreeCounts : undefined + // Undefined counts render globally, which is exactly the pre-fix bug. + expect(counts).toBeDefined() + expect(counts?.get(SSH_HOST)).toBe(1) + }) + + it('gives a folder-only host an explicit empty id array', () => { + const sshGroup: ProjectGroup = { ...GROUP, connectionId: 'target-1' } + const rows = buildRows( + 'workspace-status', + [], + new Map(), + null, + new Set(), + undefined, + undefined, + 'manual', + {}, + new Map(), + false, + undefined, + [sshGroup], + new Set(), + new Map(), + new Map(), + [], + undefined, + [makeFolderWorkspace()] + ) + const header = rows.find((row) => row.type === 'header') + const ids = header && 'hostWorktreeIds' in header ? header.hostWorktreeIds : undefined + // The key must exist even though folder workspaces contribute no worktree + // ids, or the host-section fallback leaks the global id list into it. + expect(ids?.has(SSH_HOST)).toBe(true) + expect(ids?.get(SSH_HOST)).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts index a5e129a97ad..5d3838f2f62 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts @@ -14,8 +14,8 @@ import { ALL_GROUP_KEY, ALL_GROUP_META } from './group-keys' import { appendOrderedGroups } from './group-sections' import type { SectionAppendContext } from './group-sections' import { - getHostWorktreeCounts, - getHostWorktreeIds, + getLaneHostWorktreeCounts, + getLaneHostWorktreeIds, getMixedWorktreeHostContextLabels } from './host-labels' import { buildProjectGroupingIndex } from './project-grouping' @@ -23,7 +23,15 @@ import type { ProjectGroupingModel } from './project-grouping' import { appendProjectGroupSections } from './project-group-sections' import { getPinnedSectionWorktrees } from '../../pinned-section-worktrees' import { emitPinnedGroup } from './pinned-group-rows' -import { appendWorktreeRows, buildPendingCreationRow } from './row-builders' +import { + appendWorktreeRows, + buildFolderWorkspaceRow, + buildPendingCreationRow +} from './row-builders' +import { + compareFolderWorkspacesForDisplay, + getRenderableFolderWorkspaces +} from './folder-workspace-lanes' import { getPinnedWorktreeDisplayPolicy } from './row-types' import type { ImportedWorktreesCardCandidate, @@ -65,6 +73,9 @@ export function buildRows( ): Row[] { const result: Row[] = [] const projectIndex = buildProjectGroupingIndex(projectGrouping) + // Membership is decided once, above the groupBy switch: every mode renders the + // same set of folder workspaces and only chooses where they land (#15362). + const renderableFolderWorkspaces = getRenderableFolderWorkspaces(folderWorkspaces, projectGroups) const cyclicLineageIds = nestLineage ? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) : new Set() @@ -124,16 +135,28 @@ export function buildRows( cyclicLineageIds ) if (groupBy === 'none') { - if (naturalWorktrees.length > 0) { + // Why folder workspaces gate this too: an account with only folder + // workspaces rendered nothing at all in flat mode before (#15362). + if (naturalWorktrees.length > 0 || renderableFolderWorkspaces.length > 0) { result.push({ type: 'header', key: ALL_GROUP_KEY, label: ALL_GROUP_META.label, - count: naturalWorktrees.length, + count: naturalWorktrees.length + renderableFolderWorkspaces.length, tone: ALL_GROUP_META.tone, icon: ALL_GROUP_META.icon, - hostWorktreeCounts: getHostWorktreeCounts(naturalWorktrees, repoMap, defaultHostId), - hostWorktreeIds: getHostWorktreeIds(naturalWorktrees, repoMap, defaultHostId), + hostWorktreeCounts: getLaneHostWorktreeCounts( + naturalWorktrees, + renderableFolderWorkspaces, + repoMap, + defaultHostId + ), + hostWorktreeIds: getLaneHostWorktreeIds( + naturalWorktrees, + renderableFolderWorkspaces, + repoMap, + defaultHostId + ), worktreeIds: naturalWorktrees.map((worktree) => worktree.id) }) if (!collapsedGroups.has(ALL_GROUP_KEY)) { @@ -145,6 +168,11 @@ export function buildRows( hostContextLabelByWorktreeIdentity: mixedWorktreeHostContextLabels, cyclicLineageIds }) + for (const pair of [...renderableFolderWorkspaces].sort((left, right) => + compareFolderWorkspacesForDisplay(left.folderWorkspace, right.folderWorkspace) + )) { + result.push(buildFolderWorkspaceRow(pair, 0)) + } } } return result @@ -163,7 +191,8 @@ export function buildRows( newExternalWorktreesInboxByRepo, pendingByRepo, repoOrder, - projectOrderBy + projectOrderBy, + folderWorkspaces: renderableFolderWorkspaces }) const sectionContext: SectionAppendContext = { @@ -196,7 +225,7 @@ export function buildRows( appendProjectGroupSections(sectionContext, { orderedGroups, projectGroups, - folderWorkspaces, + folderWorkspaces: renderableFolderWorkspaces, projectOrderBy, repoOrder }) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/folder-workspace-lanes.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/folder-workspace-lanes.ts new file mode 100644 index 00000000000..706c05c5ed6 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/folder-workspace-lanes.ts @@ -0,0 +1,77 @@ +import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../../shared/project-group-types' +import type { WorkspaceStatusDefinition } from '../../../../../../shared/worktree/types' +import { + getWorkspaceStatus, + getWorkspaceStatusGroupKey +} from '../../../../../../shared/workspace-statuses' +import { ALL_GROUP_KEY, getPRLaneKey } from './group-keys' +import type { WorktreeGroupBy } from './row-types' + +/** A folder workspace paired with the project group that owns it. The pair is + * carried through grouping because FolderWorkspaceRow needs a non-optional + * ProjectGroup and the section emitters have no owner resolver. */ +export type RenderableFolderWorkspace = { + folderWorkspace: FolderWorkspace + projectGroup: ProjectGroup +} + +/** + * The single place that decides which folder workspaces can render. + * + * Why one place: membership used to be computed inside the repo-grouping branch, + * so every other Group by mode dropped folder workspaces entirely (#15362). + * Modes may choose a lane; they may never subtract from this list. + */ +export function getRenderableFolderWorkspaces( + folderWorkspaces: readonly FolderWorkspace[], + projectGroups: readonly ProjectGroup[] +): RenderableFolderWorkspace[] { + const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group])) + const renderable: RenderableFolderWorkspace[] = [] + for (const folderWorkspace of folderWorkspaces) { + const projectGroup = projectGroupsById.get(folderWorkspace.projectGroupId) + // A group filtered out for host visibility legitimately hides its workspaces. + if (!projectGroup?.parentPath) { + continue + } + renderable.push({ folderWorkspace, projectGroup }) + } + return renderable +} + +/** + * Which lane a folder workspace belongs to, per Group by mode. + * + * Deliberately exhaustive with no `default:` so a new WorktreeGroupBy variant is + * a compile error here rather than a silent fall-through that hides folder + * workspaces again. Total by construction: a folder workspace always gets a lane. + */ +export function getFolderWorkspaceLaneKey( + pair: RenderableFolderWorkspace, + groupBy: Exclude, + workspaceStatuses: readonly WorkspaceStatusDefinition[] +): string { + switch (groupBy) { + case 'workspace-status': + return getWorkspaceStatusGroupKey(getWorkspaceStatus(pair.folderWorkspace, workspaceStatuses)) + case 'pr-status': + // Why in-progress: a folder workspace has no branch or repo, so it can + // never resolve a PR. getPRGroupKey returns this same lane for any + // worktree without one, so the two stay consistent. + return getPRLaneKey('in-progress') + case 'none': + return ALL_GROUP_KEY + } +} + +/** Sidebar display order: user-authored order first, then name. Mirrors the rule + * the project-group emitter has always used, so lanes and groups agree. */ +export function compareFolderWorkspacesForDisplay( + left: FolderWorkspace, + right: FolderWorkspace +): number { + const leftOrder = left.manualOrder ?? left.sortOrder + const rightOrder = right.manualOrder ?? right.sortOrder + return rightOrder - leftOrder || left.name.localeCompare(right.name) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/group-keys.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/group-keys.ts index 3266033408a..76d0e88ebfc 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/group-keys.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/group-keys.ts @@ -21,6 +21,12 @@ export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed' export const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed'] +/** Section key for a PR lane. Shared so worktree and folder-workspace bucketing + * cannot drift onto different prefixes. */ +export function getPRLaneKey(prGroup: PRGroupKey): string { + return `pr:${prGroup}` +} + export const PR_GROUP_META: Record< PRGroupKey, { diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts index 15bd81ace63..e8ba7fe47f2 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts @@ -8,10 +8,15 @@ import { } from '../../workspace-status' import { PROJECT_GROUP_META, PR_GROUP_META } from './group-keys' import type { PRGroupKey } from './group-keys' -import { getHostWorktreeCounts, getHostWorktreeIds, getMixedHostContextLabels } from './host-labels' +import { + getLaneHostWorktreeCounts, + getLaneHostWorktreeIds, + getMixedHostContextLabels +} from './host-labels' import type { OrderedGroupEntry, ProjectGroupingIndex } from './project-grouping' import { appendWorktreeRows, + buildFolderWorkspaceRow, buildImportedWorktreesCardRow, buildNewExternalWorktreesInboxRow, buildPendingCreationRow @@ -71,6 +76,7 @@ export function appendOrderedGroups( for (const [key, group] of groupsToAppend) { const isCollapsed = collapsedGroups.has(key) const repo = group.repo + const folderPairs = group.folderWorkspaces ?? [] const header = groupBy === 'repo' ? { @@ -95,11 +101,21 @@ export function appendOrderedGroups( type: 'header' as const, key, label: definition?.label ?? workspaceStatus, - count: group.items.length, + count: group.items.length + folderPairs.length, tone: meta.tone, icon: meta.icon, - hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId), - hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId), + hostWorktreeCounts: getLaneHostWorktreeCounts( + group.items, + folderPairs, + repoMap, + defaultHostId + ), + hostWorktreeIds: getLaneHostWorktreeIds( + group.items, + folderPairs, + repoMap, + defaultHostId + ), worktreeIds: group.items.map((worktree) => worktree.id) } })() @@ -110,11 +126,21 @@ export function appendOrderedGroups( type: 'header' as const, key, label: meta.label, - count: group.items.length, + count: group.items.length + folderPairs.length, tone: meta.tone, icon: meta.icon, - hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId), - hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId), + hostWorktreeCounts: getLaneHostWorktreeCounts( + group.items, + folderPairs, + repoMap, + defaultHostId + ), + hostWorktreeIds: getLaneHostWorktreeIds( + group.items, + folderPairs, + repoMap, + defaultHostId + ), worktreeIds: group.items.map((worktree) => worktree.id) } })() @@ -171,6 +197,9 @@ export function appendOrderedGroups( hostContextLabelByWorktreeIdentity, cyclicLineageIds }) + for (const pair of folderPairs) { + result.push(buildFolderWorkspaceRow(pair, projectGroupDepth)) + } } } } diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts index 6b077e0a07c..a2949d0f806 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts @@ -8,6 +8,8 @@ import { import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' import type { ProjectGroupingIndex, WorktreeGroupEntry } from './project-grouping' +import { getFolderWorkspaceHostId } from '../../folder-workspace-host-id' +import type { RenderableFolderWorkspace } from './folder-workspace-lanes' function getRepoHostLabel( repoId: string, @@ -111,3 +113,55 @@ export function getHostWorktreeIds( } return idsByHost } + +/** + * Host counts for a lane that may contain folder workspaces as well as worktrees. + * + * Why not getHostWorktreeCounts alone: it returns undefined for an empty + * worktree list, and a header with undefined counts is treated as global. A lane + * whose only members are folder workspaces would therefore render outside its + * host section (#15362). + */ +export function getLaneHostWorktreeCounts( + worktrees: readonly Worktree[], + folderWorkspaces: readonly RenderableFolderWorkspace[], + repoMap: Map, + defaultHostId: ExecutionHostId +): Map | undefined { + if (worktrees.length === 0 && folderWorkspaces.length === 0) { + return undefined + } + const counts = getHostWorktreeCounts(worktrees, repoMap, defaultHostId) ?? new Map() + for (const { folderWorkspace, projectGroup } of folderWorkspaces) { + const hostId = getFolderWorkspaceHostId(folderWorkspace, projectGroup, defaultHostId) + counts.set(hostId, (counts.get(hostId) ?? 0) + 1) + } + return counts +} + +/** + * Host-scoped worktree ids for a lane that may contain folder workspaces. + * + * Folder workspaces are not worktrees, so they contribute no ids — but their + * host must still get an explicit empty array, or the host-section fallbacks + * treat the missing key as "unscoped" and leak the global worktree ids into + * that section. + */ +export function getLaneHostWorktreeIds( + worktrees: readonly Worktree[], + folderWorkspaces: readonly RenderableFolderWorkspace[], + repoMap: Map, + defaultHostId: ExecutionHostId +): Map | undefined { + if (worktrees.length === 0 && folderWorkspaces.length === 0) { + return undefined + } + const idsByHost = getHostWorktreeIds(worktrees, repoMap, defaultHostId) ?? new Map() + for (const { folderWorkspace, projectGroup } of folderWorkspaces) { + const hostId = getFolderWorkspaceHostId(folderWorkspace, projectGroup, defaultHostId) + if (!idsByHost.has(hostId)) { + idsByHost.set(hostId, []) + } + } + return idsByHost +} diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts index da42befc634..525829ae282 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts @@ -1,4 +1,7 @@ -import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types' +import { + compareFolderWorkspacesForDisplay, + type RenderableFolderWorkspace +} from './folder-workspace-lanes' import type { ProjectGroup } from '../../../../../../shared/project-group-types' import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types' import { getEffectiveProjectGroupManualRank } from '../../../../../../shared/project-groups' @@ -11,13 +14,14 @@ import { recentRankForEntry, withRepoSectionDisplayLabels } from './section-order' +import { buildFolderWorkspaceRow } from './row-builders' export function appendProjectGroupSections( ctx: SectionAppendContext, args: { orderedGroups: OrderedGroupEntry[] projectGroups: readonly ProjectGroup[] - folderWorkspaces: readonly FolderWorkspace[] + folderWorkspaces: readonly RenderableFolderWorkspace[] projectOrderBy: ProjectOrderBy repoOrder: Map | undefined } @@ -51,22 +55,19 @@ export function appendProjectGroupSections( } const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group])) - const folderWorkspacesByProjectGroupId = new Map() - for (const workspace of folderWorkspaces) { - const group = projectGroupsById.get(workspace.projectGroupId) - if (!group?.parentPath) { - continue - } - const list = folderWorkspacesByProjectGroupId.get(workspace.projectGroupId) ?? [] - list.push(workspace) - folderWorkspacesByProjectGroupId.set(workspace.projectGroupId, list) + // Membership already decided by getRenderableFolderWorkspaces in buildRows, so + // repo grouping no longer owns the filter — it only groups and orders (#15362). + const folderWorkspacesByProjectGroupId = new Map() + for (const pair of folderWorkspaces) { + const groupId = pair.folderWorkspace.projectGroupId + const list = folderWorkspacesByProjectGroupId.get(groupId) ?? [] + list.push(pair) + folderWorkspacesByProjectGroupId.set(groupId, list) } for (const list of folderWorkspacesByProjectGroupId.values()) { - list.sort((left, right) => { - const leftOrder = left.manualOrder ?? left.sortOrder - const rightOrder = right.manualOrder ?? right.sortOrder - return rightOrder - leftOrder || left.name.localeCompare(right.name) - }) + list.sort((left, right) => + compareFolderWorkspacesForDisplay(left.folderWorkspace, right.folderWorkspace) + ) } const childGroupsByParentId = new Map() for (const group of projectGroups) { @@ -107,15 +108,8 @@ export function appendProjectGroupSections( projectGroupDepth: depth }) if (!collapsedGroups.has(key)) { - for (const folderWorkspace of folderWorkspacesByProjectGroupId.get(projectGroup.id) ?? []) { - result.push({ - type: 'folder-workspace', - key: `folder-workspace:${folderWorkspace.id}`, - folderWorkspace, - projectGroup, - depth: 0, - groupDepth: depth + 1 - }) + for (const pair of folderWorkspacesByProjectGroupId.get(projectGroup.id) ?? []) { + result.push(buildFolderWorkspaceRow(pair, depth + 1)) } appendOrderedGroups(ctx, withRepoSectionDisplayLabels(repoEntries), depth + 1) for (const childGroup of childGroups) { diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts index 961a0c61d88..3135a73b986 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts @@ -1,6 +1,7 @@ import type { Project, ProjectHostSetup } from '../../../../../../shared/project-types' import type { Repo } from '../../../../../../shared/repo-types' import type { Worktree } from '../../../../../../shared/worktree/types' +import type { RenderableFolderWorkspace } from './folder-workspace-lanes' import { toSshExecutionHostId } from '../../../../../../shared/execution-host' import { parseWslUncPath } from '../../../../../../shared/wsl-paths' import { @@ -20,6 +21,9 @@ export type WorktreeGroupEntry = { items: Worktree[] repo?: Repo repoIds: Set + /** Folder workspaces bucketed into this lane under non-repo grouping. Carries + * the owning group because FolderWorkspaceRow requires a non-optional one. */ + folderWorkspaces?: RenderableFolderWorkspace[] } export type ProjectGroupingIndex = { diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts index 38991fb8d81..5359d216ea5 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts @@ -5,7 +5,9 @@ import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host- import { isValidResolvedWorktreeLineageEdge } from '../../../../../../shared/resolved-worktree-lineage' import { getProjectedWorktreeLineage } from '../../worktree-lineage-projection' import { getWorktreeLineageGroupKey } from './group-keys' +import type { RenderableFolderWorkspace } from './folder-workspace-lanes' import type { + FolderWorkspaceRow, ImportedWorktreesCardCandidate, ImportedWorktreesCardRow, NewExternalWorktreesInboxCandidate, @@ -240,3 +242,19 @@ export function appendWorktreeRows( } } } + +/** The one folder-workspace row constructor, shared by the project-group, + * grouped-lane and flat emitters so their rows cannot diverge. */ +export function buildFolderWorkspaceRow( + pair: RenderableFolderWorkspace, + groupDepth: number +): FolderWorkspaceRow { + return { + type: 'folder-workspace', + key: `folder-workspace:${pair.folderWorkspace.id}`, + folderWorkspace: pair.folderWorkspace, + projectGroup: pair.projectGroup, + depth: 0, + groupDepth + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts index 350da045585..b6449acd2a1 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts @@ -2,8 +2,18 @@ import type { Repo } from '../../../../../../shared/repo-types' import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types' import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types' import type { AppState } from '../../../../store/types' -import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../../workspace-status' -import { PR_GROUP_META, PR_GROUP_ORDER, getPRGroupKey } from './group-keys' +import { + getWorkspaceStatus, + getWorkspaceStatusFromGroupKey, + getWorkspaceStatusGroupKey +} from '../../workspace-status' +import { + compareFolderWorkspacesForDisplay, + getFolderWorkspaceLaneKey, + type RenderableFolderWorkspace +} from './folder-workspace-lanes' +import { PR_GROUP_META, PR_GROUP_ORDER, getPRGroupKey, getPRLaneKey } from './group-keys' +import type { PRGroupKey } from './group-keys' import { addRepoIdToGroup, getProjectGroupingForRepo } from './project-grouping' import type { OrderedGroupEntry, @@ -18,6 +28,22 @@ import type { } from './row-types' import { getManualOrderAnchorRepo, sortProjectEntries } from './section-order' +/** Lane label for a lane a folder workspace opened before any worktree did. */ +function getLaneLabelForKey( + key: string, + groupBy: Exclude, + workspaceStatuses: readonly WorkspaceStatusDefinition[] +): string { + if (groupBy === 'workspace-status') { + const status = getWorkspaceStatusFromGroupKey(key, workspaceStatuses) + return workspaceStatuses.find((entry) => entry.id === status)?.label ?? status ?? key + } + if (groupBy === 'pr-status') { + return PR_GROUP_META[key.replace(/^pr:/, '') as PRGroupKey].label + } + return key +} + /** Buckets worktrees into their sections and returns them in render order. */ export function buildOrderedGroups(args: { groupBy: WorktreeGroupBy @@ -33,6 +59,7 @@ export function buildOrderedGroups(args: { pendingByRepo: ReadonlyMap repoOrder: Map | undefined projectOrderBy: ProjectOrderBy + folderWorkspaces?: readonly RenderableFolderWorkspace[] }): OrderedGroupEntry[] { const { groupBy, @@ -47,7 +74,8 @@ export function buildOrderedGroups(args: { newExternalWorktreesInboxByRepo, pendingByRepo, repoOrder, - projectOrderBy + projectOrderBy, + folderWorkspaces = [] } = args const grouped = new Map() @@ -67,7 +95,7 @@ export function buildOrderedGroups(args: { workspaceStatuses.find((status) => status.id === workspaceStatus)?.label ?? workspaceStatus } else { const prGroup = getPRGroupKey(w, repoMap, prCache, settings) - key = `pr:${prGroup}` + key = getPRLaneKey(prGroup) label = PR_GROUP_META[prGroup].label } if (!grouped.has(key)) { @@ -77,6 +105,30 @@ export function buildOrderedGroups(args: { group.items.push(w) addRepoIdToGroup(group, w.repoId) } + // Why: folder workspaces are not worktrees, so they never appear in the loop + // above. Bucketing them here — and creating the lane when no worktree opened + // one — is what lets a folder workspace be the sole occupant of a lane (#15362). + if (groupBy !== 'repo') { + for (const pair of folderWorkspaces) { + const key = getFolderWorkspaceLaneKey(pair, groupBy, workspaceStatuses) + if (!grouped.has(key)) { + grouped.set(key, { + label: getLaneLabelForKey(key, groupBy, workspaceStatuses), + items: [], + repo: undefined, + repoIds: new Set() + }) + } + const group = grouped.get(key)! + group.folderWorkspaces ??= [] + group.folderWorkspaces.push(pair) + } + for (const group of grouped.values()) { + group.folderWorkspaces?.sort((left, right) => + compareFolderWorkspacesForDisplay(left.folderWorkspace, right.folderWorkspace) + ) + } + } if (groupBy === 'repo') { for (const repoId of placeholderRepoIds) { const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts index bc9ef82d046..2c838f00247 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts @@ -8,6 +8,9 @@ import { PINNED_GROUP_KEY, getLineageGroupKey } from '../grouping/group-keys' import type { PinnedWorktreeDisplayPolicy, WorktreeGroupBy } from '../grouping/row-types' import type { ProjectGroupingModel } from '../grouping/project-grouping' import { getGroupKeysForWorktree } from '../grouping/worktree-group-keys' +import { getFolderWorkspaceRevealGroupKeys } from '../navigation/folder-reveal' +import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { isPinnedSectionWorktree } from '../../pinned-section-worktrees' import { getWorktreeLineageAncestors } from '../../worktree-lineage-projection' @@ -26,6 +29,8 @@ export function useEffectiveCollapsedGroups(args: { settings: AppState['settings'] projectGroups: readonly ProjectGroup[] projectGrouping: ProjectGroupingModel + folderWorkspaces: readonly FolderWorkspace[] + defaultHostId: ExecutionHostId }): Set { const { collapsedGroups, @@ -40,7 +45,9 @@ export function useEffectiveCollapsedGroups(args: { workspaceStatuses, settings, projectGroups, - projectGrouping + projectGrouping, + folderWorkspaces, + defaultHostId } = args return useMemo(() => { if (!agentSendTargetWorktreeId) { @@ -48,7 +55,22 @@ export function useEffectiveCollapsedGroups(args: { } const targetWorktree = worktreeMap.get(agentSendTargetWorktreeId) if (!targetWorktree) { - return collapsedGroups + // Why: folder workspaces are absent from worktreeMap, so without this the + // agent-send picker could never open the section hiding one (#15362). + const folderKeys = getFolderWorkspaceRevealGroupKeys( + agentSendTargetWorktreeId, + folderWorkspaces, + projectGroups, + { groupBy, workspaceStatuses, defaultHostId } + ) + if (folderKeys.length === 0) { + return collapsedGroups + } + const nextForFolder = new Set(collapsedGroups) + for (const groupKey of folderKeys) { + nextForFolder.delete(groupKey) + } + return nextForFolder } const next = new Set(collapsedGroups) if ( @@ -92,6 +114,8 @@ export function useEffectiveCollapsedGroups(args: { settings, workspaceStatuses, worktreeLineageById, - worktreeMap + worktreeMap, + folderWorkspaces, + defaultHostId ]) } diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts index bfe5226f889..fd51f5e0fd9 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts @@ -119,3 +119,39 @@ describe('worktree list folder reveal', () => { ).toEqual([getProjectGroupHeaderKey(root.id), getProjectGroupHeaderKey(child.id)]) }) }) + +describe('reveal keys under non-repo grouping', () => { + const group = makeProjectGroup({ id: 'group-child', connectionId: 'target-1' }) + const folderWorkspace = makeFolderWorkspace({ workspaceStatus: 'in-progress' }) + const workspaceKey = folderWorkspaceKey(folderWorkspace.id) + + it('returns the status lane key so a collapsed lane can be expanded', () => { + // Pre-fix only project-group headers came back, and those do not exist + // under status grouping, so the reveal could never expand the lane. + const keys = getFolderWorkspaceRevealGroupKeys(workspaceKey, [folderWorkspace], [group], { + groupBy: 'workspace-status', + workspaceStatuses: [], + defaultHostId: 'local' + }) + expect(keys).toContain('workspace-status:in-progress') + }) + + it('returns the host key so a collapsed host can be expanded too', () => { + const keys = getFolderWorkspaceRevealGroupKeys(workspaceKey, [folderWorkspace], [group], { + groupBy: 'workspace-status', + workspaceStatuses: [], + defaultHostId: 'local' + }) + expect(keys).toContain('host:ssh:target-1') + }) + + it('still returns project-group keys under repo grouping', () => { + const keys = getFolderWorkspaceRevealGroupKeys(workspaceKey, [folderWorkspace], [group], { + groupBy: 'repo', + workspaceStatuses: [], + defaultHostId: 'local' + }) + expect(keys).toContain(getProjectGroupHeaderKey(group.id)) + expect(keys.some((key) => key.startsWith('workspace-status:'))).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts index 7b0ac70b463..fd6766e1c4a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts @@ -1,10 +1,13 @@ import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types' import type { ProjectGroup } from '../../../../../../shared/project-group-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types' import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspace-worktree' import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' import { getProjectGroupHeaderKey } from '../grouping/group-keys' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { getFolderWorkspaceLaneKey } from '../grouping/folder-workspace-lanes' +import type { WorktreeGroupBy } from '../grouping/row-types' +import { getFolderWorkspaceHostId } from '../../folder-workspace-host-id' function findFolderWorkspaceByKey( worktreeId: string, @@ -57,7 +60,12 @@ export function sidebarWorkspaceStillExists( export function getFolderWorkspaceRevealGroupKeys( worktreeId: string, folderWorkspaces: readonly FolderWorkspace[], - projectGroups: readonly ProjectGroup[] + projectGroups: readonly ProjectGroup[], + options?: { + groupBy?: WorktreeGroupBy + workspaceStatuses?: readonly WorkspaceStatusDefinition[] + defaultHostId?: ExecutionHostId + } ): string[] { const folderWorkspace = findFolderWorkspaceByKey(worktreeId, folderWorkspaces) if (!folderWorkspace) { @@ -77,5 +85,24 @@ export function getFolderWorkspaceRevealGroupKeys( keys.unshift(getProjectGroupHeaderKey(group.id)) groupId = group.parentGroupId } + + // Under non-repo grouping the project-group headers above do not exist, so the + // lane and host headers are the ones actually hiding the row (#15362). Lane + // keys come from the same function grouping uses, so the two cannot disagree. + const owningGroup = groupsById.get(folderWorkspace.projectGroupId) + if (options?.groupBy && options.groupBy !== 'repo' && owningGroup) { + keys.push( + getFolderWorkspaceLaneKey( + { folderWorkspace, projectGroup: owningGroup }, + options.groupBy, + options.workspaceStatuses ?? [] + ) + ) + } + if (owningGroup && options?.defaultHostId) { + keys.push( + `host:${getFolderWorkspaceHostId(folderWorkspace, owningGroup, options.defaultHostId)}` + ) + } return keys } diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts index 7e9b5e77824..a0aa9a51520 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts @@ -60,7 +60,12 @@ export function expandGroupsForWorktreeReveal( const folderGroupKeys = getFolderWorkspaceRevealGroupKeys( worktreeId, args.folderWorkspaces, - args.projectGroups + args.projectGroups, + { + groupBy: args.groupBy, + workspaceStatuses: args.workspaceStatuses, + defaultHostId: args.defaultHostId + } ) if (folderGroupKeys.length > 0) { for (const groupKey of folderGroupKeys) { diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts new file mode 100644 index 00000000000..d56020c488b --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../../shared/project-group-types' +import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' +import type { RenderRow } from '../listing/render-row' +import { findPreferredRenderRowIndexForWorktreeIdentity } from './render-row-lookup' + +const FOLDER_WORKSPACE: FolderWorkspace = { + id: 'fw-1', + projectGroupId: 'group-1', + name: 'Folder workspace', + folderPath: '/tmp/parent', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 +} + +const PROJECT_GROUP: ProjectGroup = { + id: 'group-1', + name: 'Group', + parentPath: '/tmp/parent', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +describe('host-qualified reveal lookup finds folder workspaces', () => { + it('returns the folder row index instead of -1', () => { + // Reveal requests for the current workspace carry executionHostId, which + // routes here. A walker that only knows item rows returned -1 and the + // reveal silently never completed (#15362). + const rows: RenderRow[] = [ + { + type: 'folder-workspace', + key: `folder-workspace:${FOLDER_WORKSPACE.id}`, + folderWorkspace: FOLDER_WORKSPACE, + projectGroup: PROJECT_GROUP, + depth: 0, + groupDepth: 0 + } as RenderRow + ] + + const index = findPreferredRenderRowIndexForWorktreeIdentity( + rows, + { id: folderWorkspaceKey(FOLDER_WORKSPACE.id), hostId: undefined }, + 'single-location' + ) + + expect(index).toBe(0) + }) + + it('does not match a different folder workspace', () => { + const rows: RenderRow[] = [ + { + type: 'folder-workspace', + key: 'folder-workspace:other', + folderWorkspace: { ...FOLDER_WORKSPACE, id: 'other' }, + projectGroup: PROJECT_GROUP, + depth: 0, + groupDepth: 0 + } as RenderRow + ] + + expect( + findPreferredRenderRowIndexForWorktreeIdentity( + rows, + { id: folderWorkspaceKey(FOLDER_WORKSPACE.id), hostId: undefined }, + 'single-location' + ) + ).toBe(-1) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts index d8900b958c6..9728ff4634e 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts @@ -109,6 +109,14 @@ export function findPreferredRenderRowIndexForWorktreeIdentity( let fallbackIndex = -1 for (let index = 0; index < renderRows.length; index++) { const row = renderRows[index] + // Why: host-qualified reveals are emitted for folder workspaces too, and a + // walker that only knows item rows returns -1 so the reveal never lands. + if (row.type === 'folder-workspace') { + if (folderWorkspaceKey(row.folderWorkspace.id) === worktree.id) { + return index + } + continue + } const itemRows = row.type === 'lineage-group' ? row.rows : row.type === 'item' ? [row] : [] const itemRow = itemRows.find( (candidate) => getWorktreeHostIdentity(candidate.worktree) === identity diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts index 3d720043ad8..acca657bfd7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts @@ -11,6 +11,7 @@ export function leafIdForPane(paneId: number): string { export type ConnectCallbacks = { onReattachDetermined?: () => void onConnect?: () => void + onStreamRecovered?: () => void onData?: ( data: string, meta?: { seq?: number; rawLength?: number; background?: boolean; droppedOutput?: boolean } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-visible-pane-output-pause-latch.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-visible-pane-output-pause-latch.test.ts new file mode 100644 index 00000000000..8cf68aa4188 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-visible-pane-output-pause-latch.test.ts @@ -0,0 +1,243 @@ +/** + * STA-4593 incident: typing into a visible remote Codex pane produced no + * visible output until the user switched workspaces away and back. The host + * DROPS output while a stream is paused yet keeps accepting input, and every + * hide→show cycle heals the pane with an unconditional snapshot repaint — + * which is why the switch "fixed" it and why nothing ever logged an error. + * + * Invariant under test: a VISIBLE pane must never be left with a paused output + * intent. The pause bit must be re-derived from live visibility whenever the + * chance to sync it was missed. + * + * Causal boundary: syncHiddenRendererPtyDelivery issues setOutputPaused only + * when canUseHiddenOutputSnapshot(ptyId) — which requires + * transport.getPtyId() === ptyId. During a stream rebind getPtyId() is + * transiently null, so a reveal that lands in that window is a silent no-op: + * desiredOutputPaused stays true, the transport replays it verbatim onto the + * recovered stream, the host drops all output, and the stall watchdog never + * arms on a paused stream. Nothing re-derives the bit on onConnect or + * onStreamRecovered. + * + * Rig: the hidden-backlog-snapshot suite's mock-transport harness; fake + * visibility flips drive the real connectPanePty binding. + */ +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +const { scheduleRuntimeGraphSync, shouldSeedCacheTimerOnInitialTitle } = vi.hoisted(() => ({ + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false) +})) + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState, + subscribe: (listener: (state: StoreState) => void) => { + storeSubscribers.push(listener) + return () => { + storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle +})) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn(() => { + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + }) +})) + +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn(() => { + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + }) +})) + +vi.mock('./pty-dispatcher', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + getEagerPtyBufferHandle: vi.fn(() => undefined) + } +}) + +const REMOTE_PTY_ID = 'remote:env-1@@terminal-1' + +function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) +} + +type Binding = { syncProcessTracking: () => void; dispose: () => void } + +async function connectVisibleRemotePane(): Promise<{ + transport: MockTransport + binding: Binding + deps: ReturnType + callbacks: () => ConnectCallbacks + pauseCalls: () => boolean[] +}> { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport(REMOTE_PTY_ID) + transport.setOutputPaused = vi.fn(() => true) + transport.serializeBuffer = vi.fn() + const captured: { callbacks: ConnectCallbacks | null } = { callbacks: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + captured.callbacks = callbacks + return REMOTE_PTY_ID + }) + transportFactoryQueue.push(transport) + const deps = createDeps({ isVisibleRef: { current: true } }) + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + deps as never + ) as Binding + await flushAsyncTicks(6) + expect(captured.callbacks).not.toBeNull() + return { + transport, + binding, + deps, + callbacks: () => captured.callbacks!, + pauseCalls: () => + (transport.setOutputPaused as ReturnType).mock.calls.map( + ([paused]) => paused as boolean + ) + } +} + +describe('remote pane output pause vs visibility', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('control: a hide/show cycle with a stable stream ends unpaused', async () => { + const { binding, deps, pauseCalls } = await connectVisibleRemotePane() + + ;(deps.isVisibleRef as { current: boolean }).current = false + binding.syncProcessTracking() + expect(pauseCalls().at(-1)).toBe(true) + ;(deps.isVisibleRef as { current: boolean }).current = true + binding.syncProcessTracking() + + expect(pauseCalls().at(-1)).toBe(false) + binding.dispose() + }) + + it('red: a reveal that lands mid-rebind must still end with the visible pane unpaused', async () => { + const { transport, binding, deps, callbacks, pauseCalls } = await connectVisibleRemotePane() + + // Hide the pane: the transport is asked to pause host output. + ;(deps.isVisibleRef as { current: boolean }).current = false + binding.syncProcessTracking() + expect(pauseCalls().at(-1)).toBe(true) + + // The stream drops and a resubscribe is in flight: the transport has no + // bound PTY id during the rebind window. + transport.getPtyId.mockImplementation(() => null) + + // The user switches back while the rebind is in flight. The sync cannot + // reach the host right now — the defect is that nothing ever retries it. + ;(deps.isVisibleRef as { current: boolean }).current = true + binding.syncProcessTracking() + + // The rebind completes: the transport re-derives the same pane and reports + // the recovered stream. Production replays desiredOutputPaused onto the new + // stream verbatim, so unless this moment re-derives pause-from-visibility, + // the host keeps dropping every byte for a pane the user is looking at — + // while its input keeps being accepted, and the paused stream never arms + // the stall watchdog. + transport.getPtyId.mockImplementation(() => REMOTE_PTY_ID) + callbacks().onStreamRecovered?.() + callbacks().onConnect?.() + await flushAsyncTicks(6) + + expect( + pauseCalls().at(-1), + `a visible pane was left output-paused after a mid-rebind reveal (setOutputPaused calls: ${JSON.stringify(pauseCalls())})` + ).toBe(false) + binding.dispose() + }) + + it('red: a content-bearing recovery fires only onConnect, which must re-derive the pause bit alone', async () => { + const { transport, binding, deps, callbacks, pauseCalls } = await connectVisibleRemotePane() + + ;(deps.isVisibleRef as { current: boolean }).current = false + binding.syncProcessTracking() + expect(pauseCalls().at(-1)).toBe(true) + + transport.getPtyId.mockImplementation(() => null) + ;(deps.isVisibleRef as { current: boolean }).current = true + binding.syncProcessTracking() + + // When the recovery subscribe's push snapshot carried content — the common + // case — the transport fires ONLY onConnect (onStreamRecovered is gated on + // an empty snapshot). onConnect must therefore re-derive the bit by itself. + transport.getPtyId.mockImplementation(() => REMOTE_PTY_ID) + callbacks().onConnect?.() + await flushAsyncTicks(6) + + expect( + pauseCalls().at(-1), + `onConnect alone did not unpause a visible pane (setOutputPaused calls: ${JSON.stringify(pauseCalls())})` + ).toBe(false) + binding.dispose() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 4f23e2a42bc..49a24c32d16 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -5859,10 +5859,15 @@ export function connectPanePty( onConnect: (): void => { if (isCurrent()) { reportRemoteRendererSerializerReady() + // Why: a visibility flip during a rebind can't reach the host (no bound + // pty id), and the transport replays the stale pause bit onto the new + // stream — re-derive it from live visibility once the stream is bound. + syncHiddenRendererPtyDelivery() } }, onStreamRecovered: (): void => { if (isCurrent()) { + syncHiddenRendererPtyDelivery() markHiddenOutputRestoreNeeded() } }, diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts index cd8fa2722dc..fbbee1f771c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts @@ -29,8 +29,9 @@ function openHtmlFileInBrowser(filePath: string, worktreeId: string): void { const store = useAppStore.getState() if (worktreeId) { // Why: following an HTML file link changes which worktree is foregrounded, - // so it must record a history visit before opening the browser tab. - activateAndRevealWorktree(worktreeId) + // so it must record a history visit before opening the browser tab — but the + // browser tab is the surface, so an emptied workspace must not gain a shell. + activateAndRevealWorktree(worktreeId, { providesInitialSurface: true }) } const fileUrl = absolutePathToFileUri(filePath) const title = filePath.split(/[/\\]/).pop() ?? filePath @@ -208,10 +209,9 @@ export function openDetectedFilePath( const store = useAppStore.getState() if (worktreeId) { - // Why: terminal file links can jump across worktrees. Reusing the shared - // activation path keeps those jumps in the same history stack as sidebar - // and palette navigation before the editor opens the destination file. - activateAndRevealWorktree(worktreeId) + // Why: cross-worktree file links share the activation history stack with sidebar and + // palette navigation, but the editor file is the surface — don't seed a shell. + activateAndRevealWorktree(worktreeId, { providesInitialSurface: true }) } const language = detectLanguage(mappedFilePath) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts index 73f6d83a0b0..e648d32c3c5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts @@ -6,8 +6,16 @@ export type TerminalImeCompositionTracker = IDisposable & { * IME-owned: during a live composition, and briefly after compositionend to * absorb the committing key's trailing press/release. */ isCandidateKeyGuardActive: () => boolean + /** True when the most recent preedit was Hangul, where a bare digit ends the + * syllable as literal text instead of picking a candidate. Expires with the + * same staleness window as the other guards. */ + isHangulPreedit: () => boolean } +// Jamo, compatibility jamo, extended jamo, and precomposed syllables — every +// form a Hangul preedit can take while a syllable is being assembled. +const HANGUL_PREEDIT_PATTERN = /[ᄀ-ᇿ㄰-㆏ꥠ-꥿가-힣]/ + // Why: suppressed candidate keys are preventDefault-ed and fire no input // event, so a stale tracker (missed compositionend) has no natural unstick // path. Expire the guard so Space/digits cannot stay dead indefinitely. @@ -26,12 +34,24 @@ export function installTerminalImeCompositionTracker( let lastCompositionEventAt: number | null = null let compositionEndedAt: number | null = null let sawEmptyCompositionUpdate = false + // Why the preedit and not compositionend data: a Pinyin IME's preedit is the + // Latin spelling it is picking candidates for, while its compositionend data + // is the committed Han text. Reading the commit would misclassify Pinyin. + let hangulPreedit = false const isActiveAt = (at: number): boolean => active && (lastCompositionEventAt === null || at - lastCompositionEventAt <= TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS) + // Why time-bound: an engine switch (Hangul -> Pinyin) moves no DOM focus and + // the orphan-digit path emits no composition or input events, so a latched + // flag would disable the Pinyin candidate-digit guard for the whole session. + const isHangulPreeditAt = (at: number): boolean => + hangulPreedit && + lastCompositionEventAt !== null && + at - lastCompositionEventAt <= TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS + const isCandidateKeyGuardActive = (): boolean => { const at = now() if (isActiveAt(at)) { @@ -47,6 +67,7 @@ export function installTerminalImeCompositionTracker( return { isActive: () => active, isCandidateKeyGuardActive, + isHangulPreedit: () => isHangulPreeditAt(now()), dispose: () => undefined } } @@ -56,6 +77,8 @@ export function installTerminalImeCompositionTracker( lastCompositionEventAt = now() compositionEndedAt = null sawEmptyCompositionUpdate = false + // Why safe: the following compositionupdate re-reads the preedit script. + hangulPreedit = false } const updateComposition = (event: Event): void => { lastCompositionEventAt = now() @@ -69,6 +92,7 @@ export function installTerminalImeCompositionTracker( sawEmptyCompositionUpdate = true return } + hangulPreedit = HANGUL_PREEDIT_PATTERN.test(event.data) active = true } const handleCompositionEnd = (): void => { @@ -93,6 +117,7 @@ export function installTerminalImeCompositionTracker( lastCompositionEventAt = null compositionEndedAt = null sawEmptyCompositionUpdate = false + hangulPreedit = false } terminalElement.addEventListener('compositionstart', markActive, true) @@ -104,6 +129,7 @@ export function installTerminalImeCompositionTracker( return { isActive: () => isActiveAt(now()), isCandidateKeyGuardActive, + isHangulPreedit: () => isHangulPreeditAt(now()), dispose: () => { terminalElement.removeEventListener('compositionstart', markActive, true) terminalElement.removeEventListener('compositionupdate', updateComposition, true) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-hangul-terminating-digit.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-hangul-terminating-digit.test.ts new file mode 100644 index 00000000000..30e8782d0b8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-hangul-terminating-digit.test.ts @@ -0,0 +1,203 @@ +// @vitest-environment happy-dom +/** + * A digit that terminates a Hangul composition must reach the pty (#15299: typing `아1` in the + * integrated terminal on Wayland produces `아`). + * + * The Linux candidate-digit guards exist for Sogou/fcitx Pinyin, where a bare digit picks a + * numbered candidate and must never be typed (#7543, #8241). Only the orphan-keyup guard is at + * fault here: it arms off a lone letter keyup, cannot see which engine produced it, and spends + * its one suppression on the key that ends a Korean syllable. The composition-window guards stay + * as they are — ibus-hangul's Hanja lookup table does index candidates by digit, over a live + * preedit those guards already own. + * + * The composition shape below follows the recorded ibus-hangul trace + * (`__fixtures__/ibus-hangul-mixed-ascii-terminal-trace.json`): non-empty preedit updates, then + * compositionend, then the commit's own `insertText`. Neither recording leaves an empty + * compositionupdate standing before the digit, so the 250ms post-composition window never arms + * for this bug and no test may lean on it. + */ +import { describe, expect, it } from 'vitest' +import { + installTerminalImeCompositionTracker, + TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS, + type TerminalImeCompositionTracker +} from './terminal-ime-composition-tracker' +import { createTerminalImeLinuxCandidateState } from './terminal-ime-linux-candidate-state' +import { + shouldPreventDefaultTerminalImeCandidateKey, + shouldSuppressTerminalImeKeyboardEvent, + type XtermImeKeyboardOptions +} from './xterm-bypass-policy' +import { event } from './xterm-bypass-event-fixture' + +type ImeHarness = { + tracker: TerminalImeCompositionTracker + element: HTMLElement + candidateState: ReturnType + advance: (ms: number) => void + dispose: () => void +} + +function installImeHarness(): ImeHarness { + let time = 1_000 + const element = document.createElement('div') + document.body.appendChild(element) + const tracker = installTerminalImeCompositionTracker(element, { now: () => time }) + return { + tracker, + element, + candidateState: createTerminalImeLinuxCandidateState(() => time), + advance: (ms) => { + time += ms + }, + dispose: () => { + tracker.dispose() + element.remove() + } + } +} + +function dispatchComposition(element: HTMLElement, type: string, data: string): void { + const composition = new CompositionEvent(type, { bubbles: true }) + Object.defineProperty(composition, 'data', { value: data }) + element.dispatchEvent(composition) +} + +function dispatchInput(element: HTMLElement, inputType: string, data: string | null): void { + const input = new InputEvent('input', { bubbles: true }) + Object.defineProperty(input, 'inputType', { value: inputType }) + Object.defineProperty(input, 'data', { value: data }) + element.dispatchEvent(input) +} + +/** Drives a Hangul syllable into the tracker the way the recorded ibus trace reports one. */ +function composeHangulSyllable(element: HTMLElement): void { + dispatchComposition(element, 'compositionstart', '') + dispatchComposition(element, 'compositionupdate', '아') + dispatchInput(element, 'insertCompositionText', '아') + dispatchComposition(element, 'compositionend', '아') + dispatchInput(element, 'insertText', '아') +} + +/** + * Arms the #8241 orphan-digit window the way the Wayland input-method grab does: it swallows the + * jamo keydown, so only the release reaches the page and reads as a legacy single-letter commit. + */ +function armOrphanCandidateDigitWindow(harness: ImeHarness): void { + const jamoKeyup = event({ type: 'keyup', key: 'k', code: 'KeyK', keyCode: 75 }) + harness.candidateState.observeKeyboardEvent( + jamoKeyup, + harness.candidateState.classifyKeyboardEvent(jamoKeyup) + ) +} + +const digitKeydown = event({ type: 'keydown', key: '1', code: 'Digit1', keyCode: 49 }) + +/** The gate as `use-terminal-pane-lifecycle` assembles it for a Linux pane. */ +function imeKeyboardOptions(harness: ImeHarness): XtermImeKeyboardOptions { + return { + compositionActive: harness.tracker.isActive(), + candidateKeyGuardActive: harness.tracker.isCandidateKeyGuardActive(), + pendingCandidateKeyReleaseActive: false, + linuxOrphanCandidateDigitGuardActive: + harness.candidateState.classifyKeyboardEvent(digitKeydown).candidateDigitGuardActive, + hangulPreedit: harness.tracker.isHangulPreedit(), + isMac: false, + isLinux: true + } +} + +function suppressesDigit(harness: ImeHarness): boolean { + return shouldSuppressTerminalImeKeyboardEvent(digitKeydown, imeKeyboardOptions(harness)) +} + +describe('a digit that terminates a Hangul composition', () => { + it('survives the orphan-keyup window a compositor-grabbed Hangul keypress opens', () => { + const harness = installImeHarness() + composeHangulSyllable(harness.element) + + // The bug is exclusively after the commit, on the orphan path: no composition window is open. + expect(harness.tracker.isActive()).toBe(false) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + + armOrphanCandidateDigitWindow(harness) + harness.advance(120) + + // The orphan window still arms — it cannot see the composition. The policy is what declines + // to spend it on a digit that a Hangul preedit has already claimed as literal text. + expect(imeKeyboardOptions(harness).linuxOrphanCandidateDigitGuardActive).toBe(true) + expect(suppressesDigit(harness)).toBe(false) + expect( + shouldPreventDefaultTerminalImeCandidateKey(digitKeydown, imeKeyboardOptions(harness)) + ).toBe(false) + + harness.dispose() + }) + + it("stays literal across the commit's own insertText", () => { + const harness = installImeHarness() + composeHangulSyllable(harness.element) + + // Both recordings deliver `insertText` for the committed syllable; on Wayland the digit can + // follow it, so the commit must not be read as "ordinary typing resumed". + expect(harness.tracker.isHangulPreedit()).toBe(true) + + harness.dispose() + }) + + it('keeps the digit IME-owned while the Hangul preedit is still live', () => { + const harness = installImeHarness() + dispatchComposition(harness.element, 'compositionstart', '') + dispatchComposition(harness.element, 'compositionupdate', '아') + dispatchInput(harness.element, 'insertCompositionText', '아') + + // ibus-hangul's Hanja conversion (Hanja key / F9) puts a numbered lookup table over a live + // Hangul preedit and its symbol table behaves the same, so digits are selectors there. + expect(harness.tracker.isHangulPreedit()).toBe(true) + expect(suppressesDigit(harness)).toBe(true) + + harness.dispose() + }) + + it('stops claiming digits once the Hangul preedit has gone stale', () => { + const harness = installImeHarness() + composeHangulSyllable(harness.element) + + // Switching engine (Hangul -> Pinyin) moves no DOM focus, and the #8241 orphan path emits no + // composition or input events at all, so only expiry can retire the Hangul classification. + harness.advance(TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS + 1) + armOrphanCandidateDigitWindow(harness) + + expect(harness.tracker.isHangulPreedit()).toBe(false) + expect(suppressesDigit(harness)).toBe(true) + + harness.dispose() + }) + + it('re-reads the preedit script when the next composition starts', () => { + const harness = installImeHarness() + composeHangulSyllable(harness.element) + dispatchComposition(harness.element, 'compositionstart', '') + + // compositionstart carries no data, so the classification cannot survive into a session that + // may belong to another engine; the following compositionupdate re-establishes it. + expect(harness.tracker.isHangulPreedit()).toBe(false) + + harness.dispose() + }) + + it('still lets a Pinyin preedit claim its numbered candidate digit', () => { + const harness = installImeHarness() + + // Sogou/fcitx Pinyin picks candidates by digit over a Latin preedit (#7543/#8241), and + // delivers the selector as a plain keydown. That must stay IME-owned. + dispatchComposition(harness.element, 'compositionstart', '') + dispatchComposition(harness.element, 'compositionupdate', 'nihao') + dispatchInput(harness.element, 'insertCompositionText', 'nihao') + + expect(harness.tracker.isHangulPreedit()).toBe(false) + expect(suppressesDigit(harness)).toBe(true) + + harness.dispose() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts index 159b73eb914..16203985686 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { openDetectedFilePath } from './terminal-link-handlers' import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures' import { @@ -58,6 +59,11 @@ describe('handleOscLink', () => { expect.objectContaining({ title: 'report.html', activate: true }) ) expect(openFilePathMock).not.toHaveBeenCalled() + // Why: the browser tab is the surface — activation must not re-seed a shell into a + // workspace whose last terminal the user closed. + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + providesInitialSurface: true + }) }) it('also opens local .htm paths in Orca browser tabs with the platform modifier', async () => { @@ -96,6 +102,10 @@ describe('handleOscLink', () => { matchLength: 0 }) expect(openFilePathMock).not.toHaveBeenCalled() + // Why: the editor file is the surface — the cross-worktree jump must not add a shell. + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + providesInitialSurface: true + }) }) it('preserves explicit column for Orca opens from :line:column links', async () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts b/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts index fb0bf470004..b78a3a07e5c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts @@ -4,7 +4,7 @@ import { useAppStore } from '@/store' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher' import { subscribeToPtyExit } from './pty-dispatcher' -import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' +import { discardPreHandlerPtyState, hasPreHandlerPtyExit } from './pty-pre-handler-buffer' import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' import { isParkRestorableTerminalPty, @@ -34,6 +34,17 @@ export function startParkedPtyWatcher(args: { const state = useAppStore.getState() const ptyId = pane.ptyId // Why: the tab model can change after the park decision, and legacy leaf ids make pane keys throw. + // Why: the pane's primary exit handler is gone from unmount and this sidecar + // arrives a passive effect later, so an exit landing in between is buffered + // and replayed to nobody. Registering would pin a dead PTY as a live parked + // owner, and the runtime graph publishes its leaf on exactly that claim. + if (ptyId && !entry.disposersByPtyId.has(ptyId) && hasPreHandlerPtyExit(ptyId)) { + // Mirror handlePtyExit's first act: no live pane will ever overwrite this + // slot, so leaving it would strand a dead pane's last title (a 'working' + // agent title pins worktree status) until reveal or close. + state.clearRuntimePaneTitle(tab.id, pane.paneId) + return + } if ( !ptyId || entry.disposersByPtyId.has(ptyId) || diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index a129f033176..cd0448180ba 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -48,9 +48,13 @@ vi.mock('./pty-dispatcher', () => ({ const consumePreHandlerPtyState = vi.fn() vi.mock('./pty-pre-handler-buffer', () => ({ - discardPreHandlerPtyState: (ptyId: string) => consumePreHandlerPtyState(ptyId) + discardPreHandlerPtyState: (ptyId: string) => consumePreHandlerPtyState(ptyId), + hasPreHandlerPtyExit: (ptyId: string) => unownedExitPtyIds.has(ptyId) })) +/** PTYs whose exit was delivered with no owning handler — the park handoff gap. */ +const unownedExitPtyIds = new Set() + type CloseTerminalTabOptions = { captureRecentlyClosed?: boolean hostCloseReason?: string @@ -110,6 +114,7 @@ import { captureParkedTerminalPaneCandidates, disposeParkedTerminalWatchersForPtyIds, disposeParkedTerminalWatchersForWorktree, + collectParkedTerminalWatcherPtyIds, getParkedTerminalWatcherTabIds, pruneParkedTerminalWatchers, shouldDeferParkedPtyExitTabClose, @@ -164,6 +169,7 @@ describe('terminal-parked-tab-watchers', () => { }) afterEach(() => { + unownedExitPtyIds.clear() // Module-level registries persist across tests; clear them through the // public prune path so each test starts from an empty parked state. pruneParkedTerminalWatchers(new Set()) @@ -229,6 +235,23 @@ describe('terminal-parked-tab-watchers', () => { expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) }) + it('never starts a watcher for a PTY that exited into the park handoff gap', () => { + // The pane's primary exit handler is gone from unmount and this sidecar + // arrives a passive effect later, so an exit landing between them is + // buffered and replayed to nobody. Registering anyway would make the + // registry claim a dead PTY is a live parked owner, and the runtime graph + // publishes its leaf on exactly that claim (STA-2854). + unownedExitPtyIds.add(PTY_ID) + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked({ tabs: [{ id: TAB_ID, ptyId: PTY_ID }] }) + + expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled() + expect(collectParkedTerminalWatcherPtyIds().has(PTY_ID)).toBe(false) + // No live pane will ever overwrite this slot again, so a stranded + // 'working' title would pin worktree status forever. + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 1) + }) + it('starts a fact watcher for snapshot-capable paired PTYs', () => { mockStoreState.runtimeStatusByEnvironmentId.set('env-1', { status: { capabilities: ['terminal.paired-parking.v1'] }, diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 42de06cac37..d0c36961377 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -33,6 +33,7 @@ import { // Why: re-export so callers keep one import surface; the registry split only breaks the store-slice import cycle. export { captureParkedTerminalPaneCandidates, + collectParkedTerminalWatcherPtyIds, disposeAllParkedTerminalWatchers, disposeRemovedWorktreeParkedTerminalWatchers, disposeParkedTerminalWatchersForPtyIds, diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts index b2fb9b3de07..f0184b2b7aa 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -7,7 +7,8 @@ * mid-evaluation. Keeping the maps and pure disposal here lets the slice * import cycle-free, mirroring how pty-dispatcher exports its handler maps. */ -import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' +import { discardPreHandlerPtyState, hasPreHandlerPtyExit } from './pty-pre-handler-buffer' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' export type ParkedTerminalPaneCapture = { @@ -67,6 +68,57 @@ export function isTerminalTabParked(tabId: string): boolean { return (parkedWatchersByTabId.get(tabId)?.disposersByPtyId.size ?? 0) > 0 } +/** + * PTYs a live parked watcher owns, and can therefore prove are still alive. + * The runtime graph needs this to keep publishing an unmounted pane's leaf + * (STA-2854: a dropped leaf invalidates the terminal handle every paired + * subscriber of that terminal is bound to). + * + * Built in one pass and reused for a whole publication: the caller checks it + * once per saved leaf across every tab of every worktree, so a per-PTY scan of + * the registry would be quadratic in a large workspace. Parked tabs are NOT + * bounded by the hot-retain limits — those bound what stays warm, not what + * parks — so this can legitimately hold thousands of entries. + * + * Reads `disposersByPtyId`, never `paneIdByPtyId`: exit and per-PTY disposal + * delete only the disposer and deliberately keep the pane-id slot so the dead + * leaf's runtime title can still be cleared. + * + * Excludes remote-runtime PTYs (detected through the store-free shared id + * module — this file must stay importable from a store slice). startParkedPtyWatcher installs no PTY-exit + * subscription for them, and the parked fact stream carries no exit fact, so + * their disposer outlives the terminal and proves nothing about liveness. + * + * Also excludes a PTY holding an unowned buffered exit. startParkedPtyWatcher + * refuses to register one, so this only catches an exit that raced an existing + * registration; keeping the check here costs one lookup per watched PTY. + */ +export function collectParkedTerminalWatcherPtyIds(): Set { + const ptyIds = new Set() + for (const entry of parkedWatchersByTabId.values()) { + for (const ptyId of entry.disposersByPtyId.keys()) { + if (parseRemoteRuntimePtyId(ptyId) === null && !hasPreHandlerPtyExit(ptyId)) { + ptyIds.add(ptyId) + } + } + } + return ptyIds +} + +/** + * Pane ids the parked watchers are actually using, by PTY. + * + * Read from the watcher entry rather than the unmount capture: a parked tab + * whose layout gains a leaf gets a watcher from the layout-derived fallback, + * which the capture never learns about. The entry covers both, so a parked leaf + * publishes the identity main routes split/close and the paneKey fallback + * through, and indexes the runtime-title slot the watcher writes — instead of a + * fabricated ordinal that can name a pane PaneManager already retired. + */ +export function getParkedTerminalWatcherPaneIdsByPtyId(tabId: string): Map { + return new Map(parkedWatchersByTabId.get(tabId)?.paneIdByPtyId ?? []) +} + export function disposeParkedTabWatchers(tabId: string): void { const entry = parkedWatchersByTabId.get(tabId) if (!entry) { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 08210d1095e..f1d462d3af1 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -1057,6 +1057,7 @@ export function useTerminalPaneLifecycle({ pendingCandidateKeyReleaseActive: pendingCandidateReleaseGuardActive, linuxOrphanCandidateDigitGuardActive: linuxCandidateClassification.candidateDigitGuardActive, + hangulPreedit: imeCompositionTracker.isHangulPreedit(), isMac, isLinux } diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index d09ffd4b039..6e8c2753802 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -50,6 +50,11 @@ export type XtermImeKeyboardOptions = { /** True for the narrow Linux path where the IME emits an orphaned letter * keyup but no composition/input events before its candidate digit. */ linuxOrphanCandidateDigitGuardActive?: boolean + /** True when the most recent preedit was Hangul, where a digit ends the + * syllable and is literal text. Only the orphan-keyup guard is barred from + * claiming it (#15299): ibus-hangul's Hanja lookup table does index by digit, + * but only over a live preedit the composition guards already own. */ + hangulPreedit?: boolean // Required so no caller silently falls back to non-mac 229 suppression, // which re-swallows the first key after a macOS IME input-source switch. isMac: boolean @@ -89,6 +94,20 @@ function isXtermHandledKeyEvent(type: string): boolean { return type === 'keydown' || type === 'keyup' } +/** Returns whether the Linux orphan-keyup window may claim this digit. */ +function claimsOrphanCandidateDigit( + event: XtermBypassEvent, + options: XtermImeKeyboardOptions +): boolean { + return ( + options.linuxOrphanCandidateDigitGuardActive === true && + isTerminalImeCandidateDigitKeyEvent(event) && + // Why: the orphan window arms off a bare keyup and cannot see which engine + // produced it, so a Hangul syllable's terminating digit must opt out. + options.hangulPreedit !== true + ) +} + /** Returns whether xterm must not process an IME-owned keyboard event. */ export function shouldSuppressTerminalImeKeyboardEvent( event: XtermBypassEvent, @@ -98,17 +117,14 @@ export function shouldSuppressTerminalImeKeyboardEvent( compositionActive, candidateKeyGuardActive, pendingCandidateKeyReleaseActive, - linuxOrphanCandidateDigitGuardActive = false, isMac, isLinux } = options - const suppressOrphanCandidateDigit = - isLinux && linuxOrphanCandidateDigitGuardActive && isTerminalImeCandidateDigitKeyEvent(event) const suppressCandidateKey = isLinux && (pendingCandidateKeyReleaseActive || (candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event)) || - suppressOrphanCandidateDigit) + claimsOrphanCandidateDigit(event, options)) if (event.type === 'keypress') { // Why: a suppressed candidate keydown is not preventDefault-ed by xterm, // so its native keypress still fires and _keyPress would forward the @@ -147,8 +163,7 @@ export function shouldPreventDefaultTerminalImeCandidateKey( event.type === 'keydown' && options.isLinux && ((options.candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event)) || - (options.linuxOrphanCandidateDigitGuardActive === true && - isTerminalImeCandidateDigitKeyEvent(event))) + claimsOrphanCandidateDigit(event, options)) ) } diff --git a/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts b/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts new file mode 100644 index 00000000000..0d3aaeb58c2 --- /dev/null +++ b/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Why: Terminal.tsx is the passive half of the closed-last-terminal contract — it must keep +// honouring the tombstone while explicit activation re-seeds. Mounting a 2892-line component +// to prove that costs far more than it returns, and the e2e that covers it only runs when an +// e2e spec changes. Assert the wiring as source text instead, the same way app-startup-routing +// and the startup ordering tests pin call order they cannot cheaply execute. +const TERMINAL_PATH = 'src/renderer/src/components/Terminal.tsx' + +function readSource(relativePath: string): string { + return readFileSync(join(process.cwd(), relativePath), 'utf8') +} + +describe('Terminal auto-create wiring', () => { + const source = readSource(TERMINAL_PATH) + + it('derives the tombstone from the active worktree row', () => { + expect(source).toContain('Object.hasOwn(tabsByWorktree, activeWorktreeId)') + }) + + it('passes that derivation into shouldAutoCreateInitialTerminal', () => { + // Why: the regression #14590 fixed is re-introduced by dropping this second argument, and a + // literal here would pin nothing — it has to be the identifier the effect actually derives. + // Exactly one call site: a second (flagless) call could otherwise hide behind this one. + expect( + source.split('shouldAutoCreateInitialTerminal(').length - 1, + 'expected exactly one shouldAutoCreateInitialTerminal call in Terminal.tsx' + ).toBe(1) + expect(source).toContain( + 'shouldAutoCreateInitialTerminal(renderableTabCount, activeWorktreeHasTerminalState)' + ) + }) + + it('keeps that derivation in the effect dependencies', () => { + // Why: without the dep the effect never re-runs when the row appears or disappears. + // Anchored to the neighbouring dep so a stray trailing comma elsewhere can't satisfy it. + expect(source).toContain('activeWorktreeId,\n activeWorktreeHasTerminalState,') + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx index 6779c05f6e0..e0f5be05afc 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx @@ -43,6 +43,7 @@ describe('WorkspaceCleanupCandidateList', () => { root?.render( candidate.worktreeId} scrollElement={null} renderRow={(candidate) => { rendered.push(candidate.worktreeId) @@ -68,6 +69,7 @@ describe('WorkspaceCleanupCandidateList', () => { root?.render( candidate.worktreeId} scrollElement={scrollElement} renderRow={(candidate) =>
} /> @@ -82,6 +84,40 @@ describe('WorkspaceCleanupCandidateList', () => { expect(windowed || mounted === 0).toBe(true) }) + it('keys windowed rows through getRowKey, so same-id hosts stay distinct', () => { + // Two hosts publish the same worktreeId; only the caller knows the host, so + // the list must ask for the key instead of reaching for the colliding id. + const rows = [ + ...makeRows(WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS - 2), + makeCandidate({ worktreeId: 'shared', executionHostId: 'local' }), + makeCandidate({ worktreeId: 'shared', executionHostId: 'ssh:box' }) + ] + const scrollElement = document.createElement('div') + const asked: string[] = [] + + act(() => { + root?.render( + { + const key = `${candidate.executionHostId ?? ''}|${candidate.worktreeId}` + asked.push(key) + return key + }} + scrollElement={scrollElement} + renderRow={(_candidate, index) =>
} + /> + ) + }) + + expect(asked.length).toBeGreaterThan(0) + expect(asked).toContain('local|shared') + expect(asked).toContain('ssh:box|shared') + expect(new Set(asked).size).toBe( + new Set(rows.map((r) => `${r.executionHostId ?? ''}|${r.worktreeId}`)).size + ) + }) + it('memoizes CandidateRow so unchanged rows skip re-render', () => { expect((CandidateRow as { $$typeof?: symbol }).$$typeof).toBe(Symbol.for('react.memo')) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx index 7cfd365bc60..8ad251fc570 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx @@ -24,6 +24,7 @@ const WORKSPACE_CLEANUP_ROW_OVERSCAN = 8 export function WorkspaceCleanupCandidateList({ rows, renderRow, + getRowKey, estimatedRowHeight = WORKSPACE_CLEANUP_ROW_ESTIMATE_PX, // Why: a state-held element, not a ref — the ScrollArea viewport is not // attached when this component first mounts, so a ref would leave the @@ -32,6 +33,10 @@ export function WorkspaceCleanupCandidateList React.ReactNode + // Why: required — the same worktreeId exists on two hosts, so a row key must + // be host-qualified by the caller that knows the host. Omitting it is a + // compile error rather than a silent fall back to the colliding bare id. + getRowKey: (row: Row) => string estimatedRowHeight?: number scrollElement: HTMLDivElement | null }): React.JSX.Element { @@ -45,7 +50,10 @@ export function WorkspaceCleanupCandidateList rows[index]?.worktreeId ?? index + getItemKey: (index) => { + const row = rows[index] + return row === undefined ? index : getRowKey(row) + } }) if (!virtualize) { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx index 7c0cb8d9ef1..ee068fe3f21 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx @@ -140,6 +140,7 @@ export function WorkspaceCleanupConfirmRemove({ ( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx index babc5d287e8..6a1610b9763 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx @@ -39,6 +39,7 @@ function renderRows(rows: readonly WorkspaceCleanupFacets[]): void { root?.render( row.worktreeId} scrollElement={null} renderRow={(row, index) => ( row.identity} scrollElement={props.scrollElement} renderRow={(row, index) => ( { mocks.resolveSourceControlLaunchPlatform.mockReturnValue('darwin') }) + it('activates the attached workspace as a surface-providing caller', async () => { + const { startFixChecksAgent } = await import('./fix-checks-agent-launch') + + await expect( + startFixChecksAgent({ + repoId: 'repo-1', + worktreeId: 'wt-1', + basePrompt: 'Fix checks', + launchSource: 'task_page' + }) + ).resolves.toBe(true) + + // Why: launchAgentInNewTab creates the surface; without the opt-out, activation would + // also re-seed a shell in a closed-last-terminal workspace. + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + providesInitialSurface: true + }) + }) + it('fails without launching when the requested worktree is missing', async () => { const { startFixChecksAgent } = await import('./fix-checks-agent-launch') diff --git a/src/renderer/src/lib/fix-checks-agent-launch.ts b/src/renderer/src/lib/fix-checks-agent-launch.ts index 5c6cd7ffca1..1a052fc2782 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.ts @@ -186,7 +186,8 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis toast.error(agentArgsPlan.error) return false } - if (!activateAndRevealWorktree(targetWorktreeId)) { + // launchAgentInNewTab below creates the surface; seeding here would add a stray shell. + if (!activateAndRevealWorktree(targetWorktreeId, { providesInitialSurface: true })) { toast.error( translate( 'auto.lib.fix.checks.agent.launch.03c1d61f83', diff --git a/src/renderer/src/lib/workspace-port-actions.ts b/src/renderer/src/lib/workspace-port-actions.ts index a529bc2d31b..cca201234a4 100644 --- a/src/renderer/src/lib/workspace-port-actions.ts +++ b/src/renderer/src/lib/workspace-port-actions.ts @@ -124,7 +124,9 @@ export async function openWorkspacePortInBrowser(args: { if (!worktreeId) { return { ok: false, reason: 'No workspace selected for the browser.' } } - activateAndRevealWorktree(worktreeId) + // Why: the browser tab opened below is this jump's surface; seeding a shell would add a + // PTY the user never asked for in a workspace whose last terminal they closed. + activateAndRevealWorktree(worktreeId, { providesInitialSurface: true }) if (args.runtimeTarget.kind === 'environment') { try { await assertRuntimeEnvironmentCapability( diff --git a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts new file mode 100644 index 00000000000..342770132ac --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from './worktree-activation' +import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import { + makeCreatedAgentWorktree as makeWorktree, + seedEmptyActivatableWorktree +} from '@/lib/worktree-activation-created-agent-test-state' + +const initialAppStoreState = useAppStore.getState() + +afterEach(() => { + vi.unstubAllGlobals() + useAppStore.setState(initialAppStoreState, true) +}) + +/** The state a workspace lands in once its last terminal is closed: the row survives as an + * explicit empty list rather than disappearing. */ +function seedClosedLastTerminal(worktreeId: string): void { + useAppStore.setState({ tabsByWorktree: { [worktreeId]: [] } }) + const { renderableTabCount } = useAppStore.getState().reconcileWorktreeTabModel(worktreeId) + expect(renderableTabCount).toBe(0) +} + +describe('activating a workspace whose last terminal was closed', () => { + it('re-seeds a terminal when the workspace is opened from elsewhere', () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + seedClosedLastTerminal(worktree.id) + + const result = activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + + expect(result).not.toBe(false) + expect(result === false ? null : result.primaryTabId).toBeTruthy() + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) + }) + + // Why: hydration restores an emptied workspace as active, so the user is already looking at the + // blank pane when they click its row. Suppressing the re-seed there strands them on the bug. + it('re-seeds when the restored active workspace is reopened on the same host', () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + seedClosedLastTerminal(worktree.id) + useAppStore.setState({ + activeWorktreeId: worktree.id, + activeWorkspaceExecutionHostId: 'local', + activeView: 'terminal' + }) + + activateAndRevealWorktree(worktree.id, { + executionHostId: 'local', + notifyHostRuntime: false + }) + + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) + }) + + // Why: entry points disagree about whether to pass a host for the same local workspace — the + // sidebar derives 'local', the Cmd+J palette passes nothing. Re-seeding no longer reads the + // host at all; this guards against reintroducing a host-sensitive carve-out. + it('re-seeds identically whether or not the caller passes an execution host', () => { + for (const opts of [{}, { executionHostId: 'local' as const }]) { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + seedClosedLastTerminal(worktree.id) + useAppStore.setState({ + activeWorktreeId: worktree.id, + activeWorkspaceExecutionHostId: 'local', + activeView: 'terminal' + }) + + activateAndRevealWorktree(worktree.id, { ...opts, notifyHostRuntime: false }) + + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) + } + }) + + // Why: terminal file links and check annotations activate only to route history before they + // open an editor tab. Seeding there hands the user a shell they never asked for and erases the + // tombstone permanently. See terminal-file-open-routing.ts and check-annotation-open.ts. + it('leaves the row empty when the caller opens its own surface', () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + seedClosedLastTerminal(worktree.id) + + const result = activateAndRevealWorktree(worktree.id, { + providesInitialSurface: true, + notifyHostRuntime: false + }) + + expect(result).not.toBe(false) + expect(result === false ? null : result.primaryTabId).toBeNull() + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([]) + }) + + it('leaves the row empty for startup hydration, which never opts into re-seeding', () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + seedClosedLastTerminal(worktree.id) + + const tabId = ensureWorktreeHasInitialTerminal(useAppStore.getState(), worktree.id) + + expect(tabId).toBeNull() + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([]) + }) + + // Why: this passes ahead of the tombstone check — a renderable browser tab short-circuits + // `shouldAutoCreateInitialTerminal` — so it guards `renderableTabCount`, not the re-seed flag. + it('does not add a terminal to a workspace that still renders a browser tab', () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + seedClosedLastTerminal(worktree.id) + useAppStore.setState({ + browserTabsByWorktree: { + [worktree.id]: [ + { + id: 'browser-1', + worktreeId: worktree.id, + url: 'https://example.com', + title: 'example', + sortOrder: 0, + createdAt: 1 + } + ] + }, + unifiedTabsByWorktree: { + [worktree.id]: [ + { + id: 'browser-1', + entityId: 'browser-1', + groupId: 'group-1', + worktreeId: worktree.id, + contentType: 'browser', + label: 'example', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + [worktree.id]: [ + { + id: 'group-1', + worktreeId: worktree.id, + activeTabId: 'browser-1', + tabOrder: ['browser-1'] + } + ] + }, + activeGroupIdByWorktree: { [worktree.id]: 'group-1' } + } as unknown as Partial>) + expect( + useAppStore.getState().reconcileWorktreeTabModel(worktree.id).renderableTabCount + ).toBeGreaterThan(0) + + activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([]) + }) +}) + +const FOLDER_ID = 'folder-1' +const FOLDER_KEY = folderWorkspaceKey(FOLDER_ID) +const SSH_HOST_ID = toSshExecutionHostId('conn-1') + +/** One folder id resolves to a different `FolderWorkspace` per host while both share a single + * `tabsByWorktree[FOLDER_KEY]` row. */ +function seedEmptiedFolderWorkspaceOnTwoHosts(): void { + const base = { + id: FOLDER_ID, + projectGroupId: 'group-1', + name: 'notes', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0 + } + useAppStore.setState({ + folderWorkspaces: [ + { ...base, folderPath: '/local/notes', executionHostId: 'local' }, + // Why: an SSH host, not a runtime one — runtime-owned workspaces return early from + // `ensureWorktreeHasInitialTerminal` because the host owns terminal creation. + { ...base, folderPath: '/remote/notes', executionHostId: SSH_HOST_ID, connectionId: 'conn-1' } + ], + activeView: 'terminal', + tabsByWorktree: { [FOLDER_KEY]: [] }, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + getFreshFolderWorkspacePathStatus: () => ({ exists: true }), + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } as unknown as Partial>) + expect(useAppStore.getState().reconcileWorktreeTabModel(FOLDER_KEY).renderableTabCount).toBe(0) +} + +describe('activating a folder workspace whose last terminal was closed', () => { + it('re-seeds a terminal when the workspace is opened', () => { + seedEmptiedFolderWorkspaceOnTwoHosts() + + const result = activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: 'local' }) + + expect(result).not.toBe(false) + expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1) + }) + + it('re-seeds when the restored active folder workspace is reopened on the same host', () => { + seedEmptiedFolderWorkspaceOnTwoHosts() + useAppStore.setState({ + activeWorktreeId: FOLDER_KEY, + activeWorkspaceExecutionHostId: 'local' + }) + + activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: 'local' }) + + expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1) + }) + + // Why: the opt-out must mean the same thing on both workspace shapes, or routing a + // file link through a folder workspace would silently regress to seeding a shell. + it('leaves the row empty when the caller opens its own surface', () => { + seedEmptiedFolderWorkspaceOnTwoHosts() + + const result = activateAndRevealFolderWorkspace(FOLDER_ID, { + executionHostId: 'local', + providesInitialSurface: true + }) + + expect(result).not.toBe(false) + expect(result === false ? null : result.primaryTabId).toBeNull() + expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toEqual([]) + }) + + // Why: this asserts the row is re-seeded, NOT that the tab lands on the requested host. + // getFolderWorkspaceConnectionId is host-blind (folder-workspace-connection.ts:68 takes the + // first id match), so the created tab resolves local even here. That defect is pre-existing — + // it reproduces with no row at all, on the ordinary auto-create path — and is out of scope. + it('re-seeds the shared row when opening the same folder id on a different host', () => { + seedEmptiedFolderWorkspaceOnTwoHosts() + useAppStore.setState({ + activeWorktreeId: FOLDER_KEY, + activeWorkspaceExecutionHostId: 'local' + }) + + const result = activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: SSH_HOST_ID }) + + expect(result).not.toBe(false) + expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/lib/worktree-activation-store-contract.ts b/src/renderer/src/lib/worktree-activation-store-contract.ts index d6cac74b1f0..4217f412e5d 100644 --- a/src/renderer/src/lib/worktree-activation-store-contract.ts +++ b/src/renderer/src/lib/worktree-activation-store-contract.ts @@ -63,4 +63,10 @@ export type WorktreeActivationStore = Partial & { export type InitialTerminalOptions = { activateCreatedTabs?: boolean backendStartupTerminalSpawned?: boolean + /** Why: an explicit empty terminal row is a "user closed the last tab" tombstone. Startup + * hydration honours it through Terminal.tsx's passive auto-create (which never calls this + * function), but opening the workspace on purpose (sidebar, palette, automation "Resume + * workspace", wake) has to hand back a usable surface. Activation sets this unless the + * caller says it provides its own surface; background worktree creation leaves it unset. */ + reseedEmptiedWorkspace?: boolean } diff --git a/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts b/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts new file mode 100644 index 00000000000..a9bf9a34015 --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts @@ -0,0 +1,59 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Why: `providesInitialSurface: true` is invisible to behavior tests — every opted-out caller +// still works with the flag deleted, it just re-seeds a shell the user never asked for in a +// closed-last-terminal workspace. Three review rounds each found a missed caller, so this is +// the census: activation callers that open their own surface (editor, browser, diff, agent tab) +// must appear here, and adding or removing an opt-out anywhere must update this list. +const SURFACE_PROVIDING_CALLERS = [ + 'src/renderer/src/components/editor/check-annotation-open.ts', + 'src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx', + 'src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts', + 'src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts', + 'src/renderer/src/lib/fix-checks-agent-launch.ts', + 'src/renderer/src/lib/workspace-port-actions.ts' +] + +// The activation seam itself: declares the option and forwards it into the tombstone gate. +const SEAM_FILES = ['src/renderer/src/lib/worktree-activation.ts'] + +function listSourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const fullPath = join(dir, entry.name) + if (entry.isDirectory()) { + return listSourceFiles(fullPath) + } + if (!/\.(ts|tsx)$/.test(entry.name) || /\.test\.(ts|tsx)$/.test(entry.name)) { + return [] + } + return [fullPath] + }) +} + +// Why: bound to an actual activation call so a comment or dead code containing the flag +// text cannot satisfy the census, and a variable-valued flag cannot hide in it. Comments +// are stripped first — commenting the flag out in place must fail this test. +const ACTIVATION_CALL_WITH_OPT_OUT = + /activateAndReveal(?:Worktree|FolderWorkspace|Workspace)\((?:[^()]|\([^()]*\))*?providesInitialSurface: true/ + +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') +} + +describe('providesInitialSurface caller wiring', () => { + it.each(SURFACE_PROVIDING_CALLERS)('%s opts out of tombstone re-seeding', (relativePath) => { + const source = stripComments(readFileSync(join(process.cwd(), relativePath), 'utf8')) + expect(source).toMatch(ACTIVATION_CALL_WITH_OPT_OUT) + }) + + it('the census matches every mention under src/', () => { + const root = join(process.cwd(), 'src') + const mentions = listSourceFiles(root) + .filter((filePath) => readFileSync(filePath, 'utf8').includes('providesInitialSurface')) + .map((filePath) => relative(process.cwd(), filePath).split(sep).join('/')) + .sort() + expect(mentions).toEqual([...SURFACE_PROVIDING_CALLERS, ...SEAM_FILES].sort()) + }) +}) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index b369dacd754..12f0949ae15 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -14,10 +14,8 @@ import { setWorktreeNavActivator, setWorktreeNavViewActivator } from '@/store/slices/worktree-nav-history' -import { - gateWorktreeAgentActivation, - workspaceHasSleepingAgentSessions -} from '@/lib/worktree-agent-activation-gate' +import { gateWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate' +import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope' @@ -49,7 +47,8 @@ export type ActivateAndRevealResult = { function ensureFolderWorkspaceInitialTerminal( folderWorkspace: FolderWorkspace, - startup?: WorktreeStartupPayload + startup?: WorktreeStartupPayload, + providesInitialSurface?: boolean ): string | null { const state = useAppStore.getState() const workspaceKey = folderWorkspaceKey(folderWorkspace.id) @@ -59,7 +58,8 @@ function ensureFolderWorkspaceInitialTerminal( startup, undefined, undefined, - undefined + undefined, + { reseedEmptiedWorkspace: providesInitialSurface !== true } ) return primaryTabId } @@ -79,6 +79,8 @@ export function activateAndRevealFolderWorkspace( startup?: WorktreeStartupPayload runtimeEnvironmentId?: string | null executionHostId?: ExecutionHostId + /** See activateAndRevealWorktree — same contract for folder workspaces. */ + providesInitialSurface?: boolean } ): ActivateAndRevealResult | false { const state = useAppStore.getState() @@ -128,13 +130,15 @@ export function activateAndRevealFolderWorkspace( if (!state.isNavigatingHistory) { state.recordWorktreeVisit(workspaceKey) } + // Why: same ordering as the worktree path — resume first so the seeding below sees the restored + // surface; the gate then covers only the structured inventory that is still hydrating. + resumeSleepingAgentSessionsForWorktree(workspaceKey) const shouldGateAgentActivation = !opts?.startup && - (workspaceHasSleepingAgentSessions(state, workspaceKey) || - (canInspectAgentActivationInventory() && - shouldAutoCreateInitialTerminal( - state.reconcileWorktreeTabModel(workspaceKey).renderableTabCount - ))) + canInspectAgentActivationInventory() && + shouldAutoCreateInitialTerminal( + state.reconcileWorktreeTabModel(workspaceKey).renderableTabCount + ) if (shouldGateAgentActivation) { void gateWorktreeAgentActivation(workspaceKey).then((outcome) => { if (outcome === 'empty' && useAppStore.getState().activeWorktreeId === workspaceKey) { @@ -144,7 +148,11 @@ export function activateAndRevealFolderWorkspace( } const primaryTabId = shouldGateAgentActivation ? null - : ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup) + : ensureFolderWorkspaceInitialTerminal( + folderWorkspace, + opts?.startup, + opts?.providesInitialSurface + ) if (opts?.sidebarRevealBehavior) { state.revealWorktreeInSidebar(workspaceKey, { behavior: opts.sidebarRevealBehavior }) @@ -168,6 +176,12 @@ export function activateAndRevealWorktree( revealInSidebar?: boolean executionHostId?: ExecutionHostId backendStartupTerminalSpawned?: boolean + /** Set by callers that navigate here only to open their own non-terminal surface + * (an editor file, a diff). Activation then leaves a closed-last-terminal workspace + * empty instead of adding a shell the user never asked for. Caveat: on a + * runtime-owned workspace with a live web session the host owns terminal creation, + * so ensureWebRuntimeWorktreeTerminalAfterWake may still seed one (matches main). */ + providesInitialSurface?: boolean } ): ActivateAndRevealResult | false { const state = useAppStore.getState() @@ -218,14 +232,20 @@ export function activateAndRevealWorktree( state.recordWorktreeVisit(worktreeId) } - // Why: live and structured agent inventory can hydrate after the tab model; reconcile it before an empty model authorizes a fallback terminal. + // Why: sleeping destroys the local PTY but preserves the provider session id, so waking should restore those CLI sessions automatically. + // Ordering is load-bearing: resuming synchronously creates the session's tab first, so the + // seeding below sees a renderable surface and doesn't add a bare shell next to it. + resumeSleepingAgentSessionsForWorktree(worktreeId) + + // Why: the synchronous resume above covers sleeping CLI sessions. Structured agent inventory + // hydrates asynchronously and is still invisible here, so an empty tab model can authorize a + // fallback terminal beside a chat that is about to appear. Gate on that case only. const shouldGateAgentActivation = !hasActivationWork && - (workspaceHasSleepingAgentSessions(postActivationState, worktreeId) || - (canInspectAgentActivationInventory() && - shouldAutoCreateInitialTerminal( - postActivationState.reconcileWorktreeTabModel(worktreeId).renderableTabCount - ))) + canInspectAgentActivationInventory() && + shouldAutoCreateInitialTerminal( + postActivationState.reconcileWorktreeTabModel(worktreeId).renderableTabCount + ) if (shouldGateAgentActivation) { void gateWorktreeAgentActivation(worktreeId).then((outcome) => { const currentState = useAppStore.getState() @@ -245,7 +265,10 @@ export function activateAndRevealWorktree( opts?.setup, opts?.issueCommand, opts?.defaultTabs, - opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : undefined + { + ...(opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : {}), + reseedEmptiedWorkspace: opts?.providesInitialSurface !== true + } ) if (primaryTabId && opts?.initialCwd) { useAppStore.getState().queueTabInitialCwd(primaryTabId, opts.initialCwd) @@ -295,7 +318,7 @@ export function activateAndRevealWorktree( */ export function activateAndRevealWorkspace( workspaceId: string, - opts?: { executionHostId?: ExecutionHostId } + opts?: { executionHostId?: ExecutionHostId; providesInitialSurface?: boolean } ): ActivateAndRevealResult | false { const workspaceScope = parseWorkspaceKey(workspaceId) if (workspaceScope?.type === 'folder') { diff --git a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts index 80b68c08a28..2775c5c5f68 100644 --- a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts +++ b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts @@ -112,9 +112,21 @@ export function ensureWorktreeHasInitialTerminal( } const hasExplicitLaunchWork = Boolean(sequencedStartup || setup || issueCommand) + // Why: only startup hydration honours the closed-last-tab tombstone. Every explicit + // activation (sidebar, palette, automation resume, wake) re-seeds a surface instead, + // because closing the last terminal normally deactivates the workspace too + // (terminal-tab-actions.ts closeTerminalTab, plus leaveWorktreeIfEmpty for split-group + // closes) — so reaching here through an activation means the user asked for it back. + // The paths that do leave it active stay safe: a runtime-owned close returns before that + // deactivation, but while that session is live the host owns terminal creation and this + // bails out above; an editor/browser survivor keeps renderableTabCount non-zero, so no + // terminal is added; and closeTabPreservingPty (use-terminal-pane-lifecycle.ts) skips both + // deactivation hooks for pane moves and retirement, where re-seeding is the wanted outcome. + const shouldHonourClosedTerminalTombstone = + Object.hasOwn(store.tabsByWorktree, worktreeId) && opts?.reseedEmptiedWorkspace !== true const shouldAutoCreate = shouldAutoCreateInitialTerminal( renderableTabCount, - Object.hasOwn(store.tabsByWorktree, worktreeId) + shouldHonourClosedTerminalTombstone ) const shouldCreateForExplicitWork = renderableTabCount === 0 && hasExplicitLaunchWork if (!shouldAutoCreate && !shouldCreateForExplicitWork) { diff --git a/src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts b/src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts new file mode 100644 index 00000000000..a716f578e79 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts @@ -0,0 +1,263 @@ +/** + * Cold-parked tabs keep their runtime-graph leaf while their PTY is alive. + * + * Why this matters beyond the host UI (STA-2854): the graph leaf is what mints + * the terminal handle a paired client's stream is bound to. Publishing only + * mounted panes meant a host that merely stopped *displaying* a terminal + * invalidated that handle, stalling a remote viewer who was actively driving it. + * + * The liveness proof is the parked watcher, which the park wiring starts on + * unmount and disposes on reveal, tab close, PTY exit, and worktree teardown — + * so a dead terminal still drops out of the graph. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeSyncWindowGraph } from '../../../shared/runtime-types' +import type { AppState } from '../store/types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' + +vi.mock('@/components/terminal-pane/pty-dispatcher', async (importOriginal) => { + const actual = await importOriginal>() + return { ...actual, getEagerPtyBufferHandle: vi.fn(() => undefined) } +}) + +import { getEagerPtyBufferHandle } from '@/components/terminal-pane/pty-dispatcher' +import { + bufferPreHandlerPtyExit, + clearPreHandlerPtyState +} from '@/components/terminal-pane/pty-pre-handler-buffer' +import { parkedWatchersByTabId } from '@/components/terminal-pane/terminal-parked-watcher-registry' +import { setRuntimeGraphStoreStateGetter, setRuntimeGraphSyncEnabled } from './sync-runtime-graph' + +const LEAF = '22222222-2222-4222-8222-222222222222' +const PARKED_PTY = 'wt-1::/tmp/wt@@parked-pty' +const TAB_ID = 'parked-tab-1' + +function makeState(overrides: Partial = {}): AppState { + return { + tabsByWorktree: {}, + terminalLayoutsByTabId: {} as AppState['terminalLayoutsByTabId'], + runtimePaneTitlesByTabId: {} as AppState['runtimePaneTitlesByTabId'], + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + layoutByWorktree: {}, + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + activeTabId: null, + ...overrides + } as AppState +} + +function parkedTab(): TerminalTab { + return { + id: TAB_ID, + ptyId: PARKED_PTY, + worktreeId: 'wt-1', + title: 'agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } as TerminalTab +} + +function parkedState( + ptyIdsByLeafId: Record = { [LEAF]: PARKED_PTY }, + activeLeafId: string = LEAF +): AppState { + const leafIds = Object.keys(ptyIdsByLeafId) + const root = leafIds.slice(1).reduce>( + (first, leafId) => ({ + type: 'split', + direction: 'horizontal', + first, + second: { type: 'leaf', leafId } + }), + { type: 'leaf', leafId: leafIds[0] } + ) + return makeState({ + tabsByWorktree: { 'wt-1': [parkedTab()] } as AppState['tabsByWorktree'], + terminalLayoutsByTabId: { + [TAB_ID]: { + root, + activeLeafId, + expandedLeafId: null, + ptyIdsByLeafId + } + } as unknown as AppState['terminalLayoutsByTabId'] + }) +} + +/** Installs the exact registry state the park wiring leaves behind on unmount. */ +function installParkedWatcher(ptyId: string, paneId = 1): void { + parkedWatchersByTabId.set(TAB_ID, { + worktreeId: 'wt-1', + tabPtyId: ptyId, + paneIdByPtyId: new Map([[ptyId, paneId]]), + disposersByPtyId: new Map([[ptyId, () => {}]]) + }) +} + +async function flushMicrotasks(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +afterEach(() => { + setRuntimeGraphSyncEnabled(false) + setRuntimeGraphStoreStateGetter(null) + parkedWatchersByTabId.clear() + clearPreHandlerPtyState(PARKED_PTY) + vi.mocked(getEagerPtyBufferHandle).mockReturnValue(undefined) + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +async function captureGraph( + options: { seedState?: boolean } = {} +): Promise { + vi.useFakeTimers() + const syncWindowGraph = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { runtime: { syncWindowGraph } } }) + vi.stubGlobal('HTMLElement', class HTMLElement {}) + // Why opt-out: a case that needs a non-default layout installs its own getter + // before calling in, and must not have it overwritten here. + if (options.seedState !== false) { + setRuntimeGraphStoreStateGetter(() => parkedState()) + } + setRuntimeGraphSyncEnabled(true) + await vi.advanceTimersByTimeAsync(20) + await flushMicrotasks() + expect(syncWindowGraph).toHaveBeenCalledTimes(1) + return syncWindowGraph.mock.calls[0]?.[0] as RuntimeSyncWindowGraph +} + +describe('syncRuntimeGraph cold-parked tabs', () => { + it('publishes a parked tab leaf while a parked watcher still owns its PTY', async () => { + installParkedWatcher(PARKED_PTY) + + const graph = await captureGraph() + + expect(graph.leaves).toContainEqual( + expect.objectContaining({ tabId: TAB_ID, leafId: LEAF, ptyId: PARKED_PTY }) + ) + expect(graph.tabs).toContainEqual(expect.objectContaining({ tabId: TAB_ID })) + }) + + it('drops the leaf once the watcher is disposed (reveal, close, PTY exit, teardown)', async () => { + const graph = await captureGraph() + + expect(graph.leaves).not.toContainEqual(expect.objectContaining({ tabId: TAB_ID })) + expect(graph.tabs).not.toContainEqual(expect.objectContaining({ tabId: TAB_ID })) + }) + + // Why these two cases exist: `paneIdByPtyId` and `tabPtyId` both survive the + // states below, so a predicate reading either one would pass every other test + // here while publishing a dead terminal. + it('drops the leaf when per-PTY disposal leaves only the pane-id slot behind', async () => { + // Exactly what handlePtyExit and disposeParkedTerminalWatchersForPtyIds + // leave: the disposer is gone, the pane-id slot and tabPtyId remain. + parkedWatchersByTabId.set(TAB_ID, { + worktreeId: 'wt-1', + tabPtyId: PARKED_PTY, + paneIdByPtyId: new Map([[PARKED_PTY, 1]]), + disposersByPtyId: new Map() + }) + + const graph = await captureGraph() + + expect(graph.leaves).not.toContainEqual(expect.objectContaining({ tabId: TAB_ID })) + }) + + it('drops the leaf when the PTY exited into the park handoff gap', async () => { + // The watcher entry looks live, but the exit arrived while no handler owned + // the PTY, so it was buffered and the watcher's sidecar never saw it. + installParkedWatcher(PARKED_PTY) + bufferPreHandlerPtyExit(PARKED_PTY, 0) + + const graph = await captureGraph() + + expect(graph.leaves).not.toContainEqual(expect.objectContaining({ tabId: TAB_ID })) + }) + + it('does not publish a stale saved PTY that no watcher owns', async () => { + // The tab is parked, but the layout binding points at a PTY the park wiring + // never watched — ptyIdsByLeafId is merged and never pruned, so a stale + // binding must not resurrect a leaf. + installParkedWatcher('wt-1::/tmp/wt@@some-other-pty') + + const graph = await captureGraph() + + expect(graph.leaves).not.toContainEqual(expect.objectContaining({ tabId: TAB_ID })) + }) + + it('excludes a remote-runtime PTY, whose parked watcher never sees an exit', async () => { + // startParkedPtyWatcher installs no exit subscription for remote: PTYs and + // the parked fact stream carries no exit fact, so a surviving disposer is + // not evidence the remote terminal is still alive. + const remotePtyId = 'remote:env-1@@term_remote' + parkedWatchersByTabId.set(TAB_ID, { + worktreeId: 'wt-1', + tabPtyId: remotePtyId, + paneIdByPtyId: new Map([[remotePtyId, 1]]), + disposersByPtyId: new Map([[remotePtyId, () => {}]]) + }) + setRuntimeGraphStoreStateGetter(() => parkedState({ [LEAF]: remotePtyId })) + + const graph = await captureGraph({ seedState: false }) + + expect(graph.leaves).not.toContainEqual(expect.objectContaining({ tabId: TAB_ID })) + }) + + it('publishes the pane id the watcher uses, not a positional ordinal', async () => { + // PaneManager retires a closed pane's id without renumbering, and main + // routes split/close and the paneKey fallback through this value. Sourced + // from the watcher entry, so a leaf the unmount capture never saw — one the + // layout gained while parked — is still named correctly. + installParkedWatcher(PARKED_PTY, 7) + + const graph = await captureGraph() + + expect(graph.leaves).toContainEqual( + expect.objectContaining({ tabId: TAB_ID, leafId: LEAF, paneRuntimeId: 7 }) + ) + }) + + it("publishes the parked pane's runtime title instead of discarding it", async () => { + // The parked byte watcher keeps writing this slot and main prefers + // leaf.paneTitle over its own older lastOscTitle, so publishing null pins a + // parked agent pane to a stale title for as long as it stays parked. + installParkedWatcher(PARKED_PTY, 7) + setRuntimeGraphStoreStateGetter(() => ({ + ...parkedState(), + runtimePaneTitlesByTabId: { [TAB_ID]: { 7: 'codex [working]' } } + })) + + const graph = await captureGraph({ seedState: false }) + + expect(graph.leaves).toContainEqual( + expect.objectContaining({ leafId: LEAF, paneTitle: 'codex [working]' }) + ) + }) + + it('does not name an active leaf this publication is not sending', async () => { + // Partial coverage is legitimate: one leaf's watcher can be disposed while a + // sibling stays parked, and the saved activeLeafId may be the dropped one. + const otherLeaf = '33333333-3333-4333-8333-333333333333' + const otherPty = 'wt-1::/tmp/wt@@other-parked-pty' + installParkedWatcher(PARKED_PTY) + setRuntimeGraphStoreStateGetter(() => + parkedState({ [LEAF]: PARKED_PTY, [otherLeaf]: otherPty }, otherLeaf) + ) + + const graph = await captureGraph({ seedState: false }) + + expect(graph.tabs).toContainEqual( + expect.objectContaining({ tabId: TAB_ID, activeLeafId: LEAF }) + ) + expect(graph.leaves).not.toContainEqual(expect.objectContaining({ leafId: otherLeaf })) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 3b5f1f62066..888c9642508 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -6,6 +6,10 @@ import { } from '@/components/terminal-pane/layout-serialization' import { warnTerminalLifecycleAnomaly } from '@/components/terminal-pane/terminal-lifecycle-diagnostics' import { getEagerPtyBufferHandle } from '@/components/terminal-pane/pty-dispatcher' +import { + collectParkedTerminalWatcherPtyIds, + getParkedTerminalWatcherPaneIdsByPtyId +} from '@/components/terminal-pane/terminal-parked-watcher-registry' import { createBrowserUuid } from '@/lib/browser-uuid' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { resolveLeafIdForManager } from '@/lib/pane-manager/pane-key-resolution' @@ -792,6 +796,13 @@ async function syncRuntimeGraph(): Promise { } // Why: inactive automation tabs never mount a TerminalPane; publish their leaf+ptyId from persisted layout (gated on a live buffer) or the live PTY looks orphaned. + // Cold-parked tabs are the same shape with a different liveness proof: their + // pane is unmounted but a parked watcher still owns the PTY. Dropping their + // leaf invalidates the terminal handle every paired subscriber is bound to, + // stalling a remotely-driven terminal the host merely stopped displaying. + // Built once for the whole publication; a per-leaf registry scan would be + // quadratic across a many-worktree workspace. + const parkedWatcherPtyIds = collectParkedTerminalWatcherPtyIds() for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { for (const tab of tabs) { const layout = state.terminalLayoutsByTabId[tab.id] @@ -807,17 +818,27 @@ async function syncRuntimeGraph(): Promise { typeof ptyId === 'string' && ptyId.length > 0 && isTerminalLeafId(leafId) && - Boolean(getEagerPtyBufferHandle(ptyId)) + (Boolean(getEagerPtyBufferHandle(ptyId)) || parkedWatcherPtyIds.has(ptyId)) ) if (liveLeaves.length === 0) { continue } const title = resolveRuntimeTerminalTitle(tab, generatedTitlesEnabled) + // Why: partial coverage is legitimate — one leaf's watcher can be disposed + // while its siblings stay parked — so a saved activeLeafId that did not + // survive the filter would name a leaf this publication never sends. + const publishedLeafIds = new Set(liveLeaves.map(([leafId]) => leafId)) + const savedActiveLeafId = layout?.activeLeafId + const parkedPaneIdsByPtyId = getParkedTerminalWatcherPaneIdsByPtyId(tab.id) + const parkedPaneTitles = state.runtimePaneTitlesByTabId[tab.id] ?? {} graph.tabs.push({ tabId: tab.id, worktreeId, title, - activeLeafId: layout?.activeLeafId ?? liveLeaves[0][0], + activeLeafId: + savedActiveLeafId && publishedLeafIds.has(savedActiveLeafId) + ? savedActiveLeafId + : liveLeaves[0][0], layout: resolveTerminalLayoutRoot({ authoritativeRoot: layout?.root, leafIds: liveLeaves.map(([leafId]) => leafId), @@ -828,13 +849,22 @@ async function syncRuntimeGraph(): Promise { }) }) liveLeaves.forEach(([leafId, ptyId], index) => { + // Why the watcher's id wins: PaneManager ids are allocated monotonically + // and closing a pane retires its id without renumbering, so an ordinal + // can name a pane that no longer exists — and main routes split/close and + // the paneKey fallback through this value. + const parkedPaneId = parkedPaneIdsByPtyId.get(ptyId) graph.leaves.push({ tabId: tab.id, worktreeId, leafId, - paneRuntimeId: index + 1, + paneRuntimeId: parkedPaneId ?? index + 1, ptyId, - paneTitle: null, + // Why not null: the parked byte watcher keeps writing this pane's + // runtime title, and main prefers leaf.paneTitle over its own older + // lastOscTitle. Dropping it would pin a parked agent pane to a stale + // title for as long as it stays parked. + paneTitle: (parkedPaneId === undefined ? null : parkedPaneTitles[parkedPaneId]) ?? null, title }) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts new file mode 100644 index 00000000000..79d15c68fe8 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts @@ -0,0 +1,734 @@ +/** + * STA-4593 incident: a paired client accumulated ~18 sidebar agent rows while the + * host reported 4 agent records. The host had closed (retracted) the reviewer + * tabs; every retraction stranded the client's agent-status row. + * + * Invariant under test: when a host snapshot retracts a mirrored terminal tab, + * the client owes that tab the same renderer-state sweep a local closeTab runs — + * no live agentStatusByPaneKey row, no retainedAgentsByPaneKey promotion, no + * sidebar row for a tab that no longer exists anywhere. + * + * Causal boundaries pinned here (both in the paired apply path): + * - Mechanism A: buildMirroredAgentStatusPatch's delete loop skips client-owned + * pane keys (web-session-tabs-sync.ts, ownership gate), and a retracted tab's + * pane keys are only visitable for exactly one snapshot — so a client-owned + * 'working' row outlives its tab forever and renders via the + * worktree-attributed sidebar fallback. + * - Mechanism C: nothing on the paired path plants + * recentlyClosedAgentStatusTabIds, so the retention sync promotes the + * vanished 'done' row into retainedAgentsByPaneKey permanently. + * + * Everything is injected at the seam: real snapshot mirror, real retention sync + * logic, real sidebar row builder, fake clocks. Time is never the oracle. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + RuntimeMobileSessionTabsRemovedResult, + RuntimeMobileSessionTabsResult +} from '../../../shared/runtime-types' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { getDefaultSettings } from '../../../shared/constants' +import type { AppState } from '../store/types' +import { createTestStore, makeWorktree, seedStore } from '../store/slices/store-test-helpers' +import { + markRendererOwnedAgentStatusWrite, + registerRendererOwnedAgentStatusPane, + resetRendererOwnedAgentStatusPanesForTests +} from '../components/terminal-pane/renderer-owned-agent-status-registry' +import { + applyFreshWebSessionTabsSnapshot, + applyFreshWebSessionTabsSnapshots, + resetWebSessionTabsSnapshotFreshnessForTests +} from './web-session-tabs-sync' +import { + buildRetainedAgentsSyncSnapshot, + collectRetainedAgentsOnDisappear +} from '../components/dashboard/useRetainedAgents' +import { buildWorktreeAgentRows } from '../components/sidebar/worktree-agent-rows' +import { + selectLiveAgentStatusEntriesForWorktree, + selectRetainedAgentEntriesForWorktree +} from '../components/sidebar/worktree-agent-row-selectors' +import { + selectLivePtyIdsForWorktree, + selectRuntimePaneTitlesForWorktree +} from '../components/sidebar/worktree-card-status-inputs' + +// Why: web-session-tabs-sync imports the app-level store singleton; this +// harness drives a createTestStore instance instead, like its sibling suites. +vi.mock('../store', () => ({ + useAppStore: { + setState: vi.fn(), + getState: vi.fn(() => ({})), + subscribe: vi.fn(() => () => {}) + } +})) + +const WT = 'repo1::/path/wt1' +const ENV = 'web-env-1' +const HOST_EPOCH = 'host-epoch-1' +const T0 = 1_700_000_000_000 + +const KEEP_TAB = 'host-tab-keep' +const RETRACTED_TAB = 'host-tab-reviewer' +const KEEP_LEAF = '11111111-1111-4111-8111-111111111111' +const RETRACTED_LEAF = '22222222-2222-4222-8222-222222222222' + +type TestStore = ReturnType +type RetainedSyncAgents = ReturnType['currentAgents'] + +function mirrorTabId(hostTabId: string): string { + return toWebTerminalSurfaceTabId(hostTabId) +} + +function mirrorPaneKey(hostTabId: string, leafId: string): string { + return makePaneKey(mirrorTabId(hostTabId), leafId) +} + +const GHOST_PANE_KEY = mirrorPaneKey(RETRACTED_TAB, RETRACTED_LEAF) + +/** hostAgentStatus=true models a host-hook pane; otherwise status comes from client bytes. */ +function makeHostSnapshot(args: { + snapshotVersion: number + hostTabIds: readonly string[] + hostNow: number + hostAgentStatusTabIds?: readonly string[] + hostAgentStatusState?: 'working' | 'done' +}): RuntimeMobileSessionTabsResult { + const leafByTab: Record = { + [KEEP_TAB]: KEEP_LEAF, + [RETRACTED_TAB]: RETRACTED_LEAF + } + return { + worktree: WT, + publicationEpoch: HOST_EPOCH, + snapshotVersion: args.snapshotVersion, + activeGroupId: 'host-group-1', + activeTabId: `${args.hostTabIds[0]}::${leafByTab[args.hostTabIds[0]!]}`, + activeTabType: 'terminal', + tabs: args.hostTabIds.map((hostTabId, index) => ({ + type: 'terminal' as const, + id: `${hostTabId}::${leafByTab[hostTabId]}`, + title: hostTabId === RETRACTED_TAB ? 'Review origin/main' : 'Codex', + parentTabId: hostTabId, + leafId: leafByTab[hostTabId]!, + isActive: index === 0, + launchAgent: 'codex', + status: 'ready' as const, + terminal: `terminal-${index + 1}`, + ...(args.hostAgentStatusTabIds?.includes(hostTabId) + ? { + agentStatus: { + state: args.hostAgentStatusState ?? ('working' as const), + prompt: `work on ${hostTabId}`, + updatedAt: args.hostNow, + stateStartedAt: args.hostNow - 60_000, + agentType: 'codex', + paneKey: makePaneKey(hostTabId, leafByTab[hostTabId]!), + tabId: hostTabId, + worktreeId: WT, + stateHistory: [] + } + } + : {}) + })) + } +} + +/** The synthetic tombstone buildMissingWebSessionTabsRemovals publishes when an + * environment stops listing a worktree — it empties the whole worktree mirror, + * including tabs a still-live sibling environment publishes. The host's own + * notifyMobileSessionTabsRemoved tombstone differs only in epoch shape. */ +function makeWorktreeRemovalFrame( + publicationEpoch = 'visibility-inventory-removal' +): RuntimeMobileSessionTabsRemovedResult { + return { + worktree: WT, + publicationEpoch, + snapshotVersion: 0, + removed: true, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } +} + +/** Mirrors applyWebSessionTabsStorePatch: build the patch from live state, then set it. */ +function applyHostSnapshot( + store: TestStore, + snapshot: RuntimeMobileSessionTabsResult, + now: number, + opts?: { expectChange?: boolean } +): void { + vi.setSystemTime(now) + const state = store.getState() + const patch = applyFreshWebSessionTabsSnapshot(state, snapshot, ENV, now) + if (opts?.expectChange !== false) { + expect(patch, 'host snapshot must pass the freshness gate').not.toBe(state) + } + store.setState(patch as Partial) +} + +/** + * Byte-identical replay of pty-connection for a paired-runtime pane + * (shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null): claim the + * pane at transport creation, prove it on each byte-derived write. Returns the + * release that pty-connection's unmount disposer holds. + */ +function replayClientByteStatus( + store: TestStore, + hostTabId: string, + leafId: string, + state: 'working' | 'done', + clientNow: number +): () => void { + vi.setSystemTime(clientNow) + const paneKey = mirrorPaneKey(hostTabId, leafId) + const release = registerRendererOwnedAgentStatusPane(paneKey, ENV) + markRendererOwnedAgentStatusWrite(paneKey) + store + .getState() + .setAgentStatus( + paneKey, + { state, prompt: `review on ${hostTabId}`, agentType: 'codex' }, + 'codex', + undefined, + { + tabId: mirrorTabId(hostTabId), + worktreeId: WT + } + ) + return release +} + +/** Byte-identical replay of useRetainedAgentsSync's effect body, minus React. */ +function replayRetainedAgentsSync( + store: TestStore, + previousAgents: RetainedSyncAgents, + now: number +): RetainedSyncAgents { + const state = store.getState() + const { currentAgents, existingWorktreeIds, tabIndex } = buildRetainedAgentsSyncSnapshot({ + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + tabsByWorktree: state.tabsByWorktree, + agentStatusByPaneKey: state.agentStatusByPaneKey, + now + }) + const { toRetain, consumedSuppressedPaneKeys } = collectRetainedAgentsOnDisappear({ + previousAgents, + currentAgents, + retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, + retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys, + recentlyClosedAgentStatusTabIds: state.recentlyClosedAgentStatusTabIds, + recentlyRetiredAgentStatusPaneKeys: state.recentlyRetiredAgentStatusPaneKeys, + tabIndex + }) + store.getState().retainAgents(toRetain) + store.getState().pruneRetainedAgents(existingWorktreeIds) + if (consumedSuppressedPaneKeys.length > 0) { + store.getState().clearRetentionSuppressedPaneKeys(consumedSuppressedPaneKeys) + } + return currentAgents +} + +function seedPairedClientStore(): TestStore { + const store = createTestStore() + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: true }, + worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, + activeWorktreeId: WT + } as Partial) + return store +} + +type SidebarObservation = { + tabIds: string[] + rowPaneKeys: string[] + retainedPaneKeys: string[] +} + +/** Byte-identical to useWorktreeAgentRows' inputs, minus React. */ +function observeSidebar(store: TestStore, now: number): SidebarObservation { + const state = store.getState() + const tabs = state.tabsByWorktree[WT] ?? [] + const rows = buildWorktreeAgentRows({ + tabs, + entries: selectLiveAgentStatusEntriesForWorktree(state, WT), + retained: selectRetainedAgentEntriesForWorktree(state, WT), + runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(state, WT), + ptyIdsByTabId: selectLivePtyIdsForWorktree(state, WT), + terminalLayoutsByTabId: Object.fromEntries( + tabs.map((tab) => [tab.id, state.terminalLayoutsByTabId[tab.id]]) + ), + now + }) + return { + tabIds: tabs.map((tab) => tab.id), + rowPaneKeys: rows.filter((row) => row.rowSource !== 'subagent').map((row) => row.paneKey), + retainedPaneKeys: Object.keys(state.retainedAgentsByPaneKey) + } +} + +describe('a host-retracted paired tab leaves no ghost agent row behind', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(T0) + resetWebSessionTabsSnapshotFreshnessForTests() + resetRendererOwnedAgentStatusPanesForTests() + }) + + afterEach(() => { + vi.useRealTimers() + resetRendererOwnedAgentStatusPanesForTests() + }) + + it('control: retraction sweeps a host-authoritative pane the client never owned', () => { + const store = seedPairedClientStore() + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 1, + hostTabIds: [KEEP_TAB, RETRACTED_TAB], + hostNow: T0 - 1_000, + hostAgentStatusTabIds: [KEEP_TAB, RETRACTED_TAB] + }), + T0 + ) + expect(store.getState().agentStatusByPaneKey[GHOST_PANE_KEY]).toBeDefined() + // A stranded migration entry renders the same ghost sidebar row through the + // migration path, so the sweep owes it the same retirement. + for (const [ptyId, hostTabId, leafId] of [ + ['pty-migration-ghost', RETRACTED_TAB, RETRACTED_LEAF], + ['pty-migration-keep', KEEP_TAB, KEEP_LEAF] + ] as const) { + store.getState().setMigrationUnsupportedPty({ + ptyId, + paneKey: mirrorPaneKey(hostTabId, leafId), + tabId: mirrorTabId(hostTabId), + worktreeId: WT, + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: T0 + }) + } + + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 2, + hostTabIds: [KEEP_TAB], + hostNow: T0 + 1_000, + hostAgentStatusTabIds: [KEEP_TAB] + }), + T0 + 2_000 + ) + + expect(store.getState().agentStatusByPaneKey[GHOST_PANE_KEY]).toBeUndefined() + expect(store.getState().migrationUnsupportedByPtyId['pty-migration-ghost']).toBeUndefined() + // Negative safety: the surviving tab keeps its row and its migration entry. + expect(store.getState().agentStatusByPaneKey[mirrorPaneKey(KEEP_TAB, KEEP_LEAF)]).toBeDefined() + expect(store.getState().migrationUnsupportedByPtyId['pty-migration-keep']).toBeDefined() + }) + + it('Mechanism A: a client-owned working row must not outlive its retracted tab', () => { + const store = seedPairedClientStore() + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 1, + hostTabIds: [KEEP_TAB, RETRACTED_TAB], + hostNow: T0 - 1_000 + }), + T0 + ) + // The reviewer pane's status is written from parsed bytes; on a paired + // runtime every such pane registers renderer ownership. + const release = replayClientByteStatus(store, RETRACTED_TAB, RETRACTED_LEAF, 'working', T0) + expect(store.getState().agentStatusByPaneKey[GHOST_PANE_KEY]?.state).toBe('working') + + // Host closes the reviewer tab (e.g. `orca terminal` lifecycle on the host). + applyHostSnapshot( + store, + makeHostSnapshot({ snapshotVersion: 2, hostTabIds: [KEEP_TAB], hostNow: T0 + 1_000 }), + T0 + 2_000 + ) + // Production ordering: the pane's unmount disposer releases ownership only + // AFTER the store set that removed the tab. + release() + // Any later host activity: the retracted tab's pane keys are no longer + // reachable by the mirror's delete loop. This snapshot repeats the previous + // one verbatim, so an empty patch is the correct apply — with or without the + // sweep — and only the pane keys' unreachability is under test here. + applyHostSnapshot( + store, + makeHostSnapshot({ snapshotVersion: 3, hostTabIds: [KEEP_TAB], hostNow: T0 + 3_000 }), + T0 + 4_000, + { expectChange: false } + ) + + const observed = observeSidebar(store, T0 + 4_000) + const evidence = JSON.stringify( + { observed, ghost: store.getState().agentStatusByPaneKey[GHOST_PANE_KEY] }, + null, + 2 + ) + // Two independent signals: the mirrored tab inventory dropped the tab... + expect(observed.tabIds, evidence).toEqual([mirrorTabId(KEEP_TAB)]) + // ...so no live status row may survive under it, and no sidebar row may render for it. + expect( + store.getState().agentStatusByPaneKey[GHOST_PANE_KEY], + `a live agent row outlived the tab the host retracted\n${evidence}` + ).toBeUndefined() + expect( + observed.rowPaneKeys.filter((paneKey) => paneKey === GHOST_PANE_KEY), + `the sidebar renders an agent row for a tab that no longer exists\n${evidence}` + ).toEqual([]) + }) + + it('Mechanism C: a done agent whose tab the host retracted must not become a permanent retained row', () => { + const store = seedPairedClientStore() + let previousAgents: RetainedSyncAgents = new Map() + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 1, + hostTabIds: [KEEP_TAB, RETRACTED_TAB], + hostNow: T0 - 1_000 + }), + T0 + ) + const release = replayClientByteStatus(store, RETRACTED_TAB, RETRACTED_LEAF, 'done', T0) + // The retention sync observed the done row while its tab was still live. + previousAgents = replayRetainedAgentsSync(store, previousAgents, T0 + 500) + expect([...previousAgents.keys()]).toContain(GHOST_PANE_KEY) + + applyHostSnapshot( + store, + makeHostSnapshot({ snapshotVersion: 2, hostTabIds: [KEEP_TAB], hostNow: T0 + 1_000 }), + T0 + 2_000 + ) + // The named half of the contract: the paired retraction plants the same + // closed-tab marker closeTab plants, not just any retention suppressor. + expect( + store.getState().recentlyClosedAgentStatusTabIds[mirrorTabId(RETRACTED_TAB)], + 'the paired retraction did not plant the closed-tab suppressor' + ).toBeDefined() + release() + // The tab's disappearance triggers the next retention sync tick. + previousAgents = replayRetainedAgentsSync(store, previousAgents, T0 + 2_500) + + const observed = observeSidebar(store, T0 + 3_000) + const evidence = JSON.stringify( + { observed, retained: store.getState().retainedAgentsByPaneKey[GHOST_PANE_KEY] }, + null, + 2 + ) + // A host retraction is a close; closes suppress retention. The paired path + // must plant the same closed-tab marker closeTab plants. + expect( + store.getState().retainedAgentsByPaneKey[GHOST_PANE_KEY], + `a host-retracted done agent was promoted into permanent retained state\n${evidence}` + ).toBeUndefined() + expect( + observed.rowPaneKeys.filter((paneKey) => paneKey === GHOST_PANE_KEY), + `the sidebar renders a retained ghost row for a tab that no longer exists\n${evidence}` + ).toEqual([]) + }) + + it('control: the retention suppressor works when the closed-tab marker is planted', () => { + const store = seedPairedClientStore() + let previousAgents: RetainedSyncAgents = new Map() + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 1, + hostTabIds: [KEEP_TAB, RETRACTED_TAB], + hostNow: T0 - 1_000 + }), + T0 + ) + const release = replayClientByteStatus(store, RETRACTED_TAB, RETRACTED_LEAF, 'done', T0) + previousAgents = replayRetainedAgentsSync(store, previousAgents, T0 + 500) + + // The local close path's suppressor-aware removal: sweeps the row AND + // plants recentlyClosedAgentStatusTabIds so retention cannot resurrect it. + store.getState().dropAgentStatusByTabPrefix(mirrorTabId(RETRACTED_TAB), { worktreeId: WT }) + applyHostSnapshot( + store, + makeHostSnapshot({ snapshotVersion: 2, hostTabIds: [KEEP_TAB], hostNow: T0 + 1_000 }), + T0 + 2_000 + ) + release() + previousAgents = replayRetainedAgentsSync(store, previousAgents, T0 + 2_500) + + expect(store.getState().retainedAgentsByPaneKey[GHOST_PANE_KEY]).toBeUndefined() + expect(store.getState().agentStatusByPaneKey[GHOST_PANE_KEY]).toBeUndefined() + }) + + it('a worktree tombstone frame must not sweep a client-owned row it merely un-mirrors', () => { + const store = seedPairedClientStore() + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 1, + hostTabIds: [KEEP_TAB, RETRACTED_TAB], + hostNow: T0 - 1_000 + }), + T0 + ) + const release = replayClientByteStatus(store, RETRACTED_TAB, RETRACTED_LEAF, 'working', T0) + expect(store.getState().agentStatusByPaneKey[GHOST_PANE_KEY]?.state).toBe('working') + + // One environment stops listing the worktree. The tombstone clears the whole + // mirror, but it is not a host close: the tab is still open on the host, and + // another paired environment may still be publishing it. + applyHostSnapshot(store, makeWorktreeRemovalFrame(), T0 + 1_000) + expect(store.getState().tabsByWorktree[WT] ?? []).toEqual([]) + + expect( + store.getState().agentStatusByPaneKey[GHOST_PANE_KEY], + 'a tombstone frame swept a client-owned row whose tab is still open on the host' + ).toBeDefined() + expect( + store.getState().recentlyClosedAgentStatusTabIds[mirrorTabId(RETRACTED_TAB)], + 'a tombstone frame planted a close suppressor for a tab nobody closed' + ).toBeUndefined() + + // The worktree comes back on the next real publication: the tab returns, and + // its client-owned status row must return with it (STA-3107 status authority). + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 2, + hostTabIds: [KEEP_TAB, RETRACTED_TAB], + hostNow: T0 + 2_000 + }), + T0 + 3_000 + ) + expect(store.getState().tabsByWorktree[WT]?.map((tab) => tab.id)).toContain( + mirrorTabId(RETRACTED_TAB) + ) + expect(store.getState().agentStatusByPaneKey[GHOST_PANE_KEY]?.state).toBe('working') + release() + }) + + it("the host's own removal tombstone (removed:) must not sweep either", () => { + const store = seedPairedClientStore() + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 1, + hostTabIds: [KEEP_TAB, RETRACTED_TAB], + hostNow: T0 - 1_000 + }), + T0 + ) + const release = replayClientByteStatus(store, RETRACTED_TAB, RETRACTED_LEAF, 'working', T0) + + // notifyMobileSessionTabsRemoved publishes removed:true under a + // removed: epoch — only the `removed` flag identifies it. + applyHostSnapshot(store, makeWorktreeRemovalFrame('removed:k7q2xz'), T0 + 1_000) + expect(store.getState().tabsByWorktree[WT] ?? []).toEqual([]) + + expect( + store.getState().agentStatusByPaneKey[GHOST_PANE_KEY], + 'a host removal tombstone swept a client-owned row it merely un-mirrors' + ).toBeDefined() + expect( + store.getState().recentlyClosedAgentStatusTabIds[mirrorTabId(RETRACTED_TAB)], + 'a host removal tombstone planted a close suppressor for a tab nobody closed' + ).toBeUndefined() + release() + }) + + it('a re-mirrored tab id regains its client-owned status channel', () => { + const store = seedPairedClientStore() + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 1, + hostTabIds: [KEEP_TAB, RETRACTED_TAB], + hostNow: T0 - 1_000 + }), + T0 + ) + const release = replayClientByteStatus(store, RETRACTED_TAB, RETRACTED_LEAF, 'working', T0) + + // A transient omission — host restart subset frame, cross-host collision + // replacement — retracts the tab and plants the closed-tab marker. + applyHostSnapshot( + store, + makeHostSnapshot({ snapshotVersion: 2, hostTabIds: [KEEP_TAB], hostNow: T0 + 1_000 }), + T0 + 2_000 + ) + release() + expect( + store.getState().recentlyClosedAgentStatusTabIds[mirrorTabId(RETRACTED_TAB)] + ).toBeDefined() + + // The host publishes the same tab id again (post-restart republication or + // the collision repair replay). Mirrored ids are stable, and the close-intent + // filter holds genuinely closing tabs out of snapshots — so presence is + // authoritative: the marker must lift, or the returning tab is + // agent-status-dead for the rest of the session. + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 3, + hostTabIds: [KEEP_TAB, RETRACTED_TAB], + hostNow: T0 + 3_000 + }), + T0 + 4_000 + ) + expect(store.getState().tabsByWorktree[WT]?.map((tab) => tab.id)).toContain( + mirrorTabId(RETRACTED_TAB) + ) + expect( + store.getState().recentlyClosedAgentStatusTabIds[mirrorTabId(RETRACTED_TAB)], + 'a re-mirrored tab id kept its closed-tab marker' + ).toBeUndefined() + + // The returning pane's byte-derived status must land again. + const release2 = replayClientByteStatus( + store, + RETRACTED_TAB, + RETRACTED_LEAF, + 'working', + T0 + 4_500 + ) + expect( + store.getState().agentStatusByPaneKey[GHOST_PANE_KEY]?.state, + 'a returning mirrored tab was left permanently unable to acquire agent status' + ).toBe('working') + release2() + }) + + it('a retract-and-return flap must not strand a suppressor that eats a later real retention', () => { + const store = seedPairedClientStore() + let previousAgents: RetainedSyncAgents = new Map() + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 1, + hostTabIds: [KEEP_TAB, RETRACTED_TAB], + hostNow: T0 - 1_000 + }), + T0 + ) + const release = replayClientByteStatus(store, RETRACTED_TAB, RETRACTED_LEAF, 'working', T0) + previousAgents = replayRetainedAgentsSync(store, previousAgents, T0 + 500) + + // The flap: a transient retraction and the return land inside ONE retention + // interval (React batches same-task store commits into a single effect pass). + // The pane stays mounted throughout, so the sweep found the client-owned row + // live and planted its one-shot suppressor with no disappearance to consume + // it — the pane's next live write is what must lift it (setAgentStatus's + // fresh-status suppressor lift), or it eats a later real retention. + applyHostSnapshot( + store, + makeHostSnapshot({ snapshotVersion: 2, hostTabIds: [KEEP_TAB], hostNow: T0 + 1_000 }), + T0 + 2_000 + ) + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 3, + hostTabIds: [KEEP_TAB, RETRACTED_TAB], + hostNow: T0 + 2_500 + }), + T0 + 3_000 + ) + // The still-mounted pane finishes its run cleanly and rewrites its row. + vi.setSystemTime(T0 + 3_500) + markRendererOwnedAgentStatusWrite(GHOST_PANE_KEY) + store + .getState() + .setAgentStatus( + GHOST_PANE_KEY, + { state: 'done', prompt: 'review finished', agentType: 'codex' }, + 'codex', + undefined, + { tabId: mirrorTabId(RETRACTED_TAB), worktreeId: WT } + ) + previousAgents = replayRetainedAgentsSync(store, previousAgents, T0 + 4_000) + expect(store.getState().agentStatusByPaneKey[GHOST_PANE_KEY]?.state).toBe('done') + + // Much later the pane unmounts and the host's next snapshot omits its status: + // the mirror's delete loop removes the no-longer-client-owned done row with no + // suppressor — the exact live→gone transition retention exists to catch. + release() + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 4, + hostTabIds: [KEEP_TAB, RETRACTED_TAB], + hostNow: T0 + 600_000 + }), + T0 + 601_000 + ) + expect( + store.getState().agentStatusByPaneKey[GHOST_PANE_KEY], + 'precondition: the released done row must be removed by the mirror delete loop' + ).toBeUndefined() + previousAgents = replayRetainedAgentsSync(store, previousAgents, T0 + 601_500) + + expect( + store.getState().retainedAgentsByPaneKey[GHOST_PANE_KEY], + 'a stranded retraction suppressor ate a legitimate done-agent retention' + ).toBeDefined() + }) + + it('Mechanism A holds through the batch apply path a reconnect load uses', () => { + const store = seedPairedClientStore() + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 1, + hostTabIds: [KEEP_TAB, RETRACTED_TAB], + hostNow: T0 - 1_000 + }), + T0 + ) + const release = replayClientByteStatus(store, RETRACTED_TAB, RETRACTED_LEAF, 'working', T0) + expect(store.getState().agentStatusByPaneKey[GHOST_PANE_KEY]?.state).toBe('working') + + // A reconnect delivers everything missed as ONE batch: the retraction, then + // a later snapshot that touches agent status again — the batch's final + // record republication must not undo the sweep. + vi.setSystemTime(T0 + 2_000) + const state = store.getState() + const patch = applyFreshWebSessionTabsSnapshots( + state, + [ + makeHostSnapshot({ snapshotVersion: 2, hostTabIds: [KEEP_TAB], hostNow: T0 + 1_000 }), + makeHostSnapshot({ + snapshotVersion: 3, + hostTabIds: [KEEP_TAB], + hostNow: T0 + 1_500, + hostAgentStatusTabIds: [KEEP_TAB] + }) + ], + ENV, + T0 + 2_000 + ) + expect(patch, 'the batch must pass the freshness gate').not.toBe(state) + store.setState(patch as Partial) + release() + + const observed = observeSidebar(store, T0 + 3_000) + expect(store.getState().tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual([ + mirrorTabId(KEEP_TAB) + ]) + expect( + store.getState().agentStatusByPaneKey[GHOST_PANE_KEY], + 'the batch apply left a ghost row the singular apply sweeps' + ).toBeUndefined() + expect(observed.rowPaneKeys.filter((paneKey) => paneKey === GHOST_PANE_KEY)).toEqual([]) + // Negative safety: the batch's later snapshot still landed its host row. + expect(store.getState().agentStatusByPaneKey[mirrorPaneKey(KEEP_TAB, KEEP_LEAF)]).toBeDefined() + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 44b271bcffb..c2b3919c564 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -31,6 +31,10 @@ import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-type import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' import type { OpenFile } from '../store/slices/editor' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import { + buildRetiredTerminalTabStateSweepPatch, + type RetiredTerminalTabSweepState +} from '../store/slices/retired-terminal-tab-state-sweep' import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream' import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization' import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' @@ -243,7 +247,21 @@ export type WebSessionTabsSyncState = Pick< | 'unreadTerminalTabs' | 'sortEpoch' > & - Partial> + Partial< + Pick< + AppState, + | 'acknowledgedAgentsByPaneKey' + | 'agentLaunchConfigByPaneKey' + | 'automaticAgentResumeClaimsByTabId' + | 'migrationUnsupportedByPtyId' + | 'paneForegroundAgentByPaneKey' + | 'pendingStartupByTabId' + | 'recentlyClosedAgentStatusTabIds' + | 'recentlyRetiredAgentStatusPaneKeys' + | 'retainedAgentsByPaneKey' + | 'retentionSuppressedPaneKeys' + > + > type WebSessionTabsBatchRecordKey = | 'activeBrowserTabIdByWorktree' @@ -475,6 +493,17 @@ function removeWebSessionTabsEnvironment(environmentId: string, worktreeId: stri } } +const VISIBILITY_INVENTORY_REMOVAL_EPOCH = 'visibility-inventory-removal' + +// Why: a tombstone empties the whole worktree mirror — including tabs a still-live sibling environment publishes — so it is a +// visibility fact, never evidence that the host closed anything. +function isWebSessionTabsWorktreeRemovalFrame(snapshot: RuntimeMobileSessionTabsResult): boolean { + return ( + (snapshot as { removed?: unknown }).removed === true || + snapshot.publicationEpoch === VISIBILITY_INVENTORY_REMOVAL_EPOCH + ) +} + // Why: omission means removal only because `listAllMobileSessionTabs` publishes every worktree it knows unfiltered; if a host ever // scopes that map, this turns live worktrees into tombstones, so the fence below is deliberately short-lived. function buildMissingWebSessionTabsRemovals( @@ -495,7 +524,7 @@ function buildMissingWebSessionTabsRemovals( trackedWorktree, snapshot: { worktree: trackedWorktree.worktree, - publicationEpoch: 'visibility-inventory-removal', + publicationEpoch: VISIBILITY_INVENTORY_REMOVAL_EPOCH, snapshotVersion: 0, removed: true, activeGroupId: null, @@ -1230,7 +1259,77 @@ function updateBatchAgentPaneKey( } } -/** Generates a state patch for mirrored agent statuses, merging host entries with client overrides. */ +// Why: the closed-tab marker has no TTL, and setAgentStatus hard-drops writes for a +// marked tab id — for a stable mirrored id that returns, that is a permanent silent +// blackhole unless presence in a snapshot lifts it. +function buildRemirroredClosedTabMarkerLiftPatch( + recentlyClosedAgentStatusTabIds: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'], + mirroredTerminalIds: ReadonlySet +): Partial | null { + let next: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'] | null = null + for (const tabId of mirroredTerminalIds) { + if (tabId in (recentlyClosedAgentStatusTabIds ?? {})) { + next ??= { ...recentlyClosedAgentStatusTabIds } + delete next[tabId] + } + } + return next ? { recentlyClosedAgentStatusTabIds: next } : null +} + +/** + * A host retraction owes the retracted tab closeTab's renderer-state sweep: without it, + * client-owned rows (STA-3107-exempt in the mirror's delete loop) and retention promotions + * outlive the tab forever (STA-4593). + */ +function buildRetractedMirroredTabSweepPatch( + state: WebSessionTabsSyncState, + worktreeId: string, + nextTabsByWorktree: WebSessionTabsSyncState['tabsByWorktree'], + agentStatusPatch: Pick< + WebSessionTabsSyncState, + 'agentStatusByPaneKey' | 'agentStatusEpoch' | 'sortEpoch' + > | null, + removedTerminalResourceIds: readonly string[], + batchContext?: WebSessionTabsBatchContext +): Partial | null { + // Why: only a mirrored id the host stopped publishing is a retraction — a local or provisional + // tab in this list is being renamed into its mirror, and a rename must keep its rows. + const retractedTabIds = removedTerminalResourceIds.filter(isMirroredTerminalSurfaceId) + if (retractedTabIds.length === 0) { + return null + } + const sweepState: RetiredTerminalTabSweepState = { + acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey ?? {}, + agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey ?? {}, + agentStatusByPaneKey: agentStatusPatch?.agentStatusByPaneKey ?? state.agentStatusByPaneKey, + agentStatusEpoch: agentStatusPatch?.agentStatusEpoch ?? state.agentStatusEpoch, + migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId ?? {}, + paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey ?? {}, + recentlyClosedAgentStatusTabIds: state.recentlyClosedAgentStatusTabIds ?? {}, + recentlyRetiredAgentStatusPaneKeys: state.recentlyRetiredAgentStatusPaneKeys ?? {}, + retainedAgentsByPaneKey: state.retainedAgentsByPaneKey ?? {}, + retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys ?? {}, + sortEpoch: agentStatusPatch?.sortEpoch ?? state.sortEpoch, + // Why: the drop's completed-orphan rule reads "keyed under a tab this worktree no longer has", + // so it must see the post-removal tab list, not the one the snapshot replaced. + tabsByWorktree: nextTabsByWorktree + } + const sweep = buildRetiredTerminalTabStateSweepPatch(sweepState, retractedTabIds, worktreeId) + if (!sweep?.agentStatusByPaneKey || !batchContext) { + return sweep ?? null + } + // Why: the batch republishes its own record copy at the end, which would undo the sweep. + const mutableState = state as unknown as Record + mutableState.agentStatusByPaneKey = sweep.agentStatusByPaneKey + batchContext.changedRecords.add('agentStatusByPaneKey') + for (const paneKey of Object.keys(sweepState.agentStatusByPaneKey)) { + if (!(paneKey in sweep.agentStatusByPaneKey)) { + updateBatchAgentPaneKey(paneKey, false, batchContext) + } + } + return sweep +} + function buildMirroredAgentStatusPatch( state: WebSessionTabsSyncState, currentTerminalTabs: readonly TerminalTab[], @@ -3370,9 +3469,32 @@ function applyWebSessionTabsSnapshotWithContext( now, batchContext ) + // Why: only a host snapshot that omits a tab is a retraction; a tombstone clears the mirror + // for every environment at once, and sweeping there erases rows a live sibling still owns. + const retractedTabSweepPatch = isWebSessionTabsWorktreeRemovalFrame(snapshot) + ? null + : buildRetractedMirroredTabSweepPatch( + state, + worktreeId, + nextTabsByWorktree, + agentStatusPatch, + removedTerminalResourceIds, + batchContext + ) + // Why: mirrored ids are stable, so a published-again id proves the tab is not closed — + // a lingering closed-tab marker would blackhole its byte-derived status for the whole + // session (host-restart subset frames, cross-host collision replays). The close-intent + // filter already holds genuinely closing tabs out of the snapshot. + const remirroredClosedTabLiftPatch = buildRemirroredClosedTabMarkerLiftPatch( + retractedTabSweepPatch?.recentlyClosedAgentStatusTabIds ?? + state.recentlyClosedAgentStatusTabIds, + mirroredTerminalIds + ) const patch: Partial = { ...agentStatusPatch, + ...retractedTabSweepPatch, + ...remirroredClosedTabLiftPatch, ...(nextOpenFiles !== state.openFiles ? { openFiles: nextOpenFiles } : {}), ...(nextTabsByWorktree !== state.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), ...(nextBrowserTabsByWorktree !== state.browserTabsByWorktree diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 282dc492713..6c368bfc2b6 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1,7 +1,16 @@ /* eslint-disable max-lines -- Why: the agent-status slice co-locates live map, retained snapshots, retention-suppression, and tab-prefix sweep so the teardown contract stays readable end-to-end. Splitting across files would scatter the drop/remove/retain interactions that must stay in lockstep. */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import { AGENT_STATUS_STALE_AFTER_MS, AGENT_STATE_HISTORY_MAX, type AgentStateHistoryEntry, type AgentStatusEntry, type AgentStatusOrchestrationContext, type AgentType, type MigrationUnsupportedPtyEntry, type ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import { + AGENT_STATUS_STALE_AFTER_MS, + AGENT_STATE_HISTORY_MAX, + type AgentStateHistoryEntry, + type AgentStatusEntry, + type AgentStatusOrchestrationContext, + type AgentType, + type MigrationUnsupportedPtyEntry, + type ParsedAgentStatusPayload +} from '../../../../shared/agent-status-types' import { agentSubagentsEqual } from '../../../../shared/agent-subagent-snapshot' import type { AgentStatusObservation } from '../../../../shared/agent-status-observation' import { rendererAgentStatusObservations } from '../../lib/renderer-agent-status-observations' @@ -465,7 +474,7 @@ function getLeafIdFromPaneKey(paneKey: string): string | null { } function findCompletedOrphanPaneKeysForTabClose( - state: AppState, + state: AgentStatusTabPrefixDropState, worktreeId: string | undefined, prefix: string ): string[] { @@ -1291,6 +1300,143 @@ function buildAgentStatusBatchPatch( return patch as Partial } +/** The slice of app state the tab-prefix drop reduces over — narrow so callers + * outside the store (the paired snapshot apply) can build the same patch. */ +export type AgentStatusTabPrefixDropState = Pick< + AppState, + | 'acknowledgedAgentsByPaneKey' + | 'agentLaunchConfigByPaneKey' + | 'agentStatusByPaneKey' + | 'agentStatusEpoch' + | 'migrationUnsupportedByPtyId' + | 'recentlyClosedAgentStatusTabIds' + | 'recentlyRetiredAgentStatusPaneKeys' + | 'retainedAgentsByPaneKey' + | 'retentionSuppressedPaneKeys' + | 'sortEpoch' + | 'tabsByWorktree' +> + +/** Pure form of the dropAgentStatusByTabPrefix reducer: the paired snapshot + * apply folds the same sweep into a patch it assembles itself, so the two + * paths cannot drift. `retiredAliasPaneKeys` comes from the caller because + * retiring pane-authority aliases is a registry side effect, not a reduction. */ +export function buildAgentStatusTabPrefixDropPatch( + s: AgentStatusTabPrefixDropState, + tabIdPrefix: string, + retiredAliasPaneKeys: readonly string[], + opts?: DropAgentStatusByTabPrefixOptions +): { patch: Partial; hadLive: boolean } { + const prefix = `${tabIdPrefix}:` + let hadLive = false + const buildPatch = (): Partial => { + const completedOrphanKeys = findCompletedOrphanPaneKeysForTabClose(s, opts?.worktreeId, prefix) + const completedOrphanKeySet = new Set(completedOrphanKeys) + const liveKeys = [ + ...Object.keys(s.agentStatusByPaneKey).filter((k) => k.startsWith(prefix)), + ...completedOrphanKeys + ] + const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter( + (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) + ) + const retainedKeys = Object.keys(s.retainedAgentsByPaneKey).filter( + (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) + ) + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey?.startsWith(prefix) ?? false + ) + // See removeAgentStatus for ack-cleanup rationale; ack entries are owned by the pane lifecycle regardless of live/retained state. + let nextAck = s.acknowledgedAgentsByPaneKey + const ackKeys = Object.keys(nextAck).filter( + (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) + ) + if (ackKeys.length > 0) { + nextAck = { ...nextAck } + for (const k of ackKeys) { + delete nextAck[k] + } + } + const nextClosedTabs = boundRecentlyClosedAgentStatusTabIds( + s.recentlyClosedAgentStatusTabIds, + tabIdPrefix + ) + const nextRetiredPaneKeys = boundRecentlyRetiredAgentStatusPaneKeys( + s.recentlyRetiredAgentStatusPaneKeys, + retiredAliasPaneKeys + ) + + if ( + liveKeys.length === 0 && + launchConfigKeys.length === 0 && + retainedKeys.length === 0 && + !migrationUnsupported.changed + ) { + if (nextAck !== s.acknowledgedAgentsByPaneKey) { + return { + acknowledgedAgentsByPaneKey: nextAck, + recentlyClosedAgentStatusTabIds: nextClosedTabs, + recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys + } + } + return { + recentlyClosedAgentStatusTabIds: nextClosedTabs, + recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys + } + } + hadLive = liveKeys.length > 0 + + const nextLive = liveKeys.length > 0 ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + for (const key of liveKeys) { + delete nextLive[key] + } + const nextLaunchConfigs = + launchConfigKeys.length > 0 + ? { ...s.agentLaunchConfigByPaneKey } + : s.agentLaunchConfigByPaneKey + for (const key of launchConfigKeys) { + delete nextLaunchConfigs[key] + } + + const nextRetained = + retainedKeys.length > 0 ? { ...s.retainedAgentsByPaneKey } : s.retainedAgentsByPaneKey + for (const key of retainedKeys) { + delete nextRetained[key] + } + + // Why: a suppressor is only consumed on a live→gone transition, so plant one only for live paneKeys and skip already-suppressed and completed-orphan keys — otherwise it leaks (mirrors dropAgentStatus). + const suppressorAdds = liveKeys.filter( + (k) => !completedOrphanKeySet.has(k) && !(k in s.retentionSuppressedPaneKeys) + ) + let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys + if (suppressorAdds.length > 0) { + nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys } + for (const key of suppressorAdds) { + nextRetentionSuppressedPaneKeys[key] = true + } + } + + return { + agentStatusByPaneKey: nextLive, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + retainedAgentsByPaneKey: nextRetained, + migrationUnsupportedByPtyId: migrationUnsupported.next, + retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, + recentlyClosedAgentStatusTabIds: nextClosedTabs, + recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + // Why: mirrors removeAgentStatusByTabPrefix — only bump epochs when the live map changed; retained-only sweeps don't affect sort/freshness. + agentStatusEpoch: + hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch + } + } + const patch = buildPatch() + return { patch, hadLive } +} + export const createAgentStatusSlice: StateCreator = ( storeSet, storeGet @@ -2721,117 +2867,17 @@ export const createAgentStatusSlice: StateCreator { - const prefix = `${tabIdPrefix}:` const retiredAliasPaneKeys = retireAgentPaneAuthorityAliasesByOwnerTab(tabIdPrefix) let hadLive = false set((s) => { - const completedOrphanKeys = findCompletedOrphanPaneKeysForTabClose( + const dropped = buildAgentStatusTabPrefixDropPatch( s, - opts?.worktreeId, - prefix + tabIdPrefix, + retiredAliasPaneKeys, + opts ) - const completedOrphanKeySet = new Set(completedOrphanKeys) - const liveKeys = [ - ...Object.keys(s.agentStatusByPaneKey).filter((k) => k.startsWith(prefix)), - ...completedOrphanKeys - ] - const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter( - (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) - ) - const retainedKeys = Object.keys(s.retainedAgentsByPaneKey).filter( - (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) - ) - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => entry.paneKey?.startsWith(prefix) ?? false - ) - // See removeAgentStatus for ack-cleanup rationale; ack entries are owned by the pane lifecycle regardless of live/retained state. - let nextAck = s.acknowledgedAgentsByPaneKey - const ackKeys = Object.keys(nextAck).filter( - (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) - ) - if (ackKeys.length > 0) { - nextAck = { ...nextAck } - for (const k of ackKeys) { - delete nextAck[k] - } - } - const nextClosedTabs = boundRecentlyClosedAgentStatusTabIds( - s.recentlyClosedAgentStatusTabIds, - tabIdPrefix - ) - const nextRetiredPaneKeys = boundRecentlyRetiredAgentStatusPaneKeys( - s.recentlyRetiredAgentStatusPaneKeys, - retiredAliasPaneKeys - ) - - if ( - liveKeys.length === 0 && - launchConfigKeys.length === 0 && - retainedKeys.length === 0 && - !migrationUnsupported.changed - ) { - if (nextAck !== s.acknowledgedAgentsByPaneKey) { - return { - acknowledgedAgentsByPaneKey: nextAck, - recentlyClosedAgentStatusTabIds: nextClosedTabs, - recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys - } - } - return { - recentlyClosedAgentStatusTabIds: nextClosedTabs, - recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys - } - } - hadLive = liveKeys.length > 0 - - const nextLive = - liveKeys.length > 0 ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey - for (const key of liveKeys) { - delete nextLive[key] - } - const nextLaunchConfigs = - launchConfigKeys.length > 0 - ? { ...s.agentLaunchConfigByPaneKey } - : s.agentLaunchConfigByPaneKey - for (const key of launchConfigKeys) { - delete nextLaunchConfigs[key] - } - - const nextRetained = - retainedKeys.length > 0 ? { ...s.retainedAgentsByPaneKey } : s.retainedAgentsByPaneKey - for (const key of retainedKeys) { - delete nextRetained[key] - } - - // Why: a suppressor is only consumed on a live→gone transition, so plant one only for live paneKeys and skip already-suppressed and completed-orphan keys — otherwise it leaks (mirrors dropAgentStatus). - const suppressorAdds = liveKeys.filter( - (k) => !completedOrphanKeySet.has(k) && !(k in s.retentionSuppressedPaneKeys) - ) - let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys - if (suppressorAdds.length > 0) { - nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys } - for (const key of suppressorAdds) { - nextRetentionSuppressedPaneKeys[key] = true - } - } - - return { - agentStatusByPaneKey: nextLive, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - retainedAgentsByPaneKey: nextRetained, - migrationUnsupportedByPtyId: migrationUnsupported.next, - retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, - recentlyClosedAgentStatusTabIds: nextClosedTabs, - recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}), - // Why: mirrors removeAgentStatusByTabPrefix — only bump epochs when the live map changed; retained-only sweeps don't affect sort/freshness. - agentStatusEpoch: - hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, - sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch - } + hadLive = dropped.hadLive + return dropped.patch }) if (hadLive) { queueMicrotask(() => freshness.schedule()) diff --git a/src/renderer/src/store/slices/pane-foreground-agent.ts b/src/renderer/src/store/slices/pane-foreground-agent.ts index ebdd69e6865..36c6be7c070 100644 --- a/src/renderer/src/store/slices/pane-foreground-agent.ts +++ b/src/renderer/src/store/slices/pane-foreground-agent.ts @@ -64,7 +64,12 @@ export const createPaneForegroundAgentSlice: StateCreator< }) }, clearPaneForegroundAgentByTabPrefix: (tabIdPrefix) => { - set((s) => clearEntriesByTabPrefixes(s.paneForegroundAgentByPaneKey, [`${tabIdPrefix}:`]) ?? s) + set( + (s) => + buildPaneForegroundAgentTabPrefixClearPatch(s.paneForegroundAgentByPaneKey, [ + `${tabIdPrefix}:` + ]) ?? s + ) }, clearPaneForegroundAgentByWorktree: (worktreeId) => { // Why: entries carry no worktreeId, so this must run while the worktree's @@ -72,14 +77,16 @@ export const createPaneForegroundAgentSlice: StateCreator< // awaiting terminal teardown). set((s) => { const prefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) - return clearEntriesByTabPrefixes(s.paneForegroundAgentByPaneKey, prefixes) ?? s + return ( + buildPaneForegroundAgentTabPrefixClearPatch(s.paneForegroundAgentByPaneKey, prefixes) ?? s + ) }) } }) -function clearEntriesByTabPrefixes( +export function buildPaneForegroundAgentTabPrefixClearPatch( entries: Record, - tabPrefixes: string[] + tabPrefixes: readonly string[] ): Pick | null { if (tabPrefixes.length === 0) { return null diff --git a/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts b/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts new file mode 100644 index 00000000000..cfdefcf87e0 --- /dev/null +++ b/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts @@ -0,0 +1,96 @@ +import type { AppState } from '../types' +import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity' +import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs' +import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards' +// Why: the store-free registry (not terminal-parked-tab-watchers, which imports @/store) so a slice can import this module during its own evaluation. +import { retireParkedTerminalTab } from '@/components/terminal-pane/terminal-parked-watcher-registry' +import { retireAgentPaneAuthorityAliasesByOwnerTab } from './agent-pane-authority' +import { + buildAgentStatusTabPrefixDropPatch, + type AgentStatusTabPrefixDropState +} from './agent-status' +import { buildPaneForegroundAgentTabPrefixClearPatch } from './pane-foreground-agent' + +export type RetiredTerminalTabSweepActions = Pick< + AppState, + 'dropAgentStatusByTabPrefix' | 'clearPaneForegroundAgentByTabPrefix' +> + +/** The state the sweep reduces over: the two store maps plus everything the + * agent-status drop reads. Narrow so a non-store caller can pass its own view. */ +export type RetiredTerminalTabSweepState = AgentStatusTabPrefixDropState & + Pick + +/** + * The suppressor-aware store maps plus three module registries a retired terminal tab strands. + * One unit with two callers on purpose: a second inline copy of this list is how a retirement + * path ends up sweeping none of it (STA-4593). NOT full closeTab parity — closeTab's own set() + * additionally clears pane-keyed maps outside this list (sleeping agent sessions, unread + * markers, pane timers), which a caller with a narrower set() still strands. Must run AFTER the + * tab is out of `tabsByWorktree` — the completed-orphan sweep keys on "tab this worktree no + * longer has". + */ +export function sweepRetiredTerminalTabState( + actions: RetiredTerminalTabSweepActions, + tabId: string, + worktreeId?: string | null +): void { + // Why: idempotent, and retirement has no earlier hook — closeTab still revokes these before its own + // provider teardown, where the ordering against pty exit is load-bearing. + retireParkedTerminalTab(tabId) + // Why: sweep tab agent status through its suppressor-aware removal path. + // Why the worktree: Pi can leave a completed row keyed under an already-missing tab id; passing it sweeps that orphan while preserving active pre-render child rows. + actions.dropAgentStatusByTabPrefix(tabId, worktreeId ? { worktreeId } : undefined) + // Why: retired pane keys never recur, so stranded foreground entries would accumulate for the renderer's whole lifetime. + actions.clearPaneForegroundAgentByTabPrefix(tabId) + // Why: retirement permanently retires the tab's panes (a reopen mints a fresh leafId), so drop hibernation output epochs to keep the module map from growing forever. + forgetAgentHibernationTabOutput(tabId) + // Why: same rationale — retired tab ids never recur, so drop the foreground last-seen and consumed agent-startup delivery guards. + forgetForegroundTerminalTabs([tabId]) + forgetAgentStartupDeliveriesForTabs([tabId]) +} + +/** + * The same sweep as a patch, for a caller that owns its own `set()` (the paired snapshot apply + * builds patches from a state the store has not seen yet). Registry side effects still fire. + * `state` must already exclude the retired tabs from `tabsByWorktree`. + */ +export function buildRetiredTerminalTabStateSweepPatch( + state: RetiredTerminalTabSweepState, + tabIds: readonly string[], + worktreeId?: string | null +): Partial | null { + if (tabIds.length === 0) { + return null + } + // Why: the registry side effects run while the patch is computed (possibly inside a set() + // updater) — safe because all are idempotent and the ids are genuinely retired, but a caller + // that discards the patch still mutates the registries. + let swept: RetiredTerminalTabSweepState = state + for (const tabId of tabIds) { + retireParkedTerminalTab(tabId) + const { patch } = buildAgentStatusTabPrefixDropPatch( + swept, + tabId, + retireAgentPaneAuthorityAliasesByOwnerTab(tabId), + worktreeId ? { worktreeId } : undefined + ) + const foreground = buildPaneForegroundAgentTabPrefixClearPatch( + swept.paneForegroundAgentByPaneKey, + [`${tabId}:`] + ) + swept = { ...swept, ...patch, ...foreground } + forgetAgentHibernationTabOutput(tabId) + } + forgetForegroundTerminalTabs(tabIds) + forgetAgentStartupDeliveriesForTabs(tabIds) + const changed: Record = {} + for (const key of Object.keys(swept) as (keyof RetiredTerminalTabSweepState)[]) { + if (!Object.is(swept[key], state[key])) { + changed[key] = swept[key] + } + } + return Object.keys(changed).length === 0 + ? null + : (changed as Partial) +} diff --git a/src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts b/src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts index 966663e1a2b..698b950f1f2 100644 --- a/src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../../../shared/constants' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' +import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) @@ -50,3 +51,35 @@ describe('explicit empty terminal hydration', () => { expect(store.getState().tabsByWorktree).toEqual({ [workspaceKey]: [] }) }) }) + +// Why: this is the half of PR #15513's contract that activation must NOT undo — hydration restores +// an emptied workspace as active, and the passive auto-create effect has to leave it alone. The +// end-to-end pin is tests/e2e/terminal-tab-close-restart-persistence.spec.ts, which only runs when +// an e2e spec changes, so assert the same decision here against a really-hydrated session. +describe('hydrated active workspace with a terminal tombstone', () => { + it('reports no auto-create for the restored active worktree', () => { + const store = createTestStore() + const worktreeId = 'repo1::/wt-1' + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-1' })] + } + }) + + store.getState().hydrateWorkspaceSession({ + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo1', + activeWorktreeId: worktreeId, + tabsByWorktree: { [worktreeId]: [] } + }) + + const state = store.getState() + expect(state.activeWorktreeId).toBe(worktreeId) + expect(state.tabsByWorktree[worktreeId]).toEqual([]) + const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId) + // The exact inputs Terminal.tsx's auto-create effect feeds shouldAutoCreateInitialTerminal. + expect(renderableTabCount).toBe(0) + expect(Object.hasOwn(state.tabsByWorktree, worktreeId)).toBe(true) + expect(shouldAutoCreateInitialTerminal(renderableTabCount, true)).toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 57fc16bfa22..df29d157935 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -49,10 +49,8 @@ import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shel import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload' import type { AiVaultSessionTitle } from '../../../../shared/ai-vault-session-title' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' -import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity' -import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs' import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' -import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards' +import { sweepRetiredTerminalTabState } from './retired-terminal-tab-state-sweep' import { clearTransientTerminalState, emptyLayoutSnapshot, @@ -1848,19 +1846,8 @@ export const createTerminalSlice: StateCreator : {}) } }) - // Why: sweep tab agent status through its suppressor-aware removal path. - // Why: Pi can leave a completed row keyed under an already-missing tab id; pass the worktree to sweep that orphan while preserving active pre-render child rows. - get().dropAgentStatusByTabPrefix( - tabId, - closingWorktreeId ? { worktreeId: closingWorktreeId } : undefined - ) - // Why: retired pane keys never recur, so stranded foreground entries would accumulate for the renderer's whole lifetime. - get().clearPaneForegroundAgentByTabPrefix(tabId) - // Why: closing a tab permanently retires its panes (reopen mints a fresh leafId), so drop hibernation output epochs to keep the module map from growing forever. - forgetAgentHibernationTabOutput(tabId) - // Why: same rationale — retired tab ids never recur, so drop the foreground last-seen and consumed agent-startup delivery guards. - forgetForegroundTerminalTabs([tabId]) - forgetAgentStartupDeliveriesForTabs([tabId]) + // Why shared with the paired snapshot apply: every path that removes a tab owes it the same sweep, and a second copy of the list is how one path silently misses a new entry. + sweepRetiredTerminalTabState(get(), tabId, closingWorktreeId) for (const tabs of Object.values(get().unifiedTabsByWorktree)) { const workspaceItem = tabs.find( (entry) => entry.contentType === 'terminal' && entry.entityId === tabId diff --git a/src/shared/cli-app-status-projection.ts b/src/shared/cli-app-status-projection.ts new file mode 100644 index 00000000000..4b627dbdf15 --- /dev/null +++ b/src/shared/cli-app-status-projection.ts @@ -0,0 +1,29 @@ +import type { CliStatusResult, RuntimeStatus } from './runtime-types' + +export function resolveDesktopWindowStatus( + status: RuntimeStatus +): CliStatusResult['app']['desktopWindowStatus'] { + if (status.desktopWindowStatus) { + return status.desktopWindowStatus + } + // Why: older desktop runtimes predate the explicit status but a positive + // Electron id still proves that a real window owns the graph. + return status.authoritativeWindowId !== null && status.authoritativeWindowId > 0 + ? 'available' + : undefined +} + +// Why: a status fetched from another machine describes THAT machine, so `app` must too. +// `available` is the only desktop-window status that requires a live renderer owning the graph, +// which makes it the signal separating a real desktop app from a headless `serve`. Reporting a +// fixed running value while echoing the target's own window status produces a self-contradictory +// result, and readers acted on it — a remote GUI run was written off as headless (STA-4792). +// A pid is not knowable across the boundary, so it stays null. +export function projectRemoteAppStatus(status: RuntimeStatus): CliStatusResult['app'] { + const desktopWindowStatus = resolveDesktopWindowStatus(status) + return { + running: desktopWindowStatus === 'available', + pid: null, + ...(desktopWindowStatus ? { desktopWindowStatus } : {}) + } +} diff --git a/src/shared/file-range-read.ts b/src/shared/file-range-read.ts new file mode 100644 index 00000000000..fe695e9a2c9 --- /dev/null +++ b/src/shared/file-range-read.ts @@ -0,0 +1,61 @@ +/** Largest window one `fs.readFileRange` response may carry. Derived from the + * relay writer's admission budget, NOT from the 16 MiB frame cap. + * + * A response frame over `DISPATCHER_CONTROL_QUEUE_MAX_BYTES` (1 MiB) is demoted + * to the `legacy-response` lane, which is refused outright once the producer + * queue passes `DEFAULT_PRODUCER_QUEUE_MAX_BYTES` (2 MiB) -- an opaque + * `ResponseOverCapacity` that depends on unrelated load. 256 KiB of raw bytes + * frames to ~350 KB, so a full-cap response takes the control lane instead. + * + * The control lane is a shared budget, not a per-frame one: two full-cap + * responses fit alongside each other, and the third overflows -- which for a + * response is fatal, it closes the client. That is the same exposure every + * control-lane response already carries (`fs.readFile` frames any sub-1 MiB + * file the same way), and the two-deep headroom is pinned by a test. Widening + * the cap spends that headroom, so bigger transfers belong on the ack-paced + * bulk lane (`fs.readFileStream`) rather than on a wider window here. + * + * Raising this value is a wire change even though it is a constant: an older + * host still advertising `rangedReadVersion: 1` refuses a longer window with an + * opaque error, so a wider cap needs a version bump to negotiate against. + * + * Requests above it are REJECTED, never clamped: a clamped read is + * indistinguishable from EOF, and callers page by advancing `position`. */ +export const MAX_FILE_RANGE_READ_BYTES = 256 * 1024 + +/** A range request the host will refuse. Separate from a read failure so a + * caller can tell "I asked for the wrong thing" from "the file is unreadable". */ +export class FileRangeReadRequestError extends Error { + constructor(message: string) { + super(message) + this.name = 'FileRangeReadRequestError' + } +} + +/** The single source of truth for what `fs.readFileRange` accepts. Both sides + * call it: the client so an invalid request never costs a round trip, the host + * because `position`/`length` land straight in an fd read and the relay has no + * request schema. Two independent copies would drift into a client that sends + * what the host rejects. */ +export function validateFileRangeRequest( + position: unknown, + length: unknown +): { position: number; length: number } { + if (typeof position !== 'number' || !Number.isSafeInteger(position) || position < 0) { + throw new FileRangeReadRequestError( + 'fs.readFileRange requires a non-negative safe-integer position' + ) + } + if (typeof length !== 'number' || !Number.isSafeInteger(length) || length <= 0) { + throw new FileRangeReadRequestError('fs.readFileRange requires a positive integer length') + } + if (length > MAX_FILE_RANGE_READ_BYTES) { + throw new FileRangeReadRequestError( + `fs.readFileRange length ${length} exceeds the ${MAX_FILE_RANGE_READ_BYTES}-byte limit` + ) + } + if (position > Number.MAX_SAFE_INTEGER - (length - 1)) { + throw new FileRangeReadRequestError('fs.readFileRange window exceeds safe-integer offsets') + } + return { position, length } +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index c40a0975bcf..1a8da043351 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -106,8 +106,13 @@ export type CliRuntimeState = | 'stale_bootstrap' export type CliStatusResult = { + // Why: every field below describes ONE machine. When the CLI targets a paired server the + // whole result is about that server, and callers were reading `app` as if it described their + // own laptop. Naming the subject removes the ambiguity; absent means local. + target?: { kind: 'local' } | { kind: 'environment'; environment: string } app: { running: boolean + // Null whenever the pid is not knowable, which includes every remote target. pid: number | null desktopWindowStatus?: RuntimeDesktopWindowStatus } @@ -660,6 +665,13 @@ export type RuntimeTerminalRead = { nextCursor: string | null latestCursor?: string returnedLineCount?: number + // Why: these are two different questions and they disagree whenever a program repaints. + // `stream` is the accumulated pty output with escapes stripped, so a redrawn line arrives as + // stacked fragments; `screen` is what the terminal actually renders. Naming the source keeps + // a caller that asked for one and got the other from reading the answer as the wrong thing. + // `screen-unavailable` means a screen was asked for, none could be rendered, and this is the + // stream instead — distinct from `stream`, which is the caller getting what they asked for. + source?: 'stream' | 'screen' | 'screen-unavailable' } export type RuntimeTerminalRename = { diff --git a/src/shared/wsl-exec-mode-separator.test.ts b/src/shared/wsl-exec-mode-separator.test.ts index 02b23268bfd..69028f9aa98 100644 --- a/src/shared/wsl-exec-mode-separator.test.ts +++ b/src/shared/wsl-exec-mode-separator.test.ts @@ -85,7 +85,7 @@ describe('wsl.exe mode separator', () => { it('sees a `--` separator split across lines by the formatter', () => { // Why: the guard once matched line-by-line and was blind to this exact shape, // which is how every multi-element argv array in this repo is formatted. - const formatted = ["args: [", " '-d',", " distro,", " '--',", " 'bash'", "]"].join('\n') + const formatted = ['args: [', " '-d',", ' distro,', " '--',", " 'bash'", ']'].join('\n') expect(ARGV_FORM.test(codeText(formatted))).toBe(true) }) diff --git a/tests/e2e/completed-worker-retirement-resume.unit.test.ts b/tests/e2e/completed-worker-retirement-resume.unit.test.ts index c35624e5771..b6daaa6fda0 100644 --- a/tests/e2e/completed-worker-retirement-resume.unit.test.ts +++ b/tests/e2e/completed-worker-retirement-resume.unit.test.ts @@ -559,7 +559,7 @@ describe('completed background-worker retirement resume matrix', () => { const restartAfterRetirement = persistAndParseCurrentSession() await hydrateSession(restartAfterRetirement) - // Case 8: first activation preserves explicit retirement and cannot resurrect authority. + // Case 8: first activation hands back a bare terminal and cannot resurrect authority. const beforeActivation = useAppStore.getState() expect(beforeActivation.everActivatedWorktreeIds.has(WORKTREE_ID)).toBe(false) expect(beforeActivation.agentStatusByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() @@ -572,10 +572,13 @@ describe('completed background-worker retirement resume matrix', () => { expect(activationResult).not.toBe(false) const activated = useAppStore.getState() - expect(activated.tabsByWorktree[WORKTREE_ID]).toEqual([]) + const replacementTabs = activated.tabsByWorktree[WORKTREE_ID] ?? [] + expect(replacementTabs).toHaveLength(1) + expect(replacementTabs[0]?.id).not.toBe(ORIGINAL_TAB_ID) expect(activated.terminalLayoutsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() expect(activated.ptyIdsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() expectCanaryUnchanged() + // Required invariant: explicit completion plus retirement must revoke provider-resume authority. expect(Object.keys(activated.pendingStartupByTabId)).toHaveLength(0) expect(Object.keys(activated.automaticAgentResumeClaimsByTabId)).toHaveLength(0) }) diff --git a/tests/e2e/host-cold-park-remote-subscriber.unit.test.ts b/tests/e2e/host-cold-park-remote-subscriber.unit.test.ts new file mode 100644 index 00000000000..46158689844 --- /dev/null +++ b/tests/e2e/host-cold-park-remote-subscriber.unit.test.ts @@ -0,0 +1,494 @@ +/** + * STA-2854 validation oracle (validation-only; no product change). + * + * Report: a host's renderer cold-parking policy ignores remote subscribers, so + * a pane a paired client is actively viewing is unmounted 30s after it goes + * locally hidden; reconnect remounts it without resetting the hidden clock, so + * it re-parks immediately and the client never recovers. + * + * The report is two separable claims, so this file asserts them separately: + * + * A. RENDERER POLICY — does host-local cold parking still ignore an active + * remote subscriber, and does a background (remote-subscribe-driven) + * mount still inherit a stale hidden clock? + * B. AUTHORITATIVE STREAM — does a parked host pane actually destroy the + * remote subscriber's stream, its input path, or its reconnect? + * + * B is modelled at the strictest possible park: a runtime with NO authoritative + * window at all. That is strictly more unmounted than a cold-parked pane, so a + * green B proves renderer parking cannot be the boundary that breaks the stream. + */ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import { RpcDispatcher } from '../../src/main/runtime/rpc/dispatcher' +import type { RpcRequest } from '../../src/main/runtime/rpc/core' +import { TERMINAL_METHODS } from '../../src/main/runtime/rpc/methods/terminal' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../src/shared/terminal-stream-protocol' +import { + TERMINAL_TAB_COLD_PARK_DELAY_MS, + TERMINAL_TAB_HOT_RETAIN_LIMIT, + canParkTerminalTabRenderer, + selectColdParkedTerminalTabs +} from '../../src/renderer/src/components/terminal-pane/terminal-hidden-view-parking' +import { buildTerminalTabColdParkCandidates } from '../../src/renderer/src/components/terminal-pane/terminal-tab-park-candidates' +import { createTerminalTabActivationOrder } from '../../src/renderer/src/components/terminal-pane/terminal-tab-activation-order' +import type { TerminalTab } from '../../src/shared/terminal-tab-types' + +const WORKTREE_ID = 'repo-1::/tmp/wt' +/** Host-owned, snapshot-backed local daemon PTY: the exact shape the report's + * host had (a local pane a paired client was watching). */ +const ptyIdFor = (n: number): string => `${WORKTREE_ID}@@aaaaaaa${n}` +const SUBSCRIBED_TAB_ID = 'tab-remote-viewed' +const MARKER_BEFORE_PARK = 'STA2854-BEFORE-PARK' +const MARKER_AFTER_PARK = 'STA2854-AFTER-PARK' +const MARKER_AFTER_RECONNECT = 'STA2854-AFTER-RECONNECT' +const CLIENT_INPUT = 'echo STA2854-INPUT\r' + +// --------------------------------------------------------------------------- +// A. Host renderer cold-park policy (pure, explicit clock — no timers) +// --------------------------------------------------------------------------- + +function tabModel(id: string, ptyId: string): TerminalTab { + return { id, ptyId, generation: 1 } as unknown as TerminalTab +} + +/** The report's topology, shrunk to the smallest set that still evicts: + * hot-retain limit + 1 hidden host tabs, one of them remotely subscribed. */ +function hiddenHostTabs(): TerminalTab[] { + const tabs: TerminalTab[] = [tabModel(SUBSCRIBED_TAB_ID, ptyIdFor(0))] + for (let i = 1; i <= TERMINAL_TAB_HOT_RETAIN_LIMIT + 1; i += 1) { + tabs.push(tabModel(`tab-${i}`, ptyIdFor(i))) + } + return tabs +} + +describe('STA-2854 A: host cold-park policy vs an active remote subscriber', () => { + it('parks a locally hidden host pane that a remote client is actively viewing', () => { + const tabs = hiddenHostTabs() + const hiddenSinceByTabId = new Map() + const activationOrder = createTerminalTabActivationOrder() + const t0 = 1_000_000 + + // Pass 1: the host user switches away — every tab starts its hidden clock. + buildTerminalTabColdParkCandidates({ + terminalTabs: tabs, + assignments: new Map(), + isWorktreeActive: false, + activeTerminalTabId: null, + portalTabIds: new Set(), + shouldMeasureHiddenWorktree: false, + hiddenSinceByTabId, + activationOrder, + nowMs: t0 + }) + expect(hiddenSinceByTabId.get(SUBSCRIBED_TAB_ID)).toBe(t0) + + // Pass 2: exactly the cold-park hysteresis later. The remote client has + // been driving this tab the whole time; the policy has no input for that. + const nowMs = t0 + TERMINAL_TAB_COLD_PARK_DELAY_MS + const candidates = buildTerminalTabColdParkCandidates({ + terminalTabs: tabs, + assignments: new Map(), + isWorktreeActive: false, + activeTerminalTabId: null, + portalTabIds: new Set(), + shouldMeasureHiddenWorktree: false, + hiddenSinceByTabId, + activationOrder, + nowMs + }) + const subscribedCandidate = candidates.find((c) => c.id === SUBSCRIBED_TAB_ID)! + + expect( + canParkTerminalTabRenderer({ + worktreeId: WORKTREE_ID, + terminalTab: subscribedCandidate, + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs + }) + ).toBe(true) + + const parked = selectColdParkedTerminalTabs({ + worktreeId: WORKTREE_ID, + terminalTabs: candidates, + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs + }) + // Eligible AND actually evicted: it is outside the hot-retain set. + expect(parked.has(SUBSCRIBED_TAB_ID)).toBe(true) + + // Negative control: local visibility is the only thing that saves it. + const visibleCandidate = { ...subscribedCandidate, isVisible: true, hiddenSinceMs: null } + expect( + canParkTerminalTabRenderer({ + worktreeId: WORKTREE_ID, + terminalTab: visibleCandidate, + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs + }) + ).toBe(false) + }) + + it('does not reset the hidden clock when a remote subscribe background-mounts the tab', () => { + const tabs = hiddenHostTabs() + const hiddenSinceByTabId = new Map() + const activationOrder = createTerminalTabActivationOrder() + const t0 = 1_000_000 + + buildTerminalTabColdParkCandidates({ + terminalTabs: tabs, + assignments: new Map(), + isWorktreeActive: false, + activeTerminalTabId: null, + portalTabIds: new Set(), + shouldMeasureHiddenWorktree: false, + hiddenSinceByTabId, + activationOrder, + nowMs: t0 + }) + + // Hours pass with the host untouched, then the client reconnects and the + // host renderer background-mounts the tab. A background mount never makes + // the tab locally visible, so it re-enters the candidate list with its + // original hidden stamp — already past the hysteresis on the first pass. + const remountNowMs = t0 + 4 * 60 * 60_000 + const candidates = buildTerminalTabColdParkCandidates({ + terminalTabs: tabs, + assignments: new Map(), + isWorktreeActive: false, + activeTerminalTabId: null, + portalTabIds: new Set(), + shouldMeasureHiddenWorktree: false, + hiddenSinceByTabId, + activationOrder, + nowMs: remountNowMs + }) + const subscribedCandidate = candidates.find((c) => c.id === SUBSCRIBED_TAB_ID)! + expect(subscribedCandidate.hiddenSinceMs).toBe(t0) + expect(remountNowMs - subscribedCandidate.hiddenSinceMs!).toBeGreaterThan( + TERMINAL_TAB_COLD_PARK_DELAY_MS + ) + expect( + selectColdParkedTerminalTabs({ + worktreeId: WORKTREE_ID, + terminalTabs: candidates, + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs: remountNowMs + }).has(SUBSCRIBED_TAB_ID) + ).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// B. Authoritative host stream across the strictest possible park +// --------------------------------------------------------------------------- + +type RuntimeInternals = { + recordPtyWorktree: ( + ptyId: string, + worktreeId: string, + state?: { connected?: boolean; connectionId?: string | null } + ) => unknown + issuePtyHandle: (pty: unknown) => string +} + +function internals(runtime: OrcaRuntimeService): RuntimeInternals { + return runtime as unknown as RuntimeInternals +} + +/** Host-side provider: a local daemon session whose data events are gated on + * attach, exactly like the real daemon boundary. Never renderer-gated. */ +function createHostProvider() { + const ptyId = ptyIdFor(0) + // Faithful to the report: the host pane WAS mounted (provider attached), + // then went locally hidden and cold-parked. + const session = { cols: 120, rows: 40, attached: true, screen: '' } + const attachCalls: string[] = [] + const writes: [string, string][] = [] + let runtime: OrcaRuntimeService | null = null + const controller = { + write: (id: string, text: string) => { + writes.push([id, text]) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { id: ptyId, cwd: '/tmp/wt', worktreeId: WORKTREE_ID, title: '', cols: 120, rows: 40 } + ], + // A parked pane has no renderer serializer — this is the park, expressed + // at the only boundary main can observe it from. + hasRendererSerializer: () => false, + getSize: () => ({ cols: session.cols, rows: session.rows }), + resize: () => true, + attach: async (id: string) => { + attachCalls.push(id) + session.attached = true + return true + }, + serializeProviderBuffer: async () => ({ + data: session.screen, + cols: session.cols, + rows: session.rows, + seq: 0, + source: 'headless' as const + }) + } + return { + ptyId, + controller, + attachCalls, + writes, + session, + bind(target: OrcaRuntimeService) { + runtime = target + }, + emitData(data: string): boolean { + if (!session.attached) { + return false + } + session.screen += data + runtime?.onPtyData(ptyId, data, Date.now()) + return true + } + } +} + +function startMultiplex(runtime: OrcaRuntimeService, connectionId: string) { + const messages: { result?: { type?: string; streamId?: number | null } }[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const request: RpcRequest = { + id: `req-${connectionId}`, + authToken: 'tok', + method: 'terminal.multiplex', + params: {} + } + const dispatchPromise = dispatcher.dispatchStreaming( + request, + (msg) => { + messages.push(JSON.parse(msg)) + }, + { + connectionId, + sendBinary: (bytes: Uint8Array) => { + binaryFrames.push(bytes) + return true + }, + registerBinaryStreamHandler: ( + streamId: number, + handler: (frame: NonNullable>) => void + ) => { + handlers.set(streamId, handler) + return () => { + if (handlers.get(streamId) === handler) { + handlers.delete(streamId) + } + } + } + } + ) + return { messages, binaryFrames, handlers, dispatchPromise } +} + +type Harness = ReturnType + +function sendSubscribe(harness: Harness, streamId: number, terminal: string, clientId: string) { + harness.handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId, + terminal, + client: { id: clientId, type: 'desktop' } + }) + }) + )! + ) +} + +function sendInput(harness: Harness, streamId: number, text: string) { + harness.handlers.get(streamId)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 2, + payload: encodeTerminalStreamText(text) + }) + )! + ) +} + +async function waitForSubscribed(harness: Harness, streamId: number): Promise { + await vi.waitFor(() => + expect( + harness.messages.some( + (m) => m.result?.type === 'subscribed' && m.result?.streamId === streamId + ) + ).toBe(true) + ) +} + +function outputText(harness: Harness): string { + return harness.binaryFrames + .map(decodeTerminalStreamFrame) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') +} + +function errorFrames(harness: Harness): number { + return harness.binaryFrames + .map(decodeTerminalStreamFrame) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Error).length +} + +describe('STA-2854 B: remote subscriber survives a fully unmounted host renderer', () => { + it('streams, accepts input, and re-subscribes with no host renderer pane at all', async () => { + const runtime = new OrcaRuntimeService() + const provider = createHostProvider() + provider.bind(runtime) + runtime.setPtyController(provider.controller as never) + const record = internals(runtime).recordPtyWorktree(provider.ptyId, WORKTREE_ID, { + connected: true + }) + const handle = internals(runtime).issuePtyHandle(record) + // The park, at its strictest: no authoritative window exists, so a renderer + // mount request is impossible — more unmounted than a cold-parked pane. + const mountSpy = vi.spyOn(runtime, 'requestRendererTerminalTabMount').mockReturnValue(false) + // The pane produced bytes while it was mounted, so main already owns model + // state for it — no subscriber-driven (re)attach is in play. + expect(provider.emitData('host pane was live\r\n')).toBe(true) + + const harness = startMultiplex(runtime, 'conn-client-b') + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + + sendSubscribe(harness, 1, handle, 'client-b') + await waitForSubscribed(harness, 1) + + // Two independent signals: authoritative subscriber presence AND bytes. + expect(runtime.hasRemoteTerminalViewSubscriber(provider.ptyId)).toBe(true) + // This is the exact input `syncPtyBackgroundedDelivery` reads to veto + // daemon keep-tail thinning for a host-locally-hidden PTY. + expect(runtime.hasRawTerminalViewSubscriber(provider.ptyId)).toBe(true) + + expect(provider.emitData(`${MARKER_BEFORE_PARK}\r\n`)).toBe(true) + await vi.waitFor(() => expect(outputText(harness)).toContain(MARKER_BEFORE_PARK)) + + // --- host cold-park episode: nothing about the pane exists on the host --- + expect(provider.emitData(`${MARKER_AFTER_PARK}\r\n`)).toBe(true) + await vi.waitFor(() => expect(outputText(harness)).toContain(MARKER_AFTER_PARK)) + + // Input still reaches the authoritative PTY while fully unmounted. + sendInput(harness, 1, CLIENT_INPUT) + await vi.waitFor(() => expect(provider.writes).toContainEqual([provider.ptyId, CLIENT_INPUT])) + + // PTY identity never changed and the stream never errored. + expect(provider.attachCalls).toEqual([]) + expect(errorFrames(harness)).toBe(0) + expect(harness.messages.some((m) => m.result?.type === 'error')).toBe(false) + + // --- reconnect: a fresh connection re-subscribes with no mount available --- + const reconnected = startMultiplex(runtime, 'conn-client-b-2') + await vi.waitFor(() => expect(reconnected.handlers.has(0)).toBe(true)) + sendSubscribe(reconnected, 1, handle, 'client-b') + await waitForSubscribed(reconnected, 1) + expect(provider.emitData(`${MARKER_AFTER_RECONNECT}\r\n`)).toBe(true) + await vi.waitFor(() => expect(outputText(reconnected)).toContain(MARKER_AFTER_RECONNECT)) + + // Stronger than expected: subscribe and reconnect never even ASK for a + // renderer mount once the leaf owns a PTY, so a re-park cannot cycle them. + expect(mountSpy).not.toHaveBeenCalled() + expect(provider.attachCalls).toEqual([]) + expect(errorFrames(reconnected)).toBe(0) + }) + + it('counterfactual: losing every subscriber is what clears the thinning veto', async () => { + const runtime = new OrcaRuntimeService() + const provider = createHostProvider() + provider.bind(runtime) + runtime.setPtyController(provider.controller as never) + const record = internals(runtime).recordPtyWorktree(provider.ptyId, WORKTREE_ID, { + connected: true + }) + const handle = internals(runtime).issuePtyHandle(record) + vi.spyOn(runtime, 'requestRendererTerminalTabMount').mockReturnValue(false) + + const harness = startMultiplex(runtime, 'conn-counterfactual') + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + sendSubscribe(harness, 1, handle, 'client-b') + await waitForSubscribed(harness, 1) + expect(runtime.hasRawTerminalViewSubscriber(provider.ptyId)).toBe(true) + + harness.handlers.get(1)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Unsubscribe, + streamId: 1, + seq: 3, + payload: encodeTerminalStreamJson({}) + }) + )! + ) + await vi.waitFor(() => expect(runtime.hasRawTerminalViewSubscriber(provider.ptyId)).toBe(false)) + // The PTY itself is untouched: parking/unsubscribing never kills it. + expect(provider.session.attached).toBe(true) + expect(provider.emitData('post-unsubscribe\r\n')).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// C. The path host parking really can starve: a host PTY main never attached +// (never-activated or parked-from-birth tab) while no renderer can mount it. +// --------------------------------------------------------------------------- + +describe('STA-2854 C: an unattached host PTY with no mountable renderer pane', () => { + it('still streams to the remote subscriber without any renderer mount', async () => { + const runtime = new OrcaRuntimeService() + const provider = createHostProvider() + // Parked/never-activated: no pane ever attached this daemon session, so the + // provider is not emitting and only main can start it. + provider.session.attached = false + provider.bind(runtime) + runtime.setPtyController(provider.controller as never) + const record = internals(runtime).recordPtyWorktree(provider.ptyId, WORKTREE_ID, { + connected: true + }) + const handle = internals(runtime).issuePtyHandle(record) + const mountSpy = vi.spyOn(runtime, 'requestRendererTerminalTabMount').mockReturnValue(false) + + const harness = startMultiplex(runtime, 'conn-client-c') + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + // Baseline: the daemon refuses to emit for an unattached session. + expect(provider.emitData('pre-subscribe\r\n')).toBe(false) + + sendSubscribe(harness, 1, handle, 'client-c') + await waitForSubscribed(harness, 1) + + // Main itself must attach: the renderer cannot help while parked. + await vi.waitFor(() => expect(provider.attachCalls).toEqual([provider.ptyId])) + expect(provider.emitData(`${MARKER_AFTER_PARK}\r\n`)).toBe(true) + await vi.waitFor(() => expect(outputText(harness)).toContain(MARKER_AFTER_PARK)) + expect(mountSpy).not.toHaveBeenCalled() + expect(errorFrames(harness)).toBe(0) + }) +}) diff --git a/tests/e2e/host-parked-pane-remote-viewer.spec.ts b/tests/e2e/host-parked-pane-remote-viewer.spec.ts new file mode 100644 index 00000000000..1f2c511f00a --- /dev/null +++ b/tests/e2e/host-parked-pane-remote-viewer.spec.ts @@ -0,0 +1,384 @@ +/** + * STA-2854 live validation: a HOST pane that cold-parks while a paired client + * is actively viewing it must keep serving that client. + * + * Topology: headed Orca desktop host (remote server) + a separate paired Orca + * desktop client. The reported shape is the inverse of every existing paired + * parking spec: those park on the CLIENT, this parks on the HOST while the + * client watches. The host user never touches the tab again after parking it. + * + * Two independent signals, as required for an end-to-end claim: + * 1. host-side: the fixture process's own sink file records the typed line — + * the bytes reached the authoritative host PTY; + * 2. client-side: the client's xterm paints the echo — output crossed back. + * Plus PTY identity: the sink must still hold exactly one READY line, so the + * park never respawned or replaced the process. + * + * Before the fix, `H±` = host pane mounted, phase = client transport, 2s samples: + * H-/recovering x8 -> H-/connected x37, input-reached=false + * The host park dropped the paired client into the "Reconnecting to remote + * runtime" state for ~16s; it reconnected on its own, but the line typed at + * park time was silently discarded forever by recoveryBlocksIo(). Reproduced + * identically at the production 30s cold-park hysteresis, so the shortened test + * override was not the cause. + * + * After the fix (parked panes keep publishing their runtime-graph leaf): + * H-/connected x45, the typed line reaches the host PTY AND its echo paints + * back in the client's own xterm within one 2s sample. + * + * Run: + * pnpm exec playwright test tests/e2e/host-parked-pane-remote-viewer.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { + HOST_TERMINAL_SURFACE_SEPARATOR, + toWebTerminalSurfaceTabId +} from '../../src/shared/terminal-surface-id' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient +} from './helpers/paired-electron-client' +import { focusActiveTerminalInput } from './helpers/terminal' +import { waitForTabParked } from './helpers/terminal-hidden-parking' + +const PARK_DELAY_MS = 30_000 +const PAINT_BUDGET_MS = 20_000 +const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-host-park-viewer-')) +const fixturePath = path.join(scratch, 'host-park-viewer-terminal.mjs') +writeFileSync( + fixturePath, + [ + "import { appendFileSync } from 'node:fs'", + 'const sink = process.argv[2]', + 'const record = (line) => appendFileSync(sink, `${line}\\n`)', + 'record(`READY:${process.pid}`)', + 'process.stdout.write(`READY:${process.pid}\\r\\n`)', + "process.stdin.setEncoding('utf8')", + "let pending = ''", + "process.stdin.on('data', (data) => {", + ' pending += data', + ' const lines = pending.split(/\\r\\n|\\r|\\n/)', + " pending = lines.pop() ?? ''", + ' for (const line of lines) {', + ' record(`LINE:${line}`)', + ' process.stdout.write(`LINE:${line}\\r\\n`)', + ' }', + '})', + 'process.stdin.resume()' + ].join('\n') +) + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +// Why: the HOST must park quickly too, so its launch env carries the override. +test.use({ orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARK_DELAY_MS) } }) + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function fixtureCommand(sinkPath: string): string { + const command = [process.execPath, fixturePath, sinkPath] + return process.platform === 'win32' + ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') + : command.map(shellQuote).join(' ') +} + +function readSink(sinkPath: string): string { + try { + return readFileSync(sinkPath, 'utf8') + } catch { + return '' + } +} + +async function callEnvironment( + page: Page, + environmentId: string, + method: string, + params: unknown +): Promise { + return page.evaluate( + async ({ environmentId, method, params }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method, + params + }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { environmentId, method, params } + ) as Promise +} + +async function readPaneContent(page: Page, tabId: string): Promise { + return page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.serializeAddon?.serialize?.() ?? '' + }, tabId) +} + +async function waitForPaneMarker( + page: Page, + tabId: string, + marker: string, + budgetMs: number +): Promise { + const deadline = Date.now() + budgetMs + while (Date.now() < deadline) { + if ((await readPaneContent(page, tabId)).includes(marker)) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + return false +} + +test('a cold-parked host pane keeps serving its paired remote viewer', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(600_000) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const previousParkDelay = process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS + process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = String(PARK_DELAY_MS) + const client = await launchPairedElectronClient(offer, testInfo, 'host-park-viewer') + const createdTerminals: string[] = [] + const sinkPath = path.join(scratch, `sink-${randomUUID()}.log`) + try { + const worktreeId = await orcaPage.evaluate(() => { + const id = window.__store?.getState().activeWorktreeId + if (!id) { + throw new Error('headed host has no active worktree') + } + return id + }) + await expect + .poll( + () => + client.page.evaluate( + (id) => + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) ?? false, + worktreeId + ), + { timeout: 60_000, message: 'paired client never saw the host worktree' } + ) + .toBe(true) + + // Host-owned terminal, created through the host runtime. + const created = await callEnvironment<{ tab: { id: string; terminal: string | null } }>( + client.page, + client.environmentId, + 'session.tabs.createTerminal', + { + worktree: `id:${worktreeId}`, + command: fixtureCommand(sinkPath), + activate: false, + select: false, + navigation: 'caller' + } + ) + if (!created.tab.terminal) { + throw new Error('host session terminal was not created') + } + createdTerminals.push(created.tab.terminal) + const hostTabId = created.tab.id.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0] + const webTabId = toWebTerminalSurfaceTabId(hostTabId) + + // 1. Host mounts the pane (the ordinary "someone looked at it" state). + await orcaPage.evaluate( + ({ worktreeId, tabId }) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(worktreeId) + state?.setActiveTab(tabId) + state?.setActiveTabType('terminal') + }, + { worktreeId, tabId: hostTabId } + ) + await expect + .poll(() => orcaPage.evaluate((id) => window.__paneManagers?.has(id) ?? false, hostTabId), { + timeout: 60_000, + message: 'host never mounted its own terminal pane' + }) + .toBe(true) + + // 2. Client subscribes and is actively viewing it. + await expect + .poll( + () => + client.page.evaluate( + (id) => (window.__store?.getState().tabsByWorktree[id] ?? []).map((tab) => tab.id), + worktreeId + ), + { timeout: 60_000, message: 'client never mirrored the host tab' } + ) + .toContain(webTabId) + await client.page.evaluate( + ({ webTabId, worktreeId }) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(worktreeId) + state?.setActiveTab(webTabId) + state?.setActiveTabType('terminal') + }, + { webTabId, worktreeId } + ) + expect( + await waitForPaneMarker(client.page, webTabId, 'READY:', PAINT_BUDGET_MS), + 'client never painted the host terminal before the host parked it' + ).toBe(true) + + // 2b. CONTROL: the same typed path must work BEFORE the host parks. Without + // this, a focus/keyboard artifact would masquerade as the park bug. + const controlToken = `sta2854-control-${randomUUID().slice(0, 8)}` + await focusActiveTerminalInput(client.page) + await client.page.keyboard.type(controlToken) + await client.page.keyboard.press('Enter') + const controlReached = await (async () => { + const deadline = Date.now() + PAINT_BUDGET_MS + while (Date.now() < deadline) { + if (readSink(sinkPath).includes(`LINE:${controlToken}`)) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + return false + })() + console.log( + `[sta2854] control-reached=${controlReached} sink=${JSON.stringify(readSink(sinkPath))}` + ) + expect( + controlReached, + 'control: client input did not reach the host PTY even before any park' + ).toBe(true) + + // 3. The host cold-parks the pane the client is watching. Nobody touches + // it on the host again. + // Two decoy tabs on the host: one to age the target, one to take the + // most-recently-hidden exemption. Sampled before and after the park lands + // so decoy churn cannot be mistaken for the park itself. + for (let i = 0; i < 2; i += 1) { + await orcaPage.evaluate((id) => { + const state = window.__store?.getState() + const tab = state?.createTab(id, undefined, undefined, { activate: true }) + if (tab) { + state?.setActiveTab(tab.id) + state?.setActiveTabType('terminal') + } + }, worktreeId) + } + const readClientState = async (): Promise => + client.page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return { + mounted: Boolean(manager), + ptyId: pane?.container?.dataset?.ptyId ?? null, + recoveryState: pane?.container?.dataset?.ptyRecoveryState ?? null + } + }, webTabId) + console.log(`[sta2854] decoys-created client=${JSON.stringify(await readClientState())}`) + await waitForTabParked(orcaPage, hostTabId, { parkDelayMs: PARK_DELAY_MS }) + console.log(`[sta2854] post-park client=${JSON.stringify(await readClientState())}`) + + // Direct probe: is the host-minted terminal handle still resolvable once + // its pane is parked? (It is — so the boundary is not handle liveness.) + const handleProbe = await client.page.evaluate( + async ({ environmentId, terminal }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'terminal.read', + params: { terminal } + }) + return response.ok + ? { ok: true as const } + : { ok: false as const, code: response.error.code, message: response.error.message } + }, + { environmentId: client.environmentId, terminal: created.tab.terminal } + ) + console.log(`[sta2854] handle-probe=${JSON.stringify(handleProbe)}`) + + // Type the moment the host parks — a user driving the pane does not wait + // for a banner. Input accepted here must reach the host PTY. + const token = `sta2854-${randomUUID().slice(0, 8)}` + await focusActiveTerminalInput(client.page) + await client.page.keyboard.type(token) + await client.page.keyboard.press('Enter') + + // Timeline: host pane mount state vs client transport phase. Long enough to + // catch a second park cycle if the recovery remounts the host pane. + const timeline: string[] = [] + const timelineDeadline = Date.now() + 90_000 + let inputReached = false + let clientEchoed = false + while (Date.now() < timelineDeadline) { + const hostMounted = await orcaPage.evaluate( + (id) => window.__paneManagers?.has(id) ?? false, + hostTabId + ) + const clientPhase = await client.page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyRecoveryState ?? 'unmounted' + }, webTabId) + inputReached ||= readSink(sinkPath).includes(`LINE:${token}`) + // Signal 2, and the reason this is not merely a host-side test: the echo + // has to come back out to the client's own xterm. + clientEchoed ||= (await readPaneContent(client.page, webTabId)).includes(`LINE:${token}`) + timeline.push( + `${hostMounted ? 'H+' : 'H-'}/${clientPhase}${inputReached ? '/in' : ''}${clientEchoed ? '/echo' : ''}` + ) + await new Promise((resolve) => setTimeout(resolve, 2_000)) + } + console.log(`[sta2854] timeline=${JSON.stringify(timeline)}`) + console.log( + `[sta2854] input-reached=${inputReached} client-echoed=${clientEchoed} sink=${JSON.stringify(readSink(sinkPath))}` + ) + + const disruptedSamples = timeline.filter((entry) => !entry.includes('/connected')) + expect( + { disrupted: disruptedSamples.length, timeline }, + 'host-local cold parking disrupted the paired client transport' + ).toEqual({ disrupted: 0, timeline }) + expect( + { inputReached, clientEchoed }, + 'the parked host pane did not carry a full round trip for its remote viewer' + ).toEqual({ inputReached: true, clientEchoed: true }) + + // 5. PTY identity: one process, never respawned across the park. + const readyLines = readSink(sinkPath) + .split('\n') + .filter((line) => line.startsWith('READY:')) + expect(readyLines, 'host PTY was replaced across the park').toHaveLength(1) + // Intentionally not asserted: whether the host pane stays parked is part of + // what the timeline above reports (a recovery-driven remount would show as + // H+ samples), not a precondition of the invariant. + } finally { + if (previousParkDelay === undefined) { + delete process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS + } else { + process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = previousParkDelay + } + for (const terminal of createdTerminals) { + await callEnvironment(client.page, client.environmentId, 'terminal.closeTab', { + terminal + }).catch(() => undefined) + } + await client.dispose() + } +}) diff --git a/tests/e2e/multi-client-navigation-isolation.spec.ts b/tests/e2e/multi-client-navigation-isolation.spec.ts index 2c1018a75fa..c8a01eff636 100644 --- a/tests/e2e/multi-client-navigation-isolation.spec.ts +++ b/tests/e2e/multi-client-navigation-isolation.spec.ts @@ -179,6 +179,101 @@ test('keeps two paired browser clients and the host on independent worktrees', a } }) +test('keeps a paired client workspace create-with-agent off the other client and the host', async ({ + orcaPage, + electronApp, + testRepoPath +}) => { + const suffix = randomUUID().slice(0, 8) + const branchA = `e2e-create-a-${suffix}` + const branchB = `e2e-create-b-${suffix}` + addGitWorktree(testRepoPath, branchA) + addGitWorktree(testRepoPath, branchB) + + await expect + .poll(() => loadTestWorktreeIds(orcaPage, branchA, branchB), { + timeout: 30_000, + message: 'Expected host plus client-selectable worktrees' + }) + .not.toBeNull() + const ids = await loadTestWorktreeIds(orcaPage, branchA, branchB) + if (!ids) { + throw new Error('Test worktrees disappeared after discovery') + } + + await selectWorktree(orcaPage, ids.host) + + let clientA: Page | null = null + let clientB: Page | null = null + try { + const offerA = await createPairingOffer(orcaPage) + clientA = await openPairedClient(electronApp, offerA, ids.clientA) + await selectWorktree(clientA, ids.clientA) + + const offerB = await createPairingOffer(orcaPage) + clientB = await openPairedClient(electronApp, offerB, ids.clientB) + await selectWorktree(clientB, ids.clientB) + + // Client A creates a workspace with a startup command, which is the only remote + // create shape the renderer sends `activate: true` for (STA-2802's field trigger). + const createdWorktreeId = await clientA.evaluate(async (name) => { + const store = window.__store + if (!store) { + throw new Error('paired client store unavailable') + } + const state = store.getState() + const repoId = state + .allWorktrees() + .find((worktree) => worktree.id === state.activeWorktreeId)?.repoId + if (!repoId) { + throw new Error('active worktree has no repo') + } + const result = await state.createWorktree( + repoId, + name, + undefined, + 'skip', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { command: 'echo sta-2802-startup' } + ) + return result.worktree.id + }, `e2e-created-${suffix}`) + + // Shared catalog state must still reach the observer... + await expect(worktreeRow(clientB, createdWorktreeId)).toBeVisible({ timeout: 30_000 }) + // ...while its view stays exactly where its own user left it. + await expectActiveWorktree(clientB, ids.clientB) + await expectActiveWorktree(orcaPage, ids.host) + + // The creator can still reach and open what it made, and doing so still moves nobody + // else. This drives the store action directly, so the composer's automatic + // self-navigation on create is covered by worktree-creation-flow.test.ts and by the + // host-side composer journey in worktree.spec.ts, not here. + await selectWorktree(clientA, createdWorktreeId) + await expectActiveWorktree(clientB, ids.clientB) + await expectActiveWorktree(orcaPage, ids.host) + + // The observer keeps its own navigation authority afterwards. + await selectWorktree(clientB, ids.clientA2) + await expectActiveWorktree(clientA, createdWorktreeId) + await expectActiveWorktree(orcaPage, ids.host) + } finally { + await clientB?.close() + await clientA?.close() + } +}) + test('shows only provider-backed creation actions in paired web', async ({ electronApp, orcaPage diff --git a/tests/e2e/terminal-hangul-terminating-digit-native.spec.ts b/tests/e2e/terminal-hangul-terminating-digit-native.spec.ts new file mode 100644 index 00000000000..2fb90a9c992 --- /dev/null +++ b/tests/e2e/terminal-hangul-terminating-digit-native.spec.ts @@ -0,0 +1,250 @@ +/** + * Drives a real Hangul input method through a real compositor and asserts the bytes that reach + * the pty. Written to reproduce #15299, where a digit typed straight after a Hangul syllable was + * dropped under Wayland but not under X11. + * + * THIS DOES NOT RUN IN CI. It is gated on ORCA_E2E_NATIVE_IBUS_HANGUL=1 and needs a compositor + * session that CI does not have, so it is a manual reproduction harness rather than coverage. + * That is stated plainly because this repo already carries native IME specs that are skipped + * everywhere and were mistaken for coverage they never provided. + * + * To run it, on a machine with gnome-shell and ibus-hangul: + * + * Xvfb :65 -extension GLX & + * DISPLAY=:65 gnome-shell --nested --wayland # nested, NOT --headless + * ORCA_E2E_NATIVE_IBUS_HANGUL=1 ORCA_E2E_IME_INJECTOR=nested npx playwright test \ + * tests/e2e/terminal-hangul-terminating-digit-native.spec.ts + * + * Eight things that decide whether a run is real or a silent false negative, each of which cost a + * failed attempt: + * + * - Nested, not headless. A headless mutter never answers RemoteDesktop.CreateSession, so there + * is no way to inject input; nested makes the whole compositor an X window that xdotool can + * type into. ydotool does not help either - headless has no evdev seat. + * - Pick an unused display. Attaching to a stale Xvfb silently hands the run someone else's + * session, and a stale ibus-daemon wins the XIM selection over the new one. + * - The session script must not exit: ibus-daemon dies with its parent. + * - Under Playwright the window can stay hidden in a Wayland session, so the compositor has + * nothing to focus and zero DOM events arrive. The spec forces show()/focus(). + * - Send Escape before the byte reader starts, or GNOME's overview keeps focus and the Escape + * bytes corrupt the first line. + * - Check the session's ibus-daemon is not running --panel=disable before trusting anything that + * depends on seeing a candidate window. No panel means no lookup table is ever drawn, so a + * candidate-selection run measures nothing and reads as "the IME ignored the key". + * - Launch the browser with --password-store=basic --use-mock-keychain. A gnome-keyring unlock + * dialog or a Chrome update bubble grabs the keyboard, xdotool keys never reach the page, and + * the empty event log looks exactly like a broken IME. + * - Click the input to focus it. windowactivate alone does not give web contents real focus. + * + * The keys and expected text are environment-tunable, so other IME issues can reuse this + * unchanged rather than writing another one. + */ +import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + focusActiveTerminalInput, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + disposeTerminalImeBoundaryProbe, + installTerminalImeBoundaryProbe, + readTerminalImeBoundaryTrace +} from './terminal-ime-boundary-probe' +import { + createTerminalImeByteReader, + removeTerminalImeByteReader, + startTerminalImeByteReader, + waitForTerminalImeBytes +} from './terminal-ime-byte-reader' + +const NATIVE_COMMAND_TIMEOUT_MS = 10_000 +const REPETITIONS = Number(process.env.ORCA_E2E_DIGIT_REPETITIONS ?? 3) +const INJECTOR = process.env.ORCA_E2E_IME_INJECTOR ?? 'xdotool' +const WAYLAND_INJECT = process.env.ORCA_E2E_WAYLAND_INJECT ?? '/tmp/ime15299/wayland-inject.py' +// The nested compositor is one X11 window; keys land on it and mutter routes +// them to the focused Wayland client through the IME. +const NESTED_FOCUS_CMD = process.env.ORCA_E2E_NESTED_FOCUS_CMD ?? '/tmp/ime15299/focus-nested.sh' +// Dubeolsik: d=ㅇ, k=ㅏ, so `d k` composes 아 and the digit terminates it. +const KEY_TOKENS = (process.env.ORCA_E2E_DIGIT_KEYS ?? 'd k 1 Return').split(' ').filter(Boolean) +const EXPECTED_LINE = process.env.ORCA_E2E_DIGIT_EXPECTED ?? '아1' + +test.use({ + orcaAppExtraEnv: { + GTK_IM_MODULE: 'ibus', + IBUS_ENABLE_SYNC_MODE: '1', + QT_IM_MODULE: 'ibus', + XMODIFIERS: '@im=ibus', + ...(process.env.ORCA_E2E_EXTRA_APP_ENV + ? (JSON.parse(process.env.ORCA_E2E_EXTRA_APP_ENV) as Record) + : {}) + }, + orcaAppExtraArgs: (process.env.ORCA_E2E_EXTRA_APP_ARGS ?? '').split(' ').filter(Boolean) +}) + +function injectKeys(tokens: string[]): void { + if (INJECTOR === 'wayland') { + execFileSync('python3', [WAYLAND_INJECT, ...tokens], { + stdio: 'pipe', + timeout: NATIVE_COMMAND_TIMEOUT_MS + }) + return + } + for (const token of tokens) { + execFileSync('xdotool', ['key', '--clearmodifiers', token], { + stdio: 'pipe', + timeout: NATIVE_COMMAND_TIMEOUT_MS + }) + } +} + +/** GNOME Shell parks keyboard focus in the overview until a window is up, so + * Escape has to run before the byte reader starts or those Escapes land in it. */ +function leaveNestedOverview(): void { + execFileSync(NESTED_FOCUS_CMD, [], { stdio: 'pipe', timeout: NATIVE_COMMAND_TIMEOUT_MS }) + for (let index = 0; index < 2; index += 1) { + execFileSync('xdotool', ['key', 'Escape'], { + stdio: 'pipe', + timeout: NATIVE_COMMAND_TIMEOUT_MS + }) + } +} + +function captureNestedScreen(name: string): void { + const dir = path.join(process.cwd(), 'test-results', 'terminal-ime-evidence') + mkdirSync(dir, { recursive: true }) + execFileSync('import', ['-window', 'root', path.join(dir, `${name}.png`)], { + stdio: 'pipe', + timeout: NATIVE_COMMAND_TIMEOUT_MS + }) +} + +async function focusNativeTerminalWindow(page: Page): Promise { + await focusActiveTerminalInput(page) + const title = `ORCA_NATIVE_IBUS_${randomUUID()}` + await page.evaluate((nextTitle) => { + document.title = nextTitle + }, title) + await expect.poll(() => page.title(), { timeout: 5_000 }).toBe(title) + if (INJECTOR === 'nested') { + execFileSync(NESTED_FOCUS_CMD, [], { + stdio: 'pipe', + timeout: NATIVE_COMMAND_TIMEOUT_MS + }) + } else if (INJECTOR !== 'wayland') { + execFileSync('xdotool', ['search', '--onlyvisible', '--name', title, 'windowfocus', '--sync'], { + stdio: 'pipe', + timeout: NATIVE_COMMAND_TIMEOUT_MS + }) + } + execFileSync('ibus', ['engine', 'hangul'], { + stdio: 'pipe', + timeout: NATIVE_COMMAND_TIMEOUT_MS + }) + const engine = execFileSync('ibus', ['engine'], { + encoding: 'utf8', + timeout: NATIVE_COMMAND_TIMEOUT_MS + }).trim() + expect(engine).toBe('hangul') +} + +async function writeEvidence( + page: Page, + testInfo: TestInfo, + name: string, + extra: Record +): Promise { + const trace = await readTerminalImeBoundaryTrace(page) + const payload = { ...extra, injector: INJECTOR, keyTokens: KEY_TOKENS, trace } + const body = `${JSON.stringify(payload, null, 2)}\n` + await testInfo.attach(`${name}.json`, { body, contentType: 'application/json' }) + const dir = path.join(process.cwd(), 'test-results', 'terminal-ime-evidence') + mkdirSync(dir, { recursive: true }) + writeFileSync(path.join(dir, `${name}.json`), body) +} + +test.describe('Hangul terminating digit @headful', () => { + test.skip(process.env.ORCA_E2E_NATIVE_IBUS_HANGUL !== '1', 'native ibus harness only') + + test('a digit typed right after a Hangul syllable reaches the pty', async ({ + electronApp, + orcaPage: page, + testRepoPath + }, testInfo) => { + const launchDiagnostics = await electronApp.evaluate(({ app: electron, BrowserWindow }) => ({ + waylandDisplay: process.env.WAYLAND_DISPLAY ?? null, + display: process.env.DISPLAY ?? null, + xdgRuntimeDir: process.env.XDG_RUNTIME_DIR ?? null, + ozonePlatform: electron.commandLine.getSwitchValue('ozone-platform'), + disableGpu: electron.commandLine.hasSwitch('disable-gpu'), + windows: BrowserWindow.getAllWindows().map((window) => ({ + visible: window.isVisible(), + minimized: window.isMinimized(), + bounds: window.getBounds() + })) + })) + console.log(`[digit-diag] ${JSON.stringify(launchDiagnostics)}`) + if (INJECTOR === 'nested') { + // Under Wayland the app's ready-to-show never fires here, so the window + // stays hidden and the compositor has nothing to give keyboard focus to. + await electronApp.evaluate(({ BrowserWindow }) => { + for (const window of BrowserWindow.getAllWindows()) { + window.show() + window.focus() + } + }) + await page.waitForTimeout(2_000) + } + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + + const ptyId = await waitForActivePanePtyId(page) + const reader = createTerminalImeByteReader(testRepoPath, REPETITIONS) + let receivedBytes: string[] = [] + const expectedHex = Buffer.from(`${EXPECTED_LINE}\n`).toString('hex') + try { + if (INJECTOR === 'nested') { + leaveNestedOverview() + await page.waitForTimeout(1_500) + captureNestedScreen('nested-before-reader') + } + await startTerminalImeByteReader(page, ptyId, reader) + await focusNativeTerminalWindow(page) + if (INJECTOR === 'nested') { + captureNestedScreen('nested-before-typing') + } + await installTerminalImeBoundaryProbe(page) + + for (let index = 0; index < REPETITIONS; index += 1) { + injectKeys(KEY_TOKENS) + await page.waitForTimeout(500) + } + if (INJECTOR === 'nested') { + captureNestedScreen('nested-after-typing') + } + + receivedBytes = await waitForTerminalImeBytes(page, reader, 20_000) + } finally { + await writeEvidence(page, testInfo, 'hangul-terminating-digit', { + expectedHex, + expectedLine: EXPECTED_LINE, + receivedBytes, + decoded: receivedBytes.map((hex) => Buffer.from(hex, 'hex').toString('utf8')) + }).catch(() => undefined) + await disposeTerminalImeBoundaryProbe(page).catch(() => undefined) + await sendToTerminal(page, ptyId, '\x03').catch(() => undefined) + removeTerminalImeByteReader(reader) + } + expect(receivedBytes.map((hex) => Buffer.from(hex, 'hex').toString('utf8'))).toEqual( + Array.from({ length: REPETITIONS }, () => `${EXPECTED_LINE}\n`) + ) + }) +}) diff --git a/tests/e2e/terminal-tab-close-restart-persistence.spec.ts b/tests/e2e/terminal-tab-close-restart-persistence.spec.ts index 1a0cab6e2fe..9df296d287b 100644 --- a/tests/e2e/terminal-tab-close-restart-persistence.spec.ts +++ b/tests/e2e/terminal-tab-close-restart-persistence.spec.ts @@ -11,6 +11,7 @@ import { waitForSessionReady } from './helpers/store' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { worktreeRowSurface } from './worktree-row-locators' import { RuntimeClient } from '../../src/cli/runtime/client' import { RuntimeRpcFailureError } from '../../src/cli/runtime/types' import type { @@ -149,6 +150,18 @@ test('durable whole-tab close removes a split tab across restart', async (// oxl worktree: `id:${worktreeId}` }) expect(afterRestart.result.terminals).toEqual([]) + + // Why: the tombstone only binds passive hydration. Clicking the sidebar row is the + // user asking for the workspace back, so explicit activation must re-seed a fresh + // terminal instead of leaving the blank tab bar that regressed in #14590. + await worktreeRowSurface(secondLaunch.page, worktreeId).click() + await expect + .poll(() => getWorktreeTabs(secondLaunch.page, worktreeId), { + message: 'Explicit sidebar activation did not re-seed a terminal tab' + }) + .toHaveLength(1) + const reseededTabs = await getWorktreeTabs(secondLaunch.page, worktreeId) + expect(reseededTabs[0]?.id).not.toBe(closedTabId) } finally { if (firstApp) { await session.close(firstApp)