Merge remote-tracking branch 'origin/main' into brennanb2025/codex-structured-core

# Conflicts:
#	src/renderer/src/lib/worktree-activation.ts
#	src/renderer/src/store/slices/terminals.ts
This commit is contained in:
Brennan Benson
2026-08-19 17:22:43 -07:00
190 changed files with 11185 additions and 1394 deletions
-5
View File
@@ -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
<!-- Optional but appreciated -->
+30 -2
View File
@@ -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
+3 -2
View File
@@ -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.
<img src="docs/assets/wechat-qr-group7.jpg" alt="WeChat group 7 QR code for the Orca community" width="160" />
<img src="docs/assets/wechat-qr-group7.jpg" alt="WeChat group 7 QR code for the Orca community" width="160" />&nbsp;&nbsp;
<img src="docs/assets/wechat-qr-group8.jpg" alt="WeChat group 8 QR code for the Orca community" width="160" />
- **Feedback &amp; Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See the [privacy &amp; telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
+118
View File
@@ -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",
@@ -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')))
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 22m">
<title>downloads: 22m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 23m">
<title>downloads: 23m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">22m</text>
<text x="90" y="14">22m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">23m</text>
<text x="90" y="14">23m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

+3 -2
View File
@@ -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 群。
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 社区微信第 7 群二维码" width="160" />
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 社区微信第 7 群二维码" width="160" />&nbsp;&nbsp;
<img src="../assets/wechat-qr-group8.jpg" alt="Orca 社区微信第 8 群二维码" width="160" />
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
+22 -39
View File
@@ -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<string | null>(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<string | null>(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<Map<string, string>>(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<string>()
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({
<SectionList
ref={sectionListRef}
sections={sections}
keyExtractor={(w) => 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}`
@@ -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> = {}): 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'])
})
})
+31 -21
View File
@@ -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<Worktree, 'worktreeId' | 'hostId'>
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<string> = 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<string, Worktree[]>()
const childIds = new Set<string>()
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<string>()
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)
}
@@ -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> = {}): 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'])
@@ -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)}`
}))
}
}
@@ -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> = {}): 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 }
])
})
})
@@ -0,0 +1,69 @@
import { composeWorktreeHostIdentity } from '../../../src/shared/worktree/host-qualified-identity'
import type { Worktree } from './workspace-list-types'
type WorktreeRowIdentitySource = Pick<Worktree, 'worktreeId' | 'hostId'>
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<string>,
confirmed: readonly Worktree[]
): Set<string> {
if (previous.size === 0) {
return previous
}
const still = new Set<string>()
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<string>,
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
})
}
+1
View File
@@ -37,6 +37,7 @@ export const BOOLEAN_FLAGS = new Set([
'mobile',
'mobile-pairing',
'no-pairing',
'screen',
'parent-current',
'provision',
'ready',
+3
View File
@@ -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'}`,
+42 -8
View File
@@ -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:<id> 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<TResult>(
client: HandlerContext['client'],
method: string,
params?: unknown
): Promise<RuntimeRpcSuccess<TResult>> {
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<TResult>(method)
: await client.call<TResult>(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<string, string | boolean>): ExecutionHostId {
const host = parseHostFlag(flags)
@@ -54,7 +80,10 @@ export const PROJECT_HANDLERS: Record<string, CommandHandler> = {
'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<string, CommandHandler> = {
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<string, CommandHandler> = {
),
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<string, CommandHandler> = {
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<string, CommandHandler> = {
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')
+19
View File
@@ -76,11 +76,30 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
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 }) => {
+5 -1
View File
@@ -229,6 +229,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
}
}
const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue')
const activate = flags.get('activate') === true || flags.get('run-hooks') === true
const result = await client.call<RuntimeWorktreeCreateResult>('worktree.create', {
repo: await getCreateRepoSelector(flags, cwdParentWorktree, client),
name: getRequiredStringFlag(flags, 'name'),
@@ -237,7 +238,10 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
...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 } : {}),
+87
View File
@@ -191,6 +191,28 @@ describe('orca cli worktree awareness', () => {
expect(logSpy.mock.calls[0]?.[0]).not.toContain('setup-by-client')
})
// Why: --host runtime:<id> 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:<id>` 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,
@@ -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,
@@ -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,
+1
View File
@@ -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
+5 -12
View File
@@ -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,
+60
View File
@@ -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<ReturnType<typeof createServer>>()
const sockets = new Set<Socket>()
@@ -74,3 +76,61 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => {
})
})
})
describe('projectRemoteAppStatus', () => {
function remoteStatus(overrides: Partial<RuntimeStatus> = {}): 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()
})
})
+7 -14
View File
@@ -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<RuntimeRpcSuccess<CliStatusResult>> {
@@ -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<CliStatusResult> {
return {
id: 'local-status',
ok: true,
result,
result: { target: { kind: 'local' }, ...result },
_meta: {
runtimeId: result.runtime.runtimeId ?? 'none'
}
+9 -3
View File
@@ -197,17 +197,23 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
{
path: ['terminal', 'read'],
summary: 'Read bounded terminal output',
usage: 'orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'cursor', 'limit'],
usage:
'orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--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 <prev> 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'
]
},
{
+9 -1
View File
@@ -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')
}
+155
View File
@@ -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<string, unknown> = {}) {
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
})
})
+14 -1
View File
@@ -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<ReturnType<typeof client.call<{ setups: ProjectHostSetup[] }>>>
try {
result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list')
} catch (error) {
// Why: --host runtime:<id> 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)
@@ -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
@@ -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
@@ -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<never> {
}
beforeEach(() => {
resetWslTranscriptFsGateForTests()
mocks.readdir.mockReset()
mocks.stat.mockReset()
releaseStall = undefined
@@ -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 }
@@ -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
+1 -1
View File
@@ -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',
@@ -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
@@ -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])
})
+38 -35
View File
@@ -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<Cookies, 'get' | 'remove'> & {
}
// 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<Cookies, 'get' | 'remove'>
clearData: Session['clearData']
snapshotClearIdentities: CookieClearStore['snapshotClearIdentities']
restoreClearIdentities: CookieClearStore['restoreClearIdentities']
}
@@ -107,13 +110,25 @@ export async function withCookieMutationLock<T>(owner: object, run: () => Promis
function removableCookieEntries(
cookies: readonly Cookie[],
preserveFamilies: ReadonlySet<string>
preserveFamilies: ReadonlySet<string>,
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<string> = new Set()
preserveFamilies: ReadonlySet<string>,
importScope: ImportedDomainScope
): Promise<void> {
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,
@@ -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<typeof vi.fn>
let cookiesSetMock: ReturnType<typeof vi.fn>
let clearDataMock: ReturnType<typeof vi.fn>
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<string, unknown>; 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'))
})
})
@@ -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'])
@@ -20,7 +20,6 @@ type CdpCookie = { name: string; value: string; partitionKey?: Record<string, un
type FixtureResult = {
beforePartitionKey: Record<string, unknown> | 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<FixtureResult> {
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([
@@ -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)
})
@@ -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<CookieClearMocks> = {}) {
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<typeof identitiesFromClearCookies>[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
@@ -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<string>
ancestors: Set<string>
descendantRoots: Set<string>
} {
}
export function importedDomainScope(domains: readonly string[]): ImportedDomainScope {
const exact = new Set<string>()
const ancestors = new Set<string>()
const descendantRoots = new Set<string>()
@@ -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<typeof importedDomainScopes>
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<ReplacedImportedDomainCookies> {
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: [] }
@@ -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<typeof NodeFs>())
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<unknown>
remove: (url: string, name: string) => Promise<void>
}
}) => ({
get: (filter: object) => targetSession.cookies.get(filter),
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; 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<string, unknown>[] {
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<typeof vi.fn>
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()
}
})
})
+38 -16
View File
@@ -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[] = []
@@ -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()
}
}
@@ -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<string, () => 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<void>((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<void>
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<void>
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)
}
})
})
@@ -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 })
})
+144 -15
View File
@@ -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<string, boolean>()
// 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<number, PendingMainFrameNavigation>()
@@ -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<boolean> {
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<void> {
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)
@@ -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 }
})
})
})
@@ -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 {
+22 -5
View File
@@ -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<string, string>
pendingCookieImports: Record<string, PendingBrowserCookieImport>
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<string, string> =
data && typeof data.pendingCookieImports === 'object' && data.pendingCookieImports
? { ...data.pendingCookieImports }
: {}
const pendingCookieImports: Record<string, PendingBrowserCookieImport> = {}
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
}
@@ -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)
@@ -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()
@@ -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
+2 -1
View File
@@ -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.
+78
View File
@@ -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()
+10
View File
@@ -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',
+11
View File
@@ -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"\'*) ;;',
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileSyncMock, resolveCodexCommandMock } = vi.hoisted(() => ({
execFileSyncMock: vi.fn(),
resolveCodexCommandMock: vi.fn()
@@ -27,11 +27,7 @@ async function wsl(command: string, ...args: string[]): Promise<string> {
async function readThroughRawLoginShell(linuxPath: string): Promise<string> {
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 })
+1 -3
View File
@@ -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()
})
})
@@ -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()
@@ -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()
})
@@ -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<DirEntry[]>
readFile(filePath: string, limits?: FileReadLimits): Promise<FileReadResult>
/** 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<FileRangeReadResult>
/** Cached capability probe for `readFileRange`. */
supportsFileRangeRead?(options?: { signal?: AbortSignal }): Promise<boolean>
readTerminalArtifact?(
filePath: string,
options: TerminalArtifactAccessOptions
@@ -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) })
)
})
@@ -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<string, unknown>) => void = () => {}
const mux = {
request: vi
.fn()
.mockImplementationOnce(
() =>
new Promise<Record<string, unknown>>((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)
})
})
@@ -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<SshChannelMultiplexer, Promise<boolean>>()
/** `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<string, unknown> | null
const capabilitiesByMux = new WeakMap<SshChannelMultiplexer, Promise<RelayFsCapabilities>>()
/** 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<RelayFsCapabilities> {
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<string, unknown>) : 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<boolean> {
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<boolean> {
return readSshFsCapabilities(mux, signal).then(
(capabilities) => capabilities?.rangedReadVersion === 1
)
}
@@ -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<typeof vi.fn>): 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)
})
})
+33 -45
View File
@@ -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<FileRangeReadResult> {
return readSshFileRange(this.mux, filePath, position, length, options?.signal)
}
supportsFileRangeRead(options?: { signal?: AbortSignal }): Promise<boolean> {
return probeSshRangedReadCapability(this.mux, options?.signal)
}
readTerminalArtifact(
filePath: string,
options: TerminalArtifactAccessOptions
): Promise<FileReadResult> {
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<FileStat> {
return writeSshTerminalArtifact(this.mux, filePath, content, options)
}
async downloadFile(sourcePath: string, destinationPath: string): Promise<void> {
@@ -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<FileStat> {
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<void> {
await this.writeFileBase64Chunk(filePath, contentBase64, false)
}
@@ -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<FileRangeReadResult> {
// 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 }
}
@@ -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<FileReadResult> {
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<FileStat> {
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
}
+3
View File
@@ -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'
@@ -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 })
})
})
@@ -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<string, unknown>) => boolean
) => Promise<Record<string, unknown>>
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<WebSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(endpoint)
ws.once('open', () => resolve(ws))
ws.once('error', reject)
})
}
function nextMessage(ws: WebSocket): Promise<string> {
return new Promise((resolve) => {
ws.once('message', (data) => resolve(typeof data === 'string' ? data : data.toString('utf-8')))
})
}
async function authenticate(pairingUrl: string): Promise<PairedSession> {
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<string, unknown>): void {
session.ws.send(encrypt(JSON.stringify(request), session.sharedKey))
}
function createReader(session: PairedSession): ResponseReader {
type Waiter = {
id: string
predicate: (response: Record<string, unknown>) => boolean
resolve: (response: Record<string, unknown>) => void
}
const queued: Record<string, unknown>[] = []
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<string, unknown>
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, unknown>): string | undefined {
return (response.result as { type?: string } | undefined)?.type
}
function activeTabId(response: Record<string, unknown>): string | null {
return (response.result as RuntimeMobileSessionTabsResult | undefined)?.activeTabId ?? null
}
function snapshotVersion(response: Record<string, unknown>): 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<void> {
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<void>((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()
@@ -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<string, string> = { [LEAF_A]: PTY_A, [LEAF_B]: PTY_B, [LEAF_C]: PTY_A }
const INC_BY_LEAF: Record<string, string> = { [LEAF_A]: INC_A, [LEAF_B]: INC_B, [LEAF_C]: INC_A }
type TabSpec = { tabId: string; leafId: string; ptyId: string }
const TAB_SPECS: Record<string, TabSpec> = {
[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<void>((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<string, { runtimeSessionOwned: boolean }>
}
).ptysById.get(spec.ptyId)!
record.runtimeSessionOwned = true
;(
runtime as unknown as { pairedRendererSessionOwnedPtyIds: Set<string> }
).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<string, { runtimeSessionOwned: boolean }> }
).ptysById.get(ptyId)?.runtimeSessionOwned === true,
markCreatePending: (tabId: string) => {
;(
runtime as unknown as {
pendingMobileTerminalCreatesByKey: Map<string, object>
}
).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<string, { runtimeSessionOwned: boolean }> }
).ptysById.get(PTY_BY_LEAF[leafId]!)!
record.runtimeSessionOwned = true
;(
runtime as unknown as { pairedRendererSessionOwnedPtyIds: Set<string> }
).pairedRendererSessionOwnedPtyIds.add(PTY_BY_LEAF[leafId]!)
}
publishSplitSnapshot([LEAF_A, LEAF_B], 1)
return {
runtime,
publishSplitSnapshot,
isRuntimeSessionOwned: (ptyId: string) =>
(
runtime as unknown as { ptysById: Map<string, { runtimeSessionOwned: boolean }> }
).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<string[]> {
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<string[]> {
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)
})
})
+246 -42
View File
@@ -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<string, RuntimeLeafRecord>()
private graphSyncCallbacks: (() => void)[] = []
private waitersByHandle = new Map<string, Set<TerminalWaiter>>()
private ptyExitListenersByPtyId = new Map<string, Set<() => 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<RetiredTerminalSurface, 'worktreeId' | 'parentTabId' | 'leafId'>
@@ -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<RuntimeTerminalRead> {
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<RuntimeTerminalRead> {
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'
}
}
@@ -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<string, unknown>) => boolean
) => Promise<Record<string, unknown>>
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<string, unknown>) => {
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<WebSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(endpoint)
ws.once('open', () => resolve(ws))
ws.once('error', reject)
})
}
function nextMessage(ws: WebSocket): Promise<string> {
return new Promise((resolve) => {
ws.once('message', (data) => resolve(typeof data === 'string' ? data : data.toString('utf-8')))
})
}
export async function authenticate(pairingUrl: string): Promise<PairedSession> {
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<string, unknown>): void {
session.ws.send(encrypt(JSON.stringify(request), session.sharedKey))
}
export function createReader(session: PairedSession): ResponseReader {
type Waiter = {
id: string
predicate: (response: Record<string, unknown>) => boolean
resolve: (response: Record<string, unknown>) => void
}
const queued: Record<string, unknown>[] = []
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<string, unknown>
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, unknown>): string | undefined {
return (response.result as { type?: string } | undefined)?.type
}
export function activeTabId(response: Record<string, unknown>): string | null {
return (response.result as RuntimeMobileSessionTabsResult | undefined)?.activeTabId ?? null
}
export function snapshotVersion(response: Record<string, unknown>): 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)
}
}
@@ -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:<its own env id>`. 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)
})
})
@@ -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:<environment-id>` 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:<their-id>`, 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(),
+67 -19
View File
@@ -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<void>((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<typeof createTerminalOutputBatcher> | null = null
let unsubscribeData = (): void => {}
let unsubscribeFit = (): void => {}
let stopWatchingLifetime = (): void => {}
let resolveStream = (): void => {}
const streamClosed = new Promise<void>((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<boolean> | null = null
let outputBatcher: ReturnType<typeof createTerminalOutputBatcher> | 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<ArrayBufferLike> = new Uint8Array(),
@@ -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,
@@ -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<typeof WorktreeCreate>
type ManagedWorktreeCreateArgs = Parameters<OrcaRuntimeService['createManagedWorktree']>[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,
@@ -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 = <T>(_repo: string, _id: string | undefined, run: () => Promise<T>) =>
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' })
)
})
})
@@ -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
})
@@ -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', () => {
@@ -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<typeof WorkspaceLinkedItemSchema> | null
linkedTaskSourceContext?: z.infer<typeof TaskSourceContextSchema> | 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
@@ -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,
+13 -10
View File
@@ -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
+5 -15
View File
@@ -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> = {}): OrcaRuntimeService {
return {
@@ -313,19 +312,10 @@ describe('RpcDispatcher streaming', () => {
cleanupSubscriptionIfOwnedByConnection: vi.fn(
registry.cleanupSubscriptionIfOwnedByConnection
),
waitForTerminal: vi.fn(
() =>
new Promise<RuntimeTerminalWait>((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
@@ -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')
}
)
})
@@ -22,6 +22,7 @@ export function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): 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: () => () => {},
@@ -16,6 +16,7 @@ import {
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): 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: () => () => {},
@@ -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 }),
@@ -10,6 +10,7 @@ import type { RuntimeTerminalWait } from '../../../shared/runtime-types'
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
return {
getRuntimeId: () => 'test-runtime',
subscribeToPtyExit: vi.fn(() => vi.fn()),
registerRemoteTerminalViewSubscriber: () => () => {},
requestRendererTerminalTabMount: () => false,
getRendererTerminalSerializerGenerationForHandle: () => 0,
@@ -15,6 +15,7 @@ import {
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): 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: () => () => {},
@@ -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<RuntimeTerminalWait>(() => {}))
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')
})
})

Some files were not shown because too many files have changed in this diff Show More