mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
Merge origin/main into brennanb2025/ssh-stop-verdict-2
This commit is contained in:
@@ -23,14 +23,20 @@ jobs:
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
# Why no checkout: the Files API already has per-file additions/deletions.
|
||||
# Why the default branch and never pull/<n>/head: this job holds a write-scoped
|
||||
# GITHUB_TOKEN, so it may only execute reviewed code. A PR that edits these
|
||||
# scripts takes effect once merged.
|
||||
- name: Count test vs non-test LoC
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
TRUSTED_REF: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for script in pr-test-loc-table.mjs pr-test-loc-summary.mjs; do
|
||||
gh api "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/${script}?ref=pull/${{ github.event.pull_request.number }}/head" \
|
||||
gh api "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/${script}?ref=${TRUSTED_REF}" \
|
||||
--jq .content | base64 --decode > "$RUNNER_TEMP/${script}"
|
||||
done
|
||||
node "$RUNNER_TEMP/pr-test-loc-summary.mjs" --update-pr "${{ github.event.pull_request.number }}"
|
||||
node "$RUNNER_TEMP/pr-test-loc-summary.mjs" --update-pr "$PR_NUMBER"
|
||||
|
||||
@@ -270,10 +270,7 @@ jobs:
|
||||
src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
|
||||
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
test:
|
||||
@@ -312,10 +309,7 @@ jobs:
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
|
||||
@@ -88,13 +88,11 @@ inline src/relay/git-handler.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/relay/relay.ts
|
||||
inline src/relay/workspace-space-scan.ts
|
||||
inline src/renderer/src/components/GitHubItemDialog.tsx
|
||||
inline src/renderer/src/components/GitLabItemDialog.tsx
|
||||
inline src/renderer/src/components/JiraIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearItemDrawer.tsx
|
||||
inline src/renderer/src/components/NewWorkspaceComposerCard.tsx
|
||||
inline src/renderer/src/components/PullRequestPage.tsx
|
||||
inline src/renderer/src/components/TaskPage.tsx
|
||||
inline src/renderer/src/components/Terminal.tsx
|
||||
inline src/renderer/src/components/UpdateCard.tsx
|
||||
|
||||
@@ -943,10 +943,10 @@
|
||||
"providers": ["lan", "tailscale", "cloud-relay"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["lan", "tailscale", "cloud-relay"],
|
||||
"coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, direct-winner cancellation, and concurrent LAN/Tailscale authentication. Physical iOS/Android radios, GFE, and production relay recovery remain live-test gaps.",
|
||||
"coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, direct-winner cancellation, credential-gated Relay presentation, and concurrent LAN/Tailscale authentication. Physical iOS/Android radios, GFE, and production relay recovery remain live-test gaps.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca-cloud/pull/96"],
|
||||
"invariant": "A foregrounded paired phone must recover from relay HOST_OFFLINE without a foreground or network-change signal, while direct recovery must select the first authenticated configured LAN or Tailscale endpoint without serial timeout delays. Backgrounding, direct success, or stop must cancel pending work, and losing probes must close without affecting the winner.",
|
||||
"oracle": "Inject deterministic relay close codes, random bytes, fake timers, and independently controlled direct clients. Require HOST_OFFLINE to replace any faster transport timer with one 5-15 second retry, require no retry before the selected delay, race all unique non-relay endpoints, select the first authenticated path, close every loser exactly once, and retain no retry after direct connectivity wins.",
|
||||
"invariant": "A foregrounded paired phone must recover from relay HOST_OFFLINE without a foreground or network-change signal, while direct recovery must select the first authenticated configured LAN or Tailscale endpoint without serial timeout delays. Relay recovery presentation starts only for a failed active Relay or a dial backed by an eligible credential, and remains pending through a genuine failed-dial cooldown. Backgrounding, direct success, or stop must cancel pending work, and losing probes must close without affecting the winner.",
|
||||
"oracle": "Inject deterministic relay close codes, missing and expired credentials, delayed credential reads, random bytes, fake timers, and independently controlled direct clients. Require HOST_OFFLINE to replace any faster transport timer with one 5-15 second retry, require no retry before the selected delay, keep Relay presentation absent when no socket can open, clear it across lifecycle races, retain it through real dial cooldowns, race all unique non-relay endpoints, select the first authenticated path, close every loser exactly once, and retain no retry after direct connectivity wins.",
|
||||
"commands": [
|
||||
"pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/mobile-relay-close-codes.test.ts --reporter=dot"
|
||||
@@ -975,7 +975,10 @@
|
||||
{
|
||||
"file": "mobile/src/transport/mobile-endpoint-supervisor.test.ts",
|
||||
"assertions": [
|
||||
"a foregrounded supervisor retries HOST_OFFLINE without an external lifecycle signal"
|
||||
"a foregrounded supervisor retries HOST_OFFLINE without an external lifecycle signal",
|
||||
"a missing or expired credential never presents a Relay connection that cannot start",
|
||||
"backgrounding during credential selection cannot publish a stale Relay recovery path",
|
||||
"a genuine failed Relay dial remains pending through its retry cooldown"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -985,21 +988,21 @@
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-25",
|
||||
"date": "2026-08-16",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.89,
|
||||
"summary": "Three focused mobile transport files passed with 39 assertions."
|
||||
"durationSeconds": 1.23,
|
||||
"summary": "Three focused mobile transport files passed with 54 assertions."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-25",
|
||||
"date": "2026-08-16",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/mobile-relay-close-codes.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.19,
|
||||
"durationSeconds": 0.17,
|
||||
"summary": "The shared close-code contract passed with five assertions."
|
||||
}
|
||||
],
|
||||
@@ -1130,7 +1133,8 @@
|
||||
"file": "mobile/src/transport/stable-logical-rpc-client.test.ts",
|
||||
"assertions": [
|
||||
"a delivery-unknown mutation is not replayed onto a replacement Relay",
|
||||
"Relay recovery path changes publish and remain pending between failed dials"
|
||||
"Relay recovery path changes publish and remain pending between failed dials",
|
||||
"a connected path clears recovery state before any later disconnect"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1148,8 +1152,8 @@
|
||||
"platform": "macos",
|
||||
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/connection-health.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.74,
|
||||
"summary": "Eleven focused files passed 84 deterministic provider lifecycle, path presentation, direct/Relay liveness, traffic, and delivery-ambiguity tests."
|
||||
"durationSeconds": 0.77,
|
||||
"summary": "Eleven focused files passed 85 deterministic provider lifecycle, path presentation, direct/Relay liveness, traffic, and delivery-ambiguity tests."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
function source(path) {
|
||||
return readFileSync(join(projectDir, path), 'utf8')
|
||||
}
|
||||
|
||||
function sourceBetween(contents, startMarker, endMarker) {
|
||||
const start = contents.indexOf(startMarker)
|
||||
const end = contents.indexOf(endMarker, start + startMarker.length)
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
|
||||
}
|
||||
return contents.slice(start, end)
|
||||
}
|
||||
|
||||
describe('Windows computer-use horizontal scroll', () => {
|
||||
it('routes left and right through the horizontal wheel with native signs', () => {
|
||||
const windows = source('native/computer-use-windows/runtime.ps1')
|
||||
const mouseEvents = sourceBetween(windows, '$MouseEvents = @{', 'function Write-OrcaJson')
|
||||
const scroll = sourceBetween(windows, ' "scroll" {', ' "drag" {')
|
||||
const left = sourceBetween(
|
||||
scroll,
|
||||
'} elseif ($Operation.direction -eq "left") {',
|
||||
'} elseif ($Operation.direction -eq "right") {'
|
||||
)
|
||||
const right = sourceBetween(
|
||||
scroll,
|
||||
'} elseif ($Operation.direction -eq "right") {',
|
||||
'} elseif ($Operation.direction -ne "up") {'
|
||||
)
|
||||
|
||||
expect(mouseEvents).toContain('HorizontalWheel = 0x01000')
|
||||
expect(scroll).toContain('$mouseEvent = $MouseEvents.Wheel')
|
||||
expect(left).toContain('$mouseEvent = $MouseEvents.HorizontalWheel')
|
||||
expect(left).toContain('$delta = -1 * $delta')
|
||||
expect(right).toContain('$mouseEvent = $MouseEvents.HorizontalWheel')
|
||||
expect(right).not.toContain('$delta = -1 * $delta')
|
||||
expect(scroll).toContain(
|
||||
'[OrcaDesktopWin32]::mouse_event($mouseEvent, 0, 0, $delta, [UIntPtr]::Zero)'
|
||||
)
|
||||
expect(scroll).not.toContain('mouse_event($MouseEvents.Wheel')
|
||||
expect(scroll).toContain('throw "unsupported scroll direction: $($Operation.direction)"')
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const locScript = join(projectDir, '.github/scripts/pr-test-loc-summary.mjs')
|
||||
const locWorkflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/pr-test-loc.yml'), 'utf8')
|
||||
)
|
||||
const locJob = locWorkflow.jobs.loc
|
||||
const locStep = locJob.steps[0]
|
||||
const tempDirs = []
|
||||
|
||||
function runLoc(args, { env } = {}) {
|
||||
@@ -178,20 +183,36 @@ describe('PR test LoC summary', () => {
|
||||
})
|
||||
|
||||
it('is a no-checkout GitHub-hosted PR workflow', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/pr-test-loc.yml'), 'utf8')
|
||||
)
|
||||
const locJob = workflow.jobs.loc
|
||||
const serialized = JSON.stringify(workflow)
|
||||
|
||||
expect(locJob['runs-on']).toBe('ubuntu-latest')
|
||||
expect(locJob.steps).toHaveLength(1)
|
||||
expect(locJob.steps[0].run).toContain('gh api')
|
||||
expect(locJob.steps[0].run).toContain('pr-test-loc-table.mjs')
|
||||
expect(locJob.steps[0].run).toContain('pr-test-loc-summary.mjs')
|
||||
expect(locJob.steps[0].run).toContain('--update-pr')
|
||||
expect(workflow.permissions['pull-requests']).toBe('write')
|
||||
expect(serialized).not.toContain('actions/checkout')
|
||||
expect(serialized).not.toContain('self-hosted')
|
||||
expect(locStep.run).toContain('gh api')
|
||||
expect(locStep.run).toContain('pr-test-loc-table.mjs')
|
||||
expect(locStep.run).toContain('pr-test-loc-summary.mjs')
|
||||
expect(locStep.run).toContain('--update-pr')
|
||||
expect(locWorkflow.permissions['pull-requests']).toBe('write')
|
||||
expect(JSON.stringify(locWorkflow)).not.toContain('actions/checkout')
|
||||
expect(JSON.stringify(locWorkflow)).not.toContain('self-hosted')
|
||||
})
|
||||
|
||||
// The write-scoped GITHUB_TOKEN makes any PR-authored code a privilege escalation.
|
||||
it('executes only default-branch script code, never pull-request head code', () => {
|
||||
const serialized = JSON.stringify(locWorkflow)
|
||||
|
||||
expect(locStep.env.TRUSTED_REF).toBe('${{ github.event.repository.default_branch }}')
|
||||
expect(locStep.run).toContain('?ref=${TRUSTED_REF}')
|
||||
expect(serialized).not.toContain('pull_request_target')
|
||||
expect(serialized).not.toContain('pull/')
|
||||
expect(serialized).not.toContain('pull_request.head')
|
||||
expect(serialized).not.toContain('/merge')
|
||||
})
|
||||
|
||||
it('passes event data through env instead of interpolating it into the shell', () => {
|
||||
expect(locStep.env.PR_NUMBER).toBe('${{ github.event.pull_request.number }}')
|
||||
expect(locStep.run).toContain('--update-pr "$PR_NUMBER"')
|
||||
expect(locStep.run).not.toContain('${{')
|
||||
})
|
||||
|
||||
it('fails the step when a script download fails instead of running a truncated file', () => {
|
||||
expect(locStep.run).toContain('set -euo pipefail')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,6 @@ const shellContractFiles = [
|
||||
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
|
||||
'src/main/zsh-scoped-histfile.live-shell.test.ts',
|
||||
'src/shared/posix-command-path-lookup.test.ts'
|
||||
]
|
||||
const patchedNodePtyContractFiles = [
|
||||
|
||||
@@ -320,7 +320,7 @@ async function verifyInstalledLauncher(home, payload) {
|
||||
)
|
||||
if (
|
||||
!command ||
|
||||
!command.includes('"$HOME/.orca/agent-hooks/claude-hook.sh"') ||
|
||||
!command.includes('"${HOME-}/.orca/agent-hooks/claude-hook.sh"') ||
|
||||
!command.includes('] && [ -r ') ||
|
||||
!command.includes('else { command -p cat')
|
||||
) {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
|
||||
"../src/main/agent-hooks/runtime-home-hook-command.ts",
|
||||
"../src/main/agent-hooks/windows-powershell-hook-launcher.ts",
|
||||
"../src/main/amp/agent-status-plugin-source.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/amp/managed-plugin-install-status.ts",
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.43",
|
||||
"version": "0.0.44",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -75,7 +75,7 @@
|
||||
"allowBackup": false,
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 12
|
||||
"versionCode": 13
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -208,7 +208,6 @@ import {
|
||||
confirmsMirroredTabSelection,
|
||||
type AppliedSnapshotMarker
|
||||
} from '../../../../src/session/session-tab-snapshot-gate'
|
||||
import { hasPendingTerminalHandleRecoveryNeed } from '../../../../src/session/pending-terminal-handle-recovery'
|
||||
import {
|
||||
createInitialSessionAutoCreateState,
|
||||
useInitialSessionTerminalAutoCreate,
|
||||
@@ -255,6 +254,7 @@ import type {
|
||||
} from '../../../../src/session/mobile-session-tabs-stream-health'
|
||||
import { useMobileSessionTabsFetchReporting } from '../../../../src/session/use-mobile-session-tabs-fetch-reporting'
|
||||
import { useMobileSessionTabsReconciliation } from '../../../../src/session/use-mobile-session-tabs-reconciliation'
|
||||
import { PendingTerminalHandleRecoveryContextCache } from '../../../../src/session/pending-terminal-handle-recovery'
|
||||
import {
|
||||
getRepoIdFromMobileWorktreeId,
|
||||
getActiveTabIdForHandle,
|
||||
@@ -938,6 +938,9 @@ export default function SessionScreen() {
|
||||
const pendingBrowserFocusPageIdRef = useRef<string | null>(null)
|
||||
const switchSessionTabRef = useRef<((tab: MobileSessionTab) => void) | null>(null)
|
||||
const pendingTerminalActivationAttemptRef = useRef<string | null>(null)
|
||||
const [parkedPendingTerminalContext, setParkedPendingTerminalContext] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
// Why: route the terminal URL tap through a ref so it runs the current handleCreateBrowser closure (the memoized one may hold a null-client render).
|
||||
const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise<boolean>) | null>(null)
|
||||
|
||||
@@ -2286,16 +2289,25 @@ export default function SessionScreen() {
|
||||
() =>
|
||||
closedTabTombstonesRef.current.size > 0 ||
|
||||
pendingBrowserFocusPageIdRef.current !== null ||
|
||||
// Why: a pending-handle terminal only turns ready in a fresh snapshot. A live
|
||||
// stream otherwise parks the poll, and a host that mints the handle without
|
||||
// republishing strands the pane on its spinner forever (STA-4256).
|
||||
hasPendingTerminalHandleRecoveryNeed(sessionTabsRef.current, activeSessionTabIdRef.current) ||
|
||||
// Why: a chat-covered handle that ran out of rearms and left `terminal.list`
|
||||
// was reminted by a desktop graph reload. Only a fresh tab snapshot carries
|
||||
// the replacement handle, so force one instead of holding the composer locked.
|
||||
nativeChatStream.hasTabsRecoveryNeed(),
|
||||
[nativeChatStream]
|
||||
)
|
||||
const pendingTerminalRecoveryContextCache = useMemo(
|
||||
() => new PendingTerminalHandleRecoveryContextCache(),
|
||||
[]
|
||||
)
|
||||
const getPendingTerminalRecoveryContextKey = useCallback(
|
||||
() =>
|
||||
pendingTerminalRecoveryContextCache.read(
|
||||
sessionTabsRef.current,
|
||||
activeSessionTabIdRef.current
|
||||
),
|
||||
[pendingTerminalRecoveryContextCache]
|
||||
)
|
||||
const pendingTerminalRecoveryContextKey = getPendingTerminalRecoveryContextKey()
|
||||
const getSessionTabsApplicationRevision = useCallback(
|
||||
() => appliedSessionTabsRevisionRef.current,
|
||||
[]
|
||||
@@ -2304,18 +2316,25 @@ export default function SessionScreen() {
|
||||
worktreeId,
|
||||
diagnosticsRef: terminalDiagnosticsRef
|
||||
})
|
||||
const { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs } =
|
||||
useMobileSessionTabsReconciliation<SessionTabsResult, MobileSessionTab>({
|
||||
client,
|
||||
connState,
|
||||
worktreeId,
|
||||
applySessionTabs,
|
||||
consumeAcceptedSessionTabs,
|
||||
fetchTerminals,
|
||||
hasRecoveryNeed: hasSessionTabsRecoveryNeed,
|
||||
getApplicationRevision: getSessionTabsApplicationRevision,
|
||||
...sessionTabsFetchReporting
|
||||
})
|
||||
const {
|
||||
fetchSessionTabs,
|
||||
ensureSessionTabs,
|
||||
fetchPendingBrowserSessionTabs,
|
||||
retryPendingTerminalRecovery
|
||||
} = useMobileSessionTabsReconciliation<SessionTabsResult, MobileSessionTab>({
|
||||
client,
|
||||
connState,
|
||||
worktreeId,
|
||||
applySessionTabs,
|
||||
consumeAcceptedSessionTabs,
|
||||
fetchTerminals,
|
||||
hasRecoveryNeed: hasSessionTabsRecoveryNeed,
|
||||
pendingTerminalRecoveryContextKey,
|
||||
getPendingTerminalRecoveryContextKey,
|
||||
onPendingTerminalRecoveryParked: setParkedPendingTerminalContext,
|
||||
getApplicationRevision: getSessionTabsApplicationRevision,
|
||||
...sessionTabsFetchReporting
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (connState === 'connected') {
|
||||
@@ -4077,6 +4096,9 @@ export default function SessionScreen() {
|
||||
activeSessionTab?.type === 'terminal' && typeof activeSessionTab.terminal !== 'string'
|
||||
? activeSessionTab
|
||||
: null
|
||||
const isPendingTerminalRecoveryParked =
|
||||
pendingTerminalRecoveryContextKey !== null &&
|
||||
pendingTerminalRecoveryContextKey === parkedPendingTerminalContext
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || connState !== 'connected' || !activePendingTerminalTab) {
|
||||
@@ -4596,10 +4618,27 @@ export default function SessionScreen() {
|
||||
</View>
|
||||
) : activePendingTerminalTab ? (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
{!isPendingTerminalRecoveryParked && (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
)}
|
||||
<Text style={styles.emptyText}>
|
||||
{activePendingTerminalTab.title || 'Loading terminal'}
|
||||
{isPendingTerminalRecoveryParked
|
||||
? 'Terminal is taking longer than expected'
|
||||
: activePendingTerminalTab.title || 'Loading terminal'}
|
||||
</Text>
|
||||
{isPendingTerminalRecoveryParked && (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Retry loading terminal"
|
||||
style={({ pressed }) => [
|
||||
styles.createButton,
|
||||
pressed && styles.newTerminalButtonPressed
|
||||
]}
|
||||
onPress={() => void retryPendingTerminalRecovery()}
|
||||
>
|
||||
<Text style={styles.createButtonText}>Retry</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
|
||||
@@ -187,16 +187,38 @@ describe('mobile session startup', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('counts a pending-handle active terminal as a tabs recovery need (STA-4256)', () => {
|
||||
const recoveryNeed = sliceBetween(
|
||||
'const hasSessionTabsRecoveryNeed = useCallback(',
|
||||
'const getSessionTabsApplicationRevision ='
|
||||
it('wires pending-handle recovery through its bounded context (STA-4256)', () => {
|
||||
const applySessionTabs = sliceBetween(
|
||||
'const applySessionTabs = useCallback(',
|
||||
'const consumeAcceptedSessionTabs = useCallback('
|
||||
)
|
||||
const recoveryContext = sliceBetween(
|
||||
'const pendingTerminalRecoveryContextCache = useMemo(',
|
||||
'const getSessionTabsApplicationRevision'
|
||||
)
|
||||
|
||||
expect(recoveryNeed).toContain('hasPendingTerminalHandleRecoveryNeed(')
|
||||
expect(recoveryNeed).toContain('sessionTabsRef.current')
|
||||
expect(recoveryNeed).toContain('activeSessionTabIdRef.current')
|
||||
// The predicate only reaches the poll loop through this hook's option.
|
||||
const tabsRefWrite = 'sessionTabsRef.current = nextTabs'
|
||||
const tabsStateWrite = 'setSessionTabs((prev)'
|
||||
const activeRefWrite = 'activeSessionTabIdRef.current = active?.id ?? null'
|
||||
const activeStateWrite = 'setActiveSessionTabId(active?.id ?? null)'
|
||||
for (const write of [tabsRefWrite, tabsStateWrite, activeRefWrite, activeStateWrite]) {
|
||||
expect(applySessionTabs).toContain(write)
|
||||
}
|
||||
expect(applySessionTabs.indexOf(tabsRefWrite)).toBeLessThan(
|
||||
applySessionTabs.indexOf(tabsStateWrite)
|
||||
)
|
||||
expect(applySessionTabs.indexOf(activeRefWrite)).toBeLessThan(
|
||||
applySessionTabs.indexOf(activeStateWrite)
|
||||
)
|
||||
expect(recoveryContext).toContain('() => new PendingTerminalHandleRecoveryContextCache()')
|
||||
expect(recoveryContext).toContain('sessionTabsRef.current,')
|
||||
expect(recoveryContext).toContain('activeSessionTabIdRef.current')
|
||||
expect(recoveryContext).toContain(
|
||||
'const pendingTerminalRecoveryContextKey = getPendingTerminalRecoveryContextKey()'
|
||||
)
|
||||
expect(source).toContain('hasRecoveryNeed: hasSessionTabsRecoveryNeed')
|
||||
expect(source).toContain('getPendingTerminalRecoveryContextKey,')
|
||||
expect(source).toContain('onPendingTerminalRecoveryParked: setParkedPendingTerminalContext')
|
||||
expect(source).toContain('retryPendingTerminalRecovery()')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -134,6 +134,29 @@ describe('MobileSessionTabsStreamHealth', () => {
|
||||
expect(harness.apply).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('coalesces repeated retries and releases the retry cohort after failure', async () => {
|
||||
const harness = makeHarness()
|
||||
harness.controller.setReconciliationActive(true)
|
||||
|
||||
const halfOpen = harness.controller.requestReconciliation()
|
||||
const retry = harness.controller.retryReconciliation()
|
||||
const repeatedRetry = harness.controller.retryReconciliation()
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(repeatedRetry).toBe(retry)
|
||||
|
||||
harness.requests[1]!.resolve(failure())
|
||||
await retry
|
||||
const nextRetry = harness.controller.retryReconciliation()
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(3)
|
||||
expect(nextRetry).not.toBe(retry)
|
||||
|
||||
harness.requests[2]!.resolve(success(result(3)))
|
||||
await nextRetry
|
||||
harness.requests[0]!.resolve(success(result(1)))
|
||||
await halfOpen
|
||||
expect(harness.apply).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('starts distinct pre- and post-snapshot lists and discards the stale barrier', async () => {
|
||||
const harness = makeHarness()
|
||||
harness.controller.setReconciliationActive(true)
|
||||
|
||||
@@ -19,6 +19,7 @@ type RequestOwner = {
|
||||
type RequestCohort = {
|
||||
promise: Promise<void>
|
||||
resolve: () => void
|
||||
retry: boolean
|
||||
}
|
||||
|
||||
type ControllerOptions<Result, Tab> = {
|
||||
@@ -31,6 +32,7 @@ type ControllerOptions<Result, Tab> = {
|
||||
source: SessionTabsStreamSource
|
||||
) => void
|
||||
hasRecoveryNeed: () => boolean
|
||||
allowRecoveryPoll?: () => boolean
|
||||
getApplicationRevision?: () => number
|
||||
onFetchStarted?: () => void
|
||||
onFetchSucceeded?: (result: Result) => void
|
||||
@@ -84,16 +86,35 @@ export class MobileSessionTabsStreamHealth<Result, Tab> {
|
||||
return this.requestReconciliation()
|
||||
}
|
||||
|
||||
retryReconciliation(): Promise<void> {
|
||||
this.syncGeneration()
|
||||
const currentRequest = this.inFlight.get(`${this.generation}:${this.barrier}`)
|
||||
if (currentRequest?.retry) {
|
||||
return currentRequest.promise
|
||||
}
|
||||
this.requirementRevision += 1
|
||||
this.barrier += currentRequest ? 1 : 0
|
||||
return this.startCurrentRequest(true)
|
||||
}
|
||||
|
||||
poll(): Promise<void> | null {
|
||||
this.syncGeneration()
|
||||
if (
|
||||
!this.reconciliationActive ||
|
||||
(this.health === 'live' &&
|
||||
!this.options.hasRecoveryNeed() &&
|
||||
this.requirementRevision <= this.satisfiedRevision)
|
||||
) {
|
||||
if (!this.reconciliationActive) {
|
||||
return null
|
||||
}
|
||||
const recoveryNeeded = this.options.hasRecoveryNeed()
|
||||
if (this.health === 'live') {
|
||||
if (!recoveryNeeded && this.requirementRevision <= this.satisfiedRevision) {
|
||||
return null
|
||||
}
|
||||
const currentRequest = this.inFlight.get(`${this.generation}:${this.barrier}`)
|
||||
if (currentRequest) {
|
||||
return currentRequest.promise
|
||||
}
|
||||
if (recoveryNeeded && this.options.allowRecoveryPoll?.() === false) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return this.ensureReconciliation()
|
||||
}
|
||||
|
||||
@@ -186,7 +207,7 @@ export class MobileSessionTabsStreamHealth<Result, Tab> {
|
||||
this.requirementRevision += 1
|
||||
}
|
||||
|
||||
private startCurrentRequest(): Promise<void> {
|
||||
private startCurrentRequest(retry = false): Promise<void> {
|
||||
if (this.disposed || !this.reconciliationActive) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
@@ -199,7 +220,7 @@ export class MobileSessionTabsStreamHealth<Result, Tab> {
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
const cohort = { promise, resolve: resolveRequest }
|
||||
const cohort = { promise, resolve: resolveRequest, retry }
|
||||
this.inFlight.set(key, cohort)
|
||||
this.runCohortRequest(key, cohort)
|
||||
return promise
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
import { PendingTerminalHandleRecoveryContextCache } from './pending-terminal-handle-recovery'
|
||||
|
||||
function terminalTab(id: string, terminal: string | null): MobileSessionTab {
|
||||
return {
|
||||
type: 'terminal',
|
||||
id,
|
||||
title: 'zsh',
|
||||
parentTabId: 'parent',
|
||||
leafId: 'leaf',
|
||||
status: terminal === null ? 'pending-handle' : 'ready',
|
||||
terminal,
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('PendingTerminalHandleRecoveryContextCache', () => {
|
||||
it('reads authoritative tab refs before the next render', () => {
|
||||
const cache = new PendingTerminalHandleRecoveryContextCache()
|
||||
const pendingTabs = [terminalTab('terminal-a', null)]
|
||||
const readyTabs = [terminalTab('terminal-a', 'pty-a')]
|
||||
|
||||
expect(cache.read(pendingTabs, 'terminal-a')).not.toBeNull()
|
||||
expect(cache.read(readyTabs, 'terminal-a')).toBeNull()
|
||||
expect(cache.read(pendingTabs, null)).toBeNull()
|
||||
expect(cache.read(pendingTabs, 'terminal-a')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('recomputes when the pending terminal identity changes', () => {
|
||||
const cache = new PendingTerminalHandleRecoveryContextCache()
|
||||
const first = terminalTab('terminal-a', null)
|
||||
const parentChanged = { ...first, parentTabId: 'other-parent' }
|
||||
const leafChanged = { ...first, leafId: 'other-leaf' }
|
||||
const collisionLeft = { ...first, parentTabId: 'ab', leafId: 'c' }
|
||||
const collisionRight = { ...first, parentTabId: 'a', leafId: 'bc' }
|
||||
const firstKey = cache.read([first], first.id)
|
||||
|
||||
expect(cache.read([parentChanged], parentChanged.id)).not.toBe(firstKey)
|
||||
expect(cache.read([leafChanged], leafChanged.id)).not.toBe(firstKey)
|
||||
const collisionLeftKey = cache.read([collisionLeft], collisionLeft.id)
|
||||
expect(cache.read([collisionRight], collisionRight.id)).not.toBe(collisionLeftKey)
|
||||
})
|
||||
|
||||
it('does not rescan an unchanged context on the polling hot path', () => {
|
||||
let skippedTabIdReads = 0
|
||||
const skippedTab = terminalTab('terminal-b', 'pty-b')
|
||||
Object.defineProperty(skippedTab, 'id', {
|
||||
get: () => {
|
||||
skippedTabIdReads += 1
|
||||
return 'terminal-b'
|
||||
}
|
||||
})
|
||||
const tabs = [skippedTab, terminalTab('terminal-a', null)]
|
||||
const cache = new PendingTerminalHandleRecoveryContextCache()
|
||||
|
||||
const contextKey = cache.read(tabs, 'terminal-a')
|
||||
const readsAfterFirstLookup = skippedTabIdReads
|
||||
expect(readsAfterFirstLookup).toBeGreaterThan(0)
|
||||
expect(cache.read(tabs, 'terminal-a')).toBe(contextKey)
|
||||
expect(skippedTabIdReads).toBe(readsAfterFirstLookup)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,371 @@
|
||||
import { createElement, Suspense } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type { SessionTabsApplyOutcome } from './mobile-session-tabs-stream-health'
|
||||
import { PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS } from './pending-terminal-handle-recovery'
|
||||
import { useMobileSessionTabsReconciliation } from './use-mobile-session-tabs-reconciliation'
|
||||
|
||||
type TestResult = {
|
||||
type?: 'snapshot' | 'updated'
|
||||
snapshotVersion: number
|
||||
tabs: string[]
|
||||
}
|
||||
|
||||
type HarnessProps = {
|
||||
onParked?: (key: string | null) => void
|
||||
suspend?: Promise<never>
|
||||
}
|
||||
|
||||
const lifecycle = vi.hoisted(() => ({
|
||||
appState: 'active',
|
||||
focused: true,
|
||||
listeners: new Set<(state: string) => void>()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: {
|
||||
get currentState() {
|
||||
return lifecycle.appState
|
||||
},
|
||||
addEventListener(_event: string, listener: (state: string) => void) {
|
||||
lifecycle.listeners.add(listener)
|
||||
return { remove: () => lifecycle.listeners.delete(listener) }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
useFocusEffect(effect: () => void | (() => void)): void {
|
||||
React.useEffect(() => (lifecycle.focused ? effect() : undefined), [effect, lifecycle.focused])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function result(type?: TestResult['type']): TestResult {
|
||||
return { snapshotVersion: 1, tabs: ['pending-terminal'], ...(type ? { type } : {}) }
|
||||
}
|
||||
|
||||
function makeHarness() {
|
||||
const requestTimes: number[] = []
|
||||
const parkedContexts: (string | null)[] = []
|
||||
let contextKey: string | null = 'terminal-a'
|
||||
let otherRecoveryNeeded = false
|
||||
let connState: 'connected' | 'disconnected' = 'connected'
|
||||
let streamListener: ((payload: unknown) => void) | null = null
|
||||
let actions: ReturnType<typeof useMobileSessionTabsReconciliation<TestResult, string>> | null =
|
||||
null
|
||||
const sendRequest = vi.fn(async (): Promise<RpcSuccess> => {
|
||||
requestTimes.push(Date.now())
|
||||
return {
|
||||
id: `list-${requestTimes.length}`,
|
||||
ok: true,
|
||||
result: result(),
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
})
|
||||
const subscribe = vi.fn(
|
||||
(_method: string, _params: unknown, listener: (payload: unknown) => void) => {
|
||||
streamListener = listener
|
||||
return () => {}
|
||||
}
|
||||
)
|
||||
const client = { sendRequest, subscribe } as unknown as RpcClient
|
||||
const consumeAcceptedSessionTabs = () => {}
|
||||
const fetchTerminals = async () => {}
|
||||
const getPendingTerminalRecoveryContextKey = () => contextKey
|
||||
const hasRecoveryNeed = () => otherRecoveryNeeded
|
||||
const onPendingTerminalRecoveryParked = (key: string | null) => parkedContexts.push(key)
|
||||
const applySessionTabs = (value: TestResult): SessionTabsApplyOutcome<string> => ({
|
||||
accepted: true,
|
||||
effectiveTabs: value.tabs
|
||||
})
|
||||
|
||||
function Harness({ onParked = onPendingTerminalRecoveryParked, suspend }: HarnessProps): null {
|
||||
actions = useMobileSessionTabsReconciliation<TestResult, string>({
|
||||
client,
|
||||
connState,
|
||||
worktreeId: 'repo::worktree',
|
||||
applySessionTabs,
|
||||
consumeAcceptedSessionTabs,
|
||||
fetchTerminals,
|
||||
hasRecoveryNeed,
|
||||
pendingTerminalRecoveryContextKey: contextKey,
|
||||
getPendingTerminalRecoveryContextKey,
|
||||
onPendingTerminalRecoveryParked: onParked
|
||||
})
|
||||
if (suspend) {
|
||||
throw suspend
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
Harness,
|
||||
actions: () => actions,
|
||||
emit(payload: TestResult) {
|
||||
streamListener?.(payload)
|
||||
},
|
||||
parkedContexts,
|
||||
requestTimes,
|
||||
sendRequest,
|
||||
setConnectionState(value: typeof connState) {
|
||||
connState = value
|
||||
},
|
||||
setContextKey(value: string | null) {
|
||||
contextKey = value
|
||||
},
|
||||
setOtherRecoveryNeeded(value: boolean) {
|
||||
otherRecoveryNeeded = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let turn = 0; turn < 8; turn += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// react-test-renderer's types do not resolve in the type-aware lint project, so both
|
||||
// ReactTestRenderer and ReturnType<typeof create> degrade to `any` and poison this union.
|
||||
// Name the two methods this suite actually uses instead.
|
||||
type MountedHarness = {
|
||||
unmount: () => void
|
||||
update: (element: ReturnType<typeof createElement>) => void
|
||||
}
|
||||
|
||||
describe('bounded pending-handle reconciliation cadence', () => {
|
||||
let renderer: MountedHarness | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
lifecycle.appState = 'active'
|
||||
lifecycle.focused = true
|
||||
lifecycle.listeners.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function mount(harness: ReturnType<typeof makeHarness>): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(harness.Harness))
|
||||
await flush()
|
||||
})
|
||||
await act(async () => {
|
||||
harness.emit(result('snapshot'))
|
||||
await flush()
|
||||
harness.emit(result('updated'))
|
||||
await flush()
|
||||
})
|
||||
harness.sendRequest.mockClear()
|
||||
harness.requestTimes.length = 0
|
||||
harness.parkedContexts.length = 0
|
||||
}
|
||||
|
||||
async function advance(milliseconds: number): Promise<void> {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(milliseconds)
|
||||
})
|
||||
}
|
||||
|
||||
async function setAppState(state: string): Promise<void> {
|
||||
lifecycle.appState = state
|
||||
await act(async () => {
|
||||
for (const listener of lifecycle.listeners) {
|
||||
listener(state)
|
||||
}
|
||||
await flush()
|
||||
})
|
||||
}
|
||||
|
||||
it('drops the main baseline from 1800 lists per hour to five, then parks', async () => {
|
||||
const harness = makeHarness()
|
||||
await mount(harness)
|
||||
|
||||
await advance(3_600_000)
|
||||
|
||||
expect(harness.requestTimes).toEqual([2000, 4000, 6000, 8000, 10_000])
|
||||
expect(harness.parkedContexts).toContain('terminal-a')
|
||||
})
|
||||
|
||||
it('resets for active-tab and pending-terminal context changes, but not ordinary ticks', async () => {
|
||||
const harness = makeHarness()
|
||||
await mount(harness)
|
||||
await advance(12_000)
|
||||
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
|
||||
|
||||
harness.setContextKey(null)
|
||||
await advance(2000)
|
||||
const afterActiveTabChange = harness.requestTimes.length
|
||||
harness.setContextKey('terminal-a')
|
||||
await advance(2000)
|
||||
expect(harness.requestTimes).toHaveLength(afterActiveTabChange + 1)
|
||||
|
||||
harness.setContextKey('terminal-b')
|
||||
await advance(2000)
|
||||
expect(harness.requestTimes).toHaveLength(afterActiveTabChange + 2)
|
||||
})
|
||||
|
||||
it('resets when the active tab changes away and back between poll ticks', async () => {
|
||||
const harness = makeHarness()
|
||||
await mount(harness)
|
||||
await advance(12_000)
|
||||
|
||||
harness.setContextKey(null)
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Harness))
|
||||
await flush()
|
||||
})
|
||||
expect(harness.parkedContexts.at(-1)).toBeNull()
|
||||
harness.setContextKey('terminal-a')
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Harness))
|
||||
await flush()
|
||||
})
|
||||
expect(harness.parkedContexts.at(-1)).toBeNull()
|
||||
await advance(2000)
|
||||
|
||||
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 1)
|
||||
})
|
||||
|
||||
it('keeps the parked state across unrelated rerenders', async () => {
|
||||
const harness = makeHarness()
|
||||
await mount(harness)
|
||||
await advance(12_000)
|
||||
expect(harness.parkedContexts.at(-1)).toBe('terminal-a')
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Harness))
|
||||
await flush()
|
||||
})
|
||||
|
||||
expect(harness.parkedContexts.at(-1)).toBe('terminal-a')
|
||||
await advance(2000)
|
||||
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
|
||||
})
|
||||
|
||||
it('does not publish a parked callback from a suspended render', async () => {
|
||||
const harness = makeHarness()
|
||||
const committedCallback = vi.fn()
|
||||
const suspendedCallback = vi.fn()
|
||||
const neverCommits = new Promise<never>(() => {})
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(
|
||||
Suspense,
|
||||
{ fallback: null },
|
||||
createElement(harness.Harness, { onParked: committedCallback })
|
||||
)
|
||||
)
|
||||
await flush()
|
||||
})
|
||||
const committedActions = harness.actions()
|
||||
committedCallback.mockClear()
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(
|
||||
Suspense,
|
||||
{ fallback: null },
|
||||
createElement(harness.Harness, {
|
||||
onParked: suspendedCallback,
|
||||
suspend: neverCommits
|
||||
})
|
||||
)
|
||||
)
|
||||
await flush()
|
||||
})
|
||||
await act(async () => {
|
||||
await committedActions?.retryPendingTerminalRecovery()
|
||||
})
|
||||
|
||||
expect(committedCallback).toHaveBeenCalledWith(null)
|
||||
expect(suspendedCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('publishes the committed parked callback before a same-commit context reset', async () => {
|
||||
const harness = makeHarness()
|
||||
const previousCallback = vi.fn()
|
||||
const committedCallback = vi.fn()
|
||||
await act(async () => {
|
||||
renderer = create(createElement(harness.Harness, { onParked: previousCallback }))
|
||||
await flush()
|
||||
})
|
||||
previousCallback.mockClear()
|
||||
|
||||
harness.setContextKey('terminal-b')
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Harness, { onParked: committedCallback }))
|
||||
await flush()
|
||||
})
|
||||
|
||||
expect(committedCallback).toHaveBeenCalledWith(null)
|
||||
expect(previousCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps other recovery sources live while the pending-terminal budget is parked', async () => {
|
||||
const harness = makeHarness()
|
||||
await mount(harness)
|
||||
await advance(12_000)
|
||||
expect(harness.requestTimes).toEqual([2000, 4000, 6000, 8000, 10_000])
|
||||
|
||||
harness.setOtherRecoveryNeeded(true)
|
||||
await advance(14_000)
|
||||
expect(harness.requestTimes).toEqual([
|
||||
2000, 4000, 6000, 8000, 10_000, 14_000, 16_000, 18_000, 20_000, 22_000, 24_000, 26_000
|
||||
])
|
||||
|
||||
harness.setOtherRecoveryNeeded(false)
|
||||
await advance(2000)
|
||||
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 7)
|
||||
})
|
||||
|
||||
it('resets after foregrounding, reconnecting, and explicit retry', async () => {
|
||||
const harness = makeHarness()
|
||||
await mount(harness)
|
||||
await advance(12_000)
|
||||
|
||||
await setAppState('background')
|
||||
await setAppState('active')
|
||||
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 1)
|
||||
await advance(2000)
|
||||
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2)
|
||||
await advance(10_000)
|
||||
|
||||
harness.setConnectionState('disconnected')
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Harness))
|
||||
await flush()
|
||||
})
|
||||
harness.setConnectionState('connected')
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Harness))
|
||||
await flush()
|
||||
})
|
||||
await act(async () => {
|
||||
harness.emit(result('updated'))
|
||||
await flush()
|
||||
})
|
||||
const afterReconnect = harness.requestTimes.length
|
||||
await advance(2000)
|
||||
expect(harness.requestTimes).toHaveLength(afterReconnect + 1)
|
||||
|
||||
await advance(10_000)
|
||||
const beforeRetry = harness.requestTimes.length
|
||||
await act(async () => {
|
||||
await harness.actions()?.retryPendingTerminalRecovery()
|
||||
})
|
||||
expect(harness.requestTimes).toHaveLength(beforeRetry + 1)
|
||||
await advance(2000)
|
||||
expect(harness.requestTimes).toHaveLength(beforeRetry + 2)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,12 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import type { MobileSessionTab, SessionTabsResult } from './mobile-session-route-types'
|
||||
import { hasPendingTerminalHandleRecoveryNeed } from './pending-terminal-handle-recovery'
|
||||
import {
|
||||
getPendingTerminalHandleRecoveryContextKey,
|
||||
hasPendingTerminalHandleRecoveryNeed,
|
||||
PendingTerminalHandleRecoveryBudget,
|
||||
PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS
|
||||
} from './pending-terminal-handle-recovery'
|
||||
import {
|
||||
MobileSessionTabsStreamHealth,
|
||||
type SessionTabsApplyOutcome
|
||||
@@ -62,6 +67,38 @@ describe('hasPendingTerminalHandleRecoveryNeed', () => {
|
||||
expect(hasPendingTerminalHandleRecoveryNeed([terminalTab(null)], null)).toBe(false)
|
||||
expect(hasPendingTerminalHandleRecoveryNeed([terminalTab(null)], 'gone')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps recovery contexts distinct when tab identifiers contain separators', () => {
|
||||
const first = { ...terminalTab(null), id: 'a:b', parentTabId: 'c' }
|
||||
const second = { ...terminalTab(null), id: 'a', parentTabId: 'b:c' }
|
||||
|
||||
expect(getPendingTerminalHandleRecoveryContextKey([first], first.id)).not.toBe(
|
||||
getPendingTerminalHandleRecoveryContextKey([second], second.id)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PendingTerminalHandleRecoveryBudget', () => {
|
||||
it('parks after the bounded attempt window and resets for a new terminal', () => {
|
||||
const budget = new PendingTerminalHandleRecoveryBudget()
|
||||
for (let attempt = 1; attempt <= PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) {
|
||||
expect(budget.take('terminal-a')).toEqual({
|
||||
allowed: true,
|
||||
parked: false
|
||||
})
|
||||
}
|
||||
expect(budget.take('terminal-a')).toEqual({ allowed: false, parked: true })
|
||||
expect(budget.take('terminal-b')).toEqual({ allowed: true, parked: false })
|
||||
})
|
||||
|
||||
it('resets the current terminal budget explicitly', () => {
|
||||
const budget = new PendingTerminalHandleRecoveryBudget()
|
||||
for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) {
|
||||
budget.take('terminal-a')
|
||||
}
|
||||
budget.reset()
|
||||
expect(budget.take('terminal-a')).toEqual({ allowed: true, parked: false })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -82,6 +119,7 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () =>
|
||||
const client = { sendRequest, getGeneration: () => 1 } as unknown as RpcClient
|
||||
let tabs: MobileSessionTab[] = []
|
||||
let activeTabId: string | null = null
|
||||
const recoveryBudget = new PendingTerminalHandleRecoveryBudget()
|
||||
const controller = new MobileSessionTabsStreamHealth<SessionTabsResult, MobileSessionTab>({
|
||||
client,
|
||||
scope: 'id:repo::worktree',
|
||||
@@ -91,7 +129,9 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () =>
|
||||
return { accepted: true, effectiveTabs: result.tabs }
|
||||
},
|
||||
consumeAccepted: () => {},
|
||||
hasRecoveryNeed: () => hasPendingTerminalHandleRecoveryNeed(tabs, activeTabId)
|
||||
hasRecoveryNeed: () => hasPendingTerminalHandleRecoveryNeed(tabs, activeTabId),
|
||||
allowRecoveryPoll: () =>
|
||||
recoveryBudget.take(getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId)).allowed
|
||||
})
|
||||
return {
|
||||
controller,
|
||||
@@ -139,6 +179,88 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () =>
|
||||
})
|
||||
})
|
||||
|
||||
it('parks the live poll after five pending-handle lists', async () => {
|
||||
const harness = makeHarness()
|
||||
await driveToLiveStream(harness, null)
|
||||
|
||||
for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) {
|
||||
const request = harness.controller.poll()
|
||||
expect(request).not.toBeNull()
|
||||
await settle()
|
||||
harness.resolveNext(snapshot(null))
|
||||
await request
|
||||
}
|
||||
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
|
||||
expect(harness.controller.poll()).toBeNull()
|
||||
await settle()
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
|
||||
})
|
||||
|
||||
it('does not spend live fallback attempts while a slow request is coalesced', async () => {
|
||||
const harness = makeHarness()
|
||||
await driveToLiveStream(harness, null)
|
||||
|
||||
for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) {
|
||||
const request = harness.controller.poll()
|
||||
expect(request).not.toBeNull()
|
||||
for (let joinedTick = 0; joinedTick < 5; joinedTick += 1) {
|
||||
expect(harness.controller.poll()).toBe(request)
|
||||
}
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(attempt + 1)
|
||||
harness.resolveNext(snapshot(null))
|
||||
await request
|
||||
}
|
||||
|
||||
expect(harness.controller.poll()).toBeNull()
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
|
||||
})
|
||||
|
||||
it.each(['probing', 'degraded'] as const)(
|
||||
'keeps polling a pending handle while stream health is %s',
|
||||
async (health) => {
|
||||
const harness = makeHarness()
|
||||
harness.controller.setReconciliationActive(true)
|
||||
const subscription = harness.controller.beginSubscription()
|
||||
if (health === 'degraded') {
|
||||
subscription.listener({ ...snapshot(null), type: 'end' })
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2; attempt += 1) {
|
||||
const request = harness.controller.poll()
|
||||
expect(request).not.toBeNull()
|
||||
await settle()
|
||||
harness.resolveNext(snapshot(null))
|
||||
await request
|
||||
}
|
||||
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(
|
||||
PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('starts a fresh retry and fences the half-open request result', async () => {
|
||||
const harness = makeHarness()
|
||||
await driveToLiveStream(harness, null)
|
||||
|
||||
const halfOpen = harness.controller.poll()
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(1)
|
||||
const retry = harness.controller.retryReconciliation()
|
||||
expect(retry).not.toBe(halfOpen)
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(harness.controller.retryReconciliation()).toBe(retry)
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(2)
|
||||
|
||||
harness.resolveNext(snapshot('stale-handle'))
|
||||
await halfOpen
|
||||
expect(harness.readTabs()[0]).toMatchObject({ terminal: null })
|
||||
|
||||
harness.resolveNext(snapshot('fresh-handle'))
|
||||
await retry
|
||||
expect(harness.readTabs()[0]).toMatchObject({ terminal: 'fresh-handle' })
|
||||
})
|
||||
|
||||
it('stops polling once a fresh snapshot carries the handle', async () => {
|
||||
const harness = makeHarness()
|
||||
await driveToLiveStream(harness, null)
|
||||
|
||||
@@ -1,5 +1,74 @@
|
||||
import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
|
||||
export const PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS = 5
|
||||
|
||||
export type PendingTerminalHandleRecoveryAttempt = {
|
||||
allowed: boolean
|
||||
parked: boolean
|
||||
}
|
||||
|
||||
export class PendingTerminalHandleRecoveryBudget {
|
||||
private contextKey: string | null = null
|
||||
private remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS
|
||||
|
||||
observeContext(contextKey: string | null): void {
|
||||
if (contextKey === this.contextKey) {
|
||||
return
|
||||
}
|
||||
this.contextKey = contextKey
|
||||
this.remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS
|
||||
}
|
||||
|
||||
take(contextKey: string | null): PendingTerminalHandleRecoveryAttempt {
|
||||
this.observeContext(contextKey)
|
||||
if (contextKey === null) {
|
||||
return { allowed: false, parked: false }
|
||||
}
|
||||
if (this.remaining === 0) {
|
||||
return { allowed: false, parked: true }
|
||||
}
|
||||
this.remaining -= 1
|
||||
return { allowed: true, parked: false }
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.contextKey = null
|
||||
this.remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS
|
||||
}
|
||||
}
|
||||
|
||||
export class PendingTerminalHandleRecoveryContextCache {
|
||||
private activeTabId: string | null = null
|
||||
private contextKey: string | null = null
|
||||
private initialized = false
|
||||
private tabs: readonly MobileSessionTab[] | null = null
|
||||
|
||||
read(tabs: readonly MobileSessionTab[], activeTabId: string | null): string | null {
|
||||
if (this.initialized && tabs === this.tabs && activeTabId === this.activeTabId) {
|
||||
return this.contextKey
|
||||
}
|
||||
this.initialized = true
|
||||
this.tabs = tabs
|
||||
this.activeTabId = activeTabId
|
||||
this.contextKey = getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId)
|
||||
return this.contextKey
|
||||
}
|
||||
}
|
||||
|
||||
export function getPendingTerminalHandleRecoveryContextKey(
|
||||
tabs: readonly MobileSessionTab[],
|
||||
activeTabId: string | null
|
||||
): string | null {
|
||||
if (activeTabId === null) {
|
||||
return null
|
||||
}
|
||||
const active = tabs.find((tab) => tab.id === activeTabId)
|
||||
if (active?.type !== 'terminal' || typeof active.terminal === 'string') {
|
||||
return null
|
||||
}
|
||||
return JSON.stringify([active.id, active.parentTabId, active.leafId ?? null])
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the tab the user is looking at is a terminal the host published
|
||||
* without a PTY handle (`status: 'pending-handle'`). The session screen renders
|
||||
@@ -16,9 +85,5 @@ export function hasPendingTerminalHandleRecoveryNeed(
|
||||
tabs: readonly MobileSessionTab[],
|
||||
activeTabId: string | null
|
||||
): boolean {
|
||||
if (activeTabId === null) {
|
||||
return false
|
||||
}
|
||||
const active = tabs.find((tab) => tab.id === activeTabId)
|
||||
return active?.type === 'terminal' && typeof active.terminal !== 'string'
|
||||
return getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId) !== null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { AppState } from 'react-native'
|
||||
import { useFocusEffect } from 'expo-router'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SessionTabsApplyOutcome,
|
||||
type SessionTabsStreamSource
|
||||
} from './mobile-session-tabs-stream-health'
|
||||
import { PendingTerminalHandleRecoveryBudget } from './pending-terminal-handle-recovery'
|
||||
|
||||
type Params<Result, Tab> = {
|
||||
client: RpcClient | null
|
||||
@@ -21,6 +22,9 @@ type Params<Result, Tab> = {
|
||||
) => void
|
||||
fetchTerminals: () => Promise<void>
|
||||
hasRecoveryNeed: () => boolean
|
||||
pendingTerminalRecoveryContextKey?: string | null
|
||||
getPendingTerminalRecoveryContextKey?: () => string | null
|
||||
onPendingTerminalRecoveryParked?: (contextKey: string | null) => void
|
||||
getApplicationRevision?: () => number
|
||||
onFetchStarted?: () => void
|
||||
onFetchSucceeded?: (result: Result) => void
|
||||
@@ -32,6 +36,7 @@ type ResultActions = {
|
||||
fetchSessionTabs: () => Promise<void>
|
||||
ensureSessionTabs: () => Promise<void>
|
||||
fetchPendingBrowserSessionTabs: () => Promise<void>
|
||||
retryPendingTerminalRecovery: () => Promise<void>
|
||||
}
|
||||
|
||||
const resolved = Promise.resolve()
|
||||
@@ -44,12 +49,46 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
|
||||
consumeAcceptedSessionTabs,
|
||||
fetchTerminals,
|
||||
hasRecoveryNeed,
|
||||
pendingTerminalRecoveryContextKey,
|
||||
getPendingTerminalRecoveryContextKey,
|
||||
onPendingTerminalRecoveryParked,
|
||||
getApplicationRevision,
|
||||
onFetchStarted,
|
||||
onFetchSucceeded,
|
||||
onFetchFailed,
|
||||
onFetchErrored
|
||||
}: Params<Result, Tab>): ResultActions {
|
||||
const pendingTerminalRecoveryBudget = useMemo(() => new PendingTerminalHandleRecoveryBudget(), [])
|
||||
const onPendingTerminalRecoveryParkedRef = useRef(onPendingTerminalRecoveryParked)
|
||||
// Why: only poll/reset callbacks read this, and they run after commit — so writing it in
|
||||
// render would let a discarded render leak a callback that never mounted.
|
||||
useEffect(() => {
|
||||
onPendingTerminalRecoveryParkedRef.current = onPendingTerminalRecoveryParked
|
||||
})
|
||||
const combinedHasRecoveryNeed = useCallback(() => {
|
||||
const contextKey = getPendingTerminalRecoveryContextKey?.() ?? null
|
||||
pendingTerminalRecoveryBudget.observeContext(contextKey)
|
||||
return hasRecoveryNeed() || contextKey !== null
|
||||
}, [getPendingTerminalRecoveryContextKey, hasRecoveryNeed, pendingTerminalRecoveryBudget])
|
||||
const allowRecoveryPoll = useCallback(() => {
|
||||
if (hasRecoveryNeed()) {
|
||||
return true
|
||||
}
|
||||
const contextKey = getPendingTerminalRecoveryContextKey?.() ?? null
|
||||
const attempt = pendingTerminalRecoveryBudget.take(contextKey)
|
||||
if (attempt.parked) {
|
||||
onPendingTerminalRecoveryParkedRef.current?.(contextKey)
|
||||
}
|
||||
return attempt.allowed
|
||||
}, [getPendingTerminalRecoveryContextKey, hasRecoveryNeed, pendingTerminalRecoveryBudget])
|
||||
const resetPendingTerminalRecovery = useCallback(() => {
|
||||
pendingTerminalRecoveryBudget.reset()
|
||||
onPendingTerminalRecoveryParkedRef.current?.(null)
|
||||
}, [pendingTerminalRecoveryBudget])
|
||||
useEffect(() => {
|
||||
pendingTerminalRecoveryBudget.observeContext(pendingTerminalRecoveryContextKey ?? null)
|
||||
onPendingTerminalRecoveryParkedRef.current?.(null)
|
||||
}, [pendingTerminalRecoveryBudget, pendingTerminalRecoveryContextKey])
|
||||
const controller = useMemo(
|
||||
() =>
|
||||
client
|
||||
@@ -58,7 +97,8 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
|
||||
scope: `id:${worktreeId}`,
|
||||
apply: applySessionTabs,
|
||||
consumeAccepted: consumeAcceptedSessionTabs,
|
||||
hasRecoveryNeed,
|
||||
hasRecoveryNeed: combinedHasRecoveryNeed,
|
||||
allowRecoveryPoll: getPendingTerminalRecoveryContextKey ? allowRecoveryPoll : undefined,
|
||||
getApplicationRevision,
|
||||
onFetchStarted,
|
||||
onFetchSucceeded,
|
||||
@@ -71,7 +111,9 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
|
||||
client,
|
||||
consumeAcceptedSessionTabs,
|
||||
getApplicationRevision,
|
||||
hasRecoveryNeed,
|
||||
allowRecoveryPoll,
|
||||
combinedHasRecoveryNeed,
|
||||
getPendingTerminalRecoveryContextKey,
|
||||
onFetchErrored,
|
||||
onFetchFailed,
|
||||
onFetchStarted,
|
||||
@@ -91,6 +133,7 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
|
||||
if (!client || !controller || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
resetPendingTerminalRecovery()
|
||||
const subscription = controller.beginSubscription()
|
||||
const unsubscribe = client.subscribe(
|
||||
'session.tabs.subscribe',
|
||||
@@ -101,7 +144,7 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
|
||||
subscription.cancel()
|
||||
unsubscribe()
|
||||
}
|
||||
}, [client, connState, controller, worktreeId])
|
||||
}, [client, connState, controller, resetPendingTerminalRecovery, worktreeId])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -123,19 +166,21 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
|
||||
}
|
||||
const appStateSubscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
resetPendingTerminalRecovery()
|
||||
refresh(true)
|
||||
} else {
|
||||
controller.setReconciliationActive(false)
|
||||
}
|
||||
})
|
||||
const interval = setInterval(() => refresh(false), 2000)
|
||||
resetPendingTerminalRecovery()
|
||||
refresh(true)
|
||||
return () => {
|
||||
controller.setReconciliationActive(false)
|
||||
clearInterval(interval)
|
||||
appStateSubscription.remove()
|
||||
}
|
||||
}, [connState, controller, fetchTerminals])
|
||||
}, [connState, controller, fetchTerminals, resetPendingTerminalRecovery])
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -150,6 +195,10 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
|
||||
fetchPendingBrowserSessionTabs: useCallback(
|
||||
() => controller?.requestPendingRecovery() ?? resolved,
|
||||
[controller]
|
||||
)
|
||||
),
|
||||
retryPendingTerminalRecovery: useCallback(() => {
|
||||
resetPendingTerminalRecovery()
|
||||
return controller?.retryReconciliation() ?? resolved
|
||||
}, [controller, resetPendingTerminalRecovery])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,7 +752,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
attachWebglAddon(true);
|
||||
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
|
||||
if (typeof replayData === 'string' && replayData.length > 0) {
|
||||
enqueueWrite(replayData);
|
||||
// Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output.
|
||||
enqueueWrite(ESC + '[0m' + replayData);
|
||||
}
|
||||
|
||||
// Why: reset eviction tracking + attach observers for the new term.
|
||||
|
||||
@@ -26,7 +26,7 @@ type RegisteredWindowListener = {
|
||||
type: string
|
||||
}
|
||||
|
||||
function makeTerminal(writeCallbacks: Array<() => void>) {
|
||||
function makeTerminal(writeCallbacks: Array<() => void>, writes: string[]) {
|
||||
const terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
@@ -45,7 +45,8 @@ function makeTerminal(writeCallbacks: Array<() => void>) {
|
||||
getLine: () => null
|
||||
}
|
||||
},
|
||||
write(_data: string, callback?: () => void) {
|
||||
write(data: string, callback?: () => void) {
|
||||
writes.push(data)
|
||||
if (callback) {
|
||||
writeCallbacks.push(callback)
|
||||
}
|
||||
@@ -93,6 +94,7 @@ describe('terminal WebView init surface replacement', () => {
|
||||
let terminalOptions: TerminalOptions[]
|
||||
let terminals: TerminalStub[]
|
||||
let writeCallbacks: Array<() => void>
|
||||
let writes: string[]
|
||||
|
||||
beforeEach(() => {
|
||||
animationFrames = []
|
||||
@@ -100,6 +102,7 @@ describe('terminal WebView init surface replacement', () => {
|
||||
terminalOptions = []
|
||||
terminals = []
|
||||
writeCallbacks = []
|
||||
writes = []
|
||||
const addWindowEventListener = window.addEventListener.bind(window)
|
||||
vi.spyOn(window, 'addEventListener').mockImplementation(((
|
||||
type: string,
|
||||
@@ -121,7 +124,7 @@ describe('terminal WebView init surface replacement', () => {
|
||||
}
|
||||
webWindow.Terminal = function (options: TerminalOptions) {
|
||||
terminalOptions.push(options)
|
||||
const terminal = makeTerminal(writeCallbacks)
|
||||
const terminal = makeTerminal(writeCallbacks, writes)
|
||||
terminals.push(terminal)
|
||||
return terminal
|
||||
} as unknown as new (options: TerminalOptions) => TerminalStub
|
||||
@@ -153,6 +156,13 @@ describe('terminal WebView init surface replacement', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('grounds the initial replay without clearing the host live pen', () => {
|
||||
dispatchInit(80, '\x1b[1mBOLD-RUN-LEFT-OPEN')
|
||||
animationFrames.shift()?.()
|
||||
|
||||
expect(writes).toEqual(['\x1b[0m\x1b[1mBOLD-RUN-LEFT-OPEN'])
|
||||
})
|
||||
|
||||
it('commits only the newest surface when phone-fit init calls overlap', () => {
|
||||
// Why: restored terminals can receive desktop scrollback, a phone resize,
|
||||
// and phone scrollback before any xterm replay callback has completed.
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('TerminalWebView text zoom', () => {
|
||||
expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon')
|
||||
const open = terminalHtmlSource.indexOf('term.open(surface)')
|
||||
const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")
|
||||
const replay = terminalHtmlSource.indexOf('enqueueWrite(replayData)')
|
||||
const replay = terminalHtmlSource.indexOf("enqueueWrite(ESC + '[0m' + replayData)")
|
||||
expect(open).toBeGreaterThanOrEqual(0)
|
||||
expect(unicode).toBeGreaterThan(open)
|
||||
expect(replay).toBeGreaterThan(unicode)
|
||||
|
||||
@@ -17,8 +17,9 @@ export class LogicalClientConnectionPath {
|
||||
})
|
||||
}
|
||||
|
||||
clearMigrationAfterConnected(): void {
|
||||
clearAfterConnected(): void {
|
||||
this.migration = null
|
||||
this.recovery = null
|
||||
}
|
||||
|
||||
setRecovery(path: MobileConnectionPath | null): void {
|
||||
|
||||
@@ -67,6 +67,7 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl
|
||||
private path: MobileConnectionPath
|
||||
private recoveryPath: MobileConnectionPath | null = null
|
||||
private generation = 1
|
||||
private readonly pathListeners = new Set<() => void>()
|
||||
|
||||
constructor(state: ConnectionState, path: MobileConnectionPath) {
|
||||
super(state)
|
||||
@@ -90,17 +91,28 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl
|
||||
throw new Error('migration superseded')
|
||||
}
|
||||
this.path = path
|
||||
this.recoveryPath = null
|
||||
this.generation += 1
|
||||
// Connected-state publication carries the migration cleanup.
|
||||
this.publishState('connected')
|
||||
}
|
||||
)
|
||||
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
|
||||
getActivePath = () => this.path
|
||||
getPendingPath = () => this.recoveryPath
|
||||
getPendingPath = () => (this.getState() === 'connected' ? null : this.recoveryPath)
|
||||
setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => {
|
||||
const previous = this.getPendingPath()
|
||||
this.recoveryPath = path
|
||||
if (previous !== this.getPendingPath()) {
|
||||
for (const listener of this.pathListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
})
|
||||
onConnectionPathChange = vi.fn((listener: () => void) => {
|
||||
this.pathListeners.add(listener)
|
||||
return () => this.pathListeners.delete(listener)
|
||||
})
|
||||
onConnectionPathChange = vi.fn(() => () => {})
|
||||
getGeneration = () => this.generation
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,45 @@ describe('mobile endpoint supervisor', () => {
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('does not announce Relay when credential selection finishes after backgrounding', async () => {
|
||||
const logical = new FakeLogicalClient('reconnecting', 'lan')
|
||||
let finishCredentialRead: ((value: MobileRelayCredentialBundle) => void) | undefined
|
||||
const credentialReadPending = new Promise<MobileRelayCredentialBundle>((resolve) => {
|
||||
finishCredentialRead = resolve
|
||||
})
|
||||
const readBundle = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockReturnValueOnce(credentialReadPending)
|
||||
const deps = dependencies({ readBundle })
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
const starting = supervisor.start()
|
||||
await vi.waitFor(() => expect(readBundle).toHaveBeenCalledTimes(2))
|
||||
supervisor.setForeground(false)
|
||||
finishCredentialRead?.(bundle)
|
||||
await starting
|
||||
|
||||
expect(deps.openRelay).not.toHaveBeenCalled()
|
||||
expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay')
|
||||
expect(logical.getPendingPath()).toBeNull()
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('does not announce Relay without a dialable credential', async () => {
|
||||
const logical = new FakeLogicalClient('reconnecting', 'lan')
|
||||
const expired = { ...bundle, current: { ...bundle.current, expiresAt: Date.now() - 1 } }
|
||||
const deps = dependencies({ readBundle: vi.fn(async () => expired) })
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
|
||||
expect(deps.openRelay).not.toHaveBeenCalled()
|
||||
expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay')
|
||||
expect(logical.getPendingPath()).toBeNull()
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('keeps Relay pending through a failed dial cooldown and clears it on stop', async () => {
|
||||
const logical = new FakeLogicalClient('reconnecting', 'lan')
|
||||
const deps = dependencies({
|
||||
@@ -205,6 +244,7 @@ describe('mobile endpoint supervisor', () => {
|
||||
logical.publishState('disconnected')
|
||||
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
expect(logical.getPendingPath()).toBe('relay')
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
@@ -212,6 +252,22 @@ describe('mobile endpoint supervisor', () => {
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('does not announce recovery when an active Relay drops after credential expiry', async () => {
|
||||
const logical = new FakeLogicalClient('connected', 'relay')
|
||||
const expired = { ...bundle, current: { ...bundle.current, expiresAt: Date.now() - 1 } }
|
||||
const deps = dependencies({ readBundle: vi.fn(async () => expired) })
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
await supervisor.start()
|
||||
|
||||
logical.publishState('disconnected')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(deps.openRelay).not.toHaveBeenCalled()
|
||||
expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay')
|
||||
expect(logical.getPendingPath()).toBeNull()
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('escalates backoff when relay sessions connect and then drop repeatedly', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi.fn(() => new FakeRelaySession('connected', new RelayOuterError(4408)))
|
||||
@@ -450,6 +506,7 @@ describe('mobile endpoint supervisor', () => {
|
||||
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
expect(deps.writeBundle).not.toHaveBeenCalled()
|
||||
expect(logical.getPendingPath()).toBeNull()
|
||||
|
||||
logical.publishState('connected')
|
||||
await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledOnce())
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund
|
||||
import { MobileEndpointNudgeRouter } from './mobile-endpoint-nudge-router'
|
||||
import { MobileRelayDirectGraceTimer } from './mobile-relay-direct-grace-timer'
|
||||
import { MobileRelaySessionEstablisher } from './mobile-relay-session-establisher'
|
||||
import * as recoveryPresentation from './mobile-relay-recovery-presentation'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { ForegroundNudgeReason, HostProfile } from './types'
|
||||
|
||||
@@ -146,7 +147,6 @@ export class MobileEndpointSupervisor {
|
||||
}
|
||||
this.unsubscribeState = this.logical.onStateChange((state) => {
|
||||
if (state === 'connected') {
|
||||
this.logical.setRecoveryPath(null)
|
||||
this.directGrace.clear()
|
||||
if (this.logical.getActivePath() !== 'relay') {
|
||||
void this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection())
|
||||
@@ -155,16 +155,13 @@ export class MobileEndpointSupervisor {
|
||||
} else {
|
||||
// Why: the direct client enters reconnecting after its first failed
|
||||
// dial and may never publish disconnected while its retry loop lives.
|
||||
if (this.relayReconnect.needsRecovery(state)) {
|
||||
this.logical.setRecoveryPath('relay')
|
||||
}
|
||||
recoveryPresentation.onActiveFailure(this.logical, this.relayReconnect, state, this.bundle)
|
||||
this.relayReconnect.handleStateFailure(this.logical, state)
|
||||
}
|
||||
})
|
||||
if (this.relayReconnect.needsRecovery(this.logical.getState())) {
|
||||
// Why: the first direct dial can fail while encrypted relay credentials
|
||||
// are still loading, before the supervisor subscribes to state changes.
|
||||
this.logical.setRecoveryPath('relay')
|
||||
await this.recoverRelay()
|
||||
} else {
|
||||
this.directProbe.schedule()
|
||||
@@ -176,9 +173,6 @@ export class MobileEndpointSupervisor {
|
||||
const wasForeground = this.foreground
|
||||
this.foreground = foreground
|
||||
if (foreground) {
|
||||
if (this.relayReconnect.needsRecovery(this.logical.getState())) {
|
||||
this.logical.setRecoveryPath('relay')
|
||||
}
|
||||
this.relayReconnect.handleForeground(this.logical, wasForeground)
|
||||
this.directProbe.schedule(0)
|
||||
this.directGrace.arm()
|
||||
@@ -193,13 +187,12 @@ export class MobileEndpointSupervisor {
|
||||
}
|
||||
}
|
||||
|
||||
nudge(reason: ForegroundNudgeReason): void {
|
||||
this.nudgeRouter.nudge(reason)
|
||||
}
|
||||
nudge = (reason: ForegroundNudgeReason): void => this.nudgeRouter.nudge(reason)
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true
|
||||
this.unsubscribeState?.()
|
||||
this.unsubscribeState = null
|
||||
this.directProbe.clear()
|
||||
this.relayReconnect.clear()
|
||||
this.leaseRotation.clear()
|
||||
@@ -244,7 +237,6 @@ export class MobileEndpointSupervisor {
|
||||
return
|
||||
}
|
||||
this.operationInFlight = true
|
||||
let lastError: Error | null = null
|
||||
let retryAfterOperation = false
|
||||
try {
|
||||
const selection = await selectDialableRelayCredentials({
|
||||
@@ -255,6 +247,7 @@ export class MobileEndpointSupervisor {
|
||||
})
|
||||
this.bundle = selection.bundle
|
||||
if (selection.credentials.length === 0) {
|
||||
this.logical.setRecoveryPath(null)
|
||||
// Why: "expired" vs "missing" separates a sleep-past-expiry phone
|
||||
// (needs re-pair or LAN) from a Keychain failure in field reports.
|
||||
this.logRelay(
|
||||
@@ -270,6 +263,12 @@ export class MobileEndpointSupervisor {
|
||||
}
|
||||
return
|
||||
}
|
||||
const recoveryNeeded =
|
||||
forceReplacement || this.relayReconnect.needsRecovery(this.logical.getState())
|
||||
if (this.stopped || !this.foreground || !recoveryNeeded) {
|
||||
return
|
||||
}
|
||||
this.logical.setRecoveryPath('relay')
|
||||
const dialed = await this.sessionEstablisher.dialEligible(selection.credentials)
|
||||
if (dialed.outcome === 'established') {
|
||||
// Why: a fresh socket satisfies any replacement intent queued mid-dial.
|
||||
@@ -278,15 +277,16 @@ export class MobileEndpointSupervisor {
|
||||
return
|
||||
}
|
||||
if (dialed.outcome === 'aborted') {
|
||||
this.logical.setRecoveryPath(null)
|
||||
// Why: direct won the race or the supervisor went inactive — not a
|
||||
// failure; booking backoff would delay the next genuine recovery.
|
||||
return
|
||||
}
|
||||
lastError = dialed.error
|
||||
// Why: cleanup may happen while a relay dial is awaiting the network;
|
||||
// record its outcome without recreating a foreground retry timer.
|
||||
const scheduleRetry = (!forceReplacement || ownsRecovery) && this.foreground && !this.stopped
|
||||
this.relayReconnect.registerFailure(lastError, scheduleRetry)
|
||||
this.relayReconnect.registerFailure(dialed.error, scheduleRetry)
|
||||
recoveryPresentation.clearIfCredentialBlocked(this.logical, this.relayReconnect)
|
||||
if (ownsRecovery) {
|
||||
suspendRelayIfStillConnected(this.relayReconnect, this.logical)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@ import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
|
||||
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
|
||||
import { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays'
|
||||
import { RelayCredentialEligibility } from './relay-credential-eligibility'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { ConnectionState, ForegroundNudgeReason } from './types'
|
||||
|
||||
type RelayCredentialLease = { expiresAt: number; version: number }
|
||||
|
||||
export type RelayReconnectDependencies = {
|
||||
now: () => number
|
||||
randomBytes: (length: number) => Uint8Array
|
||||
@@ -28,7 +31,7 @@ export class RelayReconnectController {
|
||||
private recoveryGate: RecoveryGate | null = null
|
||||
private gateReprobePending = false
|
||||
private gateReprobeStreak = 0
|
||||
private readonly rejectedCredentialVersions = new Set<number>()
|
||||
private readonly credentials: RelayCredentialEligibility
|
||||
private readonly delays: RelayRetryDelays
|
||||
|
||||
constructor(
|
||||
@@ -36,6 +39,7 @@ export class RelayReconnectController {
|
||||
private readonly onRetry: (forceReplacement?: boolean) => void
|
||||
) {
|
||||
this.delays = new RelayRetryDelays(dependencies.randomBytes)
|
||||
this.credentials = new RelayCredentialEligibility(dependencies.now)
|
||||
}
|
||||
|
||||
handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void {
|
||||
@@ -105,7 +109,7 @@ export class RelayReconnectController {
|
||||
|
||||
resetForDirectConnection(): boolean {
|
||||
const needsCredentialRefresh =
|
||||
this.recoveryGate === 'fresh-credential' || this.rejectedCredentialVersions.size > 0
|
||||
this.recoveryGate === 'fresh-credential' || this.credentials.hasRejected()
|
||||
this.activeSession = null
|
||||
this.activeRelayConnectedAt = null
|
||||
if (needsCredentialRefresh) {
|
||||
@@ -126,22 +130,22 @@ export class RelayReconnectController {
|
||||
|
||||
completeCredentialRefresh(): void {
|
||||
if (this.recoveryGate === 'fresh-credential') {
|
||||
this.rejectedCredentialVersions.clear()
|
||||
this.credentials.clearRejected()
|
||||
this.reset()
|
||||
}
|
||||
}
|
||||
|
||||
eligibleCredentials<T extends { expiresAt: number; version: number }>(
|
||||
blocksUntilFreshCredential = (): boolean => this.recoveryGate === 'fresh-credential'
|
||||
|
||||
hasDialableCredential(...credentials: (RelayCredentialLease | null | undefined)[]): boolean {
|
||||
return this.credentials.hasDialable(...credentials)
|
||||
}
|
||||
|
||||
eligibleCredentials<T extends RelayCredentialLease>(
|
||||
...credentials: Array<T | null | undefined>
|
||||
): T[] {
|
||||
const eligible = credentials.filter((credential): credential is T =>
|
||||
Boolean(
|
||||
credential &&
|
||||
credential.expiresAt > this.dependencies.now() &&
|
||||
!this.rejectedCredentialVersions.has(credential.version)
|
||||
)
|
||||
)
|
||||
if (eligible.length === 0 && this.rejectedCredentialVersions.size > 0) {
|
||||
const eligible = this.credentials.eligible(...credentials)
|
||||
if (eligible.length === 0 && this.credentials.hasRejected()) {
|
||||
this.recoveryGate = 'fresh-credential'
|
||||
this.clearTimer()
|
||||
this.scheduleGateReprobe()
|
||||
@@ -152,7 +156,7 @@ export class RelayReconnectController {
|
||||
// For callers that found no dialable credential at all: keep a slow retry
|
||||
// alive so a later durable write can recover.
|
||||
armCredentialReprobe(): void {
|
||||
if (this.rejectedCredentialVersions.size > 0) {
|
||||
if (this.credentials.hasRejected()) {
|
||||
this.recoveryGate = 'fresh-credential'
|
||||
this.clearTimer()
|
||||
this.scheduleGateReprobe()
|
||||
@@ -176,14 +180,12 @@ export class RelayReconnectController {
|
||||
|
||||
// A durable bundle whose current version is not rejected reopens the gate.
|
||||
acceptFreshCredential(version: number): void {
|
||||
if (this.recoveryGate === 'fresh-credential' && !this.rejectedCredentialVersions.has(version)) {
|
||||
if (this.recoveryGate === 'fresh-credential' && !this.credentials.isRejected(version)) {
|
||||
this.liftGate()
|
||||
}
|
||||
}
|
||||
|
||||
recordRejectedCredential(version: number): void {
|
||||
this.rejectedCredentialVersions.add(version)
|
||||
}
|
||||
recordRejectedCredential = (version: number): void => this.credentials.recordRejected(version)
|
||||
|
||||
registerActiveFailure(logical: StableLogicalRpcClient): void {
|
||||
if (logical.getActivePath() !== 'relay') {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
|
||||
import type { RelayReconnectController } from './mobile-relay-reconnect-controller'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { ConnectionState } from './types'
|
||||
|
||||
export function onActiveFailure(
|
||||
logical: StableLogicalRpcClient,
|
||||
controller: RelayReconnectController,
|
||||
state: ConnectionState,
|
||||
bundle: MobileRelayCredentialBundle | null
|
||||
): void {
|
||||
if (
|
||||
controller.needsRecovery(state) &&
|
||||
logical.getActivePath() === 'relay' &&
|
||||
bundle &&
|
||||
controller.hasDialableCredential(bundle.current, bundle.grace)
|
||||
) {
|
||||
logical.setRecoveryPath('relay')
|
||||
}
|
||||
}
|
||||
|
||||
export function clearIfCredentialBlocked(
|
||||
logical: StableLogicalRpcClient,
|
||||
controller: RelayReconnectController
|
||||
): void {
|
||||
if (controller.blocksUntilFreshCredential()) {
|
||||
logical.setRecoveryPath(null)
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
|
||||
private path: MobileConnectionPath
|
||||
private recoveryPath: MobileConnectionPath | null = null
|
||||
private generation = 1
|
||||
private readonly pathListeners = new Set<() => void>()
|
||||
|
||||
constructor(state: ConnectionState, path: MobileConnectionPath) {
|
||||
super(state)
|
||||
@@ -106,15 +107,25 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
|
||||
this.path = path
|
||||
this.recoveryPath = null
|
||||
this.generation += 1
|
||||
// Connected-state publication carries the migration cleanup.
|
||||
this.publishState('connected')
|
||||
})
|
||||
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
|
||||
getActivePath = () => this.path
|
||||
getPendingPath = () => this.recoveryPath
|
||||
getPendingPath = () => (this.getState() === 'connected' ? null : this.recoveryPath)
|
||||
setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => {
|
||||
const previous = this.getPendingPath()
|
||||
this.recoveryPath = path
|
||||
if (previous !== this.getPendingPath()) {
|
||||
for (const listener of this.pathListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
})
|
||||
onConnectionPathChange = vi.fn((listener: () => void) => {
|
||||
this.pathListeners.add(listener)
|
||||
return () => this.pathListeners.delete(listener)
|
||||
})
|
||||
onConnectionPathChange = vi.fn(() => () => {})
|
||||
getGeneration = () => this.generation
|
||||
}
|
||||
|
||||
@@ -247,12 +258,15 @@ describe('relay runtime recovery without direct connectivity', () => {
|
||||
await supervisor.start()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(deps.openRelay).not.toHaveBeenCalled()
|
||||
expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay')
|
||||
expect(logical.getPendingPath()).toBeNull()
|
||||
|
||||
readBundle.mockImplementation(async () => bundleWith(3, Number.MAX_SAFE_INTEGER))
|
||||
// Pre-fix, an expired bundle produced a silent no-op with nothing scheduled.
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
expect(deps.openRelay).toHaveBeenCalledOnce()
|
||||
expect(logical.setRecoveryPath).toHaveBeenCalledWith('relay')
|
||||
expect(logical.getActivePath()).toBe('relay')
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
type RelayCredentialLease = { expiresAt: number; version: number }
|
||||
|
||||
export class RelayCredentialEligibility {
|
||||
private readonly rejectedVersions = new Set<number>()
|
||||
|
||||
constructor(private readonly now: () => number) {}
|
||||
|
||||
hasRejected(): boolean {
|
||||
return this.rejectedVersions.size > 0
|
||||
}
|
||||
|
||||
clearRejected(): void {
|
||||
this.rejectedVersions.clear()
|
||||
}
|
||||
|
||||
isRejected(version: number): boolean {
|
||||
return this.rejectedVersions.has(version)
|
||||
}
|
||||
|
||||
recordRejected(version: number): void {
|
||||
this.rejectedVersions.add(version)
|
||||
}
|
||||
|
||||
hasDialable(...credentials: (RelayCredentialLease | null | undefined)[]): boolean {
|
||||
return credentials.some((credential) => this.isDialable(credential))
|
||||
}
|
||||
|
||||
eligible<T extends RelayCredentialLease>(...credentials: (T | null | undefined)[]): T[] {
|
||||
return credentials.filter((credential): credential is T => this.isDialable(credential))
|
||||
}
|
||||
|
||||
private isDialable<T extends RelayCredentialLease>(
|
||||
credential: T | null | undefined
|
||||
): credential is T {
|
||||
return Boolean(
|
||||
credential &&
|
||||
credential.expiresAt > this.now() &&
|
||||
!this.rejectedVersions.has(credential.version)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -302,6 +302,17 @@ describe('stable logical RPC client', () => {
|
||||
expect(paths).toEqual(['relay', null])
|
||||
})
|
||||
|
||||
it('does not revive a stale recovery path after a connection later drops', () => {
|
||||
const direct = new FakeSession('reconnecting')
|
||||
const client = createStableLogicalRpcClient(direct, 'tailscale')
|
||||
|
||||
client.setRecoveryPath('relay')
|
||||
direct.setState('connected')
|
||||
direct.setState('reconnecting')
|
||||
|
||||
expect(client.getPendingPath()).toBeNull()
|
||||
})
|
||||
|
||||
it('drops the pending path when the previous session recovers mid-dial', async () => {
|
||||
const direct = new FakeSession('connected')
|
||||
const replacement = new FakeSession('connecting')
|
||||
|
||||
@@ -261,7 +261,7 @@ export function createStableLogicalRpcClient(
|
||||
}
|
||||
pendingRequests.clear()
|
||||
state = nextSession.getState()
|
||||
connectionPath.clearMigrationAfterConnected()
|
||||
connectionPath.clearAfterConnected()
|
||||
for (const listener of stateListeners) {
|
||||
listener(state)
|
||||
}
|
||||
@@ -321,6 +321,9 @@ export function createStableLogicalRpcClient(
|
||||
return
|
||||
}
|
||||
state = next
|
||||
if (next === 'connected') {
|
||||
connectionPath.clearAfterConnected()
|
||||
}
|
||||
for (const listener of stateListeners) {
|
||||
listener(next)
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ $MouseEvents = @{
|
||||
MiddleDown = 0x0020
|
||||
MiddleUp = 0x0040
|
||||
Wheel = 0x0800
|
||||
HorizontalWheel = 0x01000
|
||||
}
|
||||
|
||||
function Write-OrcaJson($Payload) {
|
||||
@@ -1235,16 +1236,22 @@ function Invoke-OrcaOperation($Operation) {
|
||||
}
|
||||
"scroll" {
|
||||
$delta = 120 * [int][Math]::Ceiling((Get-OrcaPositiveNumber $Operation.pages "pages"))
|
||||
if ($Operation.direction -eq "down" -or $Operation.direction -eq "right") {
|
||||
$mouseEvent = $MouseEvents.Wheel
|
||||
if ($Operation.direction -eq "down") {
|
||||
$delta = -1 * $delta
|
||||
} elseif ($Operation.direction -ne "up" -and $Operation.direction -ne "left") {
|
||||
} elseif ($Operation.direction -eq "left") {
|
||||
$mouseEvent = $MouseEvents.HorizontalWheel
|
||||
$delta = -1 * $delta
|
||||
} elseif ($Operation.direction -eq "right") {
|
||||
$mouseEvent = $MouseEvents.HorizontalWheel
|
||||
} elseif ($Operation.direction -ne "up") {
|
||||
throw "unsupported scroll direction: $($Operation.direction)"
|
||||
}
|
||||
$point = Get-OrcaElementScreenPoint $element
|
||||
if ($null -eq $point) { $point = Get-OrcaScreenPoint $Operation $windowFrame }
|
||||
[void][OrcaDesktopWin32]::SetForegroundWindow($handle)
|
||||
[void][OrcaDesktopWin32]::SetCursorPos([int]$point.x, [int]$point.y)
|
||||
[OrcaDesktopWin32]::mouse_event($MouseEvents.Wheel, 0, 0, $delta, [UIntPtr]::Zero)
|
||||
[OrcaDesktopWin32]::mouse_event($mouseEvent, 0, 0, $delta, [UIntPtr]::Zero)
|
||||
$action = [pscustomobject]@{ path = "synthetic"; actionName = "scroll"; fallbackReason = $null }
|
||||
}
|
||||
"drag" {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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'
|
||||
|
||||
const REMOTE_ROW = {
|
||||
handle: 'term_remote',
|
||||
ptyId: 'ssh:box-1@@pty-7',
|
||||
worktreeId: 'repo-ssh::/remote/wt',
|
||||
worktreePath: '/remote/wt',
|
||||
branch: 'main',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'worker',
|
||||
connected: true,
|
||||
writable: true,
|
||||
lastOutputAt: null,
|
||||
preview: '',
|
||||
executionHostId: 'ssh:box-1'
|
||||
}
|
||||
|
||||
describe('orca terminal list host scope', () => {
|
||||
useWorktreeAwarenessEnvironment({
|
||||
callMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
})
|
||||
|
||||
it('keeps the execution host and scope in --json output', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_list', {
|
||||
terminals: [REMOTE_ROW],
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] }
|
||||
})
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['terminal', 'list', '--json'], '/tmp/repo')
|
||||
|
||||
const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0]))
|
||||
expect(printed.result.terminals[0].executionHostId).toBe('ssh:box-1')
|
||||
expect(printed.result.hostScope).toEqual({
|
||||
hostIds: ['ssh:box-1'],
|
||||
omittedHostIds: ['local']
|
||||
})
|
||||
})
|
||||
|
||||
it('prints the execution host and scope in human output', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_list', {
|
||||
terminals: [REMOTE_ROW],
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] }
|
||||
})
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['terminal', 'list'], '/tmp/repo')
|
||||
|
||||
const printed = String(logSpy.mock.calls[0]?.[0])
|
||||
expect(printed).toContain('host=ssh:box-1')
|
||||
expect(printed).toContain('scope: ssh:box-1')
|
||||
expect(printed).toContain('not covered: local')
|
||||
})
|
||||
|
||||
it('does not claim a local scope when the host never reported one', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_list', { terminals: [], totalCount: 0, truncated: false })
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['terminal', 'list'], '/tmp/repo')
|
||||
|
||||
const printed = String(logSpy.mock.calls[0]?.[0])
|
||||
expect(printed).toContain('scope: unverifiable')
|
||||
expect(printed).not.toContain('scope: local')
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
RuntimeTerminalClose,
|
||||
RuntimeTerminalCreate,
|
||||
RuntimeTerminalFocus,
|
||||
RuntimeTerminalListHostScope,
|
||||
RuntimeTerminalListResult,
|
||||
RuntimeTerminalVisualLayout,
|
||||
RuntimeTerminalVisualLayoutNode,
|
||||
@@ -17,20 +18,34 @@ import type {
|
||||
} from '../shared/runtime-types'
|
||||
|
||||
export function formatTerminalList(result: RuntimeTerminalListResult): string {
|
||||
const scope = formatTerminalListHostScope(result.hostScope)
|
||||
if (result.terminals.length === 0) {
|
||||
return 'No live terminals.'
|
||||
return `No terminals listed.\n${scope}`
|
||||
}
|
||||
const body = result.terminals
|
||||
.map(
|
||||
(terminal) =>
|
||||
`${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: <empty>'}`
|
||||
`${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} host=${terminal.executionHostId ?? 'unverifiable'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: <empty>'}`
|
||||
)
|
||||
.join('\n\n')
|
||||
const visualLayout = formatTerminalVisualLayouts(result.visualLayouts)
|
||||
const bodyWithLayout = visualLayout ? `${body}\n\nvisual layout:\n${visualLayout}` : body
|
||||
const bodyWithScope = `${bodyWithLayout}\n\n${scope}`
|
||||
return result.truncated
|
||||
? `${bodyWithLayout}\n\ntruncated: showing ${result.terminals.length} of ${result.totalCount}`
|
||||
: bodyWithLayout
|
||||
? `${bodyWithScope}\ntruncated: showing ${result.terminals.length} of ${result.totalCount}`
|
||||
: bodyWithScope
|
||||
}
|
||||
|
||||
// Why: a listing that does not say what it covers reads as absolute, and an
|
||||
// absent scope means the host is too old to know — not that it covered everything.
|
||||
function formatTerminalListHostScope(scope: RuntimeTerminalListHostScope | undefined): string {
|
||||
if (!scope) {
|
||||
return 'scope: unverifiable — this host does not report which hosts it lists'
|
||||
}
|
||||
const covered = scope.hostIds.length > 0 ? scope.hostIds.join(', ') : 'none'
|
||||
const omitted =
|
||||
scope.omittedHostIds.length > 0 ? ` — not covered: ${scope.omittedHostIds.join(', ')}` : ''
|
||||
return `scope: ${covered}${omitted}`
|
||||
}
|
||||
|
||||
function formatTerminalVisualLayouts(
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatTerminalList } from './format'
|
||||
import type { RuntimeTerminalListResult, RuntimeTerminalSummary } from '../shared/runtime-types'
|
||||
|
||||
function terminal(overrides: Partial<RuntimeTerminalSummary> = {}): RuntimeTerminalSummary {
|
||||
return {
|
||||
handle: 'term_1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo::/repo',
|
||||
worktreePath: '/repo',
|
||||
branch: 'main',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'worker',
|
||||
connected: true,
|
||||
writable: true,
|
||||
lastOutputAt: null,
|
||||
preview: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function listResult(overrides: Partial<RuntimeTerminalListResult> = {}): RuntimeTerminalListResult {
|
||||
return { terminals: [terminal()], totalCount: 1, truncated: false, ...overrides }
|
||||
}
|
||||
|
||||
describe('formatTerminalList host identity', () => {
|
||||
it('prints the execution host each terminal runs on', () => {
|
||||
const output = formatTerminalList(
|
||||
listResult({ terminals: [terminal({ executionHostId: 'ssh:box-1' })] })
|
||||
)
|
||||
|
||||
expect(output).toContain('host=ssh:box-1')
|
||||
})
|
||||
|
||||
it('prints unverifiable, not local, for a row whose host the runtime could not name', () => {
|
||||
const output = formatTerminalList(listResult({ terminals: [terminal()] }))
|
||||
|
||||
expect(output).toContain('host=unverifiable')
|
||||
expect(output).not.toContain('host=local')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTerminalList scope declaration', () => {
|
||||
it('states the covered and omitted hosts', () => {
|
||||
const output = formatTerminalList(
|
||||
listResult({
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: ['ssh:box-1'] }
|
||||
})
|
||||
)
|
||||
|
||||
expect(output).toContain('scope: local')
|
||||
expect(output).toContain('not covered: ssh:box-1')
|
||||
})
|
||||
|
||||
it('keeps an empty listing self-describing instead of reading as absolute', () => {
|
||||
const output = formatTerminalList(
|
||||
listResult({
|
||||
terminals: [],
|
||||
totalCount: 0,
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: ['ssh:box-1'] }
|
||||
})
|
||||
)
|
||||
|
||||
expect(output).toContain('No terminals listed')
|
||||
expect(output).toContain('scope: local')
|
||||
expect(output).toContain('not covered: ssh:box-1')
|
||||
})
|
||||
|
||||
it('says the scope is unverifiable when the host predates the field', () => {
|
||||
const output = formatTerminalList(listResult())
|
||||
|
||||
expect(output).toContain('scope: unverifiable')
|
||||
expect(output).not.toContain('scope: local')
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,22 @@ describe('createHookStatusSessionTabsInvalidator', () => {
|
||||
expect(changed(working({}, payload))).toBe(true)
|
||||
})
|
||||
|
||||
it('invalidates when the completion stamp is added, changed, or removed', () => {
|
||||
const changed = createHookStatusSessionTabsInvalidator()
|
||||
changed(working())
|
||||
|
||||
expect(changed(working({}, { turnCompletedAt: 100 }))).toBe(true)
|
||||
expect(changed(working({}, { turnCompletedAt: 200 }))).toBe(true)
|
||||
expect(changed(working())).toBe(true)
|
||||
})
|
||||
|
||||
it('invalidates when the assistant body changes', () => {
|
||||
const changed = createHookStatusSessionTabsInvalidator()
|
||||
changed(working({}, { lastAssistantMessage: 'First answer' }))
|
||||
|
||||
expect(changed(working({}, { lastAssistantMessage: 'Corrected answer' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores resume-identity rows, which the provider-session path owns', () => {
|
||||
const changed = createHookStatusSessionTabsInvalidator()
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ export function createHookStatusSessionTabsInvalidator(): {
|
||||
(previous.agentType ?? null) !== (next.agentType ?? null) ||
|
||||
(previous.toolName ?? null) !== (next.toolName ?? null) ||
|
||||
(previous.interactivePrompt ?? null) !== (next.interactivePrompt ?? null) ||
|
||||
(previous.interrupted ?? false) !== (next.interrupted ?? false)
|
||||
(previous.interrupted ?? false) !== (next.interrupted ?? false) ||
|
||||
(previous.turnCompletedAt ?? null) !== (next.turnCompletedAt ?? null) ||
|
||||
(previous.lastAssistantMessage ?? null) !== (next.lastAssistantMessage ?? null)
|
||||
)
|
||||
}
|
||||
// Why: a cleared pane must re-arm, else the memo swallows the first event of the
|
||||
|
||||
@@ -71,7 +71,10 @@ describe('readHooksJsonWithRaw', () => {
|
||||
for (const contents of [`\uFEFF\uFEFF${body}`, ` \uFEFF${body}`, `{\uFEFF"hooks": {}}`]) {
|
||||
writeFileSync(configPath, contents, 'utf-8')
|
||||
|
||||
expect(readHooksJsonWithRaw(configPath)).toEqual({ raw: contents, config: null })
|
||||
expect(readHooksJsonWithRaw(configPath)).toEqual({
|
||||
raw: contents,
|
||||
config: null
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -82,7 +85,10 @@ describe('readHooksJsonWithRaw', () => {
|
||||
it('keeps the raw bytes when the contents are not a JSON object', () => {
|
||||
writeFileSync(configPath, 'not json\n', 'utf-8')
|
||||
|
||||
expect(readHooksJsonWithRaw(configPath)).toEqual({ raw: 'not json\n', config: null })
|
||||
expect(readHooksJsonWithRaw(configPath)).toEqual({
|
||||
raw: 'not json\n',
|
||||
config: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,7 +101,9 @@ describe('writeHooksJson', () => {
|
||||
writeHooksJson(configPath, { hooks: { Stop: [] } })
|
||||
|
||||
expect(lstatSync(configPath).isSymbolicLink()).toBe(true)
|
||||
expect(JSON.parse(readFileSync(targetPath, 'utf-8'))).toEqual({ hooks: { Stop: [] } })
|
||||
expect(JSON.parse(readFileSync(targetPath, 'utf-8'))).toEqual({
|
||||
hooks: { Stop: [] }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not replace a dangling hook config symlink', () => {
|
||||
@@ -182,9 +190,15 @@ describe('writeHooksJson', () => {
|
||||
})
|
||||
|
||||
it('updates the .bak file to the previous version on each write', () => {
|
||||
const v1: HooksConfig = { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v1' }] }] } }
|
||||
const v2: HooksConfig = { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v2' }] }] } }
|
||||
const v3: HooksConfig = { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v3' }] }] } }
|
||||
const v1: HooksConfig = {
|
||||
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v1' }] }] }
|
||||
}
|
||||
const v2: HooksConfig = {
|
||||
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v2' }] }] }
|
||||
}
|
||||
const v3: HooksConfig = {
|
||||
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v3' }] }] }
|
||||
}
|
||||
|
||||
writeHooksJson(configPath, v1)
|
||||
writeHooksJson(configPath, v2)
|
||||
@@ -256,6 +270,16 @@ describe('createManagedCommandMatcher', () => {
|
||||
expect(match(command)).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the pre-default-form launcher so upgrades replace it instead of duplicating', () => {
|
||||
// Why: installs before the ${VAR-} conversion emitted bare $HOME/$SYSTEMROOT. The sweep must
|
||||
// still recognize them, or an upgrade would leave the stale entry beside the new one.
|
||||
expect(
|
||||
match(
|
||||
'if [ -z "$HOME" ]; then :; else if [ -f "$HOME/.orca/agent-hooks/claude-hook.sh" ]; then /bin/sh "$HOME/.orca/agent-hooks/claude-hook.sh"; fi; fi'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('matches PowerShell and POSIX variants across Copilot platform switches', () => {
|
||||
const matchPosix = createManagedCommandMatcher('copilot-hook.sh')
|
||||
const matchPowerShell = createManagedCommandMatcher('copilot-hook.ps1')
|
||||
@@ -310,7 +334,10 @@ describe('removeManagedCommands', () => {
|
||||
[
|
||||
{
|
||||
hooks: [
|
||||
{ type: 'command', command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"' },
|
||||
{
|
||||
type: 'command',
|
||||
command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"'
|
||||
},
|
||||
{ type: 'command', command: 'echo keep me' }
|
||||
]
|
||||
}
|
||||
@@ -381,7 +408,14 @@ describe('hookDefinitionHasManagedCommand', () => {
|
||||
).toBe(true)
|
||||
expect(
|
||||
hookDefinitionHasManagedCommand(
|
||||
{ hooks: [{ type: 'command', command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"' }] },
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"'
|
||||
}
|
||||
]
|
||||
},
|
||||
match
|
||||
)
|
||||
).toBe(true)
|
||||
@@ -539,7 +573,7 @@ describe('wrapPosixHookCommand', () => {
|
||||
})
|
||||
|
||||
const qualifiedWindowsPowerShellCommand =
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/
|
||||
|
||||
function decodeWindowsHookCommand(command: string): string {
|
||||
const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1]
|
||||
@@ -549,7 +583,8 @@ function decodeWindowsHookCommand(command: string): string {
|
||||
|
||||
function expectedDecodedWindowsHookCommand(scriptPath: string): string {
|
||||
const quoted = `'${scriptPath.replaceAll("'", "''")}'`
|
||||
return `if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0`
|
||||
// Why: PowerShell progress CLIXML corrupts consumers that merge stderr into JSON stdout.
|
||||
return `$ProgressPreference='SilentlyContinue'; if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0`
|
||||
}
|
||||
|
||||
describe('wrapWindowsHookCommand', () => {
|
||||
@@ -653,13 +688,29 @@ describe('wrapRuntimeHomeHookCommand', () => {
|
||||
const command = wrapRuntimeHomeHookCommand('claude-hook')
|
||||
|
||||
expect(command).toContain('case "${OSTYPE-}" in msys*|cygwin*|win32*)')
|
||||
expect(command).toContain('case "$HOME" in *\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*)')
|
||||
expect(command).toContain('case "${HOME-}" in *\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*)')
|
||||
expect(command).not.toContain('uname')
|
||||
expect(command).toContain('"$HOME/.orca/agent-hooks/claude-hook.cmd"')
|
||||
expect(command).toContain('/bin/sh "$HOME/.orca/agent-hooks/claude-hook.sh"')
|
||||
expect(command).toContain('"${HOME-}/.orca/agent-hooks/claude-hook.cmd"')
|
||||
expect(command).toContain('/bin/sh "${HOME-}/.orca/agent-hooks/claude-hook.sh"')
|
||||
expect(command).not.toMatch(/[A-Z]:[\\/]|\/Users\/|\/home\//)
|
||||
})
|
||||
|
||||
// Why: a static hook precheck (Grok) rejects the whole command on any bare reference it cannot
|
||||
// resolve, including one in a branch that platform never takes.
|
||||
it.each([
|
||||
['default', undefined],
|
||||
['neutral-json', { neutralJsonWhenMissing: true }]
|
||||
])(
|
||||
'references every variable in default form (%s) so a static precheck cannot reject it',
|
||||
(_label, options) => {
|
||||
const command = wrapRuntimeHomeHookCommand('claude-hook', options)
|
||||
|
||||
expect(command).toContain('"${SYSTEMROOT-}/System32/WindowsPowerShell/v1.0/powershell.exe"')
|
||||
expect(command).not.toMatch(/\$(?!\{)[A-Za-z_]/)
|
||||
expect(command).not.toMatch(/\$\{[A-Za-z_][A-Za-z0-9_]*\}/)
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects a script base name that could inject shell syntax', () => {
|
||||
expect(() => wrapRuntimeHomeHookCommand('claude-hook; echo injected')).toThrow(
|
||||
'Invalid managed script base name'
|
||||
@@ -725,11 +776,32 @@ describe('wrapRuntimeHomeHookCommand', () => {
|
||||
process.platform === 'win32'
|
||||
? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe')
|
||||
: '/bin/sh'
|
||||
const result = spawnSync(shell, ['-c', command], { input: Buffer.alloc(1_000_000, 'x') })
|
||||
const result = spawnSync(shell, ['-c', command], {
|
||||
input: Buffer.alloc(1_000_000, 'x')
|
||||
})
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status).toBe(0)
|
||||
})
|
||||
|
||||
it('emits neutral JSON when a lifecycle script is missing', () => {
|
||||
const shell =
|
||||
process.platform === 'win32'
|
||||
? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe')
|
||||
: '/bin/sh'
|
||||
const result = spawnSync(
|
||||
shell,
|
||||
['-c', wrapRuntimeHomeHookCommand('missing-orca-hook', { neutralJsonWhenMissing: true })],
|
||||
{
|
||||
env: { ...process.env, HOME: tmpDir.replaceAll('\\', '/') },
|
||||
input: Buffer.alloc(1_000_000, 'x')
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status, result.stderr.toString()).toBe(0)
|
||||
expect(JSON.parse(result.stdout.toString().trim())).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWindowsAgentHookPostCommand', () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { grantDirAcl, isPermissionError } from '../win32-utils'
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
|
||||
import { resolveHooksJsonWritePath } from './hook-config-write-path'
|
||||
import { writeRollingFileBackup } from '../rolling-file-backup'
|
||||
import { wrapWindowsPowerShellEncodedCommand } from './windows-powershell-hook-launcher'
|
||||
|
||||
export type HookCommandConfig = {
|
||||
type: 'command'
|
||||
@@ -120,16 +121,14 @@ export function wrapPosixHookCommand(scriptPath: string, env: Record<string, str
|
||||
return `if [ -f ${quoted} ] && [ -r ${quoted} ] && [ -x ${quoted} ]; then ${invocation}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
}
|
||||
|
||||
function quotePowerShellString(value: string): string {
|
||||
export function quotePowerShellString(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
function getWindowsPowerShellExecutablePath(): string {
|
||||
const systemRoot = process.env.SystemRoot || 'C:\\Windows'
|
||||
// Why: PATH lookup lets a worktree-local powershell.exe hijack hook payloads.
|
||||
// Forward slashes keep this absolute path shell-friendly for cmd.exe and Git Bash.
|
||||
return `${systemRoot.replaceAll('\\', '/')}/System32/WindowsPowerShell/v1.0/powershell.exe`
|
||||
}
|
||||
export {
|
||||
wrapWindowsPowerShellEncodedCommand,
|
||||
WINDOWS_POWERSHELL_HOOK_SWITCHES
|
||||
} from './windows-powershell-hook-launcher'
|
||||
|
||||
export function wrapWindowsHookCommand(
|
||||
scriptPath: string,
|
||||
@@ -141,8 +140,7 @@ export function wrapWindowsHookCommand(
|
||||
.map(([key, value]) => `$env:${key} = ${quotePowerShellString(value)}; `)
|
||||
.join('')
|
||||
const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0`
|
||||
const encodedCommand = Buffer.from(command, 'utf16le').toString('base64')
|
||||
return `${getWindowsPowerShellExecutablePath()} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
|
||||
return wrapWindowsPowerShellEncodedCommand(command)
|
||||
}
|
||||
|
||||
export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/
|
||||
|
||||
@@ -69,6 +69,7 @@ vi.mock('os', async (importOriginal) => {
|
||||
|
||||
import { AntigravityHookService } from '../antigravity/hook-service'
|
||||
import { ClaudeHookService } from '../claude/hook-service'
|
||||
import { getRemoteManagedCommand } from '../claude/hook-settings'
|
||||
import { CodexHookService } from '../codex/hook-service'
|
||||
import { CommandCodeHookService } from '../command-code/hook-service'
|
||||
import { CopilotHookService } from '../copilot/hook-service'
|
||||
@@ -155,6 +156,7 @@ const LOCAL_INSTALLERS = [
|
||||
type HookRun = {
|
||||
exitCode: number | null
|
||||
stdinErrors: NodeJS.ErrnoException[]
|
||||
stderr: string
|
||||
stdout: string
|
||||
}
|
||||
|
||||
@@ -164,8 +166,9 @@ function runHookProcess(
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<HookRun> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(executable, args, { env, stdio: ['pipe', 'pipe', 'ignore'] })
|
||||
const child = spawn(executable, args, { env, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
const stdinErrors: NodeJS.ErrnoException[] = []
|
||||
let stderr = ''
|
||||
let stdout = ''
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
@@ -178,10 +181,13 @@ function runHookProcess(
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString()
|
||||
})
|
||||
child.stdin.on('error', (error: NodeJS.ErrnoException) => stdinErrors.push(error))
|
||||
child.on('close', (exitCode) => {
|
||||
clearTimeout(timeout)
|
||||
resolve({ exitCode, stdinErrors, stdout })
|
||||
resolve({ exitCode, stdinErrors, stderr, stdout })
|
||||
})
|
||||
child.stdin.end(LARGE_PAYLOAD)
|
||||
})
|
||||
@@ -401,9 +407,75 @@ describe('Windows managed hook stdin structure', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Why: command-shape tests missed conhost discarding the JSON consumers observe (#14818).
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'emits parseable JSON on stdout from the registered Claude hook command, through cmd.exe and Git Bash',
|
||||
async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdout-json-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
try {
|
||||
expect(new ClaudeHookService().install().state).toBe('installed')
|
||||
const settings = JSON.parse(
|
||||
readFileSync(join(home, '.claude', 'settings.json'), 'utf8')
|
||||
) as { hooks: Record<string, { hooks: { command: string; args?: string[] }[] }[]> }
|
||||
const entry = settings.hooks.PreToolUse[0].hooks[0]
|
||||
expect(entry.args).toBeUndefined()
|
||||
|
||||
// Why: MSYS rewrites switches and paths, so the command must survive both shells (#14815).
|
||||
const gitBash = findGitBash()
|
||||
const shells = [
|
||||
{ name: 'cmd.exe', executable: 'cmd.exe', args: ['/d', '/c', entry.command] },
|
||||
{ name: 'Git Bash', executable: gitBash, args: ['-c', entry.command] }
|
||||
]
|
||||
// Why: cover guard exit, reached curl, and the launcher's missing-script fallback.
|
||||
const environments = [
|
||||
{ name: 'no Orca env', env: hookEnvironment({ USERPROFILE: home }) },
|
||||
{
|
||||
name: 'Orca env with dead listener',
|
||||
env: hookEnvironment({
|
||||
USERPROFILE: home,
|
||||
ORCA_AGENT_HOOK_PORT: '59999',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token',
|
||||
ORCA_PANE_KEY: 'tab:leaf'
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'missing managed script',
|
||||
env: hookEnvironment({ USERPROFILE: join(home, 'absent') })
|
||||
}
|
||||
]
|
||||
for (const shell of shells) {
|
||||
for (const environment of environments) {
|
||||
const label = `${shell.name} / ${environment.name}`
|
||||
const result = await runHookProcess(shell.executable, shell.args, environment.env)
|
||||
expect(result.exitCode, `${label} exit code`).toBe(0)
|
||||
expect(result.stderr, `${label} stderr`).toBe('')
|
||||
expect(() => JSON.parse(result.stdout.trim()), `${label} stdout is JSON`).not.toThrow()
|
||||
expect(JSON.parse(result.stdout.trim()), `${label} stdout`).toEqual({})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
// Why: six shell launches can overrun the default while the suite competes for cores.
|
||||
60_000
|
||||
)
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () => {
|
||||
it('emits neutral JSON when the Claude lifecycle script is missing', async () => {
|
||||
const command = getRemoteManagedCommand('/home/dev/.orca/agent-hooks/claude-hook.sh')
|
||||
const result = await runPosixHook(command)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdinErrors).toHaveLength(0)
|
||||
expect(result.stderr).toBe('')
|
||||
expect(JSON.parse(result.stdout.trim())).toEqual({})
|
||||
})
|
||||
|
||||
it('captures stdin before every possible whole-script success exit', async () => {
|
||||
const scripts = await generatePosixScripts()
|
||||
for (const [agent, script] of scripts) {
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
|
||||
import {
|
||||
encodeWindowsPowerShellHookCommand,
|
||||
WINDOWS_POWERSHELL_HOOK_SWITCHES
|
||||
} from './windows-powershell-hook-launcher'
|
||||
|
||||
const MANAGED_SCRIPT_BASE_NAME = /^[A-Za-z0-9_-]+$/
|
||||
const WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE = '*\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*'
|
||||
|
||||
export function wrapRuntimeHomeHookCommand(scriptBaseName: string): string {
|
||||
export function wrapRuntimeHomeHookCommand(
|
||||
scriptBaseName: string,
|
||||
options: { neutralJsonWhenMissing?: boolean } = {}
|
||||
): string {
|
||||
if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) {
|
||||
throw new Error(`Invalid managed script base name: ${scriptBaseName}`)
|
||||
}
|
||||
const windowsScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.cmd"`
|
||||
const posixScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.sh"`
|
||||
// Why: default-form every var — a static hook precheck (Grok) rejects the whole command on a bare
|
||||
// reference it cannot resolve, even in a branch that platform never takes.
|
||||
const windowsScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.cmd"`
|
||||
const posixScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.sh"`
|
||||
const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND
|
||||
const powershell = '"$SYSTEMROOT/System32/WindowsPowerShell/v1.0/powershell.exe"'
|
||||
const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0`
|
||||
const encodedCommand = Buffer.from(powershellCommand, 'utf16le').toString('base64')
|
||||
const powershellInvocation = `${powershell} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
|
||||
const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${drain}; fi`
|
||||
const windowsBranch = `if [ -f ${windowsScript} ]; then case "$HOME" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${drain}; fi`
|
||||
const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${drain}; fi`
|
||||
const missingScriptFallback = options.neutralJsonWhenMissing ? `${drain}; printf '{}\\n'` : drain
|
||||
const powershell = '"${SYSTEMROOT-}/System32/WindowsPowerShell/v1.0/powershell.exe"'
|
||||
const powershellFallback = options.neutralJsonWhenMissing ? "; Write-Output '{}'" : ''
|
||||
const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null${powershellFallback}; exit 0`
|
||||
const encodedCommand = encodeWindowsPowerShellHookCommand(powershellCommand)
|
||||
// Why: the Git Bash and native Windows launchers must suppress windows identically (#14815).
|
||||
const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}`
|
||||
const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${missingScriptFallback}; fi`
|
||||
const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${missingScriptFallback}; fi`
|
||||
const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${missingScriptFallback}; fi`
|
||||
// Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation.
|
||||
return `if [ -z "\${HOME-}" ]; then ${drain}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi`
|
||||
return `if [ -z "\${HOME-}" ]; then ${missingScriptFallback}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi`
|
||||
}
|
||||
|
||||
@@ -27,6 +27,97 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AgentHookServer ingestTerminalStatus', () => {
|
||||
it('preserves a hook turn stamp when an OSC repaint omits hook-only completion text', () => {
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
payload: { state: 'working', prompt: 'review the PR', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
listener.mockClear()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'review the PR',
|
||||
agentType: 'claude',
|
||||
lastAssistantMessage: 'Review complete.',
|
||||
turnCompletedAt: 1_700_000_005_000
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
connectionId: 'conn-1',
|
||||
payload: { state: 'working', prompt: 'review the PR', agentType: 'claude' }
|
||||
})
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
lastAssistantMessage: 'Review complete.',
|
||||
turnCompletedAt: 1_700_000_005_000
|
||||
})
|
||||
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
connectionId: 'conn-1',
|
||||
payload: { state: 'done', prompt: 'review the PR', agentType: 'claude' }
|
||||
})
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
expect(listener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ payload: expect.objectContaining({ state: 'done' }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts an identical-prompt hook boundary after a stamped turn', () => {
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
hookEventName: 'Stop',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'check status',
|
||||
agentType: 'claude',
|
||||
turnCompletedAt: 1_700_000_005_000
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
payload: { state: 'working', prompt: 'check status', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
expect(listener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'check status',
|
||||
turnCompletedAt: undefined
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(server.getStatusSnapshot()[0]?.turnCompletedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
// Why: the OSC 9999 payload cannot carry a provider session, so letting it overwrite the row
|
||||
// erased the session id from persisted rows and from headless `orca serve` — which serves these
|
||||
// rows straight to mobile — leaving Chat UI with no transcript to subscribe to (#10630).
|
||||
|
||||
@@ -416,7 +416,8 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
|
||||
// Why: OSC never carries model/children; omit both so an equivalent OSC ping preserves the hook-cached identity graph.
|
||||
function equivalentParsedAgentStatusPayload(
|
||||
a: ParsedAgentStatusPayload,
|
||||
b: ParsedAgentStatusPayload
|
||||
b: ParsedAgentStatusPayload,
|
||||
preserveActiveTurnStamp = false
|
||||
): boolean {
|
||||
return (
|
||||
a.state === b.state &&
|
||||
@@ -425,11 +426,14 @@ function equivalentParsedAgentStatusPayload(
|
||||
a.toolName === b.toolName &&
|
||||
a.toolInput === b.toolInput &&
|
||||
a.interactivePrompt === b.interactivePrompt &&
|
||||
a.lastAssistantMessage === b.lastAssistantMessage &&
|
||||
(a.lastAssistantMessage === b.lastAssistantMessage ||
|
||||
(preserveActiveTurnStamp && b.lastAssistantMessage === undefined)) &&
|
||||
a.interrupted === b.interrupted &&
|
||||
// Why: a session-boundary done must never be deduped against a cached real done —
|
||||
// the flag has to reach receivers deterministically (STA-3386).
|
||||
a.sessionBoundary === b.sessionBoundary
|
||||
a.sessionBoundary === b.sessionBoundary &&
|
||||
(a.turnCompletedAt === b.turnCompletedAt ||
|
||||
(preserveActiveTurnStamp && b.turnCompletedAt === undefined))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -630,6 +634,7 @@ export class AgentHookServer {
|
||||
private assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
private codexSubagentPollTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
private promptSentDedupeByPaneKey = new Map<string, AgentPromptSentDedupeEntry>()
|
||||
private activeHookTurnCompletedAtByPaneKey = new Map<string, number>()
|
||||
private promptSentHashSalt = randomBytes(16).toString('hex')
|
||||
private closedAgentStatusTabIds = new Set<string>()
|
||||
private closedAgentStatusPaneKeys = new Set<string>()
|
||||
@@ -917,6 +922,9 @@ export class AgentHookServer {
|
||||
prompt: payload.prompt,
|
||||
agentType: payload.agentType,
|
||||
...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}),
|
||||
...(restored.turnCompletedAt !== undefined
|
||||
? { turnCompletedAt: restored.turnCompletedAt }
|
||||
: {}),
|
||||
...(payload.subagents ? { subagents: payload.subagents } : {})
|
||||
}
|
||||
})
|
||||
@@ -1138,6 +1146,10 @@ export class AgentHookServer {
|
||||
payload: AgentHookEventPayload,
|
||||
onAccepted?: () => void
|
||||
): EnrichedAgentHookEventPayload {
|
||||
if (payload.hookEventName === 'UserPromptSubmit') {
|
||||
// Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp.
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey)
|
||||
}
|
||||
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
| undefined
|
||||
@@ -1277,6 +1289,15 @@ export class AgentHookServer {
|
||||
}
|
||||
const cachedPayload = resolveCachedClaudeCompactOwnership(previous, effectivePayload)
|
||||
const enriched = this.attachStatusTiming(cachedPayload, now)
|
||||
if (
|
||||
typeof enriched.payload.turnCompletedAt === 'number' &&
|
||||
Number.isFinite(enriched.payload.turnCompletedAt)
|
||||
) {
|
||||
this.activeHookTurnCompletedAtByPaneKey.set(
|
||||
enriched.paneKey,
|
||||
enriched.payload.turnCompletedAt
|
||||
)
|
||||
}
|
||||
// Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible.
|
||||
if (enriched.restoredUnconfirmed) {
|
||||
this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)
|
||||
@@ -1591,6 +1612,11 @@ export class AgentHookServer {
|
||||
if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) {
|
||||
this.runtimeObservedStatusPaneKeys.add(toPaneKey)
|
||||
}
|
||||
const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey)
|
||||
if (activeTurnCompletedAt !== undefined) {
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey)
|
||||
this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt)
|
||||
}
|
||||
const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey)
|
||||
if (authorityObservation) {
|
||||
const owner = parsePaneKey(toPaneKey)
|
||||
@@ -1646,6 +1672,7 @@ export class AgentHookServer {
|
||||
this.clearAssistantMessageRetry(key)
|
||||
this.clearCodexSubagentPoll(key)
|
||||
clearPaneCacheState(this.state, key)
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(key)
|
||||
this.runtimeObservedStatusPaneKeys.delete(key)
|
||||
this.currentAuthorityObservations.delete(key)
|
||||
this.promptSentDedupeByPaneKey.delete(key)
|
||||
@@ -1679,6 +1706,7 @@ export class AgentHookServer {
|
||||
}
|
||||
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
||||
clearPaneCacheState(this.state, legacyPaneKey)
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey)
|
||||
this.currentAuthorityObservations.delete(legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) {
|
||||
@@ -1688,6 +1716,7 @@ export class AgentHookServer {
|
||||
if (shouldClearStablePaneKey) {
|
||||
// Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor.
|
||||
clearPaneCacheState(this.state, entry.stablePaneKey)
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(entry.stablePaneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey)
|
||||
this.currentAuthorityObservations.delete(entry.stablePaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey)
|
||||
@@ -1829,12 +1858,15 @@ export class AgentHookServer {
|
||||
const previous = this.state.lastStatusByPaneKey.get(paneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
| undefined
|
||||
const preserveActiveTurnStamp =
|
||||
previous?.payload.turnCompletedAt !== undefined &&
|
||||
previous.payload.turnCompletedAt === this.activeHookTurnCompletedAtByPaneKey.get(paneKey)
|
||||
if (
|
||||
!previous?.restoredUnconfirmed &&
|
||||
previous?.connectionId === connectionId &&
|
||||
previous.tabId === tabId &&
|
||||
previous.worktreeId === worktreeId &&
|
||||
equivalentParsedAgentStatusPayload(previous.payload, event.payload)
|
||||
equivalentParsedAgentStatusPayload(previous.payload, event.payload, preserveActiveTurnStamp)
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -2331,6 +2363,7 @@ export class AgentHookServer {
|
||||
return null
|
||||
}
|
||||
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey)
|
||||
if (!options?.preserveAuthority) {
|
||||
this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
|
||||
this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey)
|
||||
@@ -2411,6 +2444,7 @@ export class AgentHookServer {
|
||||
this.clearAssistantMessageRetry(paneKey)
|
||||
this.clearCodexSubagentPoll(paneKey)
|
||||
clearPaneCacheState(this.state, paneKey)
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(paneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(paneKey)
|
||||
this.currentAuthorityObservations.delete(paneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(paneKey)
|
||||
@@ -2432,6 +2466,7 @@ export class AgentHookServer {
|
||||
this.clearAssistantMessageRetry(resolvedPaneKey)
|
||||
this.clearCodexSubagentPoll(resolvedPaneKey)
|
||||
clearPaneCacheState(this.state, resolvedPaneKey)
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey)
|
||||
this.currentAuthorityObservations.delete(resolvedPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
|
||||
let clearedAlias = false
|
||||
@@ -2441,6 +2476,7 @@ export class AgentHookServer {
|
||||
paneKeys.add(legacyPaneKey)
|
||||
paneKeys.add(stablePaneKey.stablePaneKey)
|
||||
clearPaneCacheState(this.state, legacyPaneKey)
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey)
|
||||
this.currentAuthorityObservations.delete(legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
clearedAlias = true
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Why: centralizing the launcher keeps window suppression consistent across installers (#14815).
|
||||
|
||||
// Why: an absolute forward-slash path avoids PATH hijacking and survives cmd.exe and Git Bash.
|
||||
export function getWindowsSystem32Path(relativePath: string): string {
|
||||
const systemRoot = process.env.SystemRoot || 'C:\\Windows'
|
||||
return `${systemRoot.replaceAll('\\', '/')}/System32/${relativePath}`
|
||||
}
|
||||
|
||||
export function getWindowsPowerShellExecutablePath(): string {
|
||||
return getWindowsSystem32Path('WindowsPowerShell/v1.0/powershell.exe')
|
||||
}
|
||||
|
||||
// Why: unlike conhost, hidden PowerShell relays hook output and exit status (#14818).
|
||||
export const WINDOWS_POWERSHELL_HOOK_SWITCHES =
|
||||
'-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden'
|
||||
|
||||
// Why: redirected PowerShell progress becomes CLIXML that can corrupt merged JSON output.
|
||||
const HOOK_PROGRESS_SILENCER = "$ProgressPreference='SilentlyContinue'; "
|
||||
|
||||
// Why: encoding shields paths and switches from cmd.exe and MSYS rewriting (#6078, #14815).
|
||||
export function encodeWindowsPowerShellHookCommand(command: string): string {
|
||||
return Buffer.from(`${HOOK_PROGRESS_SILENCER}${command}`, 'utf16le').toString('base64')
|
||||
}
|
||||
|
||||
export function wrapWindowsPowerShellEncodedCommand(command: string): string {
|
||||
return `${getWindowsPowerShellExecutablePath()} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodeWindowsPowerShellHookCommand(command)}`
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
|
||||
type PermissionQueryResult = EventTarget & {
|
||||
state: string
|
||||
onchange: EventListener | null
|
||||
marker: string
|
||||
}
|
||||
|
||||
type PermissionStatusConstructor = {
|
||||
new (): PermissionQueryResult
|
||||
prototype: PermissionQueryResult
|
||||
}
|
||||
|
||||
type AntiDetectionContext = {
|
||||
Notification: {
|
||||
permission: string
|
||||
requestPermission: (callback?: (permission: string) => void) => Promise<string>
|
||||
}
|
||||
PermissionStatus: PermissionStatusConstructor
|
||||
dispatchPermissionChange: (name: string) => void
|
||||
navigator: {
|
||||
permissions: {
|
||||
query: (descriptor: { name: string }) => Promise<PermissionQueryResult>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(args: {
|
||||
nativeNotificationPermission: string
|
||||
requestedNotificationPermission: string
|
||||
rejectedPermissions?: string[]
|
||||
}): AntiDetectionContext & Record<string, unknown> {
|
||||
class PermissionStatus extends EventTarget {
|
||||
#state = 'denied'
|
||||
#onchange: EventListener | null = null
|
||||
marker = 'real-status'
|
||||
|
||||
get state(): string {
|
||||
return this.#state
|
||||
}
|
||||
|
||||
get onchange(): EventListener | null {
|
||||
return this.#onchange
|
||||
}
|
||||
|
||||
set onchange(listener: EventListener | null) {
|
||||
if (this.#onchange) {
|
||||
super.removeEventListener('change', this.#onchange)
|
||||
}
|
||||
this.#onchange = typeof listener === 'function' ? listener : null
|
||||
if (this.#onchange) {
|
||||
super.addEventListener('change', this.#onchange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const statuses = new Map<string, PermissionStatus[]>()
|
||||
const rejectedPermissions = new Set(args.rejectedPermissions)
|
||||
|
||||
class Permissions {
|
||||
query(descriptor: { name: string }): Promise<PermissionStatus> {
|
||||
if (rejectedPermissions.has(descriptor.name)) {
|
||||
return Promise.reject(new Error('Unsupported permission'))
|
||||
}
|
||||
const status = new PermissionStatus()
|
||||
const permissionStatuses = statuses.get(descriptor.name) ?? []
|
||||
permissionStatuses.push(status)
|
||||
statuses.set(descriptor.name, permissionStatuses)
|
||||
return Promise.resolve(status)
|
||||
}
|
||||
}
|
||||
|
||||
const Notification = {
|
||||
permission: args.nativeNotificationPermission,
|
||||
requestPermission(callback?: (permission: string) => void): Promise<string> {
|
||||
callback?.(args.requestedNotificationPermission)
|
||||
return Promise.resolve(args.requestedNotificationPermission)
|
||||
}
|
||||
}
|
||||
Object.defineProperty(Notification, 'permission', {
|
||||
configurable: true,
|
||||
get: () => args.nativeNotificationPermission
|
||||
})
|
||||
|
||||
return {
|
||||
Date,
|
||||
Event,
|
||||
EventTarget,
|
||||
Object,
|
||||
Promise,
|
||||
Set,
|
||||
performance: { now: () => 0 },
|
||||
// Why: the script's Firefox gate reads navigator.userAgent, so a non-Firefox UA keeps these
|
||||
// tests on the ordinary-page path where the PermissionStatus override applies.
|
||||
window: { chrome: {} },
|
||||
navigator: {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
|
||||
plugins: [],
|
||||
languages: [],
|
||||
permissions: new Permissions()
|
||||
},
|
||||
Permissions,
|
||||
PermissionStatus,
|
||||
Notification,
|
||||
dispatchPermissionChange(name: string): void {
|
||||
for (const status of statuses.get(name) ?? []) {
|
||||
status.dispatchEvent(new Event('change'))
|
||||
}
|
||||
}
|
||||
} as AntiDetectionContext & Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('ANTI_DETECTION_SCRIPT — PermissionStatus', () => {
|
||||
it('keeps an existing notification status current after permission changes', async () => {
|
||||
const context = createContext({
|
||||
nativeNotificationPermission: 'denied',
|
||||
requestedNotificationPermission: 'granted'
|
||||
})
|
||||
|
||||
runInNewContext(ANTI_DETECTION_SCRIPT, context)
|
||||
const status = await context.navigator.permissions.query({ name: 'notifications' })
|
||||
|
||||
expect(context.Notification.permission).toBe('default')
|
||||
expect(status.state).toBe('prompt')
|
||||
|
||||
await expect(context.Notification.requestPermission()).resolves.toBe('granted')
|
||||
|
||||
expect(context.Notification.permission).toBe('granted')
|
||||
expect(status.state).toBe('granted')
|
||||
})
|
||||
|
||||
it('preserves native PermissionStatus identity and methods', async () => {
|
||||
const context = createContext({
|
||||
nativeNotificationPermission: 'denied',
|
||||
requestedNotificationPermission: 'granted'
|
||||
})
|
||||
|
||||
runInNewContext(ANTI_DETECTION_SCRIPT, context)
|
||||
const status = await context.navigator.permissions.query({ name: 'camera' })
|
||||
const expectedSource = Function.prototype.toString.call(
|
||||
context.PermissionStatus.prototype.addEventListener
|
||||
)
|
||||
|
||||
expect(status).toBeInstanceOf(context.PermissionStatus)
|
||||
expect(status.state).toBe('prompt')
|
||||
expect(status.constructor.name).toBe('PermissionStatus')
|
||||
expect(status.marker).toBe('real-status')
|
||||
expect(status.addEventListener.name).toBe('addEventListener')
|
||||
expect(status.addEventListener).toBe(status.addEventListener)
|
||||
expect(Function.prototype.toString.call(status.addEventListener)).toBe(expectedSource)
|
||||
expect(expectedSource).toContain('addEventListener')
|
||||
})
|
||||
|
||||
it('delivers change events through the returned status with the overridden state', async () => {
|
||||
const context = createContext({
|
||||
nativeNotificationPermission: 'denied',
|
||||
requestedNotificationPermission: 'granted'
|
||||
})
|
||||
|
||||
runInNewContext(ANTI_DETECTION_SCRIPT, context)
|
||||
const status = await context.navigator.permissions.query({ name: 'notifications' })
|
||||
const events: { receiver: EventTarget; target: EventTarget | null; state: string }[] = []
|
||||
const recordEvent = function (this: EventTarget, event: Event): void {
|
||||
events.push({
|
||||
receiver: this,
|
||||
target: event.target,
|
||||
state: (event.target as PermissionQueryResult).state
|
||||
})
|
||||
}
|
||||
|
||||
status.addEventListener('change', recordEvent)
|
||||
expect(() => {
|
||||
status.onchange = function (this: EventTarget, event): void {
|
||||
recordEvent.call(this, event)
|
||||
}
|
||||
}).not.toThrow()
|
||||
|
||||
await context.Notification.requestPermission()
|
||||
context.dispatchPermissionChange('notifications')
|
||||
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events).toEqual([
|
||||
{ receiver: status, target: status, state: 'granted' },
|
||||
{ receiver: status, target: status, state: 'granted' }
|
||||
])
|
||||
})
|
||||
|
||||
// Why: 'camera' rather than 'storage-access' — #14685 narrowed the intercepted set to
|
||||
// camera/microphone, so a name outside it falls through to the real query and never reaches
|
||||
// the fallback at all.
|
||||
it('uses a non-enumerable EventTarget fallback when the native query rejects', async () => {
|
||||
const context = createContext({
|
||||
nativeNotificationPermission: 'denied',
|
||||
requestedNotificationPermission: 'granted',
|
||||
rejectedPermissions: ['camera']
|
||||
})
|
||||
|
||||
runInNewContext(ANTI_DETECTION_SCRIPT, context)
|
||||
const status = await context.navigator.permissions.query({ name: 'camera' })
|
||||
|
||||
expect(status).toBeInstanceOf(EventTarget)
|
||||
expect(status).not.toBeInstanceOf(context.PermissionStatus)
|
||||
expect(status.state).toBe('prompt')
|
||||
expect(Object.keys(status)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -86,12 +86,43 @@ export const ANTI_DETECTION_SCRIPT = `(function() {
|
||||
'camera', 'microphone'
|
||||
]);
|
||||
const origQuery = Permissions.prototype.query;
|
||||
// Why: sites must receive the genuine PermissionStatus so native events, brand checks and method
|
||||
// identity survive. Shadow only state, and resolve it lazily so existing statuses stay current.
|
||||
function withOverriddenState(realStatus, stateProvider) {
|
||||
Object.defineProperty(realStatus, 'state', {
|
||||
configurable: true,
|
||||
get: stateProvider
|
||||
});
|
||||
return realStatus;
|
||||
}
|
||||
// Why: some names the real implementation rejects outright; fall back to an EventTarget so
|
||||
// listener registration still works instead of throwing.
|
||||
function fallbackStatus(stateProvider) {
|
||||
const status = new EventTarget();
|
||||
Object.defineProperties(status, {
|
||||
state: { configurable: true, get: stateProvider },
|
||||
onchange: { configurable: true, value: null, writable: true }
|
||||
});
|
||||
return status;
|
||||
}
|
||||
function queryWithState(permissions, desc, stateProvider) {
|
||||
let real;
|
||||
try {
|
||||
real = origQuery.call(permissions, desc);
|
||||
} catch {
|
||||
return Promise.resolve(fallbackStatus(stateProvider));
|
||||
}
|
||||
return Promise.resolve(real).then(
|
||||
(status) => withOverriddenState(status, stateProvider),
|
||||
() => fallbackStatus(stateProvider)
|
||||
);
|
||||
}
|
||||
Permissions.prototype.query = function(desc) {
|
||||
if (desc.name === 'notifications') {
|
||||
return Promise.resolve({ state: notificationPermissionState(), onchange: null });
|
||||
return queryWithState(this, desc, notificationPermissionState);
|
||||
}
|
||||
if (promptPerms.has(desc.name)) {
|
||||
return Promise.resolve({ state: 'prompt', onchange: null });
|
||||
return queryWithState(this, desc, () => 'prompt');
|
||||
}
|
||||
return origQuery.call(this, desc);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isAppBoundEncryptedCookie } from './browser-cookie-import'
|
||||
|
||||
function encryptedCookie(versionPrefix: string): Buffer {
|
||||
return Buffer.concat([Buffer.from(versionPrefix), Buffer.from([0xde, 0xad, 0xbe, 0xef])])
|
||||
}
|
||||
|
||||
describe('isAppBoundEncryptedCookie', () => {
|
||||
it('detects the v20 app-bound prefix', () => {
|
||||
expect(isAppBoundEncryptedCookie(encryptedCookie('v20'))).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['v10', 'v11'])('leaves the decryptable %s prefix alone', (prefix) => {
|
||||
expect(isAppBoundEncryptedCookie(encryptedCookie(prefix))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not treat a plaintext value as app-bound', () => {
|
||||
expect(isAppBoundEncryptedCookie(Buffer.from('session=abc123'))).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty', Buffer.alloc(0)],
|
||||
['shorter than the prefix', Buffer.from('v2')]
|
||||
])('handles a buffer %s without throwing', (_label, buffer) => {
|
||||
expect(isAppBoundEncryptedCookie(buffer)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeCrypto from 'node:crypto'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
|
||||
const {
|
||||
appGetPathMock,
|
||||
createDecipherivMock,
|
||||
execFileSyncMock,
|
||||
sessionFromPartitionMock,
|
||||
dialogShowOpenDialogMock,
|
||||
setPendingCookieImportMock,
|
||||
clearPendingCookieImportMock
|
||||
} = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(),
|
||||
createDecipherivMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
sessionFromPartitionMock: vi.fn(),
|
||||
dialogShowOpenDialogMock: vi.fn(),
|
||||
setPendingCookieImportMock: vi.fn(),
|
||||
clearPendingCookieImportMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:crypto', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof NodeCrypto>()
|
||||
createDecipherivMock.mockImplementation(original.createDecipheriv)
|
||||
return { ...original, createDecipheriv: createDecipherivMock }
|
||||
})
|
||||
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: dialogShowOpenDialogMock },
|
||||
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,
|
||||
dispose: () => undefined
|
||||
})
|
||||
}))
|
||||
|
||||
import { importCookiesFromBrowser, type DetectedBrowser } from './browser-cookie-import'
|
||||
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createCipheriv, pbkdf2Sync } from 'node:crypto'
|
||||
|
||||
// Why: Linux derives its cookie key with a single PBKDF2 round, unlike the 1003 macOS uses, so
|
||||
// the shared macOS helper cannot produce a row this code path will decrypt.
|
||||
function encryptLinuxChromiumCookie(value: string, password: string, prefix: string): Buffer {
|
||||
const key = pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1')
|
||||
const cipher = createCipheriv('aes-128-cbc', key, Buffer.alloc(16, ' '))
|
||||
return Buffer.concat([
|
||||
Buffer.from(prefix),
|
||||
cipher.update(Buffer.from(value, 'latin1')),
|
||||
cipher.final()
|
||||
])
|
||||
}
|
||||
|
||||
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('importCookiesFromBrowser — undecryptable cookies', () => {
|
||||
let tmpDir: string
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
let platformSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-linux-keyring-test-'))
|
||||
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
|
||||
appGetPathMock.mockReturnValue(join(tmpDir, 'userData'))
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: {
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
set: cookiesSetMock,
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
flushStore: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
clearData: vi.fn().mockResolvedValue(undefined),
|
||||
setUserAgent: vi.fn()
|
||||
})
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
||||
// Why: both secret-tool lookups failing is exactly the keyring-unavailable case.
|
||||
execFileSyncMock.mockImplementation(() => {
|
||||
throw new Error('secret-tool: no such service')
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
platformSpy.mockRestore()
|
||||
vi.clearAllMocks()
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function seedProfile(...encryptedValues: Buffer[]): string {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
createChromiumCookieTestDatabase(
|
||||
sourceCookiesPath,
|
||||
encryptedValues.map((encryptedValue, index) => ({
|
||||
name: `sid-${index}`,
|
||||
value: '',
|
||||
encryptedValue
|
||||
}))
|
||||
).close()
|
||||
createChromiumCookieTestDatabase(
|
||||
join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies'),
|
||||
[]
|
||||
).close()
|
||||
return sourceCookiesPath
|
||||
}
|
||||
|
||||
it('does not attempt v11 CBC decryption without a keyring, even with valid wrong-key padding', async () => {
|
||||
const sourceCookiesPath = seedProfile(
|
||||
encryptLinuxChromiumCookie('wrong-key-garbage', 'peanuts', 'v11')
|
||||
)
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(createDecipherivMock).not.toHaveBeenCalled()
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.summary.importedCookies).toBe(0)
|
||||
expect(result.summary.warning).toEqual({
|
||||
code: 'cookies-undecryptable',
|
||||
failedCookies: 1,
|
||||
reason: 'linux-keyring-unavailable'
|
||||
})
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['v10', 'v99'])(
|
||||
'rejects a prefix-only %s CBC row before importing it',
|
||||
async (prefix) => {
|
||||
const sourceCookiesPath = seedProfile(Buffer.from(prefix))
|
||||
|
||||
const result = await importCookiesFromBrowser(
|
||||
chromeBrowser(sourceCookiesPath),
|
||||
'persist:test'
|
||||
)
|
||||
|
||||
expect(createDecipherivMock).not.toHaveBeenCalled()
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.summary.importedCookies).toBe(0)
|
||||
expect(result.summary.warning).toEqual({
|
||||
code: 'cookies-undecryptable',
|
||||
failedCookies: 1,
|
||||
reason: 'unknown'
|
||||
})
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('reports Windows app-bound (v20) cookies rather than an empty success', async () => {
|
||||
// Why: Chrome/Edge 140+ v20 rows can only be unwrapped by the writing browser.
|
||||
const sourceCookiesPath = seedProfile(
|
||||
Buffer.concat([Buffer.from('v20'), Buffer.from([1, 2, 3, 4, 5, 6, 7, 8])])
|
||||
)
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.summary.importedCookies).toBe(0)
|
||||
expect(result.summary.warning).toEqual({
|
||||
code: 'cookies-undecryptable',
|
||||
failedCookies: 1,
|
||||
reason: 'app-bound-encryption'
|
||||
})
|
||||
})
|
||||
|
||||
it('still imports v10 cookies, which is what a keyring-less profile actually holds', async () => {
|
||||
const sourceCookiesPath = seedProfile(encryptLinuxChromiumCookie('v10-value', 'peanuts', 'v10'))
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
// Mutation guard: replacing the keyring-unavailable branch with `return null` must break v10.
|
||||
expect(result.summary.importedCookies).toBe(1)
|
||||
expect(result.summary.warning).toBeUndefined()
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'sid-0', value: 'v10-value' })
|
||||
)
|
||||
})
|
||||
|
||||
it('uses an unknown warning when app-bound and corrupt failures are tied', async () => {
|
||||
const sourceCookiesPath = seedProfile(
|
||||
Buffer.concat([Buffer.from('v20'), Buffer.from([1, 2, 3, 4])]),
|
||||
Buffer.from('v99-corrupt')
|
||||
)
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.summary.warning).toEqual({
|
||||
code: 'cookies-undecryptable',
|
||||
failedCookies: 2,
|
||||
reason: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a dominant app-bound cause with its exact count and the remainder', async () => {
|
||||
const appBoundValues = Array.from({ length: 200 }, () =>
|
||||
Buffer.concat([Buffer.from('v20'), Buffer.from([1, 2, 3, 4])])
|
||||
)
|
||||
const sourceCookiesPath = seedProfile(...appBoundValues, Buffer.from('v99-corrupt'))
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.summary.warning).toEqual({
|
||||
code: 'cookies-undecryptable',
|
||||
failedCookies: 200,
|
||||
otherFailedCookies: 1,
|
||||
reason: 'app-bound-encryption'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -823,12 +823,13 @@ function chromiumTimestampToUnix(chromiumTs: bigint | number | string): number {
|
||||
|
||||
// Why: each platform protects the Chromium key differently: macOS/Linux PBKDF2→AES-128-CBC, Windows DPAPI→AES-256-GCM.
|
||||
|
||||
type EncryptionKeyResult = {
|
||||
key: Buffer
|
||||
mode: 'aes-128-cbc' | 'aes-256-gcm'
|
||||
// Why: Linux v10 cookies use "peanuts" and v11 the keyring password; both keys are needed to decrypt the full set.
|
||||
fallbackKey?: Buffer
|
||||
}
|
||||
type EncryptionKeyResult =
|
||||
| {
|
||||
mode: 'aes-128-cbc'
|
||||
keysByVersion: Partial<Record<'v10' | 'v11', Buffer>>
|
||||
keyringUnavailable?: boolean
|
||||
}
|
||||
| { mode: 'aes-256-gcm'; key: Buffer }
|
||||
|
||||
export type ChromiumCookieColumnInfo = {
|
||||
name: string
|
||||
@@ -971,8 +972,10 @@ function getMacEncryptionKey(
|
||||
{ encoding: 'utf-8', timeout: 30_000 }
|
||||
).trim()
|
||||
return {
|
||||
key: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1'),
|
||||
mode: 'aes-128-cbc'
|
||||
mode: 'aes-128-cbc',
|
||||
keysByVersion: {
|
||||
v10: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -983,7 +986,8 @@ function getLinuxEncryptionKey(
|
||||
keychainService: string,
|
||||
keychainAccount: string
|
||||
): EncryptionKeyResult | null {
|
||||
// Why: v10 cookies use hardcoded "peanuts", v11 the keyring password; derive both so decrypt can pick by version prefix.
|
||||
// Chromium uses v11 only with OS key storage; without it, Linux writes v10 with hardcoded
|
||||
// "peanuts". Keep eligibility explicit because CBC cannot authenticate a wrong-key result.
|
||||
const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1')
|
||||
|
||||
let keyringPassword = ''
|
||||
@@ -1003,12 +1007,20 @@ function getLinuxEncryptionKey(
|
||||
timeout: 5_000
|
||||
}).trim()
|
||||
} catch {
|
||||
diag(' Linux keyring unavailable — v11 cookies may fail to decrypt')
|
||||
diag(' Linux keyring unavailable — v11 cookies cannot be decrypted')
|
||||
}
|
||||
}
|
||||
|
||||
if (!keyringPassword) {
|
||||
return {
|
||||
mode: 'aes-128-cbc',
|
||||
keysByVersion: { v10: v10Key },
|
||||
keyringUnavailable: true
|
||||
}
|
||||
}
|
||||
|
||||
const v11Key = pbkdf2Sync(keyringPassword, PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1')
|
||||
return { key: v11Key, mode: 'aes-128-cbc', fallbackKey: v10Key }
|
||||
return { mode: 'aes-128-cbc', keysByVersion: { v10: v10Key, v11: v11Key } }
|
||||
}
|
||||
|
||||
function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult | null {
|
||||
@@ -1084,6 +1096,53 @@ function stripHmac(buf: Buffer): Buffer {
|
||||
return hasHmacPrefix(buf) ? buf.subarray(CHROMIUM_COOKIE_HMAC_LEN) : buf
|
||||
}
|
||||
|
||||
// Why: the version prefix is the only thing that survives a failed decrypt, so read it once and
|
||||
// share it between the decrypt path and the failure attribution.
|
||||
function cookieEncryptionVersion(encryptedBuffer: Buffer): string | null {
|
||||
if (encryptedBuffer.length < 3) {
|
||||
return null
|
||||
}
|
||||
const version = encryptedBuffer.subarray(0, 3).toString('utf-8')
|
||||
return /^v\d\d$/.test(version) ? version : null
|
||||
}
|
||||
|
||||
// Why: Chrome/Edge 140+ on Windows prefix every cookie with `v20` (app-bound encryption), which
|
||||
// only the writing browser can unwrap. Classify it before decrypt so it is not folded into corruption.
|
||||
export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean {
|
||||
return cookieEncryptionVersion(encryptedBuffer) === 'v20'
|
||||
}
|
||||
|
||||
// Why: a named cause must carry only its exact count; tied causes fall back to unknown.
|
||||
function buildUndecryptableWarning(counts: {
|
||||
decryptFailed: number
|
||||
appBoundFailed: number
|
||||
keyringUnavailableFailed: number
|
||||
}): BrowserCookieImportSummary['warning'] {
|
||||
if (counts.decryptFailed === 0) {
|
||||
return undefined
|
||||
}
|
||||
const unknownFailed =
|
||||
counts.decryptFailed - counts.appBoundFailed - counts.keyringUnavailableFailed
|
||||
const rankedCauses = [
|
||||
{ reason: 'app-bound-encryption' as const, count: counts.appBoundFailed },
|
||||
{ reason: 'linux-keyring-unavailable' as const, count: counts.keyringUnavailableFailed },
|
||||
{ reason: 'unknown' as const, count: unknownFailed }
|
||||
].sort((left, right) => right.count - left.count)
|
||||
const [dominant, runnerUp] = rankedCauses
|
||||
|
||||
if (dominant.reason === 'unknown' || dominant.count === runnerUp.count) {
|
||||
return { code: 'cookies-undecryptable', failedCookies: counts.decryptFailed, reason: 'unknown' }
|
||||
}
|
||||
|
||||
const otherFailedCookies = counts.decryptFailed - dominant.count
|
||||
return {
|
||||
code: 'cookies-undecryptable',
|
||||
failedCookies: dominant.count,
|
||||
reason: dominant.reason,
|
||||
...(otherFailedCookies > 0 ? { otherFailedCookies } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function decryptCookieValueRaw(
|
||||
encryptedBuffer: Buffer,
|
||||
keyResult: EncryptionKeyResult
|
||||
@@ -1101,29 +1160,25 @@ function decryptCookieValueRaw(
|
||||
}
|
||||
|
||||
// AES-128-CBC (macOS and Linux)
|
||||
const key = version === 'v10' || version === 'v11' ? keyResult.keysByVersion[version] : undefined
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ciphertext = encryptedBuffer.subarray(3)
|
||||
if (!ciphertext.length) {
|
||||
return Buffer.alloc(0)
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: Linux v10 uses the "peanuts" key, v11 the keyring key; try primary then fallback (macOS uses one key).
|
||||
const keysToTry =
|
||||
version === 'v10' && keyResult.fallbackKey
|
||||
? [keyResult.fallbackKey, keyResult.key]
|
||||
: [keyResult.key, ...(keyResult.fallbackKey ? [keyResult.fallbackKey] : [])]
|
||||
|
||||
for (const key of keysToTry) {
|
||||
try {
|
||||
const iv = Buffer.alloc(16, ' ')
|
||||
const decipher = createDecipheriv('aes-128-cbc', key, iv)
|
||||
decipher.setAutoPadding(true)
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
return stripHmac(decrypted)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const iv = Buffer.alloc(16, ' ')
|
||||
const decipher = createDecipheriv('aes-128-cbc', key, iv)
|
||||
decipher.setAutoPadding(true)
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
return stripHmac(decrypted)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | null {
|
||||
@@ -1630,6 +1685,9 @@ export async function importCookiesFromBrowser(
|
||||
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
let decryptFailed = 0
|
||||
let appBoundFailed = 0
|
||||
let keyringUnavailableFailed = 0
|
||||
let integritySkipped = 0
|
||||
let nonTransplantableSkipped = 0
|
||||
let memoryLoaded = 0
|
||||
@@ -1697,8 +1755,26 @@ export async function importCookiesFromBrowser(
|
||||
|
||||
let decryptedValue: Buffer
|
||||
if (encBuf && encBuf.length > 0) {
|
||||
const raw = sourceKey ? decryptCookieValueRaw(encBuf, sourceKey) : null
|
||||
const version = cookieEncryptionVersion(encBuf)
|
||||
const appBoundIneligible = version === 'v20'
|
||||
const keyringIneligible =
|
||||
version === 'v11' &&
|
||||
sourceKey?.mode === 'aes-128-cbc' &&
|
||||
sourceKey.keyringUnavailable === true
|
||||
const raw =
|
||||
sourceKey && !appBoundIneligible && !keyringIneligible
|
||||
? decryptCookieValueRaw(encBuf, sourceKey)
|
||||
: null
|
||||
if (!raw) {
|
||||
// Why: once decrypt returns null every failure looks identical, so attribute the cause
|
||||
// here while the version prefix is still in hand. Without this an undecryptable profile
|
||||
// is indistinguishable from an empty one and reports success.
|
||||
decryptFailed++
|
||||
if (appBoundIneligible) {
|
||||
appBoundFailed++
|
||||
} else if (keyringIneligible) {
|
||||
keyringUnavailableFailed++
|
||||
}
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
@@ -1765,7 +1841,14 @@ export async function importCookiesFromBrowser(
|
||||
)
|
||||
const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped
|
||||
|
||||
const undecryptableWarning = buildUndecryptableWarning({
|
||||
decryptFailed,
|
||||
appBoundFailed,
|
||||
keyringUnavailableFailed
|
||||
})
|
||||
|
||||
if (decryptedCookies.length === 0) {
|
||||
const zeroPathWarning = undecryptableWarning
|
||||
closeStagingDb()
|
||||
discardStagingFile()
|
||||
return {
|
||||
@@ -1776,7 +1859,10 @@ export async function importCookiesFromBrowser(
|
||||
importedCookies: 0,
|
||||
skippedCookies: skipped + integritySkipped + nonTransplantableSkipped,
|
||||
...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}),
|
||||
domains: []
|
||||
domains: [],
|
||||
// Why: a profile whose rows cannot be decrypted returns here, and without this it is
|
||||
// reported as a successful empty import.
|
||||
...(zeroPathWarning ? { warning: zeroPathWarning } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1874,6 +1960,13 @@ export async function importCookiesFromBrowser(
|
||||
// Google-bound integrity cookies are already excluded by
|
||||
// isGoogleSourceBoundCookie, which is what actually prevents CookieMismatch.
|
||||
|
||||
// Why: a partial import still drops every undecryptable row, so silence here would report it
|
||||
// as an unqualified success. The restart-fallback warning describes a lossier outcome and
|
||||
// keeps precedence.
|
||||
if (!warning && undecryptableWarning) {
|
||||
warning = undecryptableWarning
|
||||
}
|
||||
|
||||
const summary: BrowserCookieImportSummary = {
|
||||
totalCookies: sourceRows.length,
|
||||
importedCookies: imported,
|
||||
|
||||
@@ -1334,6 +1334,17 @@ export class BrowserManager {
|
||||
return this.worktreeIdByTabId.get(browserTabId)
|
||||
}
|
||||
|
||||
getRendererContextForGuest(
|
||||
guestWebContentsId: number
|
||||
): { browserPageId: string; renderer: Electron.WebContents } | null {
|
||||
const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId)
|
||||
if (!browserPageId) {
|
||||
return null
|
||||
}
|
||||
const renderer = this.resolveRendererForBrowserTab(browserPageId)
|
||||
return renderer ? { browserPageId, renderer } : null
|
||||
}
|
||||
|
||||
getSessionProfileIdForTab(browserTabId: string): string | null {
|
||||
return this.sessionProfileIdByPageId.get(browserTabId) ?? null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { requestAccountMock, cancelSessionAccountRequestsMock } = vi.hoisted(() => ({
|
||||
requestAccountMock: vi.fn(),
|
||||
cancelSessionAccountRequestsMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./browser-webauthn-account-picker', () => ({
|
||||
requestBrowserWebAuthnAccount: requestAccountMock,
|
||||
cancelBrowserWebAuthnAccountRequestsForSession: cancelSessionAccountRequestsMock
|
||||
}))
|
||||
|
||||
import { installBrowserWebAuthnAccessHandlers } from './browser-webauthn-access'
|
||||
|
||||
describe('browser WebAuthn access', () => {
|
||||
beforeEach(() => {
|
||||
requestAccountMock.mockReset()
|
||||
cancelSessionAccountRequestsMock.mockReset()
|
||||
})
|
||||
|
||||
it('dispatches multi-account assertions to the account picker', async () => {
|
||||
let selectHandler:
|
||||
| ((
|
||||
event: Electron.Event,
|
||||
details: Electron.SelectWebauthnAccountDetails,
|
||||
callback: (credentialId?: string | null) => void
|
||||
) => Promise<void>)
|
||||
| undefined
|
||||
const browserSession = {
|
||||
setDevicePermissionHandler: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
on: vi.fn((eventName: string, listener: unknown) => {
|
||||
if (eventName === 'select-webauthn-account') {
|
||||
selectHandler = listener as typeof selectHandler
|
||||
}
|
||||
})
|
||||
} as unknown as Electron.Session
|
||||
const details = {
|
||||
relyingPartyId: 'example.com',
|
||||
frame: {} as Electron.WebFrameMain,
|
||||
accounts: [{ credentialId: 'first' }, { credentialId: 'second' }]
|
||||
}
|
||||
const callback = vi.fn()
|
||||
requestAccountMock.mockResolvedValue('second')
|
||||
|
||||
installBrowserWebAuthnAccessHandlers(browserSession)
|
||||
await selectHandler?.(
|
||||
{ preventDefault: vi.fn() } as unknown as Electron.Event,
|
||||
details,
|
||||
callback
|
||||
)
|
||||
|
||||
expect(requestAccountMock).toHaveBeenCalledWith(details, browserSession)
|
||||
expect(callback).toHaveBeenCalledOnce()
|
||||
expect(callback).toHaveBeenCalledWith('second')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { Session } from 'electron'
|
||||
import {
|
||||
cancelBrowserWebAuthnAccountRequestsForSession,
|
||||
requestBrowserWebAuthnAccount
|
||||
} from './browser-webauthn-account-picker'
|
||||
|
||||
const FIDO_HID_USAGE_PAGE = 0xf1d0
|
||||
const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
|
||||
type SelectWebAuthnAccountHandler = (
|
||||
event: Electron.Event,
|
||||
details: Electron.SelectWebauthnAccountDetails,
|
||||
callback: (credentialId?: string | null) => void
|
||||
) => Promise<void>
|
||||
const selectWebAuthnAccountHandlers = new WeakMap<Session, SelectWebAuthnAccountHandler>()
|
||||
|
||||
function isSecureBrowserOrigin(rawOrigin: string | undefined): boolean {
|
||||
if (!rawOrigin) {
|
||||
@@ -53,15 +63,23 @@ function handleBrowserSelectHidDevice(
|
||||
callback(selectedDevice?.deviceId)
|
||||
}
|
||||
|
||||
function handleBrowserSelectWebAuthnAccount(
|
||||
async function handleBrowserSelectWebAuthnAccount(
|
||||
browserSession: Session,
|
||||
event: Electron.Event,
|
||||
details: Electron.SelectWebauthnAccountDetails,
|
||||
callback: (credentialId?: string | null) => void
|
||||
): void {
|
||||
): Promise<void> {
|
||||
event.preventDefault()
|
||||
// Why: Electron cancels discoverable WebAuthn when no listener exists. Pick
|
||||
// only the unambiguous single-account case until Orca has account-picker UI.
|
||||
callback(details.accounts.length === 1 ? details.accounts[0].credentialId : null)
|
||||
if (details.accounts.length <= 1) {
|
||||
callback(details.accounts[0]?.credentialId ?? null)
|
||||
return
|
||||
}
|
||||
let credentialId: string | null = null
|
||||
try {
|
||||
credentialId = await requestBrowserWebAuthnAccount(details, browserSession)
|
||||
} finally {
|
||||
callback(credentialId)
|
||||
}
|
||||
}
|
||||
|
||||
export function installBrowserWebAuthnAccessHandlers(browserSession: Session): void {
|
||||
@@ -74,12 +92,23 @@ export function installBrowserWebAuthnAccessHandlers(browserSession: Session): v
|
||||
})
|
||||
browserSession.removeListener('select-hid-device', handleBrowserSelectHidDevice)
|
||||
browserSession.on('select-hid-device', handleBrowserSelectHidDevice)
|
||||
browserSession.removeListener('select-webauthn-account', handleBrowserSelectWebAuthnAccount)
|
||||
browserSession.on('select-webauthn-account', handleBrowserSelectWebAuthnAccount)
|
||||
const previousHandler = selectWebAuthnAccountHandlers.get(browserSession)
|
||||
if (previousHandler) {
|
||||
browserSession.removeListener('select-webauthn-account', previousHandler)
|
||||
}
|
||||
const selectWebAuthnAccountHandler: SelectWebAuthnAccountHandler = (event, details, callback) =>
|
||||
handleBrowserSelectWebAuthnAccount(browserSession, event, details, callback)
|
||||
selectWebAuthnAccountHandlers.set(browserSession, selectWebAuthnAccountHandler)
|
||||
browserSession.on('select-webauthn-account', selectWebAuthnAccountHandler)
|
||||
}
|
||||
|
||||
export function clearBrowserWebAuthnAccessHandlers(browserSession: Session): void {
|
||||
browserSession.removeListener('select-hid-device', handleBrowserSelectHidDevice)
|
||||
browserSession.removeListener('select-webauthn-account', handleBrowserSelectWebAuthnAccount)
|
||||
const selectWebAuthnAccountHandler = selectWebAuthnAccountHandlers.get(browserSession)
|
||||
if (selectWebAuthnAccountHandler) {
|
||||
browserSession.removeListener('select-webauthn-account', selectWebAuthnAccountHandler)
|
||||
selectWebAuthnAccountHandlers.delete(browserSession)
|
||||
}
|
||||
browserSession.setDevicePermissionHandler(null)
|
||||
cancelBrowserWebAuthnAccountRequestsForSession(browserSession)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { fromFrameMock, getRendererContextForGuestMock } = vi.hoisted(() => ({
|
||||
fromFrameMock: vi.fn(),
|
||||
getRendererContextForGuestMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
webContents: { fromFrame: fromFrameMock }
|
||||
}))
|
||||
|
||||
vi.mock('./browser-manager', () => ({
|
||||
browserManager: { getRendererContextForGuest: getRendererContextForGuestMock }
|
||||
}))
|
||||
|
||||
import {
|
||||
BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS,
|
||||
cancelAllBrowserWebAuthnAccountRequests,
|
||||
cancelBrowserWebAuthnAccountRequests,
|
||||
requestBrowserWebAuthnAccount,
|
||||
respondToBrowserWebAuthnAccountRequest
|
||||
} from './browser-webauthn-account-picker'
|
||||
|
||||
function mockWebContents(id: number): Electron.WebContents & EventEmitter {
|
||||
const contents = new EventEmitter() as Electron.WebContents & EventEmitter
|
||||
Object.assign(contents, {
|
||||
id,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
send: vi.fn()
|
||||
})
|
||||
return contents
|
||||
}
|
||||
|
||||
function accountDetails(): Electron.SelectWebauthnAccountDetails {
|
||||
return {
|
||||
relyingPartyId: 'accounts.example.com',
|
||||
frame: {} as Electron.WebFrameMain,
|
||||
accounts: [
|
||||
{ credentialId: 'credential-1', displayName: 'Personal', name: 'me@example.com' },
|
||||
{ credentialId: 'credential-2', displayName: 'Work', name: 'me@work.example' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function mockSession(): Electron.Session {
|
||||
return {} as Electron.Session
|
||||
}
|
||||
|
||||
describe('browser WebAuthn account picker', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
fromFrameMock.mockReset()
|
||||
getRendererContextForGuestMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cancelAllBrowserWebAuthnAccountRequests()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns the credential selected by the owning renderer', async () => {
|
||||
const guest = mockWebContents(41)
|
||||
const renderer = mockWebContents(42)
|
||||
fromFrameMock.mockReturnValue(guest)
|
||||
getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-1', renderer })
|
||||
|
||||
const selection = requestBrowserWebAuthnAccount(accountDetails(), mockSession())
|
||||
const request = vi.mocked(renderer.send).mock.calls[0][1]
|
||||
|
||||
expect(request).toMatchObject({
|
||||
browserPageId: 'page-1',
|
||||
relyingPartyId: 'accounts.example.com',
|
||||
accounts: [
|
||||
{ credentialId: 'credential-1', displayName: 'Personal', name: 'me@example.com' },
|
||||
{ credentialId: 'credential-2', displayName: 'Work', name: 'me@work.example' }
|
||||
]
|
||||
})
|
||||
expect(
|
||||
respondToBrowserWebAuthnAccountRequest(renderer, {
|
||||
requestId: request.requestId,
|
||||
credentialId: 'credential-2'
|
||||
})
|
||||
).toBe(true)
|
||||
await expect(selection).resolves.toBe('credential-2')
|
||||
})
|
||||
|
||||
it('rejects responses from another renderer or with an unoffered credential', async () => {
|
||||
const guest = mockWebContents(51)
|
||||
const renderer = mockWebContents(52)
|
||||
fromFrameMock.mockReturnValue(guest)
|
||||
getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-2', renderer })
|
||||
|
||||
const selection = requestBrowserWebAuthnAccount(accountDetails(), mockSession())
|
||||
const requestId = vi.mocked(renderer.send).mock.calls[0][1].requestId
|
||||
|
||||
expect(
|
||||
respondToBrowserWebAuthnAccountRequest(mockWebContents(53), {
|
||||
requestId,
|
||||
credentialId: 'credential-1'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
respondToBrowserWebAuthnAccountRequest(renderer, {
|
||||
requestId,
|
||||
credentialId: 'credential-not-offered'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
respondToBrowserWebAuthnAccountRequest(renderer, { requestId, credentialId: null })
|
||||
).toBe(true)
|
||||
await expect(selection).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('cancels when the tab closes or the prompt times out', async () => {
|
||||
const guest = mockWebContents(61)
|
||||
const renderer = mockWebContents(62)
|
||||
fromFrameMock.mockReturnValue(guest)
|
||||
getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-3', renderer })
|
||||
const browserSession = mockSession()
|
||||
const closedSelection = requestBrowserWebAuthnAccount(accountDetails(), browserSession)
|
||||
vi.mocked(renderer.send).mockImplementation((channel) => {
|
||||
if (channel === 'browser:webauthn-account-request-closed') {
|
||||
throw new Error('renderer destroyed during send')
|
||||
}
|
||||
})
|
||||
|
||||
cancelBrowserWebAuthnAccountRequests('page-3')
|
||||
await expect(closedSelection).resolves.toBeNull()
|
||||
|
||||
const timedOutSelection = requestBrowserWebAuthnAccount(accountDetails(), browserSession)
|
||||
await vi.advanceTimersByTimeAsync(BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS)
|
||||
await expect(timedOutSelection).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('cancels when the guest or window is destroyed', async () => {
|
||||
const guest = mockWebContents(71)
|
||||
const renderer = mockWebContents(72)
|
||||
fromFrameMock.mockReturnValue(guest)
|
||||
getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-4', renderer })
|
||||
const browserSession = mockSession()
|
||||
const guestDestroyed = requestBrowserWebAuthnAccount(accountDetails(), browserSession)
|
||||
guest.emit('destroyed')
|
||||
await expect(guestDestroyed).resolves.toBeNull()
|
||||
|
||||
const windowDestroyed = requestBrowserWebAuthnAccount(accountDetails(), browserSession)
|
||||
renderer.emit('destroyed')
|
||||
await expect(windowDestroyed).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('cancels immediately when the guest or window renderer process exits', async () => {
|
||||
const guest = mockWebContents(81)
|
||||
const renderer = mockWebContents(82)
|
||||
const browserSession = mockSession()
|
||||
fromFrameMock.mockReturnValue(guest)
|
||||
getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-5', renderer })
|
||||
|
||||
const guestCrashed = requestBrowserWebAuthnAccount(accountDetails(), browserSession)
|
||||
guest.emit('render-process-gone')
|
||||
await expect(guestCrashed).resolves.toBeNull()
|
||||
|
||||
const windowCrashed = requestBrowserWebAuthnAccount(accountDetails(), browserSession)
|
||||
renderer.emit('render-process-gone')
|
||||
await expect(windowCrashed).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { webContents } from 'electron'
|
||||
|
||||
import type {
|
||||
BrowserWebAuthnAccountRequest,
|
||||
BrowserWebAuthnAccountResponse
|
||||
} from '../../shared/browser-webauthn-account'
|
||||
import { browserManager } from './browser-manager'
|
||||
|
||||
export const BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS = 60_000
|
||||
|
||||
type PendingAccountRequest = {
|
||||
browserPageId: string
|
||||
browserSession: Electron.Session
|
||||
guest: Electron.WebContents
|
||||
renderer: Electron.WebContents
|
||||
credentialIds: Set<string>
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
onUnavailable: () => void
|
||||
settle: (credentialId: string | null) => void
|
||||
}
|
||||
|
||||
const pendingAccountRequests = new Map<string, PendingAccountRequest>()
|
||||
|
||||
function closeRendererPrompt(requestId: string, request: PendingAccountRequest): void {
|
||||
try {
|
||||
if (!request.renderer.isDestroyed()) {
|
||||
request.renderer.send('browser:webauthn-account-request-closed', { requestId })
|
||||
}
|
||||
} catch {
|
||||
// Renderer may be destroyed between the check and send.
|
||||
}
|
||||
}
|
||||
|
||||
function cleanUpRequest(requestId: string, request: PendingAccountRequest): void {
|
||||
clearTimeout(request.timer)
|
||||
request.guest.removeListener('destroyed', request.onUnavailable)
|
||||
request.guest.removeListener('render-process-gone', request.onUnavailable)
|
||||
request.renderer.removeListener('destroyed', request.onUnavailable)
|
||||
request.renderer.removeListener('render-process-gone', request.onUnavailable)
|
||||
pendingAccountRequests.delete(requestId)
|
||||
}
|
||||
|
||||
export function requestBrowserWebAuthnAccount(
|
||||
details: Electron.SelectWebauthnAccountDetails,
|
||||
browserSession: Electron.Session
|
||||
): Promise<string | null> {
|
||||
const guest = details.frame ? webContents.fromFrame(details.frame) : undefined
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
const context = browserManager.getRendererContextForGuest(guest.id)
|
||||
if (!context || context.renderer.isDestroyed()) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
const requestId = randomUUID()
|
||||
const requestPayload: BrowserWebAuthnAccountRequest = {
|
||||
requestId,
|
||||
browserPageId: context.browserPageId,
|
||||
relyingPartyId: details.relyingPartyId,
|
||||
accounts: details.accounts.map(({ credentialId, displayName, name }) => ({
|
||||
credentialId,
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(name ? { name } : {})
|
||||
}))
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const settle = (credentialId: string | null = null): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
const request = pendingAccountRequests.get(requestId)
|
||||
if (request) {
|
||||
cleanUpRequest(requestId, request)
|
||||
closeRendererPrompt(requestId, request)
|
||||
}
|
||||
resolve(credentialId)
|
||||
}
|
||||
const request: PendingAccountRequest = {
|
||||
browserPageId: context.browserPageId,
|
||||
browserSession,
|
||||
guest,
|
||||
renderer: context.renderer,
|
||||
credentialIds: new Set(details.accounts.map((account) => account.credentialId)),
|
||||
timer: setTimeout(settle, BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS),
|
||||
onUnavailable: () => settle(null),
|
||||
settle
|
||||
}
|
||||
pendingAccountRequests.set(requestId, request)
|
||||
guest.once('destroyed', request.onUnavailable)
|
||||
guest.once('render-process-gone', request.onUnavailable)
|
||||
context.renderer.once('destroyed', request.onUnavailable)
|
||||
context.renderer.once('render-process-gone', request.onUnavailable)
|
||||
try {
|
||||
context.renderer.send('browser:webauthn-account-requested', requestPayload)
|
||||
} catch {
|
||||
settle(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function respondToBrowserWebAuthnAccountRequest(
|
||||
sender: Electron.WebContents,
|
||||
response: BrowserWebAuthnAccountResponse
|
||||
): boolean {
|
||||
if (
|
||||
!response ||
|
||||
typeof response.requestId !== 'string' ||
|
||||
(response.credentialId !== null && typeof response.credentialId !== 'string')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const request = pendingAccountRequests.get(response.requestId)
|
||||
if (!request || request.renderer.id !== sender.id) {
|
||||
return false
|
||||
}
|
||||
if (response.credentialId !== null && !request.credentialIds.has(response.credentialId)) {
|
||||
return false
|
||||
}
|
||||
request.settle(response.credentialId)
|
||||
return true
|
||||
}
|
||||
|
||||
export function cancelBrowserWebAuthnAccountRequests(browserPageId: string): void {
|
||||
for (const request of pendingAccountRequests.values()) {
|
||||
if (request.browserPageId === browserPageId) {
|
||||
request.settle(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelBrowserWebAuthnAccountRequestsForSession(
|
||||
browserSession: Electron.Session
|
||||
): void {
|
||||
for (const request of pendingAccountRequests.values()) {
|
||||
if (request.browserSession === browserSession) {
|
||||
request.settle(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelAllBrowserWebAuthnAccountRequests(): void {
|
||||
for (const request of pendingAccountRequests.values()) {
|
||||
request.settle(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { fromFrameMock, getRendererContextForGuestMock, sessionFromPartitionMock } = vi.hoisted(
|
||||
() => ({
|
||||
fromFrameMock: vi.fn(),
|
||||
getRendererContextForGuestMock: vi.fn(),
|
||||
sessionFromPartitionMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => {
|
||||
throw new Error('userData unavailable in test')
|
||||
})
|
||||
},
|
||||
session: { fromPartition: sessionFromPartitionMock },
|
||||
systemPreferences: {
|
||||
askForMediaAccess: vi.fn(),
|
||||
getMediaAccessStatus: vi.fn(() => 'granted')
|
||||
},
|
||||
webContents: { fromFrame: fromFrameMock }
|
||||
}))
|
||||
|
||||
vi.mock('./browser-manager', () => ({
|
||||
browserManager: {
|
||||
getRendererContextForGuest: getRendererContextForGuestMock,
|
||||
handleGuestWillDownload: vi.fn(),
|
||||
installCertificateRequestGuard: vi.fn(),
|
||||
notifyPermissionDenied: vi.fn(),
|
||||
removeCertificateRequestGuard: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { browserSessionRegistry } from './browser-session-registry'
|
||||
import {
|
||||
cancelAllBrowserWebAuthnAccountRequests,
|
||||
respondToBrowserWebAuthnAccountRequest
|
||||
} from './browser-webauthn-account-picker'
|
||||
|
||||
type MockSession = Electron.Session & EventEmitter
|
||||
|
||||
function mockSession(): MockSession {
|
||||
return Object.assign(new EventEmitter(), {
|
||||
clearCache: vi.fn().mockResolvedValue(undefined),
|
||||
clearStorageData: vi.fn().mockResolvedValue(undefined),
|
||||
setDevicePermissionHandler: vi.fn(),
|
||||
setDisplayMediaRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
setPermissionRequestHandler: vi.fn()
|
||||
}) as unknown as MockSession
|
||||
}
|
||||
|
||||
function mockWebContents(id: number): Electron.WebContents & EventEmitter {
|
||||
return Object.assign(new EventEmitter(), {
|
||||
id,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
send: vi.fn()
|
||||
}) as unknown as Electron.WebContents & EventEmitter
|
||||
}
|
||||
|
||||
function accountDetails(frame: Electron.WebFrameMain): Electron.SelectWebauthnAccountDetails {
|
||||
return {
|
||||
relyingPartyId: 'accounts.example.com',
|
||||
frame,
|
||||
accounts: [{ credentialId: 'personal' }, { credentialId: 'work' }]
|
||||
}
|
||||
}
|
||||
|
||||
describe('browser WebAuthn profile deletion', () => {
|
||||
const sessionsByPartition = new Map<string, MockSession>()
|
||||
|
||||
beforeEach(() => {
|
||||
sessionsByPartition.clear()
|
||||
sessionFromPartitionMock.mockReset()
|
||||
sessionFromPartitionMock.mockImplementation((partition: string) => {
|
||||
const existing = sessionsByPartition.get(partition)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const created = mockSession()
|
||||
sessionsByPartition.set(partition, created)
|
||||
return created
|
||||
})
|
||||
fromFrameMock.mockReset()
|
||||
getRendererContextForGuestMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
cancelAllBrowserWebAuthnAccountRequests()
|
||||
for (const profile of browserSessionRegistry.listProfiles()) {
|
||||
if (profile.scope !== 'default') {
|
||||
await browserSessionRegistry.deleteProfile(profile.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves another session pending when a profile is deleted', async () => {
|
||||
const firstProfile = browserSessionRegistry.createProfile('isolated', 'First')
|
||||
const secondProfile = browserSessionRegistry.createProfile('isolated', 'Second')
|
||||
expect(firstProfile).not.toBeNull()
|
||||
expect(secondProfile).not.toBeNull()
|
||||
|
||||
const firstSession = sessionsByPartition.get(firstProfile!.partition)!
|
||||
const secondSession = sessionsByPartition.get(secondProfile!.partition)!
|
||||
const firstFrame = {} as Electron.WebFrameMain
|
||||
const secondFrame = {} as Electron.WebFrameMain
|
||||
const firstGuest = mockWebContents(101)
|
||||
const secondGuest = mockWebContents(102)
|
||||
const firstRenderer = mockWebContents(201)
|
||||
const secondRenderer = mockWebContents(202)
|
||||
fromFrameMock.mockImplementation((frame) => (frame === firstFrame ? firstGuest : secondGuest))
|
||||
getRendererContextForGuestMock.mockImplementation((guestId: number) =>
|
||||
guestId === firstGuest.id
|
||||
? { browserPageId: 'first-page', renderer: firstRenderer }
|
||||
: { browserPageId: 'second-page', renderer: secondRenderer }
|
||||
)
|
||||
const firstCallback = vi.fn()
|
||||
const secondCallback = vi.fn()
|
||||
const firstHandler = firstSession.listeners('select-webauthn-account')[0]
|
||||
const secondHandler = secondSession.listeners('select-webauthn-account')[0]
|
||||
|
||||
void firstHandler({ preventDefault: vi.fn() }, accountDetails(firstFrame), firstCallback)
|
||||
void secondHandler({ preventDefault: vi.fn() }, accountDetails(secondFrame), secondCallback)
|
||||
|
||||
await expect(browserSessionRegistry.deleteProfile(firstProfile!.id)).resolves.toBe(true)
|
||||
await vi.waitFor(() => expect(firstCallback).toHaveBeenCalledWith(null))
|
||||
firstGuest.emit('destroyed')
|
||||
firstRenderer.emit('render-process-gone')
|
||||
expect(firstCallback).toHaveBeenCalledOnce()
|
||||
expect(secondCallback).not.toHaveBeenCalled()
|
||||
|
||||
const secondRequest = vi.mocked(secondRenderer.send).mock.calls[0][1]
|
||||
expect(
|
||||
respondToBrowserWebAuthnAccountRequest(secondRenderer, {
|
||||
requestId: secondRequest.requestId,
|
||||
credentialId: 'work'
|
||||
})
|
||||
).toBe(true)
|
||||
await vi.waitFor(() => expect(secondCallback).toHaveBeenCalledWith('work'))
|
||||
expect(secondCallback).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -34,14 +34,28 @@ function hasManagedCommand(hook: TestHook, matcher: (command: string | undefined
|
||||
}
|
||||
|
||||
describe('getWindowsManagedLifecycleHook', () => {
|
||||
it('resolves the managed script from the runtime Windows profile', () => {
|
||||
it('resolves the managed script from the runtime Windows profile, as a single command string', () => {
|
||||
const scriptPath = 'C:\\Users\\%name%\\a^b&c\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
const hook = getWindowsManagedLifecycleHook(scriptPath)
|
||||
|
||||
expect(hook.args?.[0]).toBe('--headless')
|
||||
expect(hook.args?.[1]).toMatch(/\\System32\\cmd\.exe$/i)
|
||||
expect(hook.args?.at(-1)).toBe('%USERPROFILE%\\.orca\\agent-hooks\\claude-hook.cmd')
|
||||
expect(hook.args).not.toContain(scriptPath)
|
||||
expect(hook.args).toBeUndefined()
|
||||
expect(hook.command).toMatch(
|
||||
/\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden /
|
||||
)
|
||||
expect(hook.command).not.toContain(scriptPath)
|
||||
// Why: Git Bash/MSYS mangles backslash paths and slash-prefixed switches.
|
||||
expect(hook.command.replace(/-EncodedCommand \S+$/, '')).not.toMatch(/\\| \/[a-zA-Z]+( |$)/)
|
||||
|
||||
const encoded = hook.command.match(/-EncodedCommand (\S+)$/)?.[1]
|
||||
const decoded = Buffer.from(encoded ?? '', 'base64').toString('utf16le')
|
||||
expect(decoded).toContain('$env:USERPROFILE')
|
||||
expect(decoded).toContain('.orca\\agent-hooks\\claude-hook.cmd')
|
||||
})
|
||||
|
||||
it('is still recognized as managed by createManagedCommandMatcher (#14825)', () => {
|
||||
const scriptPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
const hook = getWindowsManagedLifecycleHook(scriptPath)
|
||||
expect(isClaudeManagedCommand(hook.command)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -199,9 +213,17 @@ describe('ClaudeHookService.install', () => {
|
||||
expect(hasManagedCommand(legacy.hooks.StopFailure[0].hooks[0], isClaudeManagedCommand)).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8')
|
||||
).toContain('DEVIN_PROJECT_DIR')
|
||||
const managedScript = readFileSync(
|
||||
join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME),
|
||||
'utf-8'
|
||||
)
|
||||
expect(managedScript).toContain('DEVIN_PROJECT_DIR')
|
||||
// Why: guard and Devin-skip paths must still return neutral JSON (#14818).
|
||||
expect(managedScript).toMatch(
|
||||
process.platform === 'win32'
|
||||
? /^@echo off\r\nsetlocal\r\necho \{\}\r\n/
|
||||
: /^#!\/bin\/sh\nprintf "\{\}\\n"\n/
|
||||
)
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(tmpHome, { recursive: true, force: true })
|
||||
@@ -220,10 +242,10 @@ describe('ClaudeHookService.install', () => {
|
||||
) as { statusLine?: { type: string; command: string } }
|
||||
expect(settings.statusLine?.type).toBe('command')
|
||||
expect(settings.statusLine?.command).toContain(
|
||||
'"$HOME/.orca/agent-hooks/claude-statusline.cmd"'
|
||||
'"${HOME-}/.orca/agent-hooks/claude-statusline.cmd"'
|
||||
)
|
||||
expect(settings.statusLine?.command).toContain(
|
||||
'"$HOME/.orca/agent-hooks/claude-statusline.sh"'
|
||||
'"${HOME-}/.orca/agent-hooks/claude-statusline.sh"'
|
||||
)
|
||||
expect(settings.statusLine?.command).not.toContain(tmpHome.replaceAll('\\', '/'))
|
||||
|
||||
@@ -255,7 +277,12 @@ describe('ClaudeHookService.install', () => {
|
||||
mkdirSync(join(tmpHome, '.claude'), { recursive: true })
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({ statusLine: { type: 'command', command: '/usr/local/bin/my-statusline' } })
|
||||
JSON.stringify({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: '/usr/local/bin/my-statusline'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(new ClaudeHookService().install().state).toBe('installed')
|
||||
@@ -339,7 +366,7 @@ describe('ClaudeHookService.install', () => {
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'runs portable managed hooks through headless exec form',
|
||||
'runs portable managed hooks through a single headless command string',
|
||||
() => {
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'orca claude home with spaces '))
|
||||
vi.stubEnv('HOME', tmpHome)
|
||||
@@ -351,24 +378,20 @@ describe('ClaudeHookService.install', () => {
|
||||
readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')
|
||||
) as { hooks: Record<string, { hooks: TestHook[] }[]> }
|
||||
|
||||
const system32 = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')
|
||||
const scriptPath = join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME)
|
||||
const runtimeScriptPath = join(
|
||||
'%USERPROFILE%',
|
||||
'.orca',
|
||||
'agent-hooks',
|
||||
CLAUDE_SCRIPT_FILE_NAME
|
||||
)
|
||||
|
||||
for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
|
||||
const hook = settings.hooks[eventName]?.[0]?.hooks?.[0]
|
||||
expect(hook).toEqual({
|
||||
type: 'command',
|
||||
command: join(system32, 'conhost.exe'),
|
||||
args: ['--headless', join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath],
|
||||
timeout: 10
|
||||
})
|
||||
expect(hook.args).not.toContain(scriptPath)
|
||||
expect(hook?.args).toBeUndefined()
|
||||
expect(hook?.command).toMatch(
|
||||
/\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden /
|
||||
)
|
||||
expect(hook?.command).not.toContain(scriptPath)
|
||||
|
||||
const encoded = hook?.command.match(/-EncodedCommand (\S+)$/)?.[1]
|
||||
const decoded = Buffer.from(encoded ?? '', 'base64').toString('utf16le')
|
||||
expect(decoded).toContain('$env:USERPROFILE')
|
||||
expect(decoded).toContain(`.orca\\agent-hooks\\${CLAUDE_SCRIPT_FILE_NAME}`)
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
@@ -393,6 +416,8 @@ describe('ClaudeHookService.install', () => {
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).toContain('/hook/claude')
|
||||
expect(script).not.toMatch(/Invoke-WebRequest/i)
|
||||
// Why: guard and Devin-skip paths must still return neutral JSON (#14818).
|
||||
expect(script.split('\r\n')[2]).toBe('echo {}')
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(tmpHome, { recursive: true, force: true })
|
||||
@@ -429,13 +454,17 @@ describe('ClaudeHookService.installRemote', () => {
|
||||
]) {
|
||||
expect(parsed.hooks[event]).toBeTruthy()
|
||||
const cmd = parsed.hooks[event][0].hooks[0].command as string
|
||||
expect(cmd).toContain('"$HOME/.orca/agent-hooks/claude-hook.sh"')
|
||||
expect(cmd).toContain('"${HOME-}/.orca/agent-hooks/claude-hook.sh"')
|
||||
expect(cmd).not.toContain('/home/dev/.orca/agent-hooks/claude-hook.sh')
|
||||
}
|
||||
// Managed script body
|
||||
const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')
|
||||
expect(script).toContain('#!/bin/sh')
|
||||
expect(script).toContain('DEVIN_PROJECT_DIR')
|
||||
// Why: remote guard paths must still return neutral JSON (#14818).
|
||||
expect(script!.indexOf('printf "{}\\n"')).toBe(
|
||||
script!.indexOf('#!/bin/sh') + '#!/bin/sh\n'.length
|
||||
)
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
// on the curl command line (EDR oversized-command-line false positive),
|
||||
// matching the Windows curl.exe hook post.
|
||||
@@ -522,8 +551,8 @@ describe('OpenClaudeHookService-compatible install', () => {
|
||||
for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
|
||||
const command = parsed.hooks[event][0].hooks[0].command as string
|
||||
expect(isOpenClaudeManagedCommand(command)).toBe(true)
|
||||
expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.cmd"')
|
||||
expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.sh"')
|
||||
expect(command).toContain('"${HOME-}/.orca/agent-hooks/openclaude-hook.cmd"')
|
||||
expect(command).toContain('"${HOME-}/.orca/agent-hooks/openclaude-hook.sh"')
|
||||
expect(command).not.toContain(tmpHome.replaceAll('\\', '/'))
|
||||
}
|
||||
expect(
|
||||
@@ -553,7 +582,7 @@ describe('OpenClaudeHookService-compatible install', () => {
|
||||
})
|
||||
const parsed = JSON.parse(fs.files.get('/home/dev/.openclaude/settings.json')!)
|
||||
const command = parsed.hooks.StopFailure[0].hooks[0].command as string
|
||||
expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.sh"')
|
||||
expect(command).toContain('"${HOME-}/.orca/agent-hooks/openclaude-hook.sh"')
|
||||
expect(command).not.toContain('/home/dev/.orca/agent-hooks/openclaude-hook.sh')
|
||||
expect(fs.files.get('/home/dev/.orca/agent-hooks/openclaude-hook.sh')).toContain('/hook/claude')
|
||||
})
|
||||
|
||||
@@ -65,6 +65,8 @@ function getManagedScript(
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
// Why: Claude-compatible permission hooks fail closed on empty stdout (#14818).
|
||||
'echo {}',
|
||||
// Why: refresh endpoint coordinates for PTYs surviving an Orca restart.
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
// Why (#11549): the env guards must outrank the Devin skip — the Devin skip parks in more.com,
|
||||
@@ -86,6 +88,8 @@ function getManagedScript(
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
// Why: Claude-compatible permission hooks fail closed on empty stdout (#14818).
|
||||
'printf "{}\\n"',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
...(options.skipWhenDevinImportsClaude
|
||||
? [
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
getSharedManagedScriptPath,
|
||||
isPlainObject,
|
||||
MANAGED_HOOK_TIMEOUT_SECONDS,
|
||||
quotePowerShellString,
|
||||
removeManagedCommands,
|
||||
wrapWindowsPowerShellEncodedCommand,
|
||||
type HookCommandConfig,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
@@ -16,38 +18,59 @@ import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-com
|
||||
export type ClaudeCompatibleHookSettings = {
|
||||
configDirName: '.claude' | '.openclaude'
|
||||
scriptBaseName: 'claude-hook' | 'openclaude-hook'
|
||||
supportsExecHookArgs: boolean
|
||||
usesWindowsPowerShellLauncher: boolean
|
||||
}
|
||||
|
||||
export const CLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = {
|
||||
configDirName: '.claude',
|
||||
scriptBaseName: 'claude-hook',
|
||||
supportsExecHookArgs: true
|
||||
usesWindowsPowerShellLauncher: true
|
||||
}
|
||||
|
||||
export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = {
|
||||
configDirName: '.openclaude',
|
||||
scriptBaseName: 'openclaude-hook',
|
||||
supportsExecHookArgs: false
|
||||
usesWindowsPowerShellLauncher: false
|
||||
}
|
||||
|
||||
export const CLAUDE_EVENTS = [
|
||||
// Why: SessionStart is the only event a resumed/idle session emits before the
|
||||
// first prompt; without it the sidebar row can't exist until the user types (STA-3386).
|
||||
{ eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{ eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{ eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{
|
||||
eventName: 'SessionStart',
|
||||
definition: { hooks: [{ type: 'command', command: '' }] }
|
||||
},
|
||||
{
|
||||
eventName: 'UserPromptSubmit',
|
||||
definition: { hooks: [{ type: 'command', command: '' }] }
|
||||
},
|
||||
{
|
||||
eventName: 'Stop',
|
||||
definition: { hooks: [{ type: 'command', command: '' }] }
|
||||
},
|
||||
// Why: OpenClaude skips normal Stop hooks after API/model errors and emits
|
||||
// StopFailure instead; without this hook Orca leaves the turn spinning.
|
||||
{ eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{
|
||||
eventName: 'StopFailure',
|
||||
definition: { hooks: [{ type: 'command', command: '' }] }
|
||||
},
|
||||
// Why: subagent/teammate lifecycle feeds the sidebar's child rows and keeps
|
||||
// a pane 'working' while background children outlive the lead's turn.
|
||||
// TeammateIdle parks turn-based teammates without trusting their permanently
|
||||
// "running" background_tasks entry to gate the pane.
|
||||
// Older Claude builds ignore unregistered event names (StopFailure precedent).
|
||||
{ eventName: 'SubagentStart', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{ eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{ eventName: 'TeammateIdle', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{
|
||||
eventName: 'SubagentStart',
|
||||
definition: { hooks: [{ type: 'command', command: '' }] }
|
||||
},
|
||||
{
|
||||
eventName: 'SubagentStop',
|
||||
definition: { hooks: [{ type: 'command', command: '' }] }
|
||||
},
|
||||
{
|
||||
eventName: 'TeammateIdle',
|
||||
definition: { hooks: [{ type: 'command', command: '' }] }
|
||||
},
|
||||
// Why: PreToolUse gives the dashboard a live readout of the in-flight tool
|
||||
// (name + input preview) before it completes.
|
||||
{
|
||||
@@ -108,11 +131,15 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S
|
||||
return `${remoteHome.replace(/\/$/, '')}/${settings.configDirName}/settings.json`
|
||||
}
|
||||
|
||||
export function getManagedCommand(scriptPath: string): string {
|
||||
export function getManagedCommand(
|
||||
scriptPath: string,
|
||||
options: { neutralJsonWhenMissing?: boolean } = {}
|
||||
): string {
|
||||
const scriptFileName = basename(scriptPath)
|
||||
const extension = extname(scriptFileName)
|
||||
return wrapRuntimeHomeHookCommand(
|
||||
extension ? scriptFileName.slice(0, -extension.length) : scriptFileName
|
||||
extension ? scriptFileName.slice(0, -extension.length) : scriptFileName,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
@@ -120,25 +147,25 @@ export function getManagedLifecycleHook(
|
||||
scriptPath: string,
|
||||
settings = CLAUDE_HOOK_SETTINGS
|
||||
): HookCommandConfig {
|
||||
if (process.platform !== 'win32' || !settings.supportsExecHookArgs) {
|
||||
return buildManagedCommandHook(getManagedCommand(scriptPath))
|
||||
if (process.platform !== 'win32' || !settings.usesWindowsPowerShellLauncher) {
|
||||
return buildManagedCommandHook(getManagedCommand(scriptPath, { neutralJsonWhenMissing: true }))
|
||||
}
|
||||
return getWindowsManagedLifecycleHook(scriptPath)
|
||||
}
|
||||
|
||||
// Why: some Claude-compatible consumers ignore `args`, so the invocation must be self-contained.
|
||||
export function getWindowsManagedLifecycleHook(scriptPath: string): HookCommandConfig {
|
||||
const system32 = win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')
|
||||
const runtimeScriptPath = win32.join(
|
||||
'%USERPROFILE%',
|
||||
'.orca',
|
||||
'agent-hooks',
|
||||
win32.basename(scriptPath)
|
||||
)
|
||||
// Why: Claude's Windows shell form opens Git Bash consoles; exec form hosts the client in a windowless console.
|
||||
const scriptFileName = win32.basename(scriptPath)
|
||||
// Why: runtime profile resolution keeps the managed entry portable across users (STA-3348).
|
||||
const quotedRelativePath = quotePowerShellString(`.orca\\agent-hooks\\${scriptFileName}`)
|
||||
// Why: compat consumers require neutral JSON even when the managed script is missing (#14818).
|
||||
const innerCommand =
|
||||
`$scriptPath = Join-Path $env:USERPROFILE ${quotedRelativePath}; ` +
|
||||
'if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; ' +
|
||||
"[Console]::In.ReadToEnd() | Out-Null; Write-Output '{}'; exit 0"
|
||||
return {
|
||||
type: 'command',
|
||||
command: win32.join(system32, 'conhost.exe'),
|
||||
args: ['--headless', win32.join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath],
|
||||
command: wrapWindowsPowerShellEncodedCommand(innerCommand),
|
||||
timeout: MANAGED_HOOK_TIMEOUT_SECONDS
|
||||
}
|
||||
}
|
||||
@@ -154,7 +181,7 @@ export function hasSameManagedHookInvocation(
|
||||
}
|
||||
|
||||
export function getRemoteManagedCommand(scriptPath: string): string {
|
||||
return getManagedCommand(scriptPath)
|
||||
return getManagedCommand(scriptPath, { neutralJsonWhenMissing: true })
|
||||
}
|
||||
|
||||
export function applyManagedHooks(
|
||||
|
||||
@@ -30,7 +30,7 @@ vi.mock('os', async (importOriginal) => {
|
||||
import { CodexHookService } from './hook-service'
|
||||
|
||||
const WINDOWS_POWERSHELL_LAUNCHER =
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/
|
||||
|
||||
const homes = setupCodexHookHomes(homedirMock, getPathMock)
|
||||
|
||||
@@ -235,24 +235,31 @@ describe('CodexHookService', () => {
|
||||
// would block the event loop and starve this handler, so the child is
|
||||
// spawned asynchronously while the server drains the request concurrently.
|
||||
let resolveReceived: (value: { headers: Record<string, unknown>; body: string }) => void
|
||||
const receivedPromise = new Promise<{ headers: Record<string, unknown>; body: string }>(
|
||||
(resolve) => {
|
||||
resolveReceived = resolve
|
||||
}
|
||||
)
|
||||
const receivedPromise = new Promise<{
|
||||
headers: Record<string, unknown>
|
||||
body: string
|
||||
}>((resolve) => {
|
||||
resolveReceived = resolve
|
||||
})
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (c: Buffer) => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
res.end('ok')
|
||||
resolveReceived({ headers: req.headers, body: Buffer.concat(chunks).toString('utf-8') })
|
||||
resolveReceived({
|
||||
headers: req.headers,
|
||||
body: Buffer.concat(chunks).toString('utf-8')
|
||||
})
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const port = (server.address() as AddressInfo).port
|
||||
|
||||
try {
|
||||
const payload = JSON.stringify({ prompt: '你好世界', hook_event_name: 'UserPromptSubmit' })
|
||||
const payload = JSON.stringify({
|
||||
prompt: '你好世界',
|
||||
hook_event_name: 'UserPromptSubmit'
|
||||
})
|
||||
// Why: this suite may run inside an Orca-launched terminal whose env
|
||||
// already carries ORCA_AGENT_HOOK_ENDPOINT/PORT/TOKEN. The managed
|
||||
// script sources that endpoint file, so leave it out or the hook posts
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock('os', async () => {
|
||||
import { CommandCodeHookService } from './hook-service'
|
||||
|
||||
const WINDOWS_POWERSHELL_LAUNCHER =
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/
|
||||
|
||||
describe('CommandCodeHookService', () => {
|
||||
let homeDir: string
|
||||
@@ -136,7 +136,10 @@ describe('CommandCodeHookService', () => {
|
||||
body += chunk
|
||||
})
|
||||
req.on('end', () => {
|
||||
requests.push({ body, token: req.headers['x-orca-agent-hook-token'] })
|
||||
requests.push({
|
||||
body,
|
||||
token: req.headers['x-orca-agent-hook-token']
|
||||
})
|
||||
res.statusCode = 204
|
||||
res.end()
|
||||
})
|
||||
|
||||
@@ -49,48 +49,6 @@ describe('crash breadcrumb store', () => {
|
||||
expect(snapshot.at(-1)?.data).toEqual({ index: 31 })
|
||||
})
|
||||
|
||||
it('retains the newest coalesced parking census across later activity', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-15T12:00:00.000Z'))
|
||||
recordCrashBreadcrumb('renderer_memory_highwater', {
|
||||
rendererSurface: 'main',
|
||||
thresholdPct: 80
|
||||
})
|
||||
recordCoalescedCrashBreadcrumb({
|
||||
name: 'terminal_parking_pass',
|
||||
data: { managers: 49, panes: 60, sampledAtMs: Date.now() },
|
||||
coalesceKey: 'terminal_parking_pass',
|
||||
minIntervalMs: 30_000
|
||||
})
|
||||
vi.advanceTimersByTime(1_000)
|
||||
recordCoalescedCrashBreadcrumb({
|
||||
name: 'terminal_parking_pass',
|
||||
data: { managers: 28, panes: 34, sampledAtMs: Date.now() },
|
||||
coalesceKey: 'terminal_parking_pass',
|
||||
minIntervalMs: 30_000
|
||||
})
|
||||
for (let index = 0; index < 32; index += 1) {
|
||||
recordCrashBreadcrumb(`later_event_${index}`, { index })
|
||||
}
|
||||
|
||||
const snapshot = getCrashBreadcrumbSnapshot()
|
||||
const parkingPasses = snapshot.filter(
|
||||
(breadcrumb) => breadcrumb.name === 'terminal_parking_pass'
|
||||
)
|
||||
|
||||
expect(snapshot).toHaveLength(30)
|
||||
expect(parkingPasses).toHaveLength(1)
|
||||
expect(snapshot.some((breadcrumb) => breadcrumb.name === 'renderer_memory_highwater')).toBe(
|
||||
true
|
||||
)
|
||||
expect(parkingPasses[0]?.data).toEqual({
|
||||
managers: 28,
|
||||
panes: 34,
|
||||
sampledAtMs: Date.now(),
|
||||
suppressedSinceLast: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('caps retained high-water profiles', () => {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
recordCrashBreadcrumb('renderer_memory_highwater', {
|
||||
@@ -104,41 +62,6 @@ describe('crash breadcrumb store', () => {
|
||||
).toEqual(['surface-1', 'surface-2', 'surface-3', 'surface-4'])
|
||||
})
|
||||
|
||||
it('keeps memory profiles and parking census from starving each other', () => {
|
||||
recordCrashBreadcrumb('terminal_parking_pass', { managers: 1, panes: 1 })
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
recordCrashBreadcrumb('renderer_memory_highwater', {
|
||||
rendererSurface: `surface-${index}`,
|
||||
thresholdPct: 80
|
||||
})
|
||||
}
|
||||
|
||||
expect(
|
||||
getCrashBreadcrumbSnapshot().filter(
|
||||
(breadcrumb) => breadcrumb.name === 'terminal_parking_pass'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
recordCrashBreadcrumb('terminal_parking_pass', { managers: index, panes: index + 1 })
|
||||
}
|
||||
|
||||
const snapshot = getCrashBreadcrumbSnapshot()
|
||||
const memoryProfiles = snapshot.filter(
|
||||
(breadcrumb) => breadcrumb.name === 'renderer_memory_highwater'
|
||||
)
|
||||
|
||||
expect(memoryProfiles.map((breadcrumb) => breadcrumb.data?.rendererSurface)).toEqual([
|
||||
'surface-8',
|
||||
'surface-9',
|
||||
'surface-10',
|
||||
'surface-11'
|
||||
])
|
||||
expect(snapshot.filter((breadcrumb) => breadcrumb.name === 'terminal_parking_pass')).toEqual([
|
||||
expect.objectContaining({ data: { managers: 11, panes: 12 } })
|
||||
])
|
||||
})
|
||||
|
||||
it('redacts sensitive breadcrumb fields before they can be snapshotted', () => {
|
||||
recordCrashBreadcrumb('workspace_opened', {
|
||||
path: '/Users/alice/project',
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
} from '../../shared/crash-reporting'
|
||||
|
||||
const MAX_BREADCRUMBS = 30
|
||||
// Why: preserve four memory highwaters and one named singleton within the 30-entry snapshot.
|
||||
const MAX_RETAINED_BREADCRUMBS = 5
|
||||
const MAX_RETAINED_MEMORY_BREADCRUMBS = 4
|
||||
const RETAINED_SINGLETON_BREADCRUMB_NAMES = new Set(['terminal_parking_pass'])
|
||||
// Why: retain two thresholds for each renderer surface without growing the ring.
|
||||
const MAX_RETAINED_BREADCRUMBS = 4
|
||||
// Why: coalesceKey embeds an open-string agentType (length-trimmed only, never
|
||||
// enum-checked), so the key space is unbounded over a long multi-agent/SSH session.
|
||||
// Bound the coalesce map the same way ProcessGoneDedupe bounds its key map.
|
||||
@@ -39,9 +37,6 @@ let retainedBreadcrumbs = new Map<string, CrashReportBreadcrumb>()
|
||||
let coalescedBreadcrumbs = new Map<string, CoalescedBreadcrumbState>()
|
||||
|
||||
function retainedBreadcrumbKey(breadcrumb: CrashReportBreadcrumb): string | null {
|
||||
if (RETAINED_SINGLETON_BREADCRUMB_NAMES.has(breadcrumb.name)) {
|
||||
return breadcrumb.name
|
||||
}
|
||||
if (breadcrumb.name !== 'renderer_memory_highwater') {
|
||||
return null
|
||||
}
|
||||
@@ -70,15 +65,6 @@ export function recordCrashBreadcrumb(
|
||||
if (retainedKey) {
|
||||
retainedBreadcrumbs.delete(retainedKey)
|
||||
retainedBreadcrumbs.set(retainedKey, breadcrumb)
|
||||
const memoryKeys = [...retainedBreadcrumbs.entries()]
|
||||
.filter(([, retained]) => retained.name === 'renderer_memory_highwater')
|
||||
.map(([key]) => key)
|
||||
while (memoryKeys.length > MAX_RETAINED_MEMORY_BREADCRUMBS) {
|
||||
const oldestMemoryKey = memoryKeys.shift()
|
||||
if (oldestMemoryKey) {
|
||||
retainedBreadcrumbs.delete(oldestMemoryKey)
|
||||
}
|
||||
}
|
||||
while (retainedBreadcrumbs.size > MAX_RETAINED_BREADCRUMBS) {
|
||||
const oldestKey = retainedBreadcrumbs.keys().next()
|
||||
if (oldestKey.done) {
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
import { ProcessGoneDedupe } from './process-gone-dedupe'
|
||||
import { recordProcessGoneCrash, type ProcessGoneCrashEvent } from './process-gone-recorder'
|
||||
|
||||
const noMinidump = async () => null
|
||||
const attachDetails = async () => null
|
||||
|
||||
function event(overrides: Partial<ProcessGoneCrashEvent> = {}): ProcessGoneCrashEvent {
|
||||
return {
|
||||
source: 'renderer',
|
||||
@@ -82,9 +85,27 @@ describe('recordProcessGoneCrash killed/1 ordering', () => {
|
||||
|
||||
recordProcessGoneCrash({ record } as never, gpuKill, dedupe)
|
||||
recordProcessGoneCrash({ record } as never, networkServiceKill, dedupe)
|
||||
recordProcessGoneCrash({ record } as never, rendererKill, dedupe)
|
||||
recordProcessGoneCrash({ record, attachDetails } as never, rendererKill, dedupe, noMinidump)
|
||||
|
||||
expect(record).toHaveBeenCalledOnce()
|
||||
// Timing proximity is evidence, not authority to discard an ambiguous report.
|
||||
expect(record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
breadcrumbs: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'process_gone_suppressed',
|
||||
data: expect.objectContaining({ source: 'child', processType: 'GPU' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'process_gone_suppressed',
|
||||
data: expect.objectContaining({
|
||||
source: 'child',
|
||||
serviceName: 'network.mojom.NetworkService'
|
||||
})
|
||||
})
|
||||
])
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('suppresses the fleet sequence only after independent session-end intent', () => {
|
||||
|
||||
@@ -436,11 +436,12 @@ describe('recordProcessGoneCrash', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Why child kills: the decode gate is source-agnostic, and a non-recoverable
|
||||
// child persists synchronously on every branch of the crash-reporting stack
|
||||
// (the renderer killed path defers behind a sibling-kill settle), so the
|
||||
// platform stub is still in force when the gate reads process.platform.
|
||||
const nonRecoverableChildKill = (overrides: Partial<ProcessGoneCrashEvent>): ProcessGoneCrashEvent =>
|
||||
// Why child kills: the decode gate is source-agnostic and reads
|
||||
// process.platform synchronously at record time, so the platform stub is
|
||||
// still in force when it runs.
|
||||
const nonRecoverableChildKill = (
|
||||
overrides: Partial<ProcessGoneCrashEvent>
|
||||
): ProcessGoneCrashEvent =>
|
||||
event({
|
||||
source: 'child',
|
||||
processType: 'Utility',
|
||||
|
||||
@@ -31,7 +31,7 @@ const CURSOR_EVENTS = [
|
||||
|
||||
const CURSOR_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'cursor-hook.cmd' : 'cursor-hook.sh'
|
||||
const WINDOWS_POWERSHELL_LAUNCHER =
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/
|
||||
|
||||
describe('CursorHookService', () => {
|
||||
let homeDir: string
|
||||
|
||||
@@ -66,9 +66,6 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process
|
||||
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
|
||||
import { resolveUnixShellPath } from '../providers/local-pty-utils'
|
||||
import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history'
|
||||
import { addWslEnvKeys } from '../wsl-env'
|
||||
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache'
|
||||
import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue'
|
||||
@@ -428,8 +425,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
|
||||
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
||||
const spawnOpts = this.withHistoryIsolation(opts)
|
||||
const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId)
|
||||
const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId)
|
||||
const operation = {
|
||||
exitsBySessionId: new Map<string, { incarnationId?: string }[]>(),
|
||||
ignoredExitIncarnationIds: new Set<string>(),
|
||||
@@ -448,9 +444,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
try {
|
||||
return await this.withHistorySpawnLock(sessionId, () =>
|
||||
this.withDaemonRetry(() =>
|
||||
this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery)
|
||||
)
|
||||
this.withDaemonRetry(() => this.doSpawn({ ...opts, sessionId }, operation, historyRecovery))
|
||||
)
|
||||
} finally {
|
||||
if (historyRecovery.freeze) {
|
||||
@@ -464,44 +458,6 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions {
|
||||
const wslContext = resolveWslSessionContext({
|
||||
cwd: opts.cwd,
|
||||
sessionId: opts.sessionId,
|
||||
shellOverride: opts.shellOverride,
|
||||
terminalWindowsWslDistro: opts.terminalWindowsWslDistro
|
||||
})
|
||||
if (
|
||||
opts.attachOnly === true ||
|
||||
(opts.sessionId !== undefined && opts.isNewSession !== true) ||
|
||||
!opts.worktreeId ||
|
||||
opts.historyIsolationEnabled !== true ||
|
||||
(process.platform === 'win32' && !wslContext)
|
||||
) {
|
||||
return opts
|
||||
}
|
||||
const env = { ...opts.env }
|
||||
const preferredShell = wslContext
|
||||
? 'bash'
|
||||
: opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh'
|
||||
const shellPath = resolveUnixShellPath(preferredShell)
|
||||
const historyArgs = [
|
||||
env,
|
||||
opts.worktreeId,
|
||||
shellPath,
|
||||
opts.cwd ?? resolveSafePtyDefaultCwd()
|
||||
] as const
|
||||
const result = wslContext
|
||||
? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro })
|
||||
: injectHistoryEnv(...historyArgs)
|
||||
if (wslContext) {
|
||||
injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro)
|
||||
addWslEnvKeys(env, ['HISTFILE', 'fish_history'])
|
||||
}
|
||||
logHistoryInjection(opts.worktreeId, result)
|
||||
return { ...opts, env }
|
||||
}
|
||||
|
||||
private async doSpawn(
|
||||
opts: PtySpawnOptions,
|
||||
operation: PendingDaemonSpawnOperation,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
|
||||
import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight'
|
||||
import { getZshFinalZdotdirRestoreBlock, getZshStartupFileSourceBlock,
|
||||
ZSH_HISTFILE_RESTORE_BLOCK
|
||||
} from '../shell-templates'
|
||||
import { getZshFinalZdotdirRestoreBlock, getZshStartupFileSourceBlock } from '../shell-templates'
|
||||
|
||||
export function getDaemonZshShellReadyRcfileContent(): string {
|
||||
return `# Orca daemon zsh shell-ready wrapper
|
||||
@@ -25,7 +23,6 @@ if [[ ! -o login ]]; then
|
||||
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
|
||||
${getPosixOmpShellWrapper()}
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
${ZSH_HISTFILE_RESTORE_BLOCK}
|
||||
${getPosixCodexShellLaunchPreflight()}
|
||||
fi
|
||||
__orca_osc133_precmd() {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getHistorySessionDirName } from './history-paths'
|
||||
import { decodeTerminalHistoryLog, LOG_HEADER_BYTES } from './terminal-history-log'
|
||||
import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import { PrioritySemaphore } from './priority-semaphore'
|
||||
import { PrioritySemaphore } from '../../shared/priority-semaphore'
|
||||
import { ColdRestoreReplayWriter } from './cold-restore-replay-writer'
|
||||
import { readTerminalHistoryBufferAsync } from './terminal-history-file-reader'
|
||||
import { detectColdRestoreFromLegacyScrollback } from './terminal-history-legacy-scrollback-restore'
|
||||
|
||||
@@ -16,8 +16,7 @@ import {
|
||||
getZshEnvTemplate,
|
||||
getZshFinalZdotdirRestoreBlock,
|
||||
getZshShellReadyMarkerRegistrationBlock,
|
||||
getZshStartupFileSourceBlock,
|
||||
ZSH_HISTFILE_RESTORE_BLOCK
|
||||
getZshStartupFileSourceBlock
|
||||
} from '../shell-templates'
|
||||
import { SHELL_READY_MARKER } from './daemon-shell-ready-marker'
|
||||
import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile'
|
||||
@@ -122,7 +121,6 @@ __orca_restore_agent_teams_path
|
||||
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
|
||||
${getPosixOmpShellWrapper()}
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
${ZSH_HISTFILE_RESTORE_BLOCK}
|
||||
${getPosixCodexShellLaunchPreflight()}
|
||||
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)}
|
||||
${getZshFinalZdotdirRestoreBlock()}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences'
|
||||
import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments'
|
||||
import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-profiles'
|
||||
import {
|
||||
COLD_RESTORE_SEED_MODE_RESET,
|
||||
RESET_GRAPHIC_RENDITION
|
||||
} from '../../shared/terminal-mode-reset-profiles'
|
||||
import type { ColdRestoreInfo } from './terminal-history-cold-restore-info'
|
||||
import type { TerminalModes } from './types'
|
||||
|
||||
@@ -38,7 +41,7 @@ describe('getRecoveredHistorySeedSegments', () => {
|
||||
restoreInfo({ pendingEscapeTailAnsi: '\x1b[3' })
|
||||
)
|
||||
expect(segments).toEqual([
|
||||
'\x1b[?1003h\x1b[?1006h',
|
||||
`${RESET_GRAPHIC_RENDITION}\x1b[?1003h\x1b[?1006h`,
|
||||
'user@host ~ $ \x1b[?1003h',
|
||||
MOUSE_OFF,
|
||||
'\x1b[3'
|
||||
@@ -50,7 +53,7 @@ describe('getRecoveredHistorySeedSegments', () => {
|
||||
getRecoveredHistorySeedSegments(
|
||||
restoreInfo({ modes: { ...ARMED_MODES, alternateScreen: true } })
|
||||
)
|
||||
).toEqual(['user@host ~ $ ', MOUSE_OFF])
|
||||
).toEqual([`${RESET_GRAPHIC_RENDITION}user@host ~ $ `, MOUSE_OFF])
|
||||
})
|
||||
|
||||
it('stays empty when there is no recovered normal buffer', () => {
|
||||
@@ -116,4 +119,10 @@ describe('getRecoveredHistorySeedSegments', () => {
|
||||
// snapshots must keep re-arming or an alt-screen TUI loses scroll forever.
|
||||
expect(buildRehydrateSequences(ARMED_MODES)).toBe('\x1b[?1003h\x1b[?1006h')
|
||||
})
|
||||
|
||||
it('grounds the pen before re-entering the alternate screen', () => {
|
||||
expect(buildRehydrateSequences({ ...ARMED_MODES, alternateScreen: true })).toBe(
|
||||
`${RESET_GRAPHIC_RENDITION}\x1b[?1049h\x1b[?1003h\x1b[?1006h`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ColdRestoreInfo } from './terminal-history-cold-restore-info'
|
||||
import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-profiles'
|
||||
import {
|
||||
COLD_RESTORE_SEED_MODE_RESET,
|
||||
RESET_GRAPHIC_RENDITION
|
||||
} from '../../shared/terminal-mode-reset-profiles'
|
||||
|
||||
// Why the reset belongs in the seed and not only at replay: the recovered stream
|
||||
// re-arms mouse reporting from two independent sources (rehydrateSequences AND
|
||||
@@ -12,7 +15,9 @@ import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-p
|
||||
export function getRecoveredHistorySeedSegments(restoreInfo: ColdRestoreInfo): readonly string[] {
|
||||
if (restoreInfo.modes.alternateScreen) {
|
||||
const normalBuffer = restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi
|
||||
return normalBuffer ? [normalBuffer, COLD_RESTORE_SEED_MODE_RESET] : []
|
||||
return normalBuffer
|
||||
? [`${RESET_GRAPHIC_RENDITION}${normalBuffer}`, COLD_RESTORE_SEED_MODE_RESET]
|
||||
: []
|
||||
}
|
||||
const recovered = [restoreInfo.rehydrateSequences, restoreInfo.snapshotAnsi].filter(
|
||||
(segment) => segment.length > 0
|
||||
@@ -25,5 +30,9 @@ export function getRecoveredHistorySeedSegments(restoreInfo: ColdRestoreInfo): r
|
||||
}
|
||||
// Why after the snapshot: it must undo the snapshot's own mode trailer, and
|
||||
// pendingEscapeTailAnsi is a torn escape that has to stay at the very end.
|
||||
return [...recovered, COLD_RESTORE_SEED_MODE_RESET, ...(escapeTail ? [escapeTail] : [])]
|
||||
const [firstRecovered, ...remainingRecovered] = recovered
|
||||
const groundedRecovered = firstRecovered
|
||||
? [`${RESET_GRAPHIC_RENDITION}${firstRecovered}`, ...remainingRecovered]
|
||||
: []
|
||||
return [...groundedRecovered, COLD_RESTORE_SEED_MODE_RESET, ...(escapeTail ? [escapeTail] : [])]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TerminalModes } from './types'
|
||||
import { RESET_GRAPHIC_RENDITION } from '../../shared/terminal-mode-reset-profiles'
|
||||
|
||||
// Why no kitty flags here: rehydrateSequences feeds renderer xterms, and
|
||||
// POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C
|
||||
@@ -10,7 +11,7 @@ export function buildRehydrateSequences(modes: TerminalModes): string {
|
||||
if (modes.alternateScreen) {
|
||||
// Why: normal-buffer serialization can leave its pen active, while the
|
||||
// separately serialized alt body assumes it starts from default SGR.
|
||||
seqs.push('\x1b[0m\x1b[?1049h')
|
||||
seqs.push(`${RESET_GRAPHIC_RENDITION}\x1b[?1049h`)
|
||||
}
|
||||
if (modes.bracketedPaste) {
|
||||
seqs.push('\x1b[?2004h')
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock('os', async () => {
|
||||
import { DroidHookService } from './hook-service'
|
||||
|
||||
const WINDOWS_POWERSHELL_LAUNCHER =
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/
|
||||
|
||||
describe('DroidHookService', () => {
|
||||
let homeDir: string
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
deleteFishHistoryFile,
|
||||
fishHistorySessionName,
|
||||
isSafeFishHistorySession,
|
||||
resolveFishHistoryDir,
|
||||
sweepOrphanedFishHistoryFiles
|
||||
} from './fish-history-session'
|
||||
|
||||
const HASH_A = 'a1b2c3d4e5f60718'
|
||||
const HASH_B = '00112233445566ff'
|
||||
const SESSION_A = fishHistorySessionName(HASH_A)
|
||||
|
||||
describe('fish history session naming', () => {
|
||||
it('mints a session name that is a valid fish variable value', () => {
|
||||
// fish falls back to the shared default for anything that is not a valid
|
||||
// variable name, which would silently un-isolate the worktree.
|
||||
expect(SESSION_A).toBe('orca_a1b2c3d4e5f60718')
|
||||
expect(SESSION_A).toMatch(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['orca_deadbeef', true],
|
||||
['orca_', false],
|
||||
['fish_history', false],
|
||||
['orca_../../etc/passwd', false],
|
||||
['orca_DEADBEEF', false],
|
||||
['', false],
|
||||
[undefined, false],
|
||||
[42, false]
|
||||
])('accepts %s as a safe session name: %s', (value, expected) => {
|
||||
expect(isSafeFishHistorySession(value)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fish history directory resolution', () => {
|
||||
// Why join rather than literals: the separator is platform-specific, and on
|
||||
// Windows `isAbsolute('/data')` is also true, so a hardcoded '/data/fish'
|
||||
// fails there for a reason that has nothing to do with the behavior tested.
|
||||
it('follows XDG_DATA_HOME when it is absolute', () => {
|
||||
expect(resolveFishHistoryDir({ XDG_DATA_HOME: '/data', HOME: '/home/me' })).toBe(
|
||||
join('/data', 'fish')
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores a relative XDG_DATA_HOME the way fish does', () => {
|
||||
expect(resolveFishHistoryDir({ XDG_DATA_HOME: 'relative', HOME: '/home/me' })).toBe(
|
||||
join('/home/me', '.local', 'share', 'fish')
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to HOME when XDG_DATA_HOME is unset', () => {
|
||||
expect(resolveFishHistoryDir({ HOME: '/home/me' })).toBe(
|
||||
join('/home/me', '.local', 'share', 'fish')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fish history deletion', () => {
|
||||
let root: string
|
||||
let fishDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-fish-history-'))
|
||||
fishDir = join(root, 'fish')
|
||||
mkdirSync(fishDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const historyFile = (session: string): string => join(fishDir, `${session}_history`)
|
||||
|
||||
it('deletes the session file rather than truncating it', () => {
|
||||
writeFileSync(historyFile(SESSION_A), '- cmd: ls\n')
|
||||
|
||||
expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(true)
|
||||
expect(existsSync(historyFile(SESSION_A))).toBe(false)
|
||||
})
|
||||
|
||||
it('tries every candidate directory, since the PTY may not share this env', () => {
|
||||
const other = join(root, 'other-xdg', 'fish')
|
||||
mkdirSync(other, { recursive: true })
|
||||
writeFileSync(join(other, `${SESSION_A}_history`), '- cmd: ls\n')
|
||||
|
||||
expect(deleteFishHistoryFile(SESSION_A, [fishDir, other])).toBe(true)
|
||||
expect(existsSync(join(other, `${SESSION_A}_history`))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a session name it did not mint', () => {
|
||||
const foreign = join(fishDir, 'fish_history')
|
||||
writeFileSync(foreign, 'the user’s real history\n')
|
||||
|
||||
expect(deleteFishHistoryFile('fish_history', [fishDir])).toBe(false)
|
||||
expect(existsSync(foreign)).toBe(true)
|
||||
})
|
||||
|
||||
// Why skipped on Windows: symlinkSync needs Developer Mode or elevation there.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'refuses to follow a symlink out of the fish data dir',
|
||||
() => {
|
||||
const outside = join(root, 'precious.txt')
|
||||
writeFileSync(outside, 'keep me\n')
|
||||
symlinkSync(outside, historyFile(SESSION_A))
|
||||
|
||||
expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(false)
|
||||
expect(existsSync(outside)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('reports false when there is nothing to delete', () => {
|
||||
expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orphaned fish history sweep', () => {
|
||||
let root: string
|
||||
let fishDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-fish-sweep-'))
|
||||
fishDir = join(root, 'fish')
|
||||
mkdirSync(fishDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('removes files for dead worktrees and keeps live ones', () => {
|
||||
writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_A)}_history`), 'live\n')
|
||||
writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n')
|
||||
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir])).toBe(1)
|
||||
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_A)}_history`))).toBe(true)
|
||||
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(false)
|
||||
})
|
||||
|
||||
it("never touches the user's own history files", () => {
|
||||
// The whole safety of the sweep rests on the orca_<hex>_ prefix.
|
||||
const untouched = ['fish_history', 'work_history', 'orca_history', 'orca_nothex_history']
|
||||
for (const name of untouched) {
|
||||
writeFileSync(join(fishDir, name), 'mine\n')
|
||||
}
|
||||
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir])).toBe(0)
|
||||
for (const name of untouched) {
|
||||
expect(existsSync(join(fishDir, name))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to sweep on an empty live set, which cannot be told from a cold store', () => {
|
||||
writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n')
|
||||
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set(), [fishDir])).toBe(0)
|
||||
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(true)
|
||||
})
|
||||
|
||||
it('spares a file younger than the age guard, since the live set is a snapshot', () => {
|
||||
// The reachable race: a worktree created after the snapshot but before the
|
||||
// sweep looks dead while it is actively writing the history it owns.
|
||||
const fresh = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`)
|
||||
writeFileSync(fresh, 'just created\n')
|
||||
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000)).toBe(0)
|
||||
expect(existsSync(fresh)).toBe(true)
|
||||
})
|
||||
|
||||
it('sweeps a file older than the age guard', () => {
|
||||
const stale = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`)
|
||||
writeFileSync(stale, 'long dead\n')
|
||||
const wellPast = Date.now() + 60 * 60 * 1000
|
||||
|
||||
expect(
|
||||
sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000, wellPast)
|
||||
).toBe(1)
|
||||
expect(existsSync(stale)).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates a directory that does not exist', () => {
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [join(root, 'absent')])).toBe(0)
|
||||
})
|
||||
|
||||
it('sweeps every candidate directory once', () => {
|
||||
const other = join(root, 'other', 'fish')
|
||||
mkdirSync(other, { recursive: true })
|
||||
for (const dir of [fishDir, other]) {
|
||||
writeFileSync(join(dir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n')
|
||||
}
|
||||
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir, other, fishDir])).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
import { lstatSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Per-worktree fish history.
|
||||
*
|
||||
* fish ignores HISTFILE entirely — history lives at `<data dir>/<name>_history`
|
||||
* where `<name>` comes only from the `fish_history` variable, and there is no
|
||||
* custom-directory knob. So the session NAME is the only isolation lever, and
|
||||
* the file necessarily lands in the user's fish data dir rather than inside
|
||||
* Orca's history tree. That is why deletion needs its own path here instead of
|
||||
* riding the tree tombstone the way bash/zsh history does.
|
||||
*
|
||||
* `XDG_DATA_HOME` is deliberately not redirected: that would move every other
|
||||
* tool's data for the session, not just fish's history.
|
||||
*/
|
||||
const SESSION_PREFIX = 'orca_'
|
||||
const SAFE_SESSION_NAME = /^orca_[0-9a-f]{1,64}$/
|
||||
const ORCA_HISTORY_FILE = /^orca_([0-9a-f]{1,64})_history$/
|
||||
|
||||
export function isSafeFishHistorySession(session: unknown): session is string {
|
||||
return typeof session === 'string' && SAFE_SESSION_NAME.test(session)
|
||||
}
|
||||
|
||||
export function fishHistorySessionName(worktreeHash: string): string {
|
||||
return `${SESSION_PREFIX}${worktreeHash}`
|
||||
}
|
||||
|
||||
/** The directory fish will write history into, for a given environment.
|
||||
* Resolve this from the PTY's SPAWN env: Orca can be launched with a different
|
||||
* `XDG_DATA_HOME`/`HOME` than the shells it spawns, and fish follows its own. */
|
||||
export function resolveFishHistoryDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const xdg = env.XDG_DATA_HOME?.trim()
|
||||
const dataHome =
|
||||
xdg && isAbsolute(xdg) ? xdg : join(env.HOME?.trim() || homedir(), '.local', 'share')
|
||||
return join(dataHome, 'fish')
|
||||
}
|
||||
|
||||
/** Removes one history file, refusing anything that is not a regular file.
|
||||
* Why lstat: a symlink here would take `rmSync` out of the fish data dir. */
|
||||
function removeHistoryFile(path: string): boolean {
|
||||
try {
|
||||
if (!lstatSync(path).isFile()) {
|
||||
return false
|
||||
}
|
||||
rmSync(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete one worktree's fish history file, trying every directory it could be in.
|
||||
* Callers pass the directory recorded at spawn time plus this process's own, since
|
||||
* the two disagree whenever Orca's environment differs from the PTY's. */
|
||||
export function deleteFishHistoryFile(session: string, dirs: Iterable<string>): boolean {
|
||||
if (!isSafeFishHistorySession(session)) {
|
||||
return false
|
||||
}
|
||||
let removed = false
|
||||
for (const dir of new Set(dirs)) {
|
||||
removed = removeHistoryFile(join(dir, `${session}_history`)) || removed
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every Orca-minted fish history file whose worktree is gone.
|
||||
*
|
||||
* Why a sweep and not just per-worktree deletion: the history file outlives the
|
||||
* directory that names it. A crash between tombstone and removal, a hand-deleted
|
||||
* history dir, or a file written by an older build all orphan one, and only a
|
||||
* scan of the data dir can find those. Matching on Orca's own `orca_<hex>_`
|
||||
* prefix means the user's real `fish_history` is never a candidate.
|
||||
*
|
||||
* `minAgeMs` mirrors the age guard the history-tree GC already applies: the
|
||||
* live-worktree set is a snapshot, so a worktree created between the snapshot
|
||||
* and this sweep would otherwise look dead and lose the history it is actively
|
||||
* writing. A live session's file keeps a recent mtime, so this doubles as a
|
||||
* second check on the set itself.
|
||||
*
|
||||
* Refuses to run at all on an empty live set: that cannot be told apart from a
|
||||
* store that failed to hydrate, and unlike the tree GC this deletes files
|
||||
* outside Orca's own directory.
|
||||
*/
|
||||
export function sweepOrphanedFishHistoryFiles(
|
||||
liveWorktreeHashes: ReadonlySet<string>,
|
||||
dirs: Iterable<string>,
|
||||
minAgeMs = 0,
|
||||
now = Date.now()
|
||||
): number {
|
||||
if (liveWorktreeHashes.size === 0) {
|
||||
return 0
|
||||
}
|
||||
let removed = 0
|
||||
for (const dir of new Set(dirs)) {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const hash = ORCA_HISTORY_FILE.exec(entry)?.[1]
|
||||
if (!hash || liveWorktreeHashes.has(hash)) {
|
||||
continue
|
||||
}
|
||||
const path = join(dir, entry)
|
||||
if (minAgeMs > 0 && !isOlderThan(path, minAgeMs, now)) {
|
||||
continue
|
||||
}
|
||||
if (removeHistoryFile(path)) {
|
||||
removed += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/** Treats an unreadable file as too young: never delete on a failed stat. */
|
||||
function isOlderThan(path: string, minAgeMs: number, now: number): boolean {
|
||||
try {
|
||||
return now - lstatSync(path).mtimeMs >= minAgeMs
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ vi.mock('os', async (importOriginal) => {
|
||||
import { GeminiHookService } from './hook-service'
|
||||
|
||||
const WINDOWS_POWERSHELL_LAUNCHER =
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/
|
||||
|
||||
describe('GeminiHookService', () => {
|
||||
let homeDir: string
|
||||
|
||||
@@ -90,9 +90,9 @@ describe('mapGhosttyToOrca — window-padding', () => {
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves odd-sum dual-value padding as a fractional average', () => {
|
||||
it('rounds odd-sum dual-value padding to the nearest fitted pixel', () => {
|
||||
const result = mapGhosttyToOrca({ 'window-padding-x': '1,2' })
|
||||
expect(result.diff).toEqual({ terminalPaddingX: 1.5 })
|
||||
expect(result.diff).toEqual({ terminalPaddingX: 2 })
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeTerminalPadding } from '../../shared/terminal-padding-settings'
|
||||
|
||||
// Why: `Number("1e10")` succeeds and passes `Number.isInteger`, so a Ghostty
|
||||
// config with `window-padding-x = 1e9` would sail through the mapper and land
|
||||
// an absurd value in the store. Restrict to plain decimal integers.
|
||||
@@ -11,8 +13,7 @@ export const parseStrictInt = (v: string): number | null => {
|
||||
}
|
||||
|
||||
// Why: Ghostty accepts "top,bottom" / "left,right" pairs for window paddings,
|
||||
// but Orca stores a single value per axis — average the pair so the total
|
||||
// padding along the axis stays the same.
|
||||
// but Orca stores an integer per axis — preserve the nearest total inset.
|
||||
export const parsePaddingValue = (v: string): number | null => {
|
||||
const parts = v.split(',')
|
||||
if (parts.length > 2) {
|
||||
@@ -26,5 +27,5 @@ export const parsePaddingValue = (v: string): number | null => {
|
||||
}
|
||||
nums.push(num)
|
||||
}
|
||||
return nums.reduce((sum, num) => sum + num, 0) / nums.length
|
||||
return normalizeTerminalPadding(nums.reduce((sum, num) => sum + num, 0) / nums.length)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh'
|
||||
const WINDOWS_POWERSHELL_LAUNCHER =
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/
|
||||
|
||||
type WindowsGrokHookRun = {
|
||||
status: number | null
|
||||
@@ -118,7 +118,7 @@ describe('GrokHookService', () => {
|
||||
expect(script).toContain('set "ORCA_GROK_HOME="')
|
||||
expect(script).toContain('if not defined GROK_HOME goto :orca_grok_home_ready')
|
||||
expect(script).toContain('%GROK_HOME:~4096,1%')
|
||||
expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"')
|
||||
expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME:"=%"')
|
||||
expect(script).toContain('%ORCA_GROK_HOME:~4096,1%')
|
||||
expect(script).toContain(':orca_grok_home_ready')
|
||||
expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready')
|
||||
@@ -269,7 +269,7 @@ describe('GrokHookService', () => {
|
||||
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
|
||||
// Why: windows-grok-hook-script.test.ts pins the GROK_HOME guard shape itself,
|
||||
// and does so on every platform rather than only on Windows runners.
|
||||
expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"')
|
||||
expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME:"=%"')
|
||||
expect(script).toContain('--data-urlencode "grokHome=%ORCA_GROK_HOME%"')
|
||||
} else {
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
@@ -343,7 +343,11 @@ describe('GrokHookService', () => {
|
||||
`${JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
Notification: [{ hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] }]
|
||||
Notification: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -26,8 +26,9 @@ function findUnguardedSubstringReads(lines: readonly string[]): string[] {
|
||||
defined.add(guarded[1])
|
||||
}
|
||||
// A value is provably non-empty only if something outside an expansion survives:
|
||||
// `set "V=%OTHER%"` may undefine V, `set "V=%V%."` cannot.
|
||||
for (const [, name, value] of line.matchAll(/set "(\w+)=([^"]*)"/g)) {
|
||||
// `set "V=%OTHER%"` may undefine V, `set "V=%V%."` cannot. The lazy value stops at
|
||||
// the quote that closes the `set`, so a substitution like `%V:"=%` stays one token.
|
||||
for (const [, name, value] of line.matchAll(/set "(\w+)=(.*?)"(?=\s|$)/g)) {
|
||||
if (value.replaceAll(/%[^%]*%/g, '') === '') {
|
||||
defined.delete(name)
|
||||
} else {
|
||||
@@ -93,6 +94,7 @@ describe.skipIf(process.platform !== 'win32')('buildWindowsGrokHookScript (win32
|
||||
grokHome: string | null
|
||||
paneKey: string | null
|
||||
worktreeId: string | null
|
||||
payload: string | null
|
||||
}
|
||||
|
||||
const PANE_KEY = 'tab!1:pane!2'
|
||||
@@ -149,7 +151,8 @@ describe.skipIf(process.platform !== 'win32')('buildWindowsGrokHookScript (win32
|
||||
stderr,
|
||||
grokHome: posted?.get('grokHome') ?? null,
|
||||
paneKey: posted?.get('paneKey') ?? null,
|
||||
worktreeId: posted?.get('worktreeId') ?? null
|
||||
worktreeId: posted?.get('worktreeId') ?? null,
|
||||
payload: posted?.get('payload') ?? null
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -188,6 +191,36 @@ describe.skipIf(process.platform !== 'win32')('buildWindowsGrokHookScript (win32
|
||||
expect(result.grokHome).toBe('C:\\Users\\test\\.grok\\.')
|
||||
})
|
||||
|
||||
// Why (#14221): `setx GROK_HOME "C:\path\"` stores `C:\path"` — the CRT turns the
|
||||
// `\"` into a literal quote. That quote unbalanced the trailing-backslash `if`, so
|
||||
// cmd aborted the whole script before curl and every Grok hook event failed.
|
||||
it('exits 0 and strips a trailing quote from GROK_HOME', async () => {
|
||||
const result = await runHook('C:\\Users\\test\\.grok"')
|
||||
|
||||
expect(result.stderr).not.toContain('unexpected at this time')
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.grokHome).toBe('C:\\Users\\test\\.grok')
|
||||
expect(result.payload).toBe('{"session_id":"s"}')
|
||||
})
|
||||
|
||||
// Why: an embedded quote closed curl's `grokHome=` argument early, which swallowed
|
||||
// the `^` continuation and dropped the `payload@-` line — a silent, exit-0 failure.
|
||||
it('strips an embedded quote without truncating the curl arguments', async () => {
|
||||
const result = await runHook('C:\\Users\\a"b\\.grok')
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.grokHome).toBe('C:\\Users\\ab\\.grok')
|
||||
expect(result.payload).toBe('{"session_id":"s"}')
|
||||
})
|
||||
|
||||
it('posts an empty grokHome when GROK_HOME is only a quote', async () => {
|
||||
const result = await runHook('"')
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.grokHome).toBe('')
|
||||
expect(result.payload).toBe('{"session_id":"s"}')
|
||||
})
|
||||
|
||||
it('drops a GROK_HOME past the envelope limit', async () => {
|
||||
const result = await runHook(`C:\\${'a'.repeat(4094)}`)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok',
|
||||
*
|
||||
* - Guard substring ops behind `if defined` + goto (not a parenthesized block).
|
||||
* - Reject oversized values before copying them onto a cmd input line.
|
||||
* - Strip `"` before the value reaches an `if` operand or the curl argument.
|
||||
*/
|
||||
export function buildWindowsGrokHookScript(): string {
|
||||
return [
|
||||
@@ -35,7 +36,10 @@ export function buildWindowsGrokHookScript(): string {
|
||||
'set "ORCA_GROK_HOME="',
|
||||
'if not defined GROK_HOME goto :orca_grok_home_ready',
|
||||
`if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" goto :orca_grok_home_ready`,
|
||||
'set "ORCA_GROK_HOME=%GROK_HOME%"',
|
||||
// Why (#14221): `setx GROK_HOME "C:\path\"` stores a literal trailing quote, which
|
||||
// unbalances the trailing-backslash `if` below (exit 255) and truncates the curl
|
||||
// argument. `"` is illegal in a Windows path, so strip it rather than preserve it.
|
||||
'set "ORCA_GROK_HOME=%GROK_HOME:"=%"',
|
||||
'if not defined ORCA_GROK_HOME goto :orca_grok_home_ready',
|
||||
'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."',
|
||||
// Why: the trailing-backslash safety sentinel counts toward the relay envelope.
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
} from './browser-grab-ipc'
|
||||
import { registerBrowserSessionProfileHandlers } from './browser-session-profile-ipc'
|
||||
import type { BrowserCertificateProceedResult } from '../../shared/browser-workspace-types'
|
||||
import {
|
||||
cancelBrowserWebAuthnAccountRequests,
|
||||
respondToBrowserWebAuthnAccountRequest
|
||||
} from '../browser/browser-webauthn-account-picker'
|
||||
import type { BrowserWebAuthnAccountResponse } from '../../shared/browser-webauthn-account'
|
||||
|
||||
let agentBrowserBridgeRef: AgentBrowserBridge | null = null
|
||||
|
||||
@@ -38,6 +43,7 @@ export function registerBrowserHandlers(): void {
|
||||
ipcMain.removeHandler('browser:unregisterGuest')
|
||||
ipcMain.removeHandler('browser:activeTabChanged')
|
||||
ipcMain.removeHandler('browser:proceedCertificate')
|
||||
ipcMain.removeHandler('browser:respondWebAuthnAccount')
|
||||
|
||||
const registerGuest = (
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
@@ -124,11 +130,22 @@ export function registerBrowserHandlers(): void {
|
||||
if (wcId !== null && agentBrowserBridgeRef) {
|
||||
agentBrowserBridgeRef.onTabClosed(wcId)
|
||||
}
|
||||
cancelBrowserWebAuthnAccountRequests(args.browserPageId)
|
||||
browserManager.unregisterGuest(args.browserPageId)
|
||||
disposeGrabModeStateForPage(args.browserPageId)
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'browser:respondWebAuthnAccount',
|
||||
(event, response: BrowserWebAuthnAccountResponse): boolean => {
|
||||
if (!isTrustedBrowserRenderer(event.sender)) {
|
||||
return false
|
||||
}
|
||||
return respondToBrowserWebAuthnAccountRequest(event.sender, response)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'browser:proceedCertificate',
|
||||
(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { spawnMock } from './pty-ipc-mock-registry'
|
||||
import { makeDeferred } from './pty-ipc-test-constants'
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
registerPtyHandlers,
|
||||
registerSshPtyProvider,
|
||||
@@ -102,11 +103,16 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
registerSshPtyProvider('ssh-a', { listProcesses: sshAList } as never)
|
||||
registerSshPtyProvider('ssh-b', { listProcesses: sshBList } as never)
|
||||
const runtime = { setPtyController: vi.fn() }
|
||||
setPtyOwnership('ssh-b-pty', 'ssh-b')
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
markPtyLivenessUnverifiable: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
|
||||
listProcesses(connectionId?: string | null): Promise<{ id: string }[]>
|
||||
listProcessesWithHostScope(): Promise<{ processes: { id: string }[]; hostIds: string[] }>
|
||||
}
|
||||
|
||||
await expect(controller.listProcesses(null)).resolves.toEqual([
|
||||
@@ -125,6 +131,17 @@ describe('registerPtyHandlers', () => {
|
||||
{ id: 'ssh-a-pty' }
|
||||
])
|
||||
expect(sshBList).toHaveBeenCalledOnce()
|
||||
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
|
||||
'ssh-b-pty',
|
||||
'ssh-b unavailable'
|
||||
)
|
||||
|
||||
await expect(controller.listProcessesWithHostScope()).resolves.toEqual({
|
||||
processes: [{ id: 'local-pty', title: 'Local', cwd: '/local' }, { id: 'ssh-a-pty' }],
|
||||
hostIds: [LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId('ssh-a')]
|
||||
})
|
||||
expect(sshBList).toHaveBeenCalledTimes(2)
|
||||
deletePtyOwnership('ssh-b-pty')
|
||||
})
|
||||
it('returns unavailable runtime confirmation for unsupported or missing providers', async () => {
|
||||
registerSshPtyProvider('ssh-1', {} as never)
|
||||
|
||||
+41
-17
@@ -18,7 +18,11 @@ import { retireTerminalSurfaceFromPersistence } from '../runtime/mobile-session-
|
||||
import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict'
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy'
|
||||
import type {
|
||||
@@ -73,6 +77,7 @@ import {
|
||||
} from '../../shared/pi-agent-kind'
|
||||
import { isPwshAvailableAsync } from '../pwsh'
|
||||
import { LocalPtyProvider } from '../providers/local-pty-provider'
|
||||
import type { PtyProcessInfo } from '../providers/pty-process-info'
|
||||
import { normalizeWindowsTerminalCwd } from '../providers/windows-shell-args'
|
||||
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
@@ -260,6 +265,36 @@ function registeredPtyProviders(): RegisteredPtyProvider[] {
|
||||
]
|
||||
}
|
||||
|
||||
async function listRegisteredPtyProcessesWithHostScope(
|
||||
onSshInventoryUnavailable?: (connectionId: string, error: unknown) => void
|
||||
): Promise<{
|
||||
processes: PtyProcessInfo[]
|
||||
hostIds: ExecutionHostId[]
|
||||
}> {
|
||||
const providers = registeredPtyProviders()
|
||||
const providerSessions = await Promise.all(
|
||||
providers.map(async ({ provider, connectionId }) => {
|
||||
try {
|
||||
return {
|
||||
processes: await provider.listProcesses(),
|
||||
hostId: connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
} catch (error) {
|
||||
if (!connectionId) {
|
||||
throw error
|
||||
}
|
||||
onSshInventoryUnavailable?.(connectionId, error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
const respondingSessions = providerSessions.filter((session) => session !== null)
|
||||
return {
|
||||
processes: respondingSessions.flatMap((session) => session.processes),
|
||||
hostIds: respondingSessions.map((session) => session.hostId)
|
||||
}
|
||||
}
|
||||
|
||||
const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000
|
||||
// Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring.
|
||||
const PRODUCER_FLOW_CONTROL_ENABLED = true
|
||||
@@ -4753,8 +4788,7 @@ export function registerPtyHandlers(
|
||||
rows: args.rows,
|
||||
cwd,
|
||||
env,
|
||||
...(isNewDaemonSession ? { isNewSession: true } : {}),
|
||||
historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true
|
||||
...(isNewDaemonSession ? { isNewSession: true } : {})
|
||||
}
|
||||
if (!args.connectionId && !isDaemonHostSpawn) {
|
||||
spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath }
|
||||
@@ -5808,19 +5842,10 @@ export function registerPtyHandlers(
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const providerSessions = await Promise.all([
|
||||
localProvider.listProcesses(),
|
||||
...Array.from(sshProviders, async ([sshConnectionId, provider]) => {
|
||||
try {
|
||||
return await provider.listProcesses()
|
||||
} catch (error) {
|
||||
markSshInventoryUnverifiable(sshConnectionId, error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
])
|
||||
return providerSessions.flat()
|
||||
return (await listRegisteredPtyProcessesWithHostScope(markSshInventoryUnverifiable)).processes
|
||||
},
|
||||
listProcessesWithHostScope: () =>
|
||||
listRegisteredPtyProcessesWithHostScope(markSshInventoryUnverifiable),
|
||||
serializeBuffer: (ptyId, opts) => {
|
||||
// Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly.
|
||||
return requestSerializedBuffer(ptyId, opts)
|
||||
@@ -6529,8 +6554,7 @@ export function registerPtyHandlers(
|
||||
cwd,
|
||||
...(prevalidatedCwd && !isDaemonHostSpawn ? { prevalidatedCwd } : {}),
|
||||
env: spawnEnv,
|
||||
...(isMintedSessionId ? { isNewSession: true } : {}),
|
||||
historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true
|
||||
...(isMintedSessionId ? { isNewSession: true } : {})
|
||||
}
|
||||
if (!args.connectionId && !isDaemonHostSpawn) {
|
||||
spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath }
|
||||
|
||||
@@ -265,7 +265,6 @@ import {
|
||||
removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval,
|
||||
recoverLocalWindowsWorktreeRemoval
|
||||
} from '../local-worktree-removal-recovery'
|
||||
import { deleteRemoteWorktreeHistory } from '../remote-worktree-history-cleanup'
|
||||
|
||||
const NullableWorkspaceLinkedItemSchema = WorkspaceLinkedItemSchema.nullable()
|
||||
const NullableTaskSourceContextSchema = TaskSourceContextSchema.nullable()
|
||||
@@ -2494,15 +2493,15 @@ export function registerWorktreeHandlers(
|
||||
'Cannot delete the project root workspace. Remove the folder project instead.'
|
||||
)
|
||||
}
|
||||
const ownerHost = parseExecutionHostId(removalHostId)
|
||||
const sshPtyProvider =
|
||||
ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined
|
||||
// Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata.
|
||||
await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => {
|
||||
// Folder projects can be SSH-backed, so fence the sweep to the owning host exactly
|
||||
// like the git paths — the local inventory must never reach a remote workspace's id.
|
||||
// The resolved repo is authoritative here: path-derived metadata is shared by
|
||||
// same-id host copies and can describe a different owner's workspace.
|
||||
const ownerHost = parseExecutionHostId(removalHostId)
|
||||
const sshPtyProvider =
|
||||
ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined
|
||||
const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime'
|
||||
await killAllProcessesForWorktree(args.worktreeId, {
|
||||
runtime,
|
||||
@@ -2525,7 +2524,6 @@ export function registerWorktreeHandlers(
|
||||
})
|
||||
})
|
||||
await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => {
|
||||
await deleteRemoteWorktreeHistory(sshPtyProvider, args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
@@ -2614,14 +2612,6 @@ export function registerWorktreeHandlers(
|
||||
} finally {
|
||||
await removalGate.finish(removalCompleted)
|
||||
}
|
||||
// Why history first: the worktree is already gone from git and
|
||||
// disk by here, so a rejecting push-target cleanup must not be
|
||||
// able to skip history removal and leave the user's commands on
|
||||
// the remote host.
|
||||
await deleteRemoteWorktreeHistory(
|
||||
getSshPtyProvider(repo.connectionId),
|
||||
args.worktreeId
|
||||
)
|
||||
await cleanupUnusedWorktreePushTargetRemoteSsh(
|
||||
provider!,
|
||||
repo.path,
|
||||
@@ -2730,14 +2720,6 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
// Why: a manually deleted worktree is already gone; persisted metadata proves it was an Orca-known row, so no force is needed.
|
||||
if (repo.connectionId) {
|
||||
// Why history first: the worktree is already gone from git and
|
||||
// disk by here, so a rejecting push-target cleanup must not be
|
||||
// able to skip history removal and leave the user's commands on
|
||||
// the remote host.
|
||||
await deleteRemoteWorktreeHistory(
|
||||
getSshPtyProvider(repo.connectionId),
|
||||
args.worktreeId
|
||||
)
|
||||
await cleanupUnusedWorktreePushTargetRemoteSsh(
|
||||
provider!,
|
||||
repo.path,
|
||||
@@ -2916,10 +2898,6 @@ export function registerWorktreeHandlers(
|
||||
removedPushTarget,
|
||||
store
|
||||
)
|
||||
await deleteRemoteWorktreeHistory(
|
||||
getSshPtyProvider(remoteConnectionId),
|
||||
args.worktreeId
|
||||
)
|
||||
rememberPreservedBranchCleanupTarget(
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ProjectHostSetup } from '../../../shared/project-types'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { mergeProjectHostSetupCompatibilityState } from './project-host-compatibility'
|
||||
|
||||
const PROJECT_ID = 'github:acme/orca'
|
||||
|
||||
function repo(overrides: Partial<Repo> & Pick<Repo, 'id'>): Repo {
|
||||
return {
|
||||
path: `/src/${overrides.id}`,
|
||||
displayName: 'orca',
|
||||
addedAt: 1,
|
||||
upstream: { owner: 'acme', repo: 'orca' },
|
||||
...overrides
|
||||
} as Repo
|
||||
}
|
||||
|
||||
function pendingSetup(overrides: Partial<ProjectHostSetup> = {}): ProjectHostSetup {
|
||||
return {
|
||||
id: `${PROJECT_ID}::ssh:devbox`,
|
||||
projectId: PROJECT_ID,
|
||||
hostId: 'ssh:devbox',
|
||||
repoId: '',
|
||||
path: '',
|
||||
displayName: 'orca',
|
||||
setupState: 'not-set-up',
|
||||
setupMethod: 'pending',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
} as ProjectHostSetup
|
||||
}
|
||||
|
||||
describe('mergeProjectHostSetupCompatibilityState', () => {
|
||||
it('keeps a placeholder for a host with no repo yet', () => {
|
||||
const merged = mergeProjectHostSetupCompatibilityState(
|
||||
{ projects: [], projectHostSetups: [pendingSetup()] },
|
||||
[repo({ id: 'local-repo' })]
|
||||
)
|
||||
|
||||
expect(merged.projectHostSetups.map((setup) => setup.hostId)).toEqual(['local', 'ssh:devbox'])
|
||||
})
|
||||
|
||||
// Setting a location on a host that already had a placeholder projects a ready setup for
|
||||
// the same project+host; the placeholder must not survive to shadow it.
|
||||
it('drops a placeholder once a repo covers the same project and host', () => {
|
||||
const merged = mergeProjectHostSetupCompatibilityState(
|
||||
{ projects: [], projectHostSetups: [pendingSetup()] },
|
||||
[repo({ id: 'local-repo' }), repo({ id: 'devbox-repo', executionHostId: 'ssh:devbox' })]
|
||||
)
|
||||
|
||||
expect(merged.projectHostSetups).toHaveLength(2)
|
||||
expect(merged.projectHostSetups.every((setup) => setup.setupState === 'ready')).toBe(true)
|
||||
expect(merged.projectHostSetups.map((setup) => setup.id)).toEqual(['local-repo', 'devbox-repo'])
|
||||
})
|
||||
|
||||
it('keeps the placeholder when the repo covers a different host', () => {
|
||||
const merged = mergeProjectHostSetupCompatibilityState(
|
||||
{ projects: [], projectHostSetups: [pendingSetup({ hostId: 'ssh:other' })] },
|
||||
[repo({ id: 'devbox-repo', executionHostId: 'ssh:devbox' })]
|
||||
)
|
||||
|
||||
expect(merged.projectHostSetups.map((setup) => setup.hostId)).toEqual([
|
||||
'ssh:devbox',
|
||||
'ssh:other'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,10 @@ export function isRepoBackedProjectHostSetup(
|
||||
return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId)
|
||||
}
|
||||
|
||||
function projectHostKey(setup: Pick<ProjectHostSetup, 'projectId' | 'hostId'>): string {
|
||||
return `${setup.projectId}\u0000${setup.hostId}`
|
||||
}
|
||||
|
||||
export function mergeProjectHostSetupCompatibilityState(
|
||||
state: Pick<PersistedState, 'projects' | 'projectHostSetups'>,
|
||||
repos: readonly Repo[]
|
||||
@@ -35,6 +39,7 @@ export function mergeProjectHostSetupCompatibilityState(
|
||||
const currentRepoIds = new Set(repos.map((repo) => repo.id))
|
||||
const projectedProjectIds = new Set(projection.projects.map((project) => project.id))
|
||||
const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id))
|
||||
const projectedHosts = new Set(projection.setups.map(projectHostKey))
|
||||
// Why: legacy/repo-backed setup rows reuse the repo id; keep only independent rows so repo deletion leaves no ghosts.
|
||||
const independentSetups = (state.projectHostSetups ?? [])
|
||||
.filter((setup) => {
|
||||
@@ -48,6 +53,10 @@ export function mergeProjectHostSetupCompatibilityState(
|
||||
const remappedProjectId = succession.remappedProjectIds.get(setup.projectId)
|
||||
return remappedProjectId ? { ...setup, projectId: remappedProjectId } : setup
|
||||
})
|
||||
// Why: a project resolves to one setup per host. Once a repo projection covers that
|
||||
// pair, a leftover placeholder is a ghost that shadows the ready row — it sorts first
|
||||
// and reads back as "not set up". Runs after the remap so renamed rows are caught too.
|
||||
.filter((setup) => !projectedHosts.has(projectHostKey(setup)))
|
||||
const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId))
|
||||
const independentProjects = (state.projects ?? [])
|
||||
.filter(
|
||||
|
||||
@@ -19,10 +19,8 @@ import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
|
||||
import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission'
|
||||
import {
|
||||
injectHistoryEnv,
|
||||
injectWslFishHistoryEnv,
|
||||
updateHistoryEnvForFallback,
|
||||
logHistoryInjection,
|
||||
type HistoryInjectionResult
|
||||
updateHistFileForFallback,
|
||||
logHistoryInjection
|
||||
} from '../terminal-history'
|
||||
import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types'
|
||||
import {
|
||||
@@ -874,15 +872,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
historyResult = injectHistoryEnv(finalEnv, worktreeId, effectiveShellPath, cwd, {
|
||||
wslDistro: launchWslDistro
|
||||
})
|
||||
if (isWslTerminal && launchWslDistro) {
|
||||
injectWslFishHistoryEnv(finalEnv, worktreeId, launchWslDistro)
|
||||
addWslEnvKeys(finalEnv, ['HISTFILE', 'fish_history'])
|
||||
}
|
||||
logHistoryInjection(worktreeId, historyResult)
|
||||
} else {
|
||||
// Why: injectHistoryEnv is what normally clears it, so when history is off
|
||||
// an inherited ORCA_HISTFILE would still reach the wrapper. Credit: #11146.
|
||||
delete finalEnv.ORCA_HISTFILE
|
||||
}
|
||||
|
||||
await prepareLocalPtySpawn(id)
|
||||
@@ -905,9 +895,8 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
ptySpawn: pty.spawn,
|
||||
getShellReadyConfig: getFallbackShellReadyConfig,
|
||||
// Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8).
|
||||
onBeforeFallbackSpawn: historyResult?.historyDir
|
||||
? (env, fallbackShell) =>
|
||||
updateHistoryEnvForFallback(env, fallbackShell, historyResult as HistoryInjectionResult)
|
||||
onBeforeFallbackSpawn: historyResult?.histFile
|
||||
? (env, fallbackShell) => updateHistFileForFallback(env, fallbackShell)
|
||||
: undefined,
|
||||
windowsFallbackAttempts
|
||||
})
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
getZshEnvTemplate,
|
||||
getZshFinalZdotdirRestoreBlock,
|
||||
getZshShellReadyMarkerRegistrationBlock,
|
||||
getZshStartupFileSourceBlock,
|
||||
ZSH_HISTFILE_RESTORE_BLOCK
|
||||
getZshStartupFileSourceBlock
|
||||
} from '../shell-templates'
|
||||
import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile'
|
||||
import {
|
||||
@@ -45,7 +44,6 @@ if [[ ! -o login ]]; then
|
||||
${getPosixOmpShellWrapper()}
|
||||
# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files.
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
${ZSH_HISTFILE_RESTORE_BLOCK}
|
||||
${getPosixCodexShellLaunchPreflight()}
|
||||
fi
|
||||
__orca_osc133_precmd() {
|
||||
@@ -98,7 +96,6 @@ __orca_restore_agent_teams_path
|
||||
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
|
||||
${getPosixOmpShellWrapper()}
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
${ZSH_HISTFILE_RESTORE_BLOCK}
|
||||
${getPosixCodexShellLaunchPreflight()}
|
||||
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)}
|
||||
${getZshFinalZdotdirRestoreBlock()}
|
||||
|
||||
@@ -70,8 +70,6 @@ export type PtySpawnOptions = {
|
||||
* Existing-session attach paths must stay false so recovery checks do not
|
||||
* replace the daemon out from under a still-live PTY. */
|
||||
isNewSession?: boolean
|
||||
/** Host setting forwarded additively to the process owner; old owners ignore it. */
|
||||
historyIsolationEnabled?: boolean
|
||||
/** Attach the named session atomically or fail without creating a process. */
|
||||
attachOnly?: boolean
|
||||
/** Exact persisted owner expected by an attach-only routing decision. */
|
||||
@@ -121,8 +119,6 @@ export type IPtyProvider = {
|
||||
/** Re-probes a degraded durable host before main commits to fallback spawn semantics. */
|
||||
recoverFreshSpawnRouting?: () => Promise<boolean>
|
||||
spawn(opts: PtySpawnOptions): Promise<PtySpawnResult>
|
||||
/** Process-owner cleanup for history stored outside the workspace tree. */
|
||||
deleteWorktreeHistory?: (worktreeId: string) => Promise<void>
|
||||
/** Whether this spawn target can append the Git guard after its final env merge. */
|
||||
supportsGitCredentialGuardHost?: (sessionId?: string) => boolean
|
||||
/** Explicit false selects pre-claim legacy spawn for a preserved old daemon. */
|
||||
|
||||
@@ -369,16 +369,14 @@ describe('SshPtyProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards shell, WSL, and scoped-history options to the relay mux', async () => {
|
||||
it('forwards explicit shellOverride and terminalWindowsWslDistro to the relay mux', async () => {
|
||||
mux.request.mockResolvedValue({ id: 'pty-2' })
|
||||
|
||||
await provider.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
shellOverride: 'powershell.exe',
|
||||
terminalWindowsWslDistro: 'Ubuntu',
|
||||
worktreeId: 'repo-1::/remote/wt',
|
||||
historyIsolationEnabled: true
|
||||
terminalWindowsWslDistro: 'Ubuntu'
|
||||
})
|
||||
|
||||
expectRequest(mux.request, 'pty.spawn', {
|
||||
@@ -387,9 +385,7 @@ describe('SshPtyProvider', () => {
|
||||
cwd: undefined,
|
||||
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
|
||||
shellOverride: 'powershell.exe',
|
||||
terminalWindowsWslDistro: 'Ubuntu',
|
||||
worktreeId: 'repo-1::/remote/wt',
|
||||
historyIsolationEnabled: true
|
||||
terminalWindowsWslDistro: 'Ubuntu'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { spawnFreshSshPty } from './ssh-agent-session-create-operation'
|
||||
import { mapSshPtyProcessList } from './ssh-agent-session-process-list'
|
||||
import {
|
||||
requestSshPtyAttach,
|
||||
reattachSshPtySessionForSpawn,
|
||||
reattachSshPtySessionWithExitFence,
|
||||
type PtySourceRecoveryRequest,
|
||||
type SshPtyAttachResult
|
||||
} from './ssh-pty-session-reattach'
|
||||
@@ -22,6 +22,7 @@ import { buildSshPtySpawnRequest } from './ssh-pty-spawn-request'
|
||||
import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race'
|
||||
import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities'
|
||||
import type { PtyProcessInspection } from './pty-process-inspection'
|
||||
import { SSH_SESSION_EXPIRED_ERROR } from './ssh-pty-errors'
|
||||
import { writeToSshPty, writeToSshPtyWithSettlement } from './ssh-pty-write'
|
||||
|
||||
// Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch.
|
||||
@@ -90,18 +91,36 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
}
|
||||
}
|
||||
if (opts.sessionId) {
|
||||
return await reattachSshPtySessionForSpawn({
|
||||
mux: this.mux,
|
||||
connectionId: this.connectionId,
|
||||
sessionId: opts.sessionId,
|
||||
options: opts,
|
||||
exitRaceTracker: this.spawnExitRaces,
|
||||
installSourceActivation: (relayPtyId, activation) =>
|
||||
this.outputState.installReceivingActivation(relayPtyId, activation),
|
||||
rememberPtyIncarnation: (relayPtyId, incarnationId) =>
|
||||
this.outputState.rememberPtyIncarnation(relayPtyId, incarnationId),
|
||||
acceptLivePty: (relayPtyId) => this.livePtyIds.add(relayPtyId)
|
||||
})
|
||||
let result: Awaited<ReturnType<typeof reattachSshPtySessionWithExitFence>> | undefined
|
||||
try {
|
||||
result = await reattachSshPtySessionWithExitFence({
|
||||
mux: this.mux,
|
||||
connectionId: this.connectionId,
|
||||
sessionId: opts.sessionId,
|
||||
options: opts,
|
||||
exitRaceTracker: this.spawnExitRaces,
|
||||
installSourceActivation: (relayPtyId, activation) =>
|
||||
this.outputState.installReceivingActivation(relayPtyId, activation),
|
||||
rememberPtyIncarnation: (relayPtyId, incarnationId) =>
|
||||
this.outputState.rememberPtyIncarnation(relayPtyId, incarnationId)
|
||||
})
|
||||
if (result.sourceRecovery?.status === 'restoreRequired') {
|
||||
throw new Error(
|
||||
`${SSH_SESSION_EXPIRED_ERROR}: ${toRelaySshPtyId(this.connectionId, result.id)}`
|
||||
)
|
||||
}
|
||||
this.livePtyIds.add(result.id)
|
||||
result.sourceActivationLease?.commit()
|
||||
const {
|
||||
sourceActivationLease: _lease,
|
||||
sourceRecovery: _sourceRecovery,
|
||||
...spawnResult
|
||||
} = result
|
||||
return spawnResult
|
||||
} catch (error) {
|
||||
result?.sourceActivationLease?.rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const supportsCreateOperation = opts.agentSessionCreateOperationId
|
||||
@@ -132,10 +151,6 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
})
|
||||
}
|
||||
|
||||
async deleteWorktreeHistory(worktreeId: string): Promise<void> {
|
||||
await this.mux.request('pty.deleteWorktreeHistory', { worktreeId })
|
||||
}
|
||||
|
||||
async supportsAgentSessionClaims(options: { signal?: AbortSignal } = {}): Promise<boolean> {
|
||||
return await this.agentSessionCapabilities.supportsClaims(options)
|
||||
}
|
||||
|
||||
@@ -255,39 +255,3 @@ export async function reattachSshPtySessionWithExitFence(
|
||||
args.exitRaceTracker.finish(operation)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The full reattach path a spawn takes when it carries a sessionId: fence the
|
||||
* exit race, reject a session the relay can no longer restore, and commit or
|
||||
* roll back the source-activation lease.
|
||||
*
|
||||
* Lives here rather than in SshPtyProvider.spawn so the lease's commit and
|
||||
* rollback stay in one place — a caller that only wrapped the fence could
|
||||
* return without committing and silently leak the activation.
|
||||
*/
|
||||
export async function reattachSshPtySessionForSpawn(
|
||||
args: Parameters<typeof reattachSshPtySessionWithExitFence>[0] & {
|
||||
acceptLivePty: (relayPtyId: string) => void
|
||||
}
|
||||
): Promise<PtySpawnResult> {
|
||||
let result: SshPtyReattachResult | undefined
|
||||
try {
|
||||
result = await reattachSshPtySessionWithExitFence(args)
|
||||
if (result.sourceRecovery?.status === 'restoreRequired') {
|
||||
throw new Error(
|
||||
`${SSH_SESSION_EXPIRED_ERROR}: ${toRelaySshPtyId(args.connectionId, result.id)}`
|
||||
)
|
||||
}
|
||||
args.acceptLivePty(result.id)
|
||||
result.sourceActivationLease?.commit()
|
||||
const {
|
||||
sourceActivationLease: _lease,
|
||||
sourceRecovery: _sourceRecovery,
|
||||
...spawnResult
|
||||
} = result
|
||||
return spawnResult
|
||||
} catch (error) {
|
||||
result?.sourceActivationLease?.rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,6 @@ export function buildSshPtySpawnRequest(args: {
|
||||
// Why: the relay needs launch identity for plugin env overlays and provider-side delivery.
|
||||
...(options.command ? { command: options.command } : {}),
|
||||
...(options.launchAgent ? { launchAgent: options.launchAgent } : {}),
|
||||
...(options.worktreeId ? { worktreeId: options.worktreeId } : {}),
|
||||
...(options.historyIsolationEnabled !== undefined
|
||||
? { historyIsolationEnabled: options.historyIsolationEnabled }
|
||||
: {}),
|
||||
...(options.shellOverride !== undefined ? { shellOverride: options.shellOverride } : {}),
|
||||
...(options.terminalWindowsWslDistro !== undefined
|
||||
? { terminalWindowsWslDistro: options.terminalWindowsWslDistro }
|
||||
|
||||
@@ -11,12 +11,20 @@ vi.mock('../wsl', async (importOriginal) => {
|
||||
})
|
||||
|
||||
import { validateWorkingDirectoryAsync } from './local-pty-utils'
|
||||
import { _resetWorkingDirectoryValidationStateForTest } from './working-directory-validation'
|
||||
|
||||
let tempDir: string
|
||||
|
||||
/** UNC probes now start after a lane acquire, so let that microtask land. */
|
||||
const flushLaneAcquire = async (): Promise<void> => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
wslUncDirectoryExistsAsyncMock.mockReset()
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(null)
|
||||
_resetWorkingDirectoryValidationStateForTest()
|
||||
tempDir = await mkdtemp(path.join(os.tmpdir(), 'orca-cwd-validate-'))
|
||||
})
|
||||
|
||||
@@ -137,6 +145,71 @@ describe('validateWorkingDirectoryAsync', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('UNC route concurrency', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('caps concurrent probes against one unreachable distro', async () => {
|
||||
// Four dead paths would otherwise hold all four libuv fs threads.
|
||||
let inFlight = 0
|
||||
let peak = 0
|
||||
wslUncDirectoryExistsAsyncMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>(() => {
|
||||
inFlight += 1
|
||||
peak = Math.max(peak, inFlight)
|
||||
})
|
||||
)
|
||||
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\Ubuntu\\capped-${index}`).catch(
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
await flushLaneAcquire()
|
||||
|
||||
expect(peak).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps a healthy local path out of a stalled share queue', async () => {
|
||||
wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise<boolean>(() => {}))
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\Ubuntu\\blocking-${index}`).catch(
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
|
||||
// A local-disk path must not queue behind a dead server.
|
||||
await expect(validateWorkingDirectoryAsync(tempDir)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('gives separate servers separate lanes', async () => {
|
||||
let inFlight = 0
|
||||
wslUncDirectoryExistsAsyncMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>(() => {
|
||||
inFlight += 1
|
||||
})
|
||||
)
|
||||
|
||||
for (const distro of ['AlphaDistro', 'BetaDistro']) {
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\${distro}\\lane-${index}`).catch(
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
}
|
||||
await flushLaneAcquire()
|
||||
|
||||
expect(inFlight).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WSL UNC paths', () => {
|
||||
const wslPath = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
|
||||
|
||||
@@ -165,6 +238,7 @@ describe('validateWorkingDirectoryAsync', () => {
|
||||
|
||||
const first = validateWorkingDirectoryAsync(wslPath)
|
||||
const second = validateWorkingDirectoryAsync(wslPath)
|
||||
await flushLaneAcquire()
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce()
|
||||
|
||||
releaseProbe()
|
||||
|
||||
@@ -5,10 +5,65 @@
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { release } from 'node:os'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import { isWslUncPath, parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { wslUncDirectoryExists, wslUncDirectoryExistsAsync } from '../wsl'
|
||||
import { PrioritySemaphore } from '../../shared/priority-semaphore'
|
||||
|
||||
const pendingWorkingDirectoryValidations = new Map<string, Promise<void>>()
|
||||
// Why: a dead UNC share answers `stat` in ~21s on Windows and holds one of
|
||||
// libuv's 4 default fs threads the whole time, so a handful of distinct paths on
|
||||
// one unreachable server would starve every other async fs read in the daemon —
|
||||
// moving the head-of-line stall off the event loop and into the thread pool.
|
||||
// Matches the per-distro lane in rate-limits/auth-filesystem-operation.ts.
|
||||
const MAX_CONCURRENT_UNC_VALIDATIONS = 2
|
||||
|
||||
type UncRouteLane = {
|
||||
readonly semaphore: PrioritySemaphore
|
||||
waiters: number
|
||||
}
|
||||
|
||||
const uncRouteLanes = new Map<string, UncRouteLane>()
|
||||
|
||||
/**
|
||||
* Groups paths by the host that must answer for them, so many dead
|
||||
* subdirectories of one share share a lane. Returns null for local-disk paths,
|
||||
* which never block long enough to be worth queueing.
|
||||
*/
|
||||
function uncRouteKey(cwd: string): string | null {
|
||||
if (!cwd.startsWith('\\\\')) {
|
||||
return null
|
||||
}
|
||||
const wslInfo = parseWslUncPath(cwd)
|
||||
if (wslInfo) {
|
||||
return `wsl:${wslInfo.distro.trim().toLowerCase()}`
|
||||
}
|
||||
const server = cwd.slice(2).split(/[\\/]/, 1)[0]
|
||||
return `unc:${server.toLowerCase()}`
|
||||
}
|
||||
|
||||
async function withUncRouteLane<T>(cwd: string, run: () => Promise<T>): Promise<T> {
|
||||
const key = uncRouteKey(cwd)
|
||||
if (key === null) {
|
||||
return run()
|
||||
}
|
||||
let lane = uncRouteLanes.get(key)
|
||||
if (!lane) {
|
||||
lane = { semaphore: new PrioritySemaphore(MAX_CONCURRENT_UNC_VALIDATIONS), waiters: 0 }
|
||||
uncRouteLanes.set(key, lane)
|
||||
}
|
||||
// Counted before acquiring so a queued caller keeps the lane alive.
|
||||
lane.waiters += 1
|
||||
const release = await lane.semaphore.acquire(0)
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
release()
|
||||
lane.waiters -= 1
|
||||
if (lane.waiters === 0) {
|
||||
uncRouteLanes.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when the caller gave up on a probe that is still running. */
|
||||
export class WorkingDirectoryValidationAbortedError extends Error {
|
||||
@@ -18,6 +73,12 @@ export class WorkingDirectoryValidationAbortedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: both maps are module-level, so an unsettled probe would leak across tests. */
|
||||
export function _resetWorkingDirectoryValidationStateForTest(): void {
|
||||
pendingWorkingDirectoryValidations.clear()
|
||||
uncRouteLanes.clear()
|
||||
}
|
||||
|
||||
export function formatLocalPtyEnvironmentDiag(extra: Record<string, string> = {}): string {
|
||||
const systemVersion =
|
||||
(process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() ||
|
||||
@@ -87,10 +148,9 @@ export function validateWorkingDirectoryAsync(
|
||||
const started = validation
|
||||
// Why: dropped only on settle. `fs.stat` is uninterruptible, so retiring a
|
||||
// still-running probe on a timer frees no libuv thread — it only lets the
|
||||
// next caller pin a second one, and a few retries against one dead mount
|
||||
// exhaust the default pool of 4 and stall every other async fs read in the
|
||||
// daemon. Callers escape through `signal` instead, so sharing one hung probe
|
||||
// no longer strands them.
|
||||
// next caller pin a second one. Callers escape through `signal` instead, so
|
||||
// sharing one hung probe no longer strands them, and the route lane below
|
||||
// caps how many distinct paths on one dead host can block at once.
|
||||
const forget = (): void => {
|
||||
if (pendingWorkingDirectoryValidations.get(key) === started) {
|
||||
pendingWorkingDirectoryValidations.delete(key)
|
||||
@@ -116,7 +176,11 @@ export function validateWorkingDirectoryAsync(
|
||||
})
|
||||
}
|
||||
|
||||
async function validateWorkingDirectoryUncached(cwd: string): Promise<void> {
|
||||
function validateWorkingDirectoryUncached(cwd: string): Promise<void> {
|
||||
return withUncRouteLane(cwd, () => probeWorkingDirectory(cwd))
|
||||
}
|
||||
|
||||
async function probeWorkingDirectory(cwd: string): Promise<void> {
|
||||
if (isWslUncPath(cwd)) {
|
||||
const existsInDistro = await wslUncDirectoryExistsAsync(cwd)
|
||||
if (existsInDistro === false) {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { deleteRemoteWorktreeHistory } from './remote-worktree-history-cleanup'
|
||||
|
||||
describe('deleteRemoteWorktreeHistory', () => {
|
||||
it('repeats idempotent cleanup through the PTY owner', async () => {
|
||||
const deleteWorktreeHistory = vi.fn().mockResolvedValue(undefined)
|
||||
const provider = { deleteWorktreeHistory } as never
|
||||
|
||||
await deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt')
|
||||
await deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt')
|
||||
|
||||
expect(deleteWorktreeHistory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('degrades safely when an old relay does not expose cleanup', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const provider = {
|
||||
deleteWorktreeHistory: vi.fn().mockRejectedValue(new Error('unknown request method'))
|
||||
} as never
|
||||
|
||||
try {
|
||||
await expect(
|
||||
deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt')
|
||||
).resolves.toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Remote cleanup unavailable'))
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { IPtyProvider } from './providers/pty-provider-contract'
|
||||
|
||||
/** Best-effort cleanup through the PTY owner; older relays may not expose the method. */
|
||||
export async function deleteRemoteWorktreeHistory(
|
||||
provider: IPtyProvider | undefined,
|
||||
worktreeId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await provider?.deleteWorktreeHistory?.(worktreeId)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[pty:history] Remote cleanup unavailable: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,30 @@ describe('detectRepoIcon', () => {
|
||||
await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns no icon for an SSH-hosted repo whose filesystem provider is missing', async () => {
|
||||
// Why: the same path exists on the client machine — reading it would hand
|
||||
// back the local repository's icon for a remote repo.
|
||||
const repoPath = await makeTempRepoDir()
|
||||
await writeFile(join(repoPath, 'favicon.png'), Buffer.from(PNG_1X1_BASE64, 'base64'))
|
||||
await writeFile(
|
||||
join(repoPath, 'package.json'),
|
||||
JSON.stringify({ homepage: 'https://app.example.com/docs' })
|
||||
)
|
||||
|
||||
await expect(
|
||||
detectRepoIcon({ repoPath, kind: 'folder', connectionId: 'ssh-target-not-connected' })
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('still detects local icons when the repo has no connection', async () => {
|
||||
const repoPath = await makeTempRepoDir()
|
||||
await writeFile(join(repoPath, 'favicon.png'), Buffer.from(PNG_1X1_BASE64, 'base64'))
|
||||
|
||||
await expect(
|
||||
detectRepoIcon({ repoPath, kind: 'folder', connectionId: null })
|
||||
).resolves.toMatchObject({ source: 'file', label: 'favicon.png' })
|
||||
})
|
||||
|
||||
it('falls back to the GitHub owner avatar for GitHub repos', async () => {
|
||||
const repoPath = await makeTempRepoDir()
|
||||
await gitExecFileAsync(['init'], { cwd: repoPath })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user