From b501a6faa0fb595bff8fc16fb32a3dba843ceeaa Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 10 May 2026 19:53:05 -0700 Subject: [PATCH 01/15] fix(github-drawer): eliminate reopen flash via useSyncExternalStore (#1680) Replace the setState-driven data flow with useSyncExternalStore so the drawer reads cached work-item details synchronously on first render. Warm reopens now paint the cached content immediately with zero blank flash. Adds a pub/sub layer (subscribeWorkItemDetailsCache / notifyWorkItemDetailsCache) to all cache-write paths so React is notified on every touch or invalidation. Includes design doc at docs/gh-work-item-drawer-cache-flash.md. Co-authored-by: Orca --- docs/gh-work-item-drawer-cache-flash.md | 132 +++++++++++ .../src/components/GitHubItemDialog.tsx | 213 ++++++++++-------- 2 files changed, 246 insertions(+), 99 deletions(-) create mode 100644 docs/gh-work-item-drawer-cache-flash.md diff --git a/docs/gh-work-item-drawer-cache-flash.md b/docs/gh-work-item-drawer-cache-flash.md new file mode 100644 index 00000000000..9d8a0cf4735 --- /dev/null +++ b/docs/gh-work-item-drawer-cache-flash.md @@ -0,0 +1,132 @@ +# GitHub Work Item Drawer: Fix Reopen Flash + +## Symptom + +After #1655, reopening a GH issue/PR drawer can still flash a loading/skeleton frame before cached content appears. + +## Verified root cause + +`GitHubItemDialog` currently paints from component state (`details`, `loading`, `error`) that is updated in an effect (`src/renderer/src/components/GitHubItemDialog.tsx:2480+`). + +Current sequence on close/reopen of the same item: + +1. Close path runs effect branch with no `workItem` and calls `setDetails(null)`. +2. Reopen renders once with `details === null`. +3. Effect reads `workItemDetailsCache` and then calls `setDetails(painted)`. +4. Second render shows cached content. + +With sheet animation, that first empty paint is visible as a flash. + +## Refactor direction + +Move drawer paint state to the module cache and subscribe with `useSyncExternalStore`. The drawer should read the cache entry synchronously during render. + +This is not optional if we want reopen to paint cached content on first render. + +## Required cache-store changes + +Add a store subscription layer around `workItemDetailsCache`: + +- `subscribeWorkItemDetailsCache(listener)` +- `notifyWorkItemDetailsCache()` + +Call `notifyWorkItemDetailsCache()` after every actual cache mutation: + +- `touchWorkItemDetailsCache` +- `invalidateWorkItemDetailsCacheForKey` +- `invalidateWorkItemDetailsCacheByMatch` + +Also increment `workItemDetailsCacheGeneration` on **all** invalidations, not only `invalidateWorkItemDetailsCacheByMatch`. Otherwise an in-flight fetch launched before exact-key invalidation can repopulate stale data. + +## `useSyncExternalStore` contract details + +Use: + +```ts +const cachedEntry = useSyncExternalStore( + subscribeWorkItemDetailsCache, + () => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined) +) +``` + +Important constraints: + +- `getSnapshot` must return a referentially stable value when store data has not changed. +- Returning the map entry object is valid **only if** writes replace entry identity (`delete+set` with a new entry object). +- Do not build fresh wrapper objects in `getSnapshot` (that would violate stability and can cause render loops). + +## Component state changes + +Remove component-local `details/loading/error` state for work-item details. + +Derive view state from `cachedEntry`: + +- `details = cachedEntry?.details ?? null` +- blocking `loading = !!cachedEntry?.pending && !cachedEntry?.details` +- blocking `error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null` + +Keep stale-on-error behavior by preserving `details` in cache while setting `error`. + +## Effect responsibilities after refactor + +The main effect should: + +- handle item-change bookkeeping (`prevItemIdRef`, `optimisticCommentsRef`, `setTab`) +- decide freshness and in-flight dedupe +- launch fetch and write results/errors/pending back into cache + +The effect should not call `setDetails`, `setLoading`, or `setError` for work-item details. + +## Optimistic comment race and merge rules + +`appendOptimisticComment` must write through cache only (no local `setDetails`). + +Rules: + +- push comment into `optimisticCommentsRef` +- patch cache entry comments (if present), mark stale (`fetchedAt: 0`), notify +- derive rendered comments from cached details plus optimistic refs + +Dependency pitfall: + +- do not depend on a freshly-created array from `optimisticCommentsRef.current` in `useMemo` +- memo should key off stable signals (cache entry identity); cache notifications will re-render after optimistic writes anyway + +This avoids unnecessary recomputation and avoids accidental infinite rerender patterns from unstable deps. + +## Invalidation while drawer is open + +When `invalidateWorkItemDetailsCacheByMatch` deletes the current key while drawer is mounted: + +- subscription notifies immediately +- render sees `cachedEntry === undefined` +- UI falls back to loading/empty state and effect triggers refetch + +This is expected and preferred over showing stale state. + +## Fetch race constraints (must keep) + +Keep current protections: + +- per-key in-flight dedupe via `entry.pending` +- generation guard to block stale writes after invalidation +- stale-on-null handling (`result === null` must not wipe existing valid cached details) + +Add one missing consistency rule: + +- if fetch resolves after invalidation and key was dropped, do not recreate entry unless generation still matches launch generation. + +## Scope of deletion + +Delete only the work-item-detail local state and call sites: + +- `useState` (`details`) +- `useState` (`loading`) for this data path +- `useState` (`error`) for this data path +- corresponding `setDetails`/`setLoading`/`setError` calls in the details effect and optimistic comment append path + +Do not touch unrelated loading/error state in other subviews. + +## Expected result + +Reopen of a cached item paints content on first render (no flash). Background refetch still runs when stale, and cross-window/local invalidations remain authoritative. diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 63bde0578bf..fca123fc066 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -1,5 +1,14 @@ /* eslint-disable max-lines -- Why: the GH item dialog keeps its header, conversation, files, and checks tabs co-located so the read-only PR/Issue surface stays in one place while this view evolves. */ -import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { + Suspense, + lazy, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore +} from 'react' import { AlignJustify, ArrowDown, @@ -509,6 +518,23 @@ type WorkItemDetailsCacheEntry = { } const workItemDetailsCache = new Map() +// Why: drawers subscribe via useSyncExternalStore so reopening a cached item +// paints synchronously on first render. Stability of the snapshot relies on +// every cache write replacing the entry object identity (delete+set), which +// touchWorkItemDetailsCache already does. +const workItemDetailsCacheListeners = new Set<() => void>() +function subscribeWorkItemDetailsCache(listener: () => void): () => void { + workItemDetailsCacheListeners.add(listener) + return () => { + workItemDetailsCacheListeners.delete(listener) + } +} +function notifyWorkItemDetailsCache(): void { + for (const listener of workItemDetailsCacheListeners) { + listener() + } +} + function getWorkItemDetailsCacheKey(args: { repoPath: string issueSourcePreference: string | undefined @@ -532,13 +558,20 @@ function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry } workItemDetailsCache.delete(oldest) } + notifyWorkItemDetailsCache() } // Why: exposed so mutation handlers (in this file and elsewhere) can drop a // stale entry after a successful local mutation. Cross-window invalidation // arrives via the `gh:workItemMutated` event listener installed below. export function invalidateWorkItemDetailsCacheForKey(key: string): void { - workItemDetailsCache.delete(key) + // Why: bump generation so an in-flight fetch launched before this exact-key + // invalidation will not write its stale result back into the cache. + workItemDetailsCacheGeneration += 1 + const existed = workItemDetailsCache.delete(key) + if (existed) { + notifyWorkItemDetailsCache() + } } // Why: monotonically increases on every invalidation so an in-flight refetch @@ -559,11 +592,16 @@ function invalidateWorkItemDetailsCacheByMatch(args: { workItemDetailsCacheGeneration += 1 const suffix = `\0${args.type}\0${args.number}` const prefix = `${args.repoPath}\0` + let removed = false for (const key of Array.from(workItemDetailsCache.keys())) { if (key.startsWith(prefix) && key.endsWith(suffix)) { workItemDetailsCache.delete(key) + removed = true } } + if (removed) { + notifyWorkItemDetailsCache() + } } // Why: install once at module load — every dialog instance shares the cache, @@ -2397,9 +2435,6 @@ export default function GitHubItemDialog({ onClose }: GitHubItemDialogProps): React.JSX.Element { const [tab, setTab] = useState('conversation') - const [details, setDetails] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) const [localState, setLocalState] = useState(workItem?.state ?? 'open') const [localLabels, setLocalLabels] = useState(workItem?.labels ?? []) const [diffViewMode, setDiffViewMode] = useState('flat') @@ -2439,7 +2474,6 @@ export default function GitHubItemDialog({ } }, [workItemId, workItemState, workItemLabels]) - const requestIdRef = useRef(0) // Why: track comments added optimistically before the detail fetch resolves // so they can be merged into the fetch result instead of being overwritten. const optimisticCommentsRef = useRef([]) @@ -2477,22 +2511,62 @@ export default function GitHubItemDialog({ } }, [workItem]) + // Why: subscribe to the module-level cache so reopening a cached item + // paints synchronously on first render. getSnapshot returns the entry + // object directly — touchWorkItemDetailsCache writes always replace entry + // identity (delete+set), so Map.get is referentially stable between writes. + const cachedEntry = useSyncExternalStore( + subscribeWorkItemDetailsCache, + useCallback( + () => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined), + [detailsCacheKey] + ) + ) + + // Why: merge optimistic comments into the cached details. Keyed off + // cachedEntry identity (stable) rather than the optimistic ref array (a + // fresh array each render) to avoid unnecessary recomputation. Cache + // notifications after optimistic writes will re-render this anyway. + const details = useMemo(() => { + const cachedDetails = cachedEntry?.details ?? null + if (!cachedDetails) { + return null + } + const opt = optimisticCommentsRef.current + if (opt.length === 0) { + return cachedDetails + } + const ids = new Set(cachedDetails.comments.map((c) => c.id)) + const missing = opt.filter((c) => !ids.has(c.id)) + if (missing.length === 0) { + return cachedDetails + } + return { ...cachedDetails, comments: [...cachedDetails.comments, ...missing] } + }, [cachedEntry]) + + const loading = !!cachedEntry?.pending && !cachedEntry?.details + const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null + + // Why: if a cross-window mutation invalidates the open drawer's entry + // (cachedEntry becomes undefined while workItem is still set), the main + // fetch effect won't re-run because its deps haven't changed. Bump a local + // tick so the fetch effect fires a refetch in that case. + const [refetchTick, setRefetchTick] = useState(0) + useEffect(() => { + if (workItem && detailsCacheKey && !cachedEntry) { + setRefetchTick((n) => n + 1) + } + }, [workItem, detailsCacheKey, cachedEntry]) + useEffect(() => { if (!workItem || !repoPath || !detailsCacheKey) { - setDetails(null) - setError(null) return } - // Why: if the user clicks through several rows quickly, discard stale - // responses by tagging each request with a monotonic id and only applying - // results whose id matches the latest one. - requestIdRef.current += 1 - const requestId = requestIdRef.current // Why: only clear optimistic comments when switching to a genuinely // different item. When reopening the same item (close → reopen), the // gh API's 60s response cache will return stale data that omits the // just-posted comment — preserving the optimistic ref lets the merge - // logic below re-attach it to the stale response. + // logic above re-attach it to the stale response. if (workItem.id !== prevItemIdRef.current) { optimisticCommentsRef.current = [] } @@ -2503,28 +2577,6 @@ export default function GitHubItemDialog({ const now = Date.now() const hasFreshData = cached?.details && now - cached.fetchedAt <= WORK_ITEM_DETAILS_FRESH_MS - // Why: paint cached data immediately when we have it so reopen feels - // instant. Only fall back to a blocking spinner when there's nothing to - // show. Optimistic comments still merge below for both paths. - if (cached?.details) { - const opt = optimisticCommentsRef.current - let painted = cached.details - if (opt.length > 0) { - const ids = new Set(painted.comments.map((c) => c.id)) - const missing = opt.filter((c) => !ids.has(c.id)) - if (missing.length > 0) { - painted = { ...painted, comments: [...painted.comments, ...missing] } - } - } - setDetails(painted) - setError(null) - setLoading(false) - } else { - setDetails(null) - setError(null) - setLoading(true) - } - if (hasFreshData) { return } @@ -2557,13 +2609,16 @@ export default function GitHubItemDialog({ inflight .then((result) => { const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration + const prev = workItemDetailsCache.get(detailsCacheKey) + if (invalidatedMidFlight) { + // Why: entry was deliberately dropped; do not recreate it. If the + // entry still exists (later open repopulated it) leave it alone too. + return + } // Why: 404/unauthorized must not overwrite valid cached data. When the // IPC resolves to null and we already have cached details, keep the // stale data — only blank entries get the null payload. - const prev = workItemDetailsCache.get(detailsCacheKey) - if (invalidatedMidFlight) { - // Skip cache write entirely — the entry was deliberately dropped. - } else if (result === null && prev?.details) { + if (result === null && prev?.details) { touchWorkItemDetailsCache(detailsCacheKey, { details: prev.details, fetchedAt: prev.fetchedAt, @@ -2576,53 +2631,24 @@ export default function GitHubItemDialog({ error: undefined }) } - if (requestId !== requestIdRef.current) { - return - } - // Why: merge any comments the user posted optimistically while the - // detail fetch was in-flight, using id to avoid duplicates. - const opt = optimisticCommentsRef.current - let merged = result - if (opt.length > 0 && merged) { - const fetchedIds = new Set(merged.comments.map((c: PRComment) => c.id)) - const missing = opt.filter((c) => !fetchedIds.has(c.id)) - if (missing.length > 0) { - merged = { ...merged, comments: [...merged.comments, ...missing] } - } - } - if (merged !== null || !cached?.details) { - setDetails(merged) - } }) .catch((err) => { const message = err instanceof Error ? err.message : 'Failed to load details' const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration + if (invalidatedMidFlight) { + return + } const prev = workItemDetailsCache.get(detailsCacheKey) // Why: stale-on-error — keep cached data if we have it, drop the // pending promise so the next open can retry. Only surface the - // blocking error when nothing is cached. If invalidated mid-flight, - // don't restore stale data into the now-empty entry. - if (!invalidatedMidFlight) { - touchWorkItemDetailsCache(detailsCacheKey, { - details: prev?.details ?? null, - fetchedAt: prev?.fetchedAt ?? 0, - error: message - }) - } - if (requestId !== requestIdRef.current) { - return - } - if (!prev?.details) { - setError(message) - } + // blocking error when nothing is cached. + touchWorkItemDetailsCache(detailsCacheKey, { + details: prev?.details ?? null, + fetchedAt: prev?.fetchedAt ?? 0, + error: message + }) }) - .finally(() => { - if (requestId !== requestIdRef.current) { - return - } - setLoading(false) - }) - }, [repoPath, workItem, detailsCacheKey]) + }, [repoPath, workItem, detailsCacheKey, refetchTick]) const Icon = workItem?.type === 'pr' ? GitPullRequest : CircleDot const body = details?.body ?? '' @@ -2636,25 +2662,11 @@ export default function GitHubItemDialog({ // that overwrites the optimistic comment. The next dialog open (after // cache expiry) will pick up the server-confirmed version. optimisticCommentsRef.current.push(comment) - setDetails((prev) => { - if (prev) { - return { ...prev, comments: [...prev.comments, comment] } - } - if (!workItem) { - return prev - } - // Why: details may still be loading — create a minimal shell - // so the optimistic comment isn't silently dropped. - return { - item: workItem, - body: '', - comments: [comment] - } - }) - // Why: keep the module-level cache in sync so a reopen paints the new - // comment without waiting on a refetch. Mark fetchedAt as stale (0) so - // the next open still triggers a background refresh to pick up - // server-side fields like reaction groups or thread bindings. + // Why: write through the module-level cache so subscribers (this + // drawer plus any concurrent ones on the same item) re-render with the + // optimistic comment. Mark fetchedAt as stale (0) so the next open + // still triggers a background refresh to pick up server-side fields + // like reaction groups or thread bindings. if (detailsCacheKey) { const prev = workItemDetailsCache.get(detailsCacheKey) if (prev?.details) { @@ -2667,9 +2679,12 @@ export default function GitHubItemDialog({ }) } } + // Why: when the cache has no details yet (still loading), the + // optimistic comment is held only in optimisticCommentsRef and will + // be merged once the in-flight fetch lands and writes details. } }, - [workItem, detailsCacheKey] + [detailsCacheKey] ) return ( From c88287fa9a6e17f100c4ec6526ef4bffc800548d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 10 May 2026 23:39:38 -0400 Subject: [PATCH 02/15] Fix Codex account auth read-back guard (#1629) * Fix Codex account auth read-back guard Co-authored-by: Orca * Guard Claude auth read-back identity Co-authored-by: Orca * Fix Claude auth read-back test on Linux Co-authored-by: Orca * Require positive Codex auth identity match Co-authored-by: Orca * Address auth read-back review gaps Co-authored-by: Orca * Harden managed auth read-back state machine Co-authored-by: Orca * Harden managed auth token read-back Co-authored-by: Orca * Isolate managed Codex launch homes Co-authored-by: Orca * Keep managed Codex homes in sync Co-authored-by: Orca * Revert "Keep managed Codex homes in sync" This reverts commit bce1ecdc387d3c7068415b77695b2de091fac0e0. * Revert "Isolate managed Codex launch homes" This reverts commit 826a6c4773ce9bf12e3dafa7cab8271eab0f26c1. * Clarify shared Codex auth routing comment Co-authored-by: Orca * Route Codex auth read-back by identity Co-authored-by: Orca * Route Claude auth read-back by identity Co-authored-by: Orca * Handle corrupt Codex auth snapshots Co-authored-by: Orca * Reject stale cold-start auth read-back Co-authored-by: Orca --------- Co-authored-by: Orca --- .../runtime-auth-service.test.ts | 886 +++++++++++++++- .../claude-accounts/runtime-auth-service.ts | 402 ++++++- src/main/claude-accounts/service.ts | 4 +- .../runtime-home-service.test.ts | 989 +++++++++++++++++- .../codex-accounts/runtime-home-service.ts | 397 ++++++- src/main/codex-accounts/service.ts | 4 +- src/shared/types.ts | 6 +- 7 files changed, 2547 insertions(+), 141 deletions(-) diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index b86fecb47fd..6957a58131c 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- test suite covers Claude runtime auth refresh, identity guards, and snapshot restore cases */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { chmodSync, @@ -6,6 +7,7 @@ import { mkdtempSync, readFileSync, rmSync, + statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -77,17 +79,22 @@ function createStore(settings: GlobalSettings) { function createManagedClaudeAuth( rootDir: string, accountId: string, - credentialsJson: string + credentialsJson: string, + oauthAccountJson = `{"accountUuid":"${accountId}"}\n` ): string { const managedAuthPath = join(rootDir, 'claude-accounts', accountId, 'auth') mkdirSync(managedAuthPath, { recursive: true }) writeFileSync(join(managedAuthPath, '.credentials.json'), credentialsJson, 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), `{"accountUuid":"${accountId}"}\n`) + writeFileSync(join(managedAuthPath, 'oauth-account.json'), oauthAccountJson, 'utf-8') testState.managedKeychainCredentials.set(accountId, credentialsJson) return managedAuthPath } -function createClaudeAccount(id: string, managedAuthPath: string): ClaudeManagedAccount { +function createClaudeAccount( + id: string, + managedAuthPath: string, + overrides: Partial = {} +): ClaudeManagedAccount { return { id, email: 'user@example.com', @@ -97,10 +104,49 @@ function createClaudeAccount(id: string, managedAuthPath: string): ClaudeManaged organizationName: null, createdAt: 1, updatedAt: 1, - lastAuthenticatedAt: 1 + lastAuthenticatedAt: 1, + ...overrides } } +function createClaudeCredentialsJson( + email: string, + accessToken: string, + organizationUuid: string | null = null, + expiresAt = Date.now() + 60_000 +): string { + return `${JSON.stringify({ + claudeAiOauth: { + email, + ...(organizationUuid ? { organizationUuid } : {}), + accessToken, + refreshToken: `${accessToken}-refresh`, + expiresAt + } + })}\n` +} + +function createClaudeCredentialsWithoutEmail( + accessToken: string, + organizationUuid: string | null = null +): string { + return `${JSON.stringify({ + claudeAiOauth: { + ...(organizationUuid ? { organizationUuid } : {}), + accessToken, + refreshToken: `${accessToken}-refresh`, + expiresAt: Date.now() + 60_000 + } + })}\n` +} + +function readManagedCredentialsForTest(accountId: string, managedAuthPath: string): string | null { + if (process.platform === 'darwin') { + return testState.managedKeychainCredentials.get(accountId) ?? null + } + return readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8') +} + describe('ClaudeRuntimeAuthService', () => { beforeEach(() => { vi.resetModules() @@ -142,13 +188,9 @@ describe('ClaudeRuntimeAuthService', () => { expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"managed"}\n') }) - it('falls back to atomic write when the unchanged check cannot read the target', async () => { - if (process.platform === 'win32') { - return - } - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + it('removes runtime credentials when deselecting with a missing system-default snapshot', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + writeFileSync(runtimeCredentialsPath, '{"token":"managed"}\n', 'utf-8') const managedAuthPath = createManagedClaudeAuth( testState.userDataDir, 'account-1', @@ -162,6 +204,36 @@ describe('ClaudeRuntimeAuthService', () => { const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') const service = new ClaudeRuntimeAuthService(store as never) + + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(existsSync(runtimeCredentialsPath)).toBe(false) + if (process.platform === 'darwin') { + expect(testState.activeKeychainCredentials).toBeNull() + } + }) + + it('falls back to atomic write when the unchanged check cannot read the target', async () => { + if (process.platform === 'win32') { + return + } + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + '{"token":"managed"}\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' await service.syncForCurrentSelection() testState.managedKeychainCredentials.set('account-1', '{"token":"rotated"}\n') @@ -178,4 +250,798 @@ describe('ClaudeRuntimeAuthService', () => { expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"rotated"}\n') }) + + it('tightens credential file permissions when unchanged content is already present', async () => { + if (process.platform === 'win32') { + return + } + + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + '{"token":"managed"}\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + chmodSync(runtimeCredentialsPath, 0o644) + await service.syncForCurrentSelection() + + expect(statSync(runtimeCredentialsPath).mode & 0o777).toBe(0o600) + }) + + it('reads back refreshed credentials when the Claude identity still matches', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') + const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + }) + + it('reads back verified same-account credentials on first sync after restart', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'original', + null, + 1_000 + ) + const refreshedCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'refreshed', + null, + 2_000 + ) + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + }) + + it('rejects older same-account Claude credentials on first sync after restart', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const staleRuntimeCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'stale', + null, + 1_000 + ) + const managedCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'managed-newer', + null, + 2_000 + ) + writeFileSync(runtimeCredentialsPath, staleRuntimeCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(managedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + }) + + it('rejects runtime read-back from a different Claude identity', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected') + const staleCredentials = createClaudeCredentialsJson('other@example.com', 'stale') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects runtime read-back from the same Claude email in a different organization', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects same-email Claude read-back using stored managed organization identity', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects same-email Claude read-back using stored oauth-account organization identity', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected') + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials, + '{"organizationUuid":"org-b"}\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects no-email Claude read-back when organization identity conflicts', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') + const staleCredentials = createClaudeCredentialsWithoutEmail('stale', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects unverifiable refreshed runtime credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsWithoutEmail('original') + const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) + }) + + it('restores the system default after rejecting unverifiable managed credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const originalCredentials = createClaudeCredentialsWithoutEmail('original') + const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + }) + + it('restores system default after same-identity managed Claude refresh on deselect', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed', 'org-1') + const externalCredentials = createClaudeCredentialsJson('user@example.com', 'external', 'org-1') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { + organizationUuid: 'org-1' + }) + ] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(externalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + }) + + it('restores system default when stale Claude credentials are rejected on deselect', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const selectedCredentials = createClaudeCredentialsJson('selected@example.com', 'selected') + const staleCredentials = createClaudeCredentialsJson('stale@example.com', 'stale') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'selected@example.com' }) + ] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + }) + + it('does not persist unverifiable stale Claude credentials into another active account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const staleUnverifiableCredentials = createClaudeCredentialsWithoutEmail('stale') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = 'account-2' + await service.syncForCurrentSelection() + writeFileSync(runtimeCredentialsPath, staleUnverifiableCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) + }) + + it('does not carry the reauth read-back skip across Claude account switches', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const account2RefreshedCredentials = createClaudeCredentialsJson( + 'two@example.com', + 'two-refreshed' + ) + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + service.clearLastWrittenCredentialsJson() + settings.activeClaudeManagedAccountId = 'account-2' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, account2RefreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe( + account2RefreshedCredentials + ) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2RefreshedCredentials) + }) + + it('does not apply inactive-account Claude reauth skip to the active account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') + const account1RefreshedCredentials = createClaudeCredentialsJson( + 'one@example.com', + 'one-refreshed' + ) + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') + service.clearLastWrittenCredentialsJson('account-2') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( + account1RefreshedCredentials + ) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) + }) + + it('keeps external Claude logout when deselecting managed account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + rmSync(runtimeCredentialsPath, { force: true }) + testState.activeKeychainCredentials = null + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(existsSync(runtimeCredentialsPath)).toBe(false) + }) + + it('reads back refreshed active keychain credentials on macOS', async () => { + if (process.platform !== 'darwin') { + return + } + + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') + const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + testState.activeKeychainCredentials = refreshedCredentials + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + }) + + it('restores system default when mismatched Claude keychain auth appears before deselect', async () => { + if (process.platform !== 'darwin') { + return + } + + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalKeychainCredentials = createClaudeCredentialsJson( + 'external@example.com', + 'external' + ) + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.activeKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + testState.activeKeychainCredentials = externalKeychainCredentials + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.activeKeychainCredentials).toBe(systemCredentials) + }) + + it('captures a fresh system-default snapshot when re-entering managed mode', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials1 = createClaudeCredentialsJson('system1@example.com', 'system1') + const systemCredentials2 = createClaudeCredentialsJson('system2@example.com', 'system2') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials1, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + writeFileSync(runtimeCredentialsPath, systemCredentials2, 'utf-8') + + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials2) + }) + + it('reads back refreshed credentials for the outgoing Claude account before switching', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original') + const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Original + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8') + settings.activeClaudeManagedAccountId = 'account-2' + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) + }) + + it('routes refreshed Claude credentials to the matching managed account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original') + const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Original + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-2' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + // A stale account-1 Claude process refreshed the shared runtime file after + // Orca selected account-2. Persist that refresh to account-1, then restore + // the selected account in the shared Claude runtime credentials. + writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8') + testState.activeKeychainCredentials = account1Refreshed + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) + if (process.platform === 'darwin') { + expect(testState.activeKeychainCredentials).toBe(account2Credentials) + } + }) + + it('rejects ambiguous Claude read-back instead of choosing a managed account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('same@example.com', 'same-original') + const refreshedCredentials = createClaudeCredentialsJson('same@example.com', 'same-refreshed') + const activeCredentials = createClaudeCredentialsJson('active@example.com', 'active') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + originalCredentials + ) + const managedAuthPath3 = createManagedClaudeAuth( + testState.userDataDir, + 'account-3', + activeCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'same@example.com' }), + createClaudeAccount('account-3', managedAuthPath3, { email: 'active@example.com' }) + ], + activeClaudeManagedAccountId: 'account-3' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + testState.activeKeychainCredentials = refreshedCredentials + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(originalCredentials) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(originalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(activeCredentials) + if (process.platform === 'darwin') { + expect(testState.activeKeychainCredentials).toBe(activeCredentials) + } + }) + + it('clears an invalid active Claude account before launch preparation', async () => { + const settings = createSettings({ + activeClaudeManagedAccountId: 'missing-account' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(store.updateSettings).toHaveBeenCalledWith({ activeClaudeManagedAccountId: null }) + expect(preparation.stripAuthEnv).toBe(false) + expect(preparation.provenance).toBe('system') + }) + + it('does not clobber fresh Claude credentials after clearLastWrittenCredentialsJson', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') + const reauthedCredentials = createClaudeCredentialsJson('user@example.com', 'reauthed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + testState.managedKeychainCredentials.set('account-1', reauthedCredentials) + writeFileSync(join(managedAuthPath, '.credentials.json'), reauthedCredentials, 'utf-8') + service.clearLastWrittenCredentialsJson() + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(reauthedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(reauthedCredentials) + }) }) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 098b42ec16b..31c7e3104ce 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: Claude account switching has one safety boundary: runtime auth materialization. Keeping file, Keychain, snapshot, and env-patch semantics together prevents PTY launch and quota fetch paths drifting. */ -import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' import { app } from 'electron' import type { ClaudeManagedAccount } from '../../shared/types' @@ -30,6 +30,16 @@ type ClaudeSystemDefaultSnapshot = { capturedAt: number } +type ClaudeAuthIdentity = { + email: string | null + organizationUuid: string | null +} + +type ClaudeReadBackResult = { status: 'unchanged' | 'persisted' | 'rejected' } +type ClaudeReadBackMatch = + | { kind: 'matched'; account: ClaudeManagedAccount } + | { kind: 'none' | 'ambiguous' } + export class ClaudeRuntimeAuthService { private readonly pathResolver = new ClaudeRuntimePathResolver() private mutationQueue: Promise = Promise.resolve() @@ -39,6 +49,7 @@ export class ClaudeRuntimeAuthService { // an external login (e.g. `claude auth login`) overwrote it — so Orca adopts // the file as the new system default instead of restoring a stale snapshot. private lastWrittenCredentialsJson: string | null = null + private skipNextReadBackForAccountId: string | null = null constructor(private readonly store: Store) { this.initializeLastSyncedState() @@ -63,7 +74,7 @@ export class ClaudeRuntimeAuthService { await this.serializeMutation(async () => { const settings = this.store.getSettings() if (!settings.activeClaudeManagedAccountId) { - await this.restoreSystemDefaultSnapshot() + await this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) this.lastSyncedAccountId = null return } @@ -100,37 +111,66 @@ export class ClaudeRuntimeAuthService { settings.claudeManagedAccounts, settings.activeClaudeManagedAccountId ) + const previousAccount = this.getActiveAccount( + settings.claudeManagedAccounts, + this.lastSyncedAccountId + ) + let outgoingReadBackResult: ClaudeReadBackResult = { status: 'unchanged' } + if (previousAccount && previousAccount.id !== activeAccount?.id) { + outgoingReadBackResult = await this.readBackRefreshedTokensForAccount(previousAccount, { + updateLastWrittenCredentialsJson: true + }) + } if (!activeAccount) { + if (settings.activeClaudeManagedAccountId) { + this.store.updateSettings({ activeClaudeManagedAccountId: null }) + } if (this.lastSyncedAccountId !== null) { - await this.restoreSystemDefaultSnapshot() + await this.restoreSystemDefaultSnapshot({ + detectExternalLogin: outgoingReadBackResult.status !== 'rejected' + }) this.lastSyncedAccountId = null } return } - await this.captureSystemDefaultSnapshotIfNeeded() - - const credentialsJson = await this.readManagedCredentials(activeAccount) + let credentialsJson = await this.readManagedCredentials(activeAccount) if (!credentialsJson) { console.warn( '[claude-runtime-auth] Active managed account is missing credentials, restoring system default' ) this.store.updateSettings({ activeClaudeManagedAccountId: null }) if (this.lastSyncedAccountId !== null) { - await this.restoreSystemDefaultSnapshot() + await this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) this.lastSyncedAccountId = null } return } + if (this.lastSyncedAccountId === null) { + await this.captureSystemDefaultSnapshot({ force: true }) + } + // Why: Claude CLI refreshes expired OAuth tokens and writes them back to // .credentials.json. If we detect the runtime file differs from what Orca // last wrote, the CLI must have refreshed — so we preserve those tokens // back to managed storage before overwriting runtime with managed state. if (this.lastSyncedAccountId === activeAccount.id) { - await this.readBackRefreshedTokens(activeAccount) + if (this.skipNextReadBackForAccountId === activeAccount.id) { + this.skipNextReadBackForAccountId = null + } else { + const readBackResult = await this.readBackRefreshedTokens(credentialsJson, { + updateLastWrittenCredentialsJson: true + }) + if (readBackResult.status === 'persisted') { + credentialsJson = (await this.readManagedCredentials(activeAccount)) ?? credentialsJson + } + } } + if (this.lastSyncedAccountId !== activeAccount.id) { + this.skipNextReadBackForAccountId = null + } this.writeRuntimeCredentials(credentialsJson) if (process.platform === 'darwin') { await writeActiveClaudeKeychainCredentials(credentialsJson) @@ -143,40 +183,105 @@ export class ClaudeRuntimeAuthService { // re-auth or add-account. Those flows write fresh tokens to managed storage, // so the read-back must be skipped to avoid overwriting them with stale // runtime tokens. - clearLastWrittenCredentialsJson(): void { - this.lastWrittenCredentialsJson = null + clearLastWrittenCredentialsJson( + accountId = this.store.getSettings().activeClaudeManagedAccountId + ): void { + if (accountId === this.store.getSettings().activeClaudeManagedAccountId) { + this.lastWrittenCredentialsJson = null + } + this.skipNextReadBackForAccountId = accountId } - private async readBackRefreshedTokens(account: ClaudeManagedAccount): Promise { + private async readBackRefreshedTokens( + baselineCredentialsJson: string, + options: { updateLastWrittenCredentialsJson: boolean } + ): Promise { try { - if (this.lastWrittenCredentialsJson === null) { - return + const runtimeContents = await this.readRuntimeCredentialsForReadBack(baselineCredentialsJson) + if (!runtimeContents) { + return { status: 'unchanged' } + } + if ( + this.lastWrittenCredentialsJson !== null && + runtimeContents === this.lastWrittenCredentialsJson + ) { + return { status: 'unchanged' } } - const paths = this.pathResolver.getRuntimePaths() - if (!existsSync(paths.credentialsPath)) { - return + const match = await this.findManagedAccountForRuntimeCredentials(runtimeContents) + if (match.kind !== 'matched') { + if (match.kind === 'ambiguous') { + console.warn('[claude-runtime-auth] Refusing ambiguous Claude auth read-back') + } + return { status: 'rejected' } + } + // Why: on cold app start we cannot tell whether matching runtime + // credentials are a fresh CLI refresh or stale state unless token + // metadata proves runtime is newer than managed storage. + if ( + this.lastWrittenCredentialsJson === null && + !this.runtimeCredentialsAreFresher(runtimeContents, baselineCredentialsJson) + ) { + return { status: 'rejected' } } - const runtimeContents = readFileSync(paths.credentialsPath, 'utf-8') - if (runtimeContents === this.lastWrittenCredentialsJson) { - return - } - - if (process.platform === 'darwin') { - await writeManagedClaudeKeychainCredentials(account.id, runtimeContents) - } else { - const credentialsPath = join(account.managedAuthPath, '.credentials.json') - writeFileAtomically(credentialsPath, runtimeContents, { mode: 0o600 }) + await this.writeManagedCredentials(match.account, runtimeContents) + if (options.updateLastWrittenCredentialsJson) { + this.writeRuntimeCredentials(runtimeContents) + this.lastWrittenCredentialsJson = runtimeContents + if (process.platform === 'darwin') { + await writeActiveClaudeKeychainCredentials(runtimeContents) + } } + return { status: 'persisted' } } catch (error) { // Why: read-back is best-effort. A transient fs error must not block the // forward sync path — the worst case is one more stale-token cycle, which // is strictly better than failing the entire sync. console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error) + return { status: 'rejected' } } } + private async readRuntimeCredentialsForReadBack( + baselineCredentialsJson: string + ): Promise { + const paths = this.pathResolver.getRuntimePaths() + const fileCredentials = existsSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') + : null + if (process.platform === 'darwin') { + const keychainCredentials = await readActiveClaudeKeychainCredentials() + if (this.lastWrittenCredentialsJson === null) { + if (keychainCredentials && keychainCredentials !== baselineCredentialsJson) { + return keychainCredentials + } + if (fileCredentials && fileCredentials !== baselineCredentialsJson) { + return fileCredentials + } + return keychainCredentials ?? fileCredentials + } + if (keychainCredentials && keychainCredentials !== this.lastWrittenCredentialsJson) { + return keychainCredentials + } + } + if (!fileCredentials) { + return null + } + return fileCredentials + } + + private async readBackRefreshedTokensForAccount( + account: ClaudeManagedAccount, + options: { updateLastWrittenCredentialsJson: boolean } + ): Promise { + const managedCredentialsJson = await this.readManagedCredentials(account) + if (!managedCredentialsJson) { + return { status: 'unchanged' } + } + return this.readBackRefreshedTokens(managedCredentialsJson, options) + } + private getPreparation(): ClaudeRuntimeAuthPreparation { const settings = this.store.getSettings() const paths = this.pathResolver.getRuntimePaths() @@ -198,6 +303,161 @@ export class ClaudeRuntimeAuthService { return accounts.find((account) => account.id === activeAccountId) ?? null } + private async findManagedAccountForRuntimeCredentials( + runtimeCredentialsJson: string + ): Promise { + const matches: ClaudeManagedAccount[] = [] + for (const account of this.store.getSettings().claudeManagedAccounts) { + const managedCredentialsJson = await this.readManagedCredentials(account) + if (!managedCredentialsJson) { + continue + } + if ( + this.runtimeCredentialsMatchAccount( + runtimeCredentialsJson, + account, + managedCredentialsJson, + this.readManagedOauthAccount(account) + ) === 'match' + ) { + matches.push(account) + } + } + + if (matches.length === 1) { + return { kind: 'matched', account: matches[0] } + } + return { kind: matches.length === 0 ? 'none' : 'ambiguous' } + } + + private runtimeCredentialsMatchAccount( + runtimeCredentialsJson: string, + account: ClaudeManagedAccount, + managedCredentialsJson: string, + managedOauthAccount: unknown + ): 'match' | 'mismatch' | 'unverifiable' { + const identity = this.readIdentityFromCredentials(runtimeCredentialsJson) + if (!identity) { + return 'mismatch' + } + const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson) + const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) + + // Why: this mirrors the Codex runtime-home guard. If another Claude login + // or missed live process rewrites shared runtime credentials, do not + // persist those credentials into the selected managed account. + const selectedOrganizationUuid = this.normalizeField( + account.organizationUuid ?? + managedIdentity?.organizationUuid ?? + managedOauthIdentity.organizationUuid + ) + if (selectedOrganizationUuid && !identity.organizationUuid) { + return 'unverifiable' + } + if ( + selectedOrganizationUuid && + identity.organizationUuid && + selectedOrganizationUuid !== identity.organizationUuid + ) { + return 'mismatch' + } + if (!identity.email) { + return 'unverifiable' + } + if (account.email && identity.email && this.normalizeField(account.email) !== identity.email) { + return 'mismatch' + } + + return 'match' + } + + private readIdentityFromCredentials(credentialsJson: string): ClaudeAuthIdentity | null { + let parsed: Record + try { + parsed = JSON.parse(credentialsJson) as Record + } catch { + return null + } + const oauth = this.asRecord(parsed.claudeAiOauth) + return { + email: this.normalizeField(this.readString(oauth, 'email')), + organizationUuid: this.normalizeField( + this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId') + ) + } + } + + private runtimeCredentialsAreFresher( + runtimeCredentialsJson: string, + managedCredentialsJson: string + ): boolean { + const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson) + const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson) + return ( + runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness > managedFreshness + ) + } + + private readFreshnessFromCredentials(credentialsJson: string): number | null { + let parsed: Record + try { + parsed = JSON.parse(credentialsJson) as Record + } catch { + return null + } + const oauth = this.asRecord(parsed.claudeAiOauth) + return ( + this.readNumber(oauth, 'expiresAt') ?? + this.readNumber(oauth, 'expires_at') ?? + this.readNumber(oauth, 'expiry') ?? + this.readNumber(oauth, 'expires') + ) + } + + private readIdentityFromOauthAccount(oauthAccount: unknown): ClaudeAuthIdentity { + const oauth = this.asRecord(oauthAccount) + return { + email: this.normalizeField( + this.readString(oauth, 'emailAddress') ?? this.readString(oauth, 'email') + ), + organizationUuid: this.normalizeField( + this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId') + ) + } + } + + private asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + return value as Record + } + + private readString(value: Record | null, key: string): string | null { + const candidate = value?.[key] + return typeof candidate === 'string' ? candidate : null + } + + private readNumber(value: Record | null, key: string): number | null { + const candidate = value?.[key] + if (typeof candidate === 'number' && Number.isFinite(candidate)) { + return candidate + } + if (typeof candidate === 'string') { + const parsed = Number(candidate) + return Number.isFinite(parsed) ? parsed : null + } + return null + } + + private normalizeField(value: string | null | undefined): string | null { + if (!value) { + return null + } + const trimmed = value.trim() + return trimmed === '' ? null : trimmed + } + private async readManagedCredentials(account: ClaudeManagedAccount): Promise { if (process.platform === 'darwin') { return readManagedClaudeKeychainCredentials(account.id) @@ -209,6 +469,18 @@ export class ClaudeRuntimeAuthService { return readFileSync(credentialsPath, 'utf-8') } + private async writeManagedCredentials( + account: ClaudeManagedAccount, + credentialsJson: string + ): Promise { + if (process.platform === 'darwin') { + await writeManagedClaudeKeychainCredentials(account.id, credentialsJson) + return + } + const credentialsPath = join(account.managedAuthPath, '.credentials.json') + writeFileAtomically(credentialsPath, credentialsJson, { mode: 0o600 }) + } + private readManagedOauthAccount(account: ClaudeManagedAccount): unknown { const oauthPath = join(account.managedAuthPath, 'oauth-account.json') if (!existsSync(oauthPath)) { @@ -221,9 +493,9 @@ export class ClaudeRuntimeAuthService { } } - private async captureSystemDefaultSnapshotIfNeeded(): Promise { + private async captureSystemDefaultSnapshot(options: { force: boolean }): Promise { const snapshotPath = this.getSystemDefaultSnapshotPath() - if (existsSync(snapshotPath)) { + if (!options.force && existsSync(snapshotPath)) { return } @@ -241,13 +513,23 @@ export class ClaudeRuntimeAuthService { this.writeJson(snapshotPath, snapshot) } - private async restoreSystemDefaultSnapshot(): Promise { - if (this.detectExternalLoginAndUpdateSnapshot()) { + private async restoreSystemDefaultSnapshot(options: { + detectExternalLogin: boolean + }): Promise { + const externalState = options.detectExternalLogin + ? await this.detectExternalLoginAndUpdateSnapshot() + : 'none' + if (externalState === 'file-logout') { return } const snapshotPath = this.getSystemDefaultSnapshotPath() if (!existsSync(snapshotPath)) { + rmSync(this.pathResolver.getRuntimePaths().credentialsPath, { force: true }) + if (process.platform === 'darwin') { + await deleteActiveClaudeKeychainCredentials() + } + this.lastWrittenCredentialsJson = null return } const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as ClaudeSystemDefaultSnapshot @@ -258,9 +540,11 @@ export class ClaudeRuntimeAuthService { } this.writeRuntimeOauthAccount(snapshot.configOauthAccount) if (process.platform === 'darwin') { - await (snapshot.keychainCredentialsJson !== null - ? writeActiveClaudeKeychainCredentials(snapshot.keychainCredentialsJson) - : deleteActiveClaudeKeychainCredentials()) + if (externalState !== 'keychain-change') { + await (snapshot.keychainCredentialsJson !== null + ? writeActiveClaudeKeychainCredentials(snapshot.keychainCredentialsJson) + : deleteActiveClaudeKeychainCredentials()) + } } } @@ -268,23 +552,43 @@ export class ClaudeRuntimeAuthService { // the credentials file while a managed account was active. If the file // differs from what Orca last wrote, that external login becomes the new // system default — no manual "refresh" button needed. - private detectExternalLoginAndUpdateSnapshot(): boolean { + private async detectExternalLoginAndUpdateSnapshot(): Promise< + 'none' | 'file-logout' | 'keychain-change' + > { if (this.lastWrittenCredentialsJson === null) { - return false + return 'none' } const paths = this.pathResolver.getRuntimePaths() if (!existsSync(paths.credentialsPath)) { - return false + const currentKeychainCredentials = + process.platform === 'darwin' ? await readActiveClaudeKeychainCredentials() : null + if ( + process.platform === 'darwin' && + currentKeychainCredentials === this.lastWrittenCredentialsJson + ) { + return 'none' + } + const snapshotPath = this.getSystemDefaultSnapshotPath() + rmSync(snapshotPath, { force: true }) + this.lastWrittenCredentialsJson = null + return 'file-logout' } const currentCredentials = readFileSync(paths.credentialsPath, 'utf-8') + const currentKeychainCredentials = + process.platform === 'darwin' ? await readActiveClaudeKeychainCredentials() : null if (currentCredentials === this.lastWrittenCredentialsJson) { - return false + if ( + process.platform === 'darwin' && + currentKeychainCredentials !== this.lastWrittenCredentialsJson + ) { + return 'keychain-change' + } + return 'none' } - // External login detected — adopt current state as the new system default const snapshotPath = this.getSystemDefaultSnapshotPath() rmSync(snapshotPath, { force: true }) this.lastWrittenCredentialsJson = null - return true + return 'file-logout' } private readRuntimeOauthAccount(): unknown { @@ -316,10 +620,17 @@ export class ClaudeRuntimeAuthService { mkdirSync(dirname(credentialsPath), { recursive: true }) // Why: repeated Claude spawns sync auth, but credentials rarely change. // Skipping unchanged rewrites avoids Windows EPERM contention in #1507. - if (this.lastWrittenCredentialsJson === contents && existsSync(credentialsPath)) { + // Still verify the file because another Claude process may have rewritten + // runtime credentials since Orca last materialized them. + if ( + this.lastWrittenCredentialsJson === contents && + this.fileContentsEqual(credentialsPath, contents) + ) { + this.ensureOwnerOnlyMode(credentialsPath) return } if (this.fileContentsEqual(credentialsPath, contents)) { + this.ensureOwnerOnlyMode(credentialsPath) this.lastWrittenCredentialsJson = contents return } @@ -345,6 +656,17 @@ export class ClaudeRuntimeAuthService { } } + private ensureOwnerOnlyMode(targetPath: string): void { + if (process.platform === 'win32') { + return + } + try { + chmodSync(targetPath, 0o600) + } catch { + /* Best effort: the next atomic write will set the restrictive mode. */ + } + } + private readJsonObject(targetPath: string): Record { if (!existsSync(targetPath)) { return {} diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 78324c2ca4a..b2a8dd90130 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -115,7 +115,7 @@ export class ClaudeAccountService { claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], activeClaudeManagedAccountId: account.id }) - this.runtimeAuth.clearLastWrittenCredentialsJson() + this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) await this.syncRuntimeAuthWithLivePtyGate() await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId) return this.getSnapshot() @@ -154,7 +154,7 @@ export class ClaudeAccountService { ) }) try { - this.runtimeAuth.clearLastWrittenCredentialsJson() + this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) await this.syncRuntimeAuthWithLivePtyGate() await this.rateLimits.refreshForClaudeAccountChange() return this.getSnapshot() diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 3a2f407e6fe..c678e5390e2 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- test suite covers snapshot, migration, auth materialization, and error-resilience scenarios */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + chmodSync, existsSync, mkdtempSync, mkdirSync, @@ -132,6 +133,39 @@ function createManagedAuth(rootDir: string, accountId: string, auth: string): st return managedHomePath } +function encodeJwtPart(value: Record): string { + return Buffer.from(JSON.stringify(value)).toString('base64url') +} + +function createCodexAuthJson( + email: string, + accountId: string, + refreshToken: string, + expiresAt?: number +): string { + const idToken = [ + encodeJwtPart({ alg: 'none', typ: 'JWT' }), + encodeJwtPart({ + email, + ...(expiresAt === undefined ? {} : { exp: expiresAt }), + 'https://api.openai.com/auth': { + chatgpt_account_id: accountId, + workspace_account_id: accountId + } + }), + '' + ].join('.') + + return `${JSON.stringify({ + tokens: { + id_token: idToken, + account_id: accountId, + ...(expiresAt === undefined ? {} : { expires_at: expiresAt }), + refresh_token: refreshToken + } + })}\n` +} + describe('CodexRuntimeHomeService', () => { beforeEach(() => { vi.resetModules() @@ -155,11 +189,14 @@ describe('CodexRuntimeHomeService', () => { new CodexRuntimeHomeService(store as never) expect( - readFileSync( - join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json'), - 'utf-8' - ) - ).toBe('{"account":"system"}\n') + existsSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json')) + ).toBe(true) + if (process.platform !== 'win32') { + expect( + statSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json')) + .mode & 0o777 + ).toBe(0o600) + } }) it('materializes the active managed account auth into ~/.codex on startup', async () => { @@ -194,11 +231,8 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') expect( - readFileSync( - join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json'), - 'utf-8' - ) - ).toBe('{"account":"system"}\n') + existsSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json')) + ).toBe(false) }) it('restores the system-default snapshot when no managed account is selected', async () => { @@ -209,6 +243,43 @@ describe('CodexRuntimeHomeService', () => { 'account-1', '{"account":"managed"}\n' ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + settings.activeCodexManagedAccountId = null + writeFileSync(runtimeAuthPath, '{"account":"managed"}\n', 'utf-8') + + service.syncForCurrentSelection() + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + }) + + it('removes runtime auth when restoring a no-login system default', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"managed"}\n' + ) const settings = createSettings({ codexManagedAccounts: [ { @@ -229,15 +300,93 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - settings.activeCodexManagedAccountId = null - writeFileSync(runtimeAuthPath, '{"account":"managed"}\n', 'utf-8') + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') + + settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + expect(existsSync(runtimeAuthPath)).toBe(false) }) - it('clears an invalid active account selection and restores the system default snapshot', async () => { + it('removes runtime auth when deselecting with a missing system-default snapshot', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') + writeFileSync(runtimeAuthPath, managedAuth, 'utf-8') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + + it('removes runtime auth when deselecting with a corrupt system-default snapshot', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null + }) + const store = createStore(settings) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + + const snapshotPath = join( + testState.userDataDir, + 'codex-runtime-home', + 'system-default-auth.json' + ) + writeFileSync(snapshotPath, '{not valid json', 'utf-8') + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + expect(existsSync(runtimeAuthPath)).toBe(false) + expect(existsSync(snapshotPath)).toBe(false) + expect(warnSpy).toHaveBeenCalledWith( + '[codex-runtime-home] Ignoring invalid system-default auth snapshot' + ) + }) + + it('clears an invalid active account selection and removes untrusted runtime auth', async () => { const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const missingManagedHomePath = join( @@ -269,10 +418,25 @@ describe('CodexRuntimeHomeService', () => { new CodexRuntimeHomeService(store as never) expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + expect(existsSync(runtimeAuthPath)).toBe(false) expect(warnSpy).toHaveBeenCalled() }) + it('clears an unknown active account id and removes untrusted runtime auth', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"stale-managed"}\n', 'utf-8') + const settings = createSettings({ + activeCodexManagedAccountId: 'missing-account' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + it('returns ~/.codex for Codex launch and rate-limit preparation', async () => { const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') @@ -318,14 +482,16 @@ describe('CodexRuntimeHomeService', () => { updatedAt: 1, lastAuthenticatedAt: 1 } - ], - activeCodexManagedAccountId: 'account-1' + ] }) const store = createStore(settings) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + // Deselect managed account — should restore system default once settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() @@ -337,7 +503,7 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-tool"}\n') }) - it('restores system default on restart when persisted active account is invalid', async () => { + it('removes untrusted runtime auth on restart when persisted active account is invalid', async () => { const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const settings = createSettings({ @@ -362,10 +528,8 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - // Constructor initializes lastSyncedAccountId='account-1' from settings, - // then syncForCurrentSelection finds missing auth.json and restores snapshot expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + expect(existsSync(runtimeAuthPath)).toBe(false) expect(warnSpy).toHaveBeenCalled() }) @@ -434,6 +598,44 @@ describe('CodexRuntimeHomeService', () => { expect(mode).toBe(0o600) }) + it('tightens auth.json permissions when unchanged content is already present', async () => { + if (process.platform === 'win32') { + return + } + + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"managed"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + chmodSync(runtimeAuthPath, 0o644) + service.syncForCurrentSelection() + + expect(statSync(runtimeAuthPath).mode & 0o777).toBe(0o600) + }) + it('does not throw when syncForCurrentSelection encounters an error', async () => { const store = createStore( createSettings({ @@ -510,11 +712,243 @@ describe('CodexRuntimeHomeService', () => { it('reads back CLI-refreshed tokens into managed storage on subsequent sync', async () => { const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"tokens":"original"}\n' + const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') + const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'refreshed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // Simulate CLI refreshing the token in ~/.codex/auth.json + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + + // Next sync should read back the refreshed token to managed storage + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + }) + + it('rejects runtime read-back from a different Codex identity', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') + const staleLivePtyAuth = createCodexAuthJson('stale@example.com', 'acct-stale', 'stale') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'selected@example.com', + managedHomePath, + providerAccountId: 'acct-selected', + workspaceLabel: null, + workspaceAccountId: 'acct-selected', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // Simulate an old live Codex PTY from another account refreshing the + // shared runtime auth after Orca has already selected account-1. + writeFileSync(runtimeAuthPath, staleLivePtyAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) + }) + + it('routes runtime read-back from a different Codex identity to its matching account', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') + const account1RefreshedAuth = createCodexAuthJson( + 'one@example.com', + 'acct-one', + 'one-refreshed' ) + const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const managedAuthPath1 = join(managedHomePath1, 'auth.json') + const managedAuthPath2 = join(managedHomePath2, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-one', + workspaceLabel: null, + workspaceAccountId: 'acct-one', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'two@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-two', + workspaceLabel: null, + workspaceAccountId: 'acct-two', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-2' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // An older account-1 Codex process refreshed the shared runtime file after + // Orca selected account-2. Persist the refresh to account-1, then restore + // the selected account in ~/.codex. + writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1RefreshedAuth) + expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(account2Auth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) + }) + + it('rejects ambiguous Codex read-back instead of choosing a managed account', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const originalAuth = createCodexAuthJson('same@example.com', 'acct-same', 'original') + const refreshedAuth = createCodexAuthJson('same@example.com', 'acct-same', 'refreshed') + const activeAuth = createCodexAuthJson('active@example.com', 'acct-active', 'active') + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', originalAuth) + const managedHomePath3 = createManagedAuth(testState.userDataDir, 'account-3', activeAuth) + const managedAuthPath1 = join(managedHomePath1, 'auth.json') + const managedAuthPath2 = join(managedHomePath2, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'same@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-same', + workspaceLabel: null, + workspaceAccountId: 'acct-same', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'same@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-same', + workspaceLabel: null, + workspaceAccountId: 'acct-same', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + }, + { + id: 'account-3', + email: 'active@example.com', + managedHomePath: managedHomePath3, + providerAccountId: 'acct-active', + workspaceLabel: null, + workspaceAccountId: 'acct-active', + createdAt: 3, + updatedAt: 3, + lastAuthenticatedAt: 3 + } + ], + activeCodexManagedAccountId: 'account-3' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(originalAuth) + expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(originalAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(activeAuth) + }) + + it('rejects runtime read-back without a positive selected-account identity match', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') + const accountOnlyAuth = `${JSON.stringify({ + tokens: { + account_id: 'acct-stale', + refresh_token: 'stale' + } + })}\n` + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'selected@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, accountOnlyAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) + }) + + it('rejects same-email runtime read-back when account ids differ from sparse managed metadata', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const selectedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'selected') + const staleLivePtyAuth = createCodexAuthJson('user@example.com', 'acct-stale', 'stale') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) const managedAuthPath = join(managedHomePath, 'auth.json') const settings = createSettings({ codexManagedAccounts: [ @@ -537,19 +971,90 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Simulate CLI refreshing the token in ~/.codex/auth.json - writeFileSync(runtimeAuthPath, '{"tokens":"refreshed"}\n', 'utf-8') - - // Next sync should read back the refreshed token to managed storage + writeFileSync(runtimeAuthPath, staleLivePtyAuth, 'utf-8') service.syncForCurrentSelection() - expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"tokens":"refreshed"}\n') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"refreshed"}\n') + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) }) - it('skips read-back on first sync after restart (lastWrittenAuthJson is null)', async () => { + it('reads back same-account refreshes for sparse managed metadata using stored auth identity', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const originalAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'original') + const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'refreshed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + }) + + it('reads back strong account-id refreshes when the runtime auth has no email', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') + const refreshedAuth = `${JSON.stringify({ + tokens: { + account_id: 'acct-1', + refresh_token: 'refreshed' + } + })}\n` + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + }) + + it('rejects unverifiable Codex read-back on first sync after restart', async () => { const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - // Pre-populate runtime with different content to simulate a CLI refresh while Orca was down writeFileSync(runtimeAuthPath, '{"tokens":"refreshed-while-down"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, @@ -579,13 +1084,77 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - // After restart, managed storage should NOT be overwritten by unknown runtime state expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"tokens":"original"}\n') - // Runtime should be overwritten with managed (conservative behavior) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"original"}\n') }) - it('skips read-back during account switch', async () => { + it('reads back verified same-account refreshes on first sync after restart', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original', 1_000) + const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'refreshed', 2_000) + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + ) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + }) + + it('rejects older same-account Codex auth on first sync after restart', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const staleRuntimeAuth = createCodexAuthJson('user@example.com', 'acct-1', 'stale', 1_000) + const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed-newer', 2_000) + writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + ) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(managedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) + }) + + it('does not contaminate the incoming Codex account during account switch', async () => { const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const managedHomePath1 = createManagedAuth( @@ -631,18 +1200,246 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Switch to account-2 — runtime still has account-1 tokens settings.activeCodexManagedAccountId = 'account-2' service.syncForCurrentSelection() - // Account-2's managed auth should NOT be contaminated with account-1's runtime tokens expect(readFileSync(managedAuthPath2, 'utf-8')).toBe('{"tokens":"account2"}\n') expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"account2"}\n') }) - it('detects external login on managed→system-default transition', async () => { + it('does not carry the reauth read-back skip across Codex account switches', async () => { const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') + const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') + const account2RefreshedAuth = createCodexAuthJson( + 'two@example.com', + 'acct-two', + 'two-refreshed' + ) + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const managedAuthPath2 = join(managedHomePath2, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-one', + workspaceLabel: null, + workspaceAccountId: 'acct-one', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'two@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-two', + workspaceLabel: null, + workspaceAccountId: 'acct-two', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + service.clearLastWrittenAuthJson() + settings.activeCodexManagedAccountId = 'account-2' + service.syncForCurrentSelection() + + writeFileSync(runtimeAuthPath, account2RefreshedAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(account2RefreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2RefreshedAuth) + }) + + it('does not apply inactive-account Codex reauth skip to the active account', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') + const account1RefreshedAuth = createCodexAuthJson( + 'one@example.com', + 'acct-one', + 'one-refreshed' + ) + const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const managedAuthPath1 = join(managedHomePath1, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-one', + workspaceLabel: null, + workspaceAccountId: 'acct-one', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'two@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-two', + workspaceLabel: null, + workspaceAccountId: 'acct-two', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') + service.clearLastWrittenAuthJson('account-2') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1RefreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth) + }) + + it('restores system default when unverified runtime auth appears before deselect', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"managed"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + + // A stale or external process overwrites runtime with auth Orca cannot + // verify against the outgoing managed account. + writeFileSync(runtimeAuthPath, '{"account":"external-login"}\n', 'utf-8') + + // Deselect managed account + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + expect( + existsSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json')) + ).toBe(true) + }) + + it('restores system default after same-identity managed Codex refresh on deselect', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8') + const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') + const externalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'external') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + + writeFileSync(runtimeAuthPath, externalAuth, 'utf-8') + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(externalAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-old"}\n') + }) + + it('restores system default when stale Codex credentials are rejected on deselect', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8') + const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') + const staleLivePtyAuth = createCodexAuthJson('stale@example.com', 'acct-stale', 'stale') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'selected@example.com', + managedHomePath, + providerAccountId: 'acct-selected', + workspaceLabel: null, + workspaceAccountId: 'acct-selected', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + + writeFileSync(runtimeAuthPath, staleLivePtyAuth, 'utf-8') + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-old"}\n') + }) + + it('keeps external Codex logout when deselecting managed account', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -669,30 +1466,21 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // External `codex auth login` overwrites runtime auth - writeFileSync(runtimeAuthPath, '{"account":"external-login"}\n', 'utf-8') - - // Deselect managed account + rmSync(runtimeAuthPath, { force: true }) settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() - // Should keep the external login, not restore stale system snapshot - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-login"}\n') - // Snapshot should be deleted so next capture picks up the external login - expect( - existsSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json')) - ).toBe(false) + expect(existsSync(runtimeAuthPath)).toBe(false) }) - it('does not clobber fresh tokens after clearLastWrittenAuthJson', async () => { + it('captures a fresh system-default snapshot when re-entering managed mode', async () => { const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + writeFileSync(runtimeAuthPath, '{"account":"system-1"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', - '{"tokens":"original"}\n' + '{"account":"managed"}\n' ) - const managedAuthPath = join(managedHomePath, 'auth.json') const settings = createSettings({ codexManagedAccounts: [ { @@ -714,16 +1502,105 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + writeFileSync(runtimeAuthPath, '{"account":"system-2"}\n', 'utf-8') + + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-2"}\n') + }) + + it('reads back refreshed tokens for the outgoing Codex account before switching', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const account1Original = createCodexAuthJson('one@example.com', 'acct-1', 'one-original') + const account1Refreshed = createCodexAuthJson('one@example.com', 'acct-1', 'one-refreshed') + const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two') + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Original) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const managedAuthPath1 = join(managedHomePath1, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'two@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-2', + workspaceLabel: null, + workspaceAccountId: 'acct-2', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, account1Refreshed, 'utf-8') + settings.activeCodexManagedAccountId = 'account-2' + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1Refreshed) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) + }) + + it('does not clobber fresh tokens after clearLastWrittenAuthJson', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') + const reauthedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'reauthed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + // Simulate re-auth: managed storage gets fresh tokens - writeFileSync(managedAuthPath, '{"tokens":"reauthed"}\n', 'utf-8') + writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') // Clear tracking before sync (as CodexAccountService would) service.clearLastWrittenAuthJson() service.syncForCurrentSelection() // Fresh re-auth tokens should survive — not be clobbered by stale runtime read-back - expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"tokens":"reauthed"}\n') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"reauthed"}\n') + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) }) it('preserves conflicting legacy session files under deterministic names', async () => { diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 3ec95ecfa68..21866294248 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -5,6 +5,7 @@ semantics from drifting across PTY launch, login, and quota fetch paths. */ import { copyFileSync, existsSync, + chmodSync, mkdirSync, readdirSync, readFileSync, @@ -18,6 +19,26 @@ import type { CodexManagedAccount } from '../../shared/types' import type { Store } from '../persistence' import { writeFileAtomically } from './fs-utils' +type CodexAuthIdentity = { + email: string | null + providerAccountId: string | null + workspaceAccountId: string | null +} + +type CodexSystemDefaultSnapshot = { + authJson: string | null +} + +type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' +type CodexReadBackMatch = + | { + kind: 'matched' + account: CodexManagedAccount + managedAuthPath: string + managedAuthContents: string + } + | { kind: 'none' | 'ambiguous' } + export class CodexRuntimeHomeService { // Why: tracks whether auth.json is currently managed by Orca. When null, // Orca does NOT own auth.json and must not overwrite external changes @@ -31,6 +52,7 @@ export class CodexRuntimeHomeService { // login (e.g. `codex auth login`) overwrote it — so Orca adopts the file as // the new system default instead of restoring a stale snapshot. private lastWrittenAuthJson: string | null = null + private skipNextReadBackForAccountId: string | null = null constructor(private readonly store: Store) { this.safeMigrateLegacyManagedState() @@ -44,31 +66,47 @@ export class CodexRuntimeHomeService { } prepareForCodexLaunch(): string { - this.safeSyncForCurrentSelection() + this.syncForCurrentSelection() return this.getRuntimeHomePath() } prepareForRateLimitFetch(): string { - this.safeSyncForCurrentSelection() + this.syncForCurrentSelection() return this.getRuntimeHomePath() } syncForCurrentSelection(): void { - this.captureSystemDefaultSnapshotIfNeeded() - const settings = this.store.getSettings() + if (this.lastSyncedAccountId === null) { + this.captureSystemDefaultSnapshot({ force: false }) + } const activeAccount = this.getActiveAccount( settings.codexManagedAccounts, settings.activeCodexManagedAccountId ) + const previousAccount = this.getActiveAccount( + settings.codexManagedAccounts, + this.lastSyncedAccountId + ) + let outgoingReadBackResult: CodexReadBackResult = 'unchanged' + if (previousAccount && previousAccount.id !== activeAccount?.id) { + outgoingReadBackResult = this.readBackRefreshedTokensForAccount(previousAccount, { + updateLastWrittenAuthJson: true + }) + } if (!activeAccount) { + if (settings.activeCodexManagedAccountId) { + this.store.updateSettings({ activeCodexManagedAccountId: null }) + } // Why: only restore the snapshot when transitioning FROM a managed // account back to system default. When no managed account was ever // active, auth.json belongs to the user and Orca must not touch it. // This prevents overwriting external auth changes (codex login or other // tools) on every PTY launch / rate-limit fetch. if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot() + this.restoreSystemDefaultSnapshot({ + detectExternalLogin: outgoingReadBackResult !== 'rejected' + }) this.lastSyncedAccountId = null } return @@ -81,20 +119,33 @@ export class CodexRuntimeHomeService { ) this.store.updateSettings({ activeCodexManagedAccountId: null }) if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot() + this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) this.lastSyncedAccountId = null } return } + if (this.lastSyncedAccountId === null) { + this.captureSystemDefaultSnapshot({ force: true }) + } + // Why: Codex CLI refreshes expired OAuth tokens and writes them back to // ~/.codex/auth.json. If we detect the runtime file differs from what Orca // last wrote, the CLI must have refreshed — so we preserve those tokens // back to managed storage before overwriting runtime with managed state. if (this.lastSyncedAccountId === activeAccount.id) { - this.readBackRefreshedTokens(activeAuthPath) + if (this.skipNextReadBackForAccountId === activeAccount.id) { + this.skipNextReadBackForAccountId = null + } else { + this.readBackRefreshedTokens({ + updateLastWrittenAuthJson: true + }) + } } + if (this.lastSyncedAccountId !== activeAccount.id) { + this.skipNextReadBackForAccountId = null + } this.lastSyncedAccountId = activeAccount.id this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8')) } @@ -103,35 +154,64 @@ export class CodexRuntimeHomeService { // re-auth or add-account. Those flows write fresh tokens to managed storage, // so the read-back must be skipped to avoid overwriting them with stale // runtime tokens. - clearLastWrittenAuthJson(): void { - this.lastWrittenAuthJson = null + clearLastWrittenAuthJson(accountId = this.store.getSettings().activeCodexManagedAccountId): void { + if (accountId === this.store.getSettings().activeCodexManagedAccountId) { + this.lastWrittenAuthJson = null + } + this.skipNextReadBackForAccountId = accountId } - private readBackRefreshedTokens(managedAuthPath: string): void { + private readBackRefreshedTokens(options: { + updateLastWrittenAuthJson: boolean + }): CodexReadBackResult { try { const runtimeAuthPath = this.getRuntimeAuthPath() if (!existsSync(runtimeAuthPath)) { - return - } - - if (this.lastWrittenAuthJson === null) { - return + return 'unchanged' } const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') - if (runtimeContents === this.lastWrittenAuthJson) { - return + if (this.lastWrittenAuthJson !== null && runtimeContents === this.lastWrittenAuthJson) { + return 'unchanged' } - writeFileAtomically(managedAuthPath, runtimeContents, { mode: 0o600 }) + const match = this.findManagedAccountForRuntimeAuth(runtimeContents) + if (match.kind !== 'matched') { + if (match.kind === 'ambiguous') { + console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back') + } + return 'rejected' + } + // Why: after app restart, Orca has no last-written baseline. Identity + // alone cannot prove runtime auth is newer than managed storage. + if ( + this.lastWrittenAuthJson === null && + !this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents) + ) { + return 'rejected' + } + + writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 }) + if (options.updateLastWrittenAuthJson) { + this.lastWrittenAuthJson = runtimeContents + } + return 'persisted' } catch (error) { // Why: read-back is best-effort. A transient fs error must not block the // forward sync path — the worst case is one more stale-token cycle, which // is strictly better than failing the entire sync. console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error) + return 'rejected' } } + private readBackRefreshedTokensForAccount( + _account: CodexManagedAccount, + options: { updateLastWrittenAuthJson: boolean } + ): CodexReadBackResult { + return this.readBackRefreshedTokens(options) + } + private safeSyncForCurrentSelection(): void { try { this.syncForCurrentSelection() @@ -150,6 +230,208 @@ export class CodexRuntimeHomeService { return accounts.find((account) => account.id === activeAccountId) ?? null } + private findManagedAccountForRuntimeAuth(runtimeAuthContents: string): CodexReadBackMatch { + const matches: { + account: CodexManagedAccount + managedAuthPath: string + managedAuthContents: string + }[] = [] + for (const account of this.store.getSettings().codexManagedAccounts) { + const managedAuthPath = join(account.managedHomePath, 'auth.json') + if (!existsSync(managedAuthPath)) { + continue + } + const managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + if (this.runtimeAuthMatchesAccount(runtimeAuthContents, account, managedAuthContents)) { + matches.push({ account, managedAuthPath, managedAuthContents }) + } + } + + if (matches.length === 1) { + return { kind: 'matched', ...matches[0] } + } + return { kind: matches.length === 0 ? 'none' : 'ambiguous' } + } + + private runtimeAuthMatchesAccount( + runtimeAuthContents: string, + activeAccount: CodexManagedAccount, + managedAuthContents: string + ): boolean { + const identity = this.readIdentityFromAuthContents(runtimeAuthContents) + if (!identity) { + return false + } + const managedIdentity = this.readIdentityFromAuthContents(managedAuthContents) + + // Why: old live Codex PTYs can still write refreshed tokens into the + // shared runtime home after the user switches accounts. Never persist + // that write into the newly active managed account unless the auth claims + // still match the account Orca believes is selected. + const selectedEmail = this.firstNonNull( + this.normalizeField(activeAccount.email), + managedIdentity?.email + ) + const selectedProviderId = this.firstNonNull( + this.normalizeField(activeAccount.providerAccountId), + managedIdentity?.providerAccountId + ) + const selectedWorkspaceId = this.firstNonNull( + this.normalizeField(activeAccount.workspaceAccountId), + managedIdentity?.workspaceAccountId + ) + const emailMatches = Boolean( + selectedEmail && identity.email && selectedEmail === identity.email + ) + if (selectedEmail && identity.email && selectedEmail !== identity.email) { + return false + } + if (!this.identityFieldMatches(selectedProviderId, identity.providerAccountId)) { + return false + } + if (!this.identityFieldMatches(selectedWorkspaceId, identity.workspaceAccountId)) { + return false + } + + const hasStrongIdentity = Boolean( + (selectedProviderId && identity.providerAccountId) || + (selectedWorkspaceId && identity.workspaceAccountId) + ) + return ( + hasStrongIdentity || + (emailMatches && !identity.providerAccountId && !identity.workspaceAccountId) + ) + } + + private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean { + const runtimeFreshness = this.readFreshnessFromAuthContents(runtimeAuthContents) + const managedFreshness = this.readFreshnessFromAuthContents(managedAuthContents) + return ( + runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness > managedFreshness + ) + } + + private identityFieldMatches(selectedField: string | null, runtimeField: string | null): boolean { + return !selectedField || Boolean(runtimeField && selectedField === runtimeField) + } + + private firstNonNull(...values: (string | null | undefined)[]): string | null { + return values.find((value): value is string => Boolean(value)) ?? null + } + + private readIdentityFromAuthContents(contents: string): CodexAuthIdentity | null { + let raw: Record + try { + raw = JSON.parse(contents) as Record + } catch { + return null + } + + const tokens = this.readRecordClaim(raw, 'tokens') + const idToken = this.normalizeField( + this.readStringClaim(tokens, 'id_token') ?? this.readStringClaim(tokens, 'idToken') + ) + const payload = idToken ? this.parseJwtPayload(idToken) : null + const authClaims = this.readRecordClaim(payload, 'https://api.openai.com/auth') + const profileClaims = this.readRecordClaim(payload, 'https://api.openai.com/profile') + + return { + email: this.normalizeField( + this.readStringClaim(payload, 'email') ?? this.readStringClaim(profileClaims, 'email') + ), + providerAccountId: this.normalizeField( + this.readStringClaim(tokens, 'account_id') ?? + this.readStringClaim(tokens, 'accountId') ?? + this.readStringClaim(authClaims, 'chatgpt_account_id') ?? + this.readStringClaim(payload, 'chatgpt_account_id') + ), + workspaceAccountId: this.normalizeField( + this.readStringClaim(authClaims, 'workspace_account_id') ?? + this.readStringClaim(tokens, 'account_id') ?? + this.readStringClaim(tokens, 'accountId') ?? + this.readStringClaim(payload, 'chatgpt_account_id') + ) + } + } + + private readFreshnessFromAuthContents(contents: string): number | null { + let raw: Record + try { + raw = JSON.parse(contents) as Record + } catch { + return null + } + + const tokens = this.readRecordClaim(raw, 'tokens') + const idToken = this.normalizeField( + this.readStringClaim(tokens, 'id_token') ?? this.readStringClaim(tokens, 'idToken') + ) + const payload = idToken ? this.parseJwtPayload(idToken) : null + return ( + this.readNumberClaim(tokens, 'expires_at') ?? + this.readNumberClaim(tokens, 'expiresAt') ?? + this.readNumberClaim(tokens, 'expiry') ?? + this.readNumberClaim(tokens, 'expires') ?? + this.readNumberClaim(payload, 'exp') ?? + this.readNumberClaim(payload, 'iat') + ) + } + + private parseJwtPayload(token: string): Record | null { + const parts = token.split('.') + if (parts.length < 2) { + return null + } + + let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/') + while (payload.length % 4 !== 0) { + payload += '=' + } + + try { + const json = Buffer.from(payload, 'base64').toString('utf-8') + return JSON.parse(json) as Record + } catch { + return null + } + } + + private readRecordClaim( + value: Record | null, + key: string + ): Record | null { + const claim = value?.[key] + if (!claim || typeof claim !== 'object' || Array.isArray(claim)) { + return null + } + return claim as Record + } + + private readStringClaim(value: Record | null, key: string): string | null { + const claim = value?.[key] + return typeof claim === 'string' ? claim : null + } + + private readNumberClaim(value: Record | null, key: string): number | null { + const claim = value?.[key] + if (typeof claim === 'number' && Number.isFinite(claim)) { + return claim + } + if (typeof claim === 'string') { + const parsed = Number(claim) + return Number.isFinite(parsed) ? parsed : null + } + return null + } + + private normalizeField(value: string | null | undefined): string | null { + if (!value) { + return null + } + const trimmed = value.trim() + return trimmed === '' ? null : trimmed + } + private safeMigrateLegacyManagedState(): void { try { this.migrateLegacyManagedStateIfNeeded() @@ -330,34 +612,48 @@ export class CodexRuntimeHomeService { writeFileAtomically(diagnosticsPath, `${existingContents}${JSON.stringify(record)}\n`) } - private captureSystemDefaultSnapshotIfNeeded(): void { + private captureSystemDefaultSnapshot(options: { force: boolean }): void { const snapshotPath = this.getSystemDefaultSnapshotPath() - if (existsSync(snapshotPath)) { + if (!options.force && existsSync(snapshotPath)) { return } const runtimeAuthPath = this.getRuntimeAuthPath() - if (!existsSync(runtimeAuthPath)) { - return + const snapshot: CodexSystemDefaultSnapshot = { + authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null } - - writeFileAtomically(snapshotPath, readFileSync(runtimeAuthPath, 'utf-8')) + writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) } - private restoreSystemDefaultSnapshot(): void { + private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { // Why: detect whether an external tool (e.g. `codex auth login`) overwrote // auth.json while a managed account was active. If so, that external login // becomes the new system default — skip the stale snapshot restore. - if (this.detectExternalLoginAndUpdateSnapshot()) { + if (options.detectExternalLogin && this.detectExternalLoginAndUpdateSnapshot()) { return } const snapshotPath = this.getSystemDefaultSnapshotPath() if (!existsSync(snapshotPath)) { + rmSync(this.getRuntimeAuthPath(), { force: true }) + this.lastWrittenAuthJson = null return } - this.writeRuntimeAuth(readFileSync(snapshotPath, 'utf-8')) + const snapshot = this.readSystemDefaultSnapshot(snapshotPath) + if (!snapshot) { + console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot') + rmSync(snapshotPath, { force: true }) + rmSync(this.getRuntimeAuthPath(), { force: true }) + this.lastWrittenAuthJson = null + return + } + if (snapshot.authJson === null) { + rmSync(this.getRuntimeAuthPath(), { force: true }) + this.lastWrittenAuthJson = null + return + } + this.writeRuntimeAuth(snapshot.authJson) } // Why: mirrors ClaudeRuntimeAuthService.detectExternalLoginAndUpdateSnapshot(). @@ -371,7 +667,10 @@ export class CodexRuntimeHomeService { const runtimeAuthPath = this.getRuntimeAuthPath() if (!existsSync(runtimeAuthPath)) { - return false + const snapshotPath = this.getSystemDefaultSnapshotPath() + rmSync(snapshotPath, { force: true }) + this.lastWrittenAuthJson = null + return true } try { @@ -392,10 +691,52 @@ export class CodexRuntimeHomeService { private writeRuntimeAuth(contents: string): void { // Why: auth.json contains sensitive credentials. Restrict to owner-only // so other users on a shared Linux/macOS machine cannot read it. + if (this.fileContentsEqual(this.getRuntimeAuthPath(), contents)) { + this.ensureOwnerOnlyMode(this.getRuntimeAuthPath()) + this.lastWrittenAuthJson = contents + return + } writeFileAtomically(this.getRuntimeAuthPath(), contents, { mode: 0o600 }) this.lastWrittenAuthJson = contents } + private fileContentsEqual(targetPath: string, contents: string): boolean { + try { + return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents + } catch { + return false + } + } + + private ensureOwnerOnlyMode(targetPath: string): void { + if (process.platform === 'win32') { + return + } + try { + chmodSync(targetPath, 0o600) + } catch { + /* Best effort: the next atomic write will set the restrictive mode. */ + } + } + + private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { + try { + const parsed = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + 'authJson' in parsed && + (typeof parsed.authJson === 'string' || parsed.authJson === null) + ) { + return parsed as CodexSystemDefaultSnapshot + } + } catch { + return null + } + return null + } + clearSystemDefaultSnapshot(): void { rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) } diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 2fcaa742c3f..8b6306e24ff 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -106,7 +106,7 @@ export class CodexAccountService { activeCodexManagedAccountId: account.id }) this.safeSyncCanonicalConfigToManagedHomes() - this.runtimeHome.clearLastWrittenAuthJson() + this.runtimeHome.clearLastWrittenAuthJson(account.id) this.runtimeHome.syncForCurrentSelection() // Why: the new account becomes active, so the previous active account is @@ -150,7 +150,7 @@ export class CodexAccountService { codexManagedAccounts: updatedAccounts }) this.safeSyncCanonicalConfigToManagedHomes() - this.runtimeHome.clearLastWrittenAuthJson() + this.runtimeHome.clearLastWrittenAuthJson(accountId) this.runtimeHome.syncForCurrentSelection() // Why: re-auth can change which actual Codex identity the managed home diff --git a/src/shared/types.ts b/src/shared/types.ts index 5224e898dcb..b98b1f46e40 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1181,9 +1181,9 @@ export type GlobalSettings = { promptCacheTtlMs: number /** Why: Codex rate-limit account routing is a durable app preference owned by * the main process, not transient UI state. Persisting the selected managed - * homes here lets Orca resolve the correct `CODEX_HOME` before the renderer - * hydrates, while keeping this scope explicitly separate from Codex usage - * analytics and external terminal sessions. */ + * auth here lets Orca prepare shared ~/.codex before the renderer hydrates, + * while keeping this scope explicitly separate from Codex usage analytics + * and external terminal sessions. */ codexManagedAccounts: CodexManagedAccount[] activeCodexManagedAccountId: string | null /** Why: Claude Code keeps conversations under one shared config root. Orca From 236087d25dbea16f999afb27c80fb873da4552f4 Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 21:02:51 -0700 Subject: [PATCH 03/15] fix: pr-bug-scan validated finding from #1671 (#1681) Add legacy version-first ID branches (3-5-sonnet, 3-5-haiku) in normalizeModelForPricing so legacy logs map to existing pricing entries instead of returning null. Co-authored-by: orca-bug-scan-bot --- src/main/claude-usage/store.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index f53a600cf73..476782abf44 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -100,12 +100,21 @@ function normalizeModelForPricing(model: string | null): string | null { if (lower.includes('sonnet-3-7') || lower.includes('sonnet-3.7')) { return 'claude-sonnet-3-7' } + // Why: legacy version-first IDs like `claude-3-5-sonnet-20241022` are still + // present in historical Claude Code/SDK logs read off disk. Match them so + // their cost is not silently dropped from the breakdown. + if (lower.includes('3-5-sonnet') || lower.includes('3.5-sonnet')) { + return 'claude-sonnet-3-7' + } if (lower.includes('haiku-4-5')) { return 'claude-haiku-4-5' } if (lower.includes('haiku-3-5') || lower.includes('haiku-3.5')) { return 'claude-haiku-3-5' } + if (lower.includes('3-5-haiku') || lower.includes('3.5-haiku')) { + return 'claude-haiku-3-5' + } if (lower.includes('haiku-3')) { return 'claude-haiku-3' } From c7c1903a2c9b0a85486f36be953b9f14c42f50c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 04:15:12 +0000 Subject: [PATCH 04/15] release: v1.3.48-rc.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d91560e55e7..dbe4c7fc479 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.48-rc.1", + "version": "1.3.48-rc.3", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From f446a8b460cce8a0afb609c5ebf3b07925d1f0b0 Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 21:29:25 -0700 Subject: [PATCH 05/15] fix: pr-bug-scan validated finding from #1680 (#1682) * fix: address pr-bug-scan validated finding from #1680 On cold open, optimistic comments are now surfaced via a loading-shell fallback in the details memo, with a state tick so the memo re-runs after appendOptimisticComment. * fix: address react-hooks lint warnings on #1680 fix-PR - handleSubmit useCallback: add missing itemType dep - details useMemo: keep optimisticTick (rerender signal for cold-open ref reads) with eslint-disable + why-comment --------- Co-authored-by: orca-bug-scan-bot Co-authored-by: nwparker --- .../src/components/GitHubItemDialog.tsx | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index fca123fc066..6f449118a9f 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -2320,7 +2320,7 @@ function GHCommentComposer({ } finally { setSubmitting(false) } - }, [autoGrow, body, repoPath, issueNumber, onCommentAdded]) + }, [autoGrow, body, repoPath, issueNumber, itemType, onCommentAdded]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -2523,16 +2523,29 @@ export default function GitHubItemDialog({ ) ) + // Why: bumped by appendOptimisticComment on cold open (no cached details + // yet) so the details memo re-runs and surfaces the optimistic comment via + // the loading-shell fallback. Without this, the comment would sit in the + // ref alone and not render until the in-flight fetch lands. The cache + // notify path handles the warm case. + const [optimisticTick, setOptimisticTick] = useState(0) + // Why: merge optimistic comments into the cached details. Keyed off // cachedEntry identity (stable) rather than the optimistic ref array (a // fresh array each render) to avoid unnecessary recomputation. Cache // notifications after optimistic writes will re-render this anyway. const details = useMemo(() => { const cachedDetails = cachedEntry?.details ?? null + const opt = optimisticCommentsRef.current if (!cachedDetails) { + // Why: details may still be loading on a cold open — surface optimistic + // comments via a minimal shell so a comment posted before the fetch + // resolves isn't held invisibly in ref-land. + if (opt.length > 0 && workItem) { + return { item: workItem, body: '', comments: [...opt] } + } return null } - const opt = optimisticCommentsRef.current if (opt.length === 0) { return cachedDetails } @@ -2542,7 +2555,13 @@ export default function GitHubItemDialog({ return cachedDetails } return { ...cachedDetails, comments: [...cachedDetails.comments, ...missing] } - }, [cachedEntry]) + // Why: optimisticTick is the rerender signal for cold-open writes — the + // memo reads optimisticCommentsRef.current (a ref, no subscription), so + // bumping the tick is what forces this memo to re-run. The lint flags it + // as "unnecessary" because it's not referenced in the body, but removing + // it would silently break the cold-open optimistic-shell path. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cachedEntry, workItem, optimisticTick]) const loading = !!cachedEntry?.pending && !cachedEntry?.details const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null @@ -2677,12 +2696,15 @@ export default function GitHubItemDialog({ fetchedAt: 0, error: undefined }) + return } } - // Why: when the cache has no details yet (still loading), the - // optimistic comment is held only in optimisticCommentsRef and will - // be merged once the in-flight fetch lands and writes details. } + // Why: when the cache has no details yet (still loading), no cache + // write/notify fires above. Bump local state so the details memo + // re-runs and surfaces the optimistic comment via the loading-shell + // fallback instead of holding it invisibly in the ref. + setOptimisticTick((n) => n + 1) }, [detailsCacheKey] ) From 908bc1823453793c543ba91bee16ed6ca92ebbe8 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 10 May 2026 21:47:15 -0700 Subject: [PATCH 06/15] feat(sidebar-filter): replace dropdown with searchable popover for repo filtering (#1684) Replaces the DropdownMenu-based repo filter with a Command/Popover combo that supports live search, All/None bulk actions, and a Clear all footer. Scales to large repo sets without scroll friction. Design doc added at docs/sidebar-filter-redesign.md. Co-authored-by: Orca --- docs/sidebar-filter-redesign.md | 87 +++++ .../src/components/sidebar/SidebarFilter.tsx | 302 +++++++++++++----- 2 files changed, 302 insertions(+), 87 deletions(-) create mode 100644 docs/sidebar-filter-redesign.md diff --git a/docs/sidebar-filter-redesign.md b/docs/sidebar-filter-redesign.md new file mode 100644 index 00000000000..4bce607be7e --- /dev/null +++ b/docs/sidebar-filter-redesign.md @@ -0,0 +1,87 @@ +# Sidebar Repo Filter Redesign + +## Problem + +The current sidebar filter UI does not scale beyond a small repo count. Rendering every repo as a checkbox row in a menu creates poor scanability, high click cost, and clipping/scroll friction. + +Constraints from current code: +- Filter semantics are store-driven (`showActiveOnly`, `hideDefaultBranchWorkspace`, `filterRepoIds`) and consumed by `computeVisibleWorktreeIds`. +- `searchRepos()` already provides ranking (display name first, path fallback). +- The trigger badge is the primary “filters active” affordance and must stay. + +## Goals + +1. Make repo filtering usable with large repo sets. +2. Keep toggle filters one click. +3. Add fast bulk actions (`All`, `None`, `Clear all`). +4. Preserve existing filter semantics and badge behavior. + +## Non-goals + +- No semantic changes to filtering logic in `visible-worktrees.ts`. +- No sticky-all persistence model like `RepoMultiCombobox`. +- No store schema migration. + +## Correctness Notes (from code audit) + +- `searchRepos()` is not debounced; it is synchronous `useMemo` filtering. +- Popover wheel rescue in `popover.tsx` only runs when the wheel handler is attached to a `PopoverContent` element that has class `popover-scroll-content`. Putting that class on an inner div does not activate the rescue path. +- `command.tsx` already includes a wheel fallback on `CommandList` for scroll-locked Radix Dialog parents. +- Sidebar filters are persisted via `window.api.ui.set` (debounced in `App.tsx`) and restored from persisted UI on launch. + +## Proposed UI + +Use `Popover` and keep trigger button/badge unchanged. + +Popover sections: +1. Header: `Filters` + `Clear all` (shown only when any filter is active). +2. Toggle rows: `Active only`, `Hide default branch`. +3. Repo section (only when `repos.length > 1`): + - Label + selected count. + - `All` and `None` actions. + - Search box (`Search repos...`). + - Scrollable repo list with checkmark, repo dot/name, and SSH badge when `connectionId` exists. + - Secondary path line for disambiguation when names collide. +4. Bottom action: `Add project` pinned below repo list. + +## Implementation Decision: cmdk vs plain input + +Use `Command` primitives (as in `repo-multi-combobox.tsx`) with `shouldFilter={false}` and feed pre-ranked `searchRepos()` results. + +Why: +- Better keyboard behavior out of the box (arrow navigation, enter selection). +- Existing `CommandList` wheel fallback handles scroll-lock contexts that currently break plain inner-scroll regions. +- Consistent behavior with an existing repo selector pattern in the app. + +## Edge Cases / Invalidation + +- Repo removed while popover is open: selection count and rows derive from live `repos`; stale ids must not count toward badge or selected count. +- Repo added while open: `All` should include the new repo immediately (derive from current `repos` each render). +- External repo mutations (sync/import) during search: filtered list updates from live `repos`; empty-state must not hide `Add project`. +- SSH repos: show SSH indicator to avoid ambiguity with local repos of same display name. +- 0 or 1 repo: hide repo filter section. Keep `Add project` visible outside the repo-count gate so users can still recover from low-repo states. +- Multi-window: filter state is window-local; this redesign does not introduce cross-window synchronization. + +## Accessibility / Focus + +- Do not use `role="menuitemcheckbox"` unless the container is a true menu. Use semantic buttons or cmdk items with explicit `aria-selected`/checked indicators. +- Autofocus search on open. +- Esc closes via Radix Popover default. +- Ensure focus returns to trigger on close. + +## Rollout + +1. Replace sidebar filter content with Popover + Command-based repo list. +2. Keep `searchRepos()` as the only ranking source. +3. Keep current filter setters and badge derivation semantics. +4. Ensure scroll behavior works inside dialog parents by using `CommandList` (or by moving wheel handling to the actual scroll container). +5. Validate with `pnpm typecheck` and `pnpm lint`. + +## Test Scope + +- Unit coverage remains in `visible-worktrees` for filter semantics. +- Add/adjust component tests for: + - keyboard navigation and selection, + - stale repo id handling, + - `All`/`None` behavior with live repo mutations, + - `Add project` visibility at repo count 0/1. diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index f9068f14fa2..8c98bfad6ec 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -1,18 +1,19 @@ -import React, { useCallback } from 'react' -import { Activity, GitBranch, ListFilter, FolderPlus, X } from 'lucide-react' +import React, { useCallback, useMemo, useState } from 'react' +import { Activity, Check, GitBranch, ListFilter, FolderPlus, Server, X } from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import RepoDotLabel from '@/components/repo/RepoDotLabel' +import { searchRepos } from '@/lib/repo-search' +import { cn } from '@/lib/utils' const SidebarFilter = React.memo(function SidebarFilter() { const showActiveOnly = useAppStore((s) => s.showActiveOnly) @@ -24,6 +25,17 @@ const SidebarFilter = React.memo(function SidebarFilter() { const repos = useAppStore((s) => s.repos) const addRepo = useAppStore((s) => s.addRepo) + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [commandValue, setCommandValue] = useState('') + + const handleOpenChange = useCallback((next: boolean) => { + setOpen(next) + if (!next) { + setQuery('') + } + }, []) + const handleToggleRepo = useCallback( (repoId: string) => { setFilterRepoIds( @@ -35,29 +47,46 @@ const SidebarFilter = React.memo(function SidebarFilter() { [filterRepoIds, setFilterRepoIds] ) - const handleToggleActive = useCallback( - () => setShowActiveOnly(!showActiveOnly), - [showActiveOnly, setShowActiveOnly] - ) - const handleToggleHideDefaultBranch = useCallback( - () => setHideDefaultBranchWorkspace(!hideDefaultBranchWorkspace), - [hideDefaultBranchWorkspace, setHideDefaultBranchWorkspace] - ) const canFilterRepos = repos.length > 1 - // Why: derive from the current repos list so stale IDs in filterRepoIds - // (e.g. lingering after a repo is removed) don't inflate the active-filter - // count or falsely signal an applied filter. - const selectedRepos = canFilterRepos ? repos.filter((r) => filterRepoIds.includes(r.id)) : [] - const hasRepoFilter = selectedRepos.length > 0 + // Why: derive from current repos so stale ids (e.g. lingering after a repo + // is removed) don't inflate counts or falsely signal an applied filter. + const selectedRepoIdSet = useMemo(() => { + const set = new Set() + for (const r of repos) { + if (filterRepoIds.includes(r.id)) { + set.add(r.id) + } + } + return set + }, [repos, filterRepoIds]) + const selectedCount = selectedRepoIdSet.size + const hasRepoFilter = selectedCount > 0 const hasAnyFilter = showActiveOnly || hideDefaultBranchWorkspace || hasRepoFilter const activeFilterCount = - (showActiveOnly ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedRepos.length + (showActiveOnly ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount + + const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query]) + const allSelected = canFilterRepos && selectedCount === repos.length + + const clearAll = useCallback(() => { + setShowActiveOnly(false) + setHideDefaultBranchWorkspace(false) + setFilterRepoIds([]) + }, [setShowActiveOnly, setHideDefaultBranchWorkspace, setFilterRepoIds]) + + // Why: derive ids from the live repos list at click time so a repo added + // while the popover is open is included immediately. + const selectAllRepos = useCallback(() => { + setFilterRepoIds(repos.map((r) => r.id)) + }, [repos, setFilterRepoIds]) + + const clearRepos = useCallback(() => setFilterRepoIds([]), [setFilterRepoIds]) return ( - + - + - + {hasAnyFilter ? 'Edit filters' : 'Filter workspaces'} - - event.preventDefault()} - > - - Active only - - event.preventDefault()} - > - - Hide default branch - - {canFilterRepos && ( - <> - - Repositories - {repos.map((r) => ( - handleToggleRepo(r.id)} - onSelect={(event) => event.preventDefault()} - > - - - ))} - - )} - {hasAnyFilter && ( - <> - - { - setShowActiveOnly(false) - setHideDefaultBranchWorkspace(false) - setFilterRepoIds([]) - }} + +
+ Filters + {hasAnyFilter ? ( + + ) : null} +
+ +
+ } + label="Active only" + checked={showActiveOnly} + onClick={() => setShowActiveOnly(!showActiveOnly)} + /> + } + label="Hide default branch" + checked={hideDefaultBranchWorkspace} + onClick={() => setHideDefaultBranchWorkspace(!hideDefaultBranchWorkspace)} + /> +
+ {canFilterRepos && ( - <> - - { - addRepo() - }} +
+
+ + Repositories + {hasRepoFilter && ( + + {selectedCount} selected + + )} + +
+ + · + +
+
+ + - - Add project - - + + + No repos match + {filteredRepos.map((r) => { + const checked = selectedRepoIdSet.has(r.id) + return ( + handleToggleRepo(r.id)} + className="items-center gap-2 px-3 py-1.5 text-xs" + > + + + + {r.connectionId && ( + + + SSH + + )} + + + ) + })} + + +
)} -
-
+ + {/* Why: per design, "Add project" stays visible regardless of repo + count so users can recover from the 0/1-repo state where the + repo section is hidden. */} +
+ +
+ + ) }) +type ToggleRowProps = { + icon: React.ReactNode + label: string + checked: boolean + onClick: () => void +} + +function ToggleRow({ icon, label, checked, onClick }: ToggleRowProps) { + // Why: the popover is not a true menu, so we use a plain button with + // aria-pressed rather than role="menuitemcheckbox". The visible checkmark + // carries the state for sighted users. + return ( + + ) +} + export default SidebarFilter From 1041ab4f7cd5cc8f6015b8b33eed487f7a01bef6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 10 May 2026 21:57:48 -0700 Subject: [PATCH 07/15] feat(agent-hooks): shared listener + relay adapter (PR 1/N for SSH agent status) (#1678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-hooks): introduce relay wire envelope + connectionId stamping Adds the shared `agent-hook-relay.ts` module with the `agent.hook` JSON-RPC notification envelope, the `agent_hook.requestReplay` / `agent_hook.installPlugins` method names, and the `ORCA_FEATURE_REMOTE_AGENT_HOOKS` flag helper. Promotes `AgentHookSource` to `shared/` so the relay can import it without dragging Electron in. Threads a `connectionId: string | null` field through `AgentHookEventPayload`, the `agentStatus:set` IPC contract, and the renderer-bound preload listener. Local hook posts stamp `null`; the relay-forwarded path will stamp from `mux` identity in a later commit. Renderer uses the stamp for stale-event filtering when an SSH connection tears down with notifications still in flight. See docs/design/agent-status-over-ssh.md §1, §5, §8 (commit #1). Co-authored-by: Orca * refactor(agent-hooks): extract shared listener; add relay-side adapter Extracts the listener internals (request parsing, payload normalization, endpoint-file writing, per-CLI extractors, warn-once Sets, slowloris timer helper, request size cap, paneKey caches) from `src/main/agent-hooks/server.ts` into a new transport-agnostic `src/shared/agent-hook-listener.ts`. The shared module uses only Node builtins (no Electron) so it is safe to import from `src/relay/`. Adds `src/relay/agent-hook-server.ts` — a thin HTTP-loopback adapter that wires the shared listener to a `forward(envelope)` callback so `relay.ts` can re-emit each parsed payload as an `agent.hook` JSON-RPC notification on the existing SshChannelMultiplexer. The adapter owns: - 127.0.0.1:0 socket + bearer-token auth, identical shape to the local server - per-paneKey last-payload cache + replayCachedPayloadsForPanes() for the request-driven replay path used after `--connect` reattach (see §5 Path 3) - clearPaneState(paneKey) for PTY-exit eviction (symmetric with local server) - buildPtyEnv() / endpoint-file writing for relay-spawned PTYs Orca's `AgentHookServer` is now a ~200-LoC adapter over the shared listener that owns the IPC fanout, listener replay, and `ingestRemote(envelope, connId)` entry point that bypasses the HTTP path for relay-forwarded events. See docs/design/agent-status-over-ssh.md §3, §8 (commit #2). Co-authored-by: Orca * fix(preload): expose connectionId on agentStatus.onSet type src/preload/index.ts already passes through `connectionId?: string | null` from main, but the PreloadApi declaration in api-types.ts was missing the field. Align the type with the runtime contract so renderer call sites can read connectionId without an `as` cast. Co-authored-by: Orca * fix(agent-hooks): harden ingestRemote + relay replay; review-driven cleanup - ingestRemote: re-run normalizeAgentStatusPayload at trust boundary; trim+validate connectionId/paneKey/tabId/worktreeId - relay: preserve source/env/version through replay via sidecar map; drop sourceFromAgentType fallback that mis-tagged unknown agents - shared listener: exhaustive switch+never on AgentHookSource dispatch chains; extractPromptText returns trimmed values; export MAX_PANE_KEY_LEN - preload: tighten connectionId from optional to required (always sent) - main IPC: reorder spread so explicit envelope fields win on collision Co-authored-by: Orca * chore(docs): drop agent-status-over-ssh design doc from PR The design RFC was useful for authoring this PR series but doesn't belong in-tree — keeping it here would freeze line-number references and design prose against future churn. Folding it into the PR description instead. Co-authored-by: Orca * chore(agent-hooks): widen ingestRemote type for env/version (PR2 prep) Declares `env?: string` and `version?: string` on the `ingestRemote` envelope parameter so PR2 only needs to add the `warnOnHookEnvOrVersionMismatch` callsite, not also widen the type. The fields are forwarded verbatim from the agent CLI POST body on the remote and let Orca's warn-once cross-build / dev-vs-prod diagnostics fire identically on remote-sourced events. Type-only addition; no runtime consumer in this PR. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/agent-hooks/server.test.ts | 157 +++ src/main/agent-hooks/server.ts | 1538 +++--------------------- src/main/index.ts | 5 +- src/preload/api-types.ts | 5 + src/preload/index.ts | 6 + src/relay/agent-hook-server.test.ts | 155 +++ src/relay/agent-hook-server.ts | 266 ++++ src/shared/agent-hook-listener.test.ts | 162 +++ src/shared/agent-hook-listener.ts | 1155 ++++++++++++++++++ src/shared/agent-hook-relay.test.ts | 57 + src/shared/agent-hook-relay.ts | 83 ++ 11 files changed, 2203 insertions(+), 1386 deletions(-) create mode 100644 src/relay/agent-hook-server.test.ts create mode 100644 src/relay/agent-hook-server.ts create mode 100644 src/shared/agent-hook-listener.test.ts create mode 100644 src/shared/agent-hook-listener.ts create mode 100644 src/shared/agent-hook-relay.test.ts create mode 100644 src/shared/agent-hook-relay.ts diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 0e291d83e15..77606ff3d5b 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -14,6 +14,7 @@ import { import { tmpdir } from 'os' import { join } from 'path' import { AgentHookServer, _internals } from './server' +import { parseAgentStatusPayload } from '../../shared/agent-status-types' const PANE = 'tab-1:0' @@ -77,6 +78,7 @@ describe('AgentHookServer listener replay', () => { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', + connectionId: null, payload: expect.objectContaining({ state: 'working', prompt: 'replay me', @@ -151,6 +153,7 @@ describe('AgentHookServer listener replay', () => { paneKey: PANE, tabId: 'tab-1', worktreeId: 'repo::/tmp/worktree with "quotes"', + connectionId: null, payload: expect.objectContaining({ state: 'working', prompt: 'form encoded', @@ -1202,3 +1205,157 @@ describe('Endpoint file lifecycle', () => { } }) }) + +describe('AgentHookServer ingestRemote', () => { + it('stamps connectionId and forwards a valid relay envelope to the listener', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'conn-1', + payload + }) + }) + + it('drops envelopes whose payload state is not in AGENT_STATUS_STATES', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + // Why: bypass parseAgentStatusPayload (which itself rejects bad states) by + // constructing an obviously-invalid payload — `ingestRemote` is the trust + // boundary we're testing, not the parser. + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'nonsense', prompt: '', agentType: 'claude' } + }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('drops envelopes whose paneKey exceeds MAX_PANE_KEY_LEN', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + // 201 chars — one past the listener's 200-char cap. + const oversized = 'a'.repeat(201) + server.ingestRemote( + { paneKey: oversized, tabId: 'tab-1', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects empty connectionId', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, + '' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects whitespace-only connectionId', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, + ' ' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects non-string tabId', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: PANE, tabId: 123 as unknown as string, worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects empty paneKey after trim', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: ' ', tabId: 'tab-1', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('normalizes inner payload via normalizeAgentStatusPayload — clamps oversized prompt', () => { + // Why: the relay normally normalizes the payload on the wire, but a buggy + // or malicious relay could forward an over-cap field. ingestRemote must + // re-run the canonical normalizer so the AGENT_STATUS_MAX_FIELD_LENGTH + // cap (200 chars) is enforced at the trust boundary. + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'x'.repeat(500), agentType: 'claude' } + }, + 'conn-1' + ) + expect(listener).toHaveBeenCalledTimes(1) + const event = listener.mock.calls[0][0] as { payload: { prompt: string } } + expect(event.payload.prompt.length).toBe(200) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index e88d08ad9ae..9dff04237c9 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1,1187 +1,36 @@ -/* eslint-disable max-lines -- Why: the hook server owns the full HTTP ingest surface (routing, body parsing, per-CLI normalization, transcript scan, pane dispatch) in one place so the contract with Claude/Codex/Gemini hooks stays consistent and doesn't drift across files. */ +// Why: this module is the Orca-main-process adapter for the shared +// agent-hook listener pipeline (`src/shared/agent-hook-listener.ts`). The +// listener internals (request parsing, payload normalization, endpoint-file +// writing, validation) live in `shared/` so the relay can host the same +// pipeline on the remote without dragging Electron in. This file owns: +// - the loopback HTTP socket + bearer-token auth +// - the IPC fanout (setListener / lastStatusByPaneKey replay) +// - the `ingestRemote` entry point that bypasses HTTP for relay-forwarded +// events (see docs/design/agent-status-over-ssh.md §5) import { createServer, type IncomingMessage, type ServerResponse } from 'http' import { randomUUID } from 'crypto' -import { - chmodSync, - closeSync, - mkdirSync, - openSync, - readdirSync, - readSync, - renameSync, - statSync, - unlinkSync, - writeFileSync -} from 'fs' import { join } from 'path' -import { - parseAgentStatusPayload, - type ParsedAgentStatusPayload -} from '../../shared/agent-status-types' + import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' +import { + clearAllListenerCaches, + clearPaneCacheState, + createHookListenerState, + getEndpointFileName, + HOOK_REQUEST_SLOWLORIS_MS, + MAX_PANE_KEY_LEN, + normalizeHookPayload, + parseFormEncodedBody, + readRequestBody, + resolveHookSource, + writeEndpointFile, + type AgentHookEventPayload, + type HookListenerState +} from '../../shared/agent-hook-listener' +import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { normalizeAgentStatusPayload } from '../../shared/agent-status-types' -// Why: Pi rides this server via a bundled extension (see -// pi/agent-status-extension-source) that fetch()es /hook/pi from inside -// the pi Node process. Like OpenCode, pi has no settings.json hook -// surface — its extensibility is the in-process TypeScript extension API -// (pi.on('agent_start'), 'tool_call', etc.), so the extension pre-maps -// pi's event names to the same hook_event_name vocabulary used here -// before POSTing. See normalizePiEvent below for the mapping. -// -// OpenCode rides this server via a bundled plugin (see opencode/hook-service) -// that fetch()es /hook/opencode from inside the OpenCode process. Unlike -// Claude/Codex/Gemini, OpenCode's event names are in-process plugin events -// (session.status, session.idle, permission.asked) rather than settings.json -// hook names, so the plugin pre-maps them to our hook_event_name vocabulary -// before POSTing. See normalizeOpenCodeEvent below for the mapping. -// -// Cursor (cursor-agent) exposes a declarative hooks.json surface that is -// conceptually similar to Claude's settings.json hooks but uses camelCase -// event names (beforeSubmitPrompt, preToolUse, postToolUse, stop, etc.) per -// https://cursor.com/docs/hooks. See normalizeCursorEvent below. -type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' | 'pi' - -type AgentHookEventPayload = { - paneKey: string - tabId?: string - worktreeId?: string - payload: ParsedAgentStatusPayload -} - -// Why: only log a given version/env mismatch once per process so a stale hook -// script that fires on every keystroke doesn't flood the logs. -const warnedVersions = new Set() -const warnedEnvs = new Set() -// Why: cap the warning Sets so a buggy or malicious local client that varies -// its `version`/`env` fields per request cannot grow these Sets without bound -// for the process lifetime. Once saturated, we drop further warnings (and skip -// inserting the new key) — the diagnostic value of "warn once" is preserved -// for the common case while memory stays bounded against untrusted input. -const MAX_WARNED_KEYS = 32 -// Why: hook events can arrive while Orca is windowless (common on macOS when -// the user closes the window but leaves the app running). Retain the latest -// normalized status per pane so reopening the window can replay current agent -// state instead of showing nothing until the next hook event happens. -const lastStatusByPaneKey = new Map() - -// Why: Claude documents `prompt` on UserPromptSubmit; other agents may use -// different field names. Probe a small allowlist so we can surface the real -// user prompt in the dashboard regardless of which agent is reporting. -function extractPromptText(hookPayload: Record): string { - const candidateKeys = ['prompt', 'user_prompt', 'userPrompt', 'message'] - for (const key of candidateKeys) { - const value = hookPayload[key] - if (typeof value === 'string' && value.trim().length > 0) { - return value - } - } - // Why: OpenCode's plugin sends MessagePart events with { role, text }. When - // role === 'user', the text *is* the prompt — surface it so the dashboard - // shows the user's most recent input even though OpenCode has no dedicated - // UserPromptSubmit event we can hook into. - if (hookPayload.role === 'user' && typeof hookPayload.text === 'string') { - const trimmed = hookPayload.text.trim() - if (trimmed.length > 0) { - return hookPayload.text - } - } - return '' -} - -function parseFormEncodedBody(body: string): Record { - const params = new URLSearchParams(body) - const parsed: Record = {} - for (const [key, value] of params.entries()) { - parsed[key] = value - } - return parsed -} - -function readRequestBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = [] - let byteLength = 0 - let settled = false - req.on('data', (chunk: Buffer) => { - if (settled) { - return - } - // Why: check size in bytes (not UTF-16 code units) and stop accumulating - // after rejection so a malicious client cannot push memory past the - // advertised 1 MB cap. - if (byteLength + chunk.length > 1_000_000) { - settled = true - reject(new Error('payload too large')) - req.destroy() - return - } - byteLength += chunk.length - chunks.push(chunk) - }) - req.on('end', () => { - if (settled) { - return - } - settled = true - try { - // Why: decode once via Buffer.concat so multi-byte UTF-8 characters that - // straddle a chunk boundary are reassembled correctly. Per-chunk - // `.toString('utf8')` would corrupt emoji or non-ASCII inside assistant - // messages. - const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf8') : '' - const contentType = req.headers['content-type'] ?? '' - if (typeof contentType === 'string' && contentType.includes('application/json')) { - resolve(body ? JSON.parse(body) : {}) - return - } - if ( - typeof contentType === 'string' && - contentType.includes('application/x-www-form-urlencoded') - ) { - resolve(parseFormEncodedBody(body)) - return - } - // Why: existing managed scripts POST JSON and the updated Unix scripts - // POST form-encoded data. Default to JSON for unknown/missing content - // types so legacy callers that omit the header still behave as before. - resolve(body ? JSON.parse(body) : {}) - } catch (error) { - reject(error) - } - }) - req.on('error', (err) => { - if (settled) { - return - } - settled = true - reject(err) - }) - // Why: req.destroy() (called by the slowloris setTimeout in the route - // handler) emits 'close'/'aborted' but not 'end' or 'error'. Without this - // handler the promise would never settle and the chunk buffers would be - // retained for the process lifetime, letting a slow client that holds a - // valid token accumulate pending closures indefinitely. - req.on('close', () => { - if (settled) { - return - } - settled = true - reject(new Error('aborted')) - }) - }) -} - -// Why: only UserPromptSubmit carries the user's prompt. Subsequent events in -// the same turn (PostToolUse, PermissionRequest, Stop, …) arrive with no -// prompt, so we cache the last prompt per pane and reuse it until a new -// prompt arrives. The cache survives across `done` so the user can still see -// what finished; it's reset on the next UserPromptSubmit. -const lastPromptByPaneKey = new Map() - -function resolvePrompt( - paneKey: string, - promptText: string, - options?: { resetOnNewTurn?: boolean } -): string { - if (options?.resetOnNewTurn) { - // Why: some turn-boundary events (e.g. Codex SessionStart, OpenCode - // SessionBusy) do not carry the new prompt yet. Clearing here prevents the - // previous turn's prompt from leaking into the new working state until a - // later prompt-bearing event arrives. - lastPromptByPaneKey.delete(paneKey) - } - if (promptText) { - lastPromptByPaneKey.set(paneKey, promptText) - return promptText - } - return lastPromptByPaneKey.get(paneKey) ?? '' -} - -type ToolSnapshot = { - toolName?: string - toolInput?: string - lastAssistantMessage?: string -} - -// Why: mirrors `lastPromptByPaneKey`. Tool + assistant metadata arrives -// piecemeal (PreToolUse gives name+input; PostToolUse gives response; -// Stop gives the final message), and later events typically omit fields -// the earlier ones provided. Caching per-pane lets the renderer show a -// coherent snapshot instead of blinking whenever a field is missing. -const lastToolByPaneKey = new Map() - -function resolveToolState( - paneKey: string, - update: ToolSnapshot, - options: { resetOnNewTurn: boolean } -): ToolSnapshot { - if (options.resetOnNewTurn) { - // Why: a fresh user turn shouldn't inherit the previous turn's - // tool/assistant state — it would look like the agent is still on - // the old step until the first new tool event lands. - lastToolByPaneKey.delete(paneKey) - } - const previous = lastToolByPaneKey.get(paneKey) ?? {} - const merged: ToolSnapshot = { - toolName: update.toolName ?? previous.toolName, - toolInput: update.toolInput ?? previous.toolInput, - lastAssistantMessage: update.lastAssistantMessage ?? previous.lastAssistantMessage - } - lastToolByPaneKey.set(paneKey, merged) - return merged -} - -// Why: per-tool allowlist (noqa style) — explicit mapping from tool name to -// the single input field worth surfacing. Tools that aren't listed render -// name-only. This avoids noisy fallbacks (e.g. "TaskUpdate 3" from the -// task_id field) and keeps the preview honest: if we don't know how to -// describe a tool's input meaningfully, we show nothing rather than guess. -// -// Ordering matters when a tool sends multiple well-known keys (e.g. Grep -// sends both `pattern` and `path`); the first match wins. -const TOOL_INPUT_KEYS_BY_TOOL: Record = { - // Claude tools (PascalCase). - Read: ['file_path', 'filePath', 'path'], - Write: ['file_path', 'filePath', 'path'], - Edit: ['file_path', 'filePath', 'path'], - MultiEdit: ['file_path', 'filePath', 'path'], - NotebookEdit: ['file_path', 'filePath', 'path'], - Bash: ['command'], - Glob: ['pattern'], - Grep: ['pattern'], - WebFetch: ['url'], - WebSearch: ['query'], - // Gemini tools (snake_case). - read_file: ['file_path', 'path'], - write_file: ['file_path', 'path'], - read_many_files: ['file_path', 'paths', 'path'], - edit_file: ['file_path', 'path'], - replace: ['file_path', 'path'], - run_shell_command: ['command'], - glob: ['pattern'], - search_file_content: ['pattern'], - web_fetch: ['url'], - google_web_search: ['query'], - // Codex tools. `exec_command` and `shell_command` both carry their command - // text under `cmd` (the Rust payload) or `command` (some wrappers); list - // both so whichever field is populated wins. `apply_patch` surfaces the - // touched path. `view_image` is path-only. `write_stdin` gets nothing - // meaningful — intentionally omitted so the row stays name-only. - exec_command: ['cmd', 'command'], - shell_command: ['cmd', 'command'], - apply_patch: ['path', 'file_path'], - view_image: ['path', 'file_path'], - // Pi tools (lowercase names matching pi's built-in tool registry). - // Why: pi's tool_call event forwards the raw input object; surface the - // canonical preview field per tool so dashboard rows show useful context - // (file path, command, search pattern) without the receiver knowing - // anything pi-specific beyond these key names. `glob` is shared with - // Gemini (same shape: { pattern }), so no separate entry is needed. - bash: ['command'], - read: ['path', 'file_path'], - write: ['path', 'file_path'], - edit: ['path', 'file_path'], - grep: ['pattern'], - web_search: ['query'], - fetch_content: ['url'] -} - -function deriveToolInputPreview( - toolName: string | undefined, - toolInput: unknown -): string | undefined { - if (typeof toolInput === 'string') { - return toolInput - } - if (typeof toolInput !== 'object' || toolInput === null) { - return undefined - } - if (!toolName) { - return undefined - } - const keys = TOOL_INPUT_KEYS_BY_TOOL[toolName] - if (!keys) { - return undefined - } - const record = toolInput as Record - for (const key of keys) { - const value = record[key] - if (typeof value === 'string' && value.trim().length > 0) { - return value - } - } - return undefined -} - -function readString(record: Record, key: string): string | undefined { - const value = record[key] - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -// Why: Claude `tool_response` can be a string, or an object with a `content` -// array shaped like `[{type: 'text', text: '...'}]`. Surface the first text -// block so PostToolUse for Task/Agent subagents carries something useful into -// the `lastAssistantMessage` slot. -function extractToolResponseText(toolResponse: unknown): string | undefined { - if (typeof toolResponse === 'string' && toolResponse.length > 0) { - return toolResponse - } - if (typeof toolResponse !== 'object' || toolResponse === null) { - return undefined - } - const record = toolResponse as Record - const content = record.content - if (Array.isArray(content)) { - for (const part of content) { - if (typeof part === 'object' && part !== null) { - const text = (part as Record).text - if (typeof text === 'string' && text.trim().length > 0) { - return text - } - } - } - } - const text = record.text - if (typeof text === 'string' && text.trim().length > 0) { - return text - } - return undefined -} - -// Why: Claude's Stop event carries `transcript_path` to a JSONL transcript. -// Reading the last assistant message gives us the "what did the agent just -// say" preview without needing to buffer tool_response text across PostToolUse -// events. We scan backward from the end of the file in chunks, stopping as -// soon as we find an assistant text entry — bounded work in the common case -// (one chunk) even when transcripts grow to hundreds of MB. -const TRANSCRIPT_CHUNK_BYTES = 64 * 1024 -// Why: ultimate safety cap so a malformed transcript (or a turn with -// pathologically many tool calls and no assistant text) cannot stall the Stop -// handler. 4 MB easily accommodates dozens of tool rounds before the final -// reply; past that, we give up rather than block the hook response. -const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 - -function extractAssistantTextFromLine(line: string): string | undefined { - let entry: unknown - try { - entry = JSON.parse(line) - } catch { - return undefined - } - if (typeof entry !== 'object' || entry === null) { - return undefined - } - const record = entry as Record - const nestedMessage = record.message as Record | undefined - const role = record.role ?? nestedMessage?.role - if (role !== 'assistant') { - return undefined - } - const content = (nestedMessage ?? record).content - if (typeof content === 'string' && content.trim().length > 0) { - return content - } - // Why: assistant entries can be pure tool_use turns with no text parts. - // Return undefined so the caller keeps scanning backward for the most - // recent entry that actually contains assistant text. - if (Array.isArray(content)) { - for (const part of content) { - if (typeof part === 'object' && part !== null) { - const text = (part as Record).text - if (typeof text === 'string' && text.trim().length > 0) { - return text - } - } - } - } - return undefined -} - -function readLastAssistantFromTranscript(transcriptPath: unknown): string | undefined { - if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { - return undefined - } - try { - const stats = statSync(transcriptPath) - const size = stats.size - if (size <= 0) { - return undefined - } - const fd = openSync(transcriptPath, 'r') - try { - // Why: track unhandled leading bytes as a raw Buffer across iterations - // so multi-byte UTF-8 codepoints that straddle chunk boundaries are not - // corrupted. A previous implementation decoded the combined chunk then - // re-encoded `lines[0]` back to UTF-8 for the carry; when a chunk - // started mid-codepoint the decode produced U+FFFD replacement chars - // and the re-encode baked those replacements into the carry bytes - // permanently, mis-joining every subsequent chunk. Splitting on \n at - // the byte level (0x0a) and only decoding complete-line regions keeps - // the carry byte-exact. - let carryBytes: Buffer = Buffer.alloc(0) - let bytesRead = 0 - while (bytesRead < size && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { - const chunkSize = Math.min(size - bytesRead, TRANSCRIPT_CHUNK_BYTES) - const position = size - bytesRead - chunkSize - const buffer = Buffer.alloc(chunkSize) - // Why: readSync may return fewer bytes than requested (short reads). - // Loop until the full window is read (or EOF) before processing so the - // backward scan windows stay aligned — a bare `bytesRead += n` would - // advance from the last read's tail and either re-read overlapping - // bytes on the next iteration or miss lines entirely if short reads - // accumulate. Rare on local regular files but fs quirks exist. - let filled = 0 - while (filled < chunkSize) { - const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) - if (n === 0) { - break - } - filled += n - } - const n = filled - bytesRead += n - if (n === 0) { - break - } - // Why: the newly-read chunk is earlier in the file than `carryBytes` - // (which came from the *previous* iteration's partial first line), - // so concatenation order is chunk first, carry second. - const combined = Buffer.concat([buffer.subarray(0, n), carryBytes]) - const atStart = bytesRead >= size - - // Find the first newline in the raw bytes. Everything before it is - // a (still) potentially-partial line when we haven't reached SOF; - // everything from it onward is a sequence of complete lines we can - // decode safely. - const firstNewline = combined.indexOf(0x0a) - let completeRegion: Buffer - let nextCarry: Buffer - if (atStart) { - // At start-of-file there is no earlier chunk; every line is complete. - completeRegion = combined - nextCarry = Buffer.alloc(0) - } else if (firstNewline === -1) { - // No newline in the combined bytes: the entire region is one - // potentially-partial line — carry all of it forward. - completeRegion = Buffer.alloc(0) - nextCarry = combined - } else { - // Bytes [0, firstNewline) are the partial-line carry; bytes - // [firstNewline+1, end) are the complete-line region. Dropping the - // newline itself avoids an empty leading "" line after split. - nextCarry = combined.subarray(0, firstNewline) - completeRegion = combined.subarray(firstNewline + 1) - } - - if (completeRegion.length > 0) { - const lines = completeRegion.toString('utf8').split('\n') - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i].trim() - if (line.length === 0) { - continue - } - const extracted = extractAssistantTextFromLine(line) - if (extracted !== undefined) { - return extracted - } - } - } - carryBytes = nextCarry - } - return undefined - } finally { - closeSync(fd) - } - } catch { - return undefined - } -} - -function extractClaudeToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - const update: ToolSnapshot = {} - if ( - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' - ) { - const toolName = readString(hookPayload, 'tool_name') - update.toolName = toolName - update.toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) - } - if (eventName === 'PostToolUse') { - const responseText = extractToolResponseText(hookPayload.tool_response) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - if (eventName === 'PostToolUseFailure') { - const errorText = - extractToolResponseText(hookPayload.tool_response) ?? - readString(hookPayload, 'error') ?? - readString(hookPayload, 'message') - if (errorText) { - update.lastAssistantMessage = errorText - } - } - if (eventName === 'Stop') { - // Why: newer Claude versions include `last_assistant_message` directly on - // the Stop payload, which is both cheaper and more reliable than reading - // the JSONL transcript. Prefer it when present; fall back to transcript - // scanning for older Claude versions that omit the field. - const direct = readString(hookPayload, 'last_assistant_message') - if (direct) { - update.lastAssistantMessage = direct - } else { - const lastFromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path) - if (lastFromTranscript) { - update.lastAssistantMessage = lastFromTranscript - } - } - } - return update -} - -function extractCodexToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { - // Why: Codex emits tool metadata under `tool_name` + `tool_input` - // (matching Claude's shape). We surface both so the dashboard row can - // show what the agent is currently doing during the otherwise-silent - // gap between UserPromptSubmit and Stop. See TOOL_INPUT_KEYS_BY_TOOL - // for which input field is previewed per Codex tool name. - const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.input) ?? - deriveToolInputPreview(toolName, hookPayload.arguments) - return { toolName, toolInput } - } - if (eventName === 'Stop') { - // Why: Codex documents `last_assistant_message` on Stop. - const message = readString(hookPayload, 'last_assistant_message') - if (message) { - return { lastAssistantMessage: message } - } - } - return {} -} - -function extractGeminiToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'PreToolUse' || eventName === 'PostToolUse' || eventName === 'AfterTool') { - const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.args) ?? - deriveToolInputPreview(toolName, hookPayload.input) - return { toolName, toolInput } - } - if (eventName === 'AfterAgent') { - // Why: Gemini's AfterAgent payload carries the final reply under - // `prompt_response` (per geminicli.com/docs/hooks/reference). This is - // Gemini's analogue of Claude/Codex's `last_assistant_message` on Stop; - // surfacing it lets the dashboard show the agent's response on done. - const message = readString(hookPayload, 'prompt_response') - if (message) { - return { lastAssistantMessage: message } - } - } - return {} -} - -function extractOpenCodeToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'MessagePart' && hookPayload.role === 'assistant') { - // Why: OpenCode streams the assistant's reply via repeated MessagePart - // events (one per text delta flush). Each event carries the cumulative - // text-so-far for that TextPart, so the latest one we see is the most - // complete snapshot to surface on `done`. We do NOT gate on SessionIdle - // because the plugin emits parts *before* session.idle fires, and gating - // would lose them. - const text = readString(hookPayload, 'text') - if (text) { - return { lastAssistantMessage: text } - } - } - return {} -} - -// Why: Cursor's preToolUse / postToolUse / postToolUseFailure payloads carry -// `tool_name` + `tool_input` (same shape as Claude). beforeShellExecution / -// beforeMCPExecution carry a `command` field directly — surface that via a -// synthetic "Shell" / "MCP" tool name so the dashboard row can show the -// pending command while cursor-agent is blocked on approval. -// afterAgentResponse carries a `text` field that is cursor's analogue of -// Claude's last_assistant_message (the final composed reply for the turn). -function extractCursorToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'preToolUse' || - eventName === 'postToolUse' || - eventName === 'postToolUseFailure' - ) { - const toolName = readString(hookPayload, 'tool_name') - const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) - const update: ToolSnapshot = { toolName, toolInput } - if (eventName === 'postToolUse') { - const responseText = extractToolResponseText(hookPayload.tool_output) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - if (eventName === 'postToolUseFailure') { - const errorText = - extractToolResponseText(hookPayload.tool_output) ?? - readString(hookPayload, 'error_message') ?? - readString(hookPayload, 'error') - if (errorText) { - update.lastAssistantMessage = errorText - } - } - return update - } - if (eventName === 'beforeShellExecution') { - const command = readString(hookPayload, 'command') - return { toolName: 'Shell', toolInput: command } - } - if (eventName === 'beforeMCPExecution') { - const toolName = readString(hookPayload, 'tool_name') ?? 'MCP' - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - readString(hookPayload, 'command') ?? - readString(hookPayload, 'url') - return { toolName, toolInput } - } - if (eventName === 'afterAgentResponse') { - const text = readString(hookPayload, 'text') - if (text) { - return { lastAssistantMessage: text } - } - } - return {} -} - -// Why: pi's tool_call / tool_execution_start / tool_execution_end events all -// carry `tool_name` + `tool_input` in the same shape, so they share one -// extraction branch; resolveToolState merges across events last-write-wins so -// the most recent one wins naturally. message_end (assistant role) carries -// `text` — pi's analogue of Claude's last_assistant_message on Stop. -function extractPiToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'tool_call' || - eventName === 'tool_execution_start' || - eventName === 'tool_execution_end' - ) { - const toolName = readString(hookPayload, 'tool_name') - const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) - return { toolName, toolInput } - } - if (eventName === 'message_end' && hookPayload.role === 'assistant') { - const text = readString(hookPayload, 'text') - if (text) { - return { lastAssistantMessage: text } - } - } - return {} -} - -function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { - if (source === 'claude') { - return eventName === 'UserPromptSubmit' - } - if (source === 'codex') { - // Why: Codex fires SessionStart at resume AND startup. Both mark the - // boundary of a fresh interactive turn from the hook's perspective, so - // clear the tool cache on either one. - return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' - } - if (source === 'gemini') { - return eventName === 'BeforeAgent' - } - if (source === 'cursor') { - // Why: Cursor's beforeSubmitPrompt is the new-turn boundary (it carries - // the fresh prompt). sessionStart also begins a fresh session and should - // not inherit any cached tool state from whatever was left on disk. - return eventName === 'beforeSubmitPrompt' || eventName === 'sessionStart' - } - if (source === 'pi') { - // Why: pi fires before_agent_start at the start of every user turn, - // carrying the fresh prompt. Reset cached tool state then so a previous - // turn's tool preview does not bleed into the new one. - return eventName === 'before_agent_start' - } - // Why: OpenCode has no UserPromptSubmit analogue, AND the plugin emits the - // user's MessagePart *before* SessionBusy (message.updated fires on prompt - // submission; session.status goes busy only once OpenCode begins processing). - // So resetting on SessionBusy would clobber the user prompt that was just - // cached. The role=user MessagePart itself naturally overwrites the cache - // with each new turn, so no separate reset is needed here. - return false -} - -function extractToolFields( - source: AgentHookSource, - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (source === 'claude') { - return extractClaudeToolFields(eventName, hookPayload) - } - if (source === 'codex') { - return extractCodexToolFields(eventName, hookPayload) - } - if (source === 'gemini') { - return extractGeminiToolFields(eventName, hookPayload) - } - if (source === 'cursor') { - return extractCursorToolFields(eventName, hookPayload) - } - if (source === 'pi') { - return extractPiToolFields(eventName, hookPayload) - } - return extractOpenCodeToolFields(eventName, hookPayload) -} - -function normalizeClaudeEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'UserPromptSubmit' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' - ? 'working' - : eventName === 'PermissionRequest' - ? 'waiting' - : eventName === 'Stop' - ? 'done' - : null - - if (!state) { - return null - } - - const snapshot = resolveToolState(paneKey, extractToolFields('claude', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('claude', eventName) - }) - - // Why: Claude Code's `Stop` hook sets `is_interrupt: true` when the turn - // ended because the user hit ESC / Ctrl+C rather than completing normally. - // This is the authoritative signal (the agent itself reports it), so we - // forward it through only on Stop — other hook events don't carry it. - const interrupted = - eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('claude', eventName) - }), - agentType: 'claude', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted - }) - ) -} - -// Why: Gemini CLI exposes BeforeAgent/AfterAgent/AfterTool hooks. BeforeAgent -// fires at turn start and AfterTool resumes the working state after a tool -// call completes; AfterAgent fires when the agent becomes idle. Gemini has no -// permission-prompt hook, so we cannot surface a waiting state for Gemini. -function normalizeGeminiEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'BeforeAgent' || - eventName === 'AfterTool' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ? 'working' - : eventName === 'AfterAgent' - ? 'done' - : null - - if (!state) { - return null - } - - const snapshot = resolveToolState(paneKey, extractToolFields('gemini', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('gemini', eventName) - }) - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('gemini', eventName) - }), - agentType: 'gemini', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage - }) - ) -} - -// Why: we deliberately do NOT map Codex `PreToolUse` to `waiting`. That event -// fires for every tool call, not just ones that actually need approval, so -// mapping it would flicker the dashboard. Instead we keep it at `working` -// (same as Claude) and use it only to update tool-name / tool-input previews -// so a running Codex turn has visible progress between UserPromptSubmit and -// Stop. Real approval signals travel through Codex's separate `notify` -// callback (different install surface); wiring that up is deferred. -function normalizeCodexEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'SessionStart' || - eventName === 'UserPromptSubmit' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ? 'working' - : eventName === 'Stop' - ? 'done' - : null - - if (!state) { - return null - } - - const snapshot = resolveToolState(paneKey, extractToolFields('codex', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('codex', eventName) - }) - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('codex', eventName) - }), - agentType: 'codex', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage - }) - ) -} - -// Why: OpenCode has no declarative hook surface — it exposes in-process plugin -// events (session.status busy/idle, session.idle, permission.asked, -// question.asked, message.updated, message.part.updated). The bundled plugin -// (see opencode/hook-service) pre-maps those to our stable hook_event_name -// vocabulary before POSTing so this normalizer can share the same switch -// shape as Claude/Codex/Gemini. SessionBusy = turn started, SessionIdle = -// turn finished, PermissionRequest = blocked on user approval, AskUserQuestion = -// blocked on user reply to an ask-the-user tool (both map to `waiting` so the -// sidebar renders the red "needs attention" indicator), MessagePart = -// incremental text from user prompt or assistant reply (stays in `working` -// because streaming chunks must not flip the row to done mid-turn). -function normalizeOpenCodeEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'SessionBusy' || eventName === 'MessagePart' - ? 'working' - : eventName === 'SessionIdle' - ? 'done' - : eventName === 'PermissionRequest' || eventName === 'AskUserQuestion' - ? 'waiting' - : null - - if (!state) { - return null - } - - const snapshot = resolveToolState( - paneKey, - extractToolFields('opencode', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('opencode', eventName) } - ) - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('opencode', eventName) - }), - agentType: 'opencode', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage - }) - ) -} - -// Why: Cursor (cursor-agent) installs hooks via ~/.cursor/hooks.json with -// camelCase event names, per https://cursor.com/docs/hooks. The CLI fires -// stdin-JSON payloads for each subscribed event; we subscribe to the subset -// that marks turn boundaries and produces meaningful working/done/waiting -// transitions for the Orca sidebar. afterAgentResponse carries the final -// assistant reply text, which is cursor's analogue of Claude's Stop -// last_assistant_message — we keep the row in `working` there because the -// true turn-end signal is `stop`. -function normalizeCursorEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'beforeSubmitPrompt' || - eventName === 'sessionStart' || - eventName === 'preToolUse' || - eventName === 'postToolUse' || - eventName === 'postToolUseFailure' || - eventName === 'afterAgentResponse' - ? 'working' - : eventName === 'stop' || eventName === 'sessionEnd' - ? 'done' - : eventName === 'beforeShellExecution' || eventName === 'beforeMCPExecution' - ? 'waiting' - : null - - if (!state) { - return null - } - - const snapshot = resolveToolState(paneKey, extractToolFields('cursor', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('cursor', eventName) - }) - - // Why: cursor-agent reports turn interrupts via `stop` with status !== - // "completed" (e.g. "cancelled", "stopped"). Forward the boolean so the - // sidebar can render the interrupted-turn treatment that Claude uses. - const interrupted = - eventName === 'stop' && - typeof hookPayload.status === 'string' && - hookPayload.status !== 'completed' - ? true - : undefined - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('cursor', eventName) - }), - agentType: 'cursor', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted - }) - ) -} - -// Why: pi exposes an in-process TypeScript extension API (no settings.json -// hook surface). The bundled orca-agent-status extension installed into the -// per-PTY pi overlay (PiTitlebarExtensionService) translates pi's lifecycle -// events into the hook_event_name vocabulary used here so the dashboard -// row sees the same working/done shape as Claude/Codex/Gemini. -// -// Mapping: -// before_agent_start | agent_start | tool_call | tool_execution_start | -// tool_execution_end | message_end → working -// agent_end | session_shutdown → done -// -// pi has no permission-prompt event we can hook today (tool_call CAN block -// via { block: true, reason } but that's a synchronous return, not a -// separate event), so there is no `waiting` state for pi yet. message_end -// stays in `working` because the true turn-end signal is agent_end — the -// assistant message body just updates lastAssistantMessage on the -// already-active turn. -function normalizePiEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'before_agent_start' || - eventName === 'agent_start' || - eventName === 'tool_call' || - eventName === 'tool_execution_start' || - eventName === 'tool_execution_end' || - eventName === 'message_end' - ? 'working' - : eventName === 'agent_end' || eventName === 'session_shutdown' - ? 'done' - : null - - if (!state) { - return null - } - - const snapshot = resolveToolState(paneKey, extractToolFields('pi', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('pi', eventName) - }) - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('pi', eventName) - }), - agentType: 'pi', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage - }) - ) -} - -function readStringField(record: Record, key: string): string | undefined { - const value = record[key] - if (typeof value !== 'string') { - return undefined - } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : undefined -} - -function normalizeHookPayload( - source: AgentHookSource, - body: unknown, - expectedEnv: string -): AgentHookEventPayload | null { - if (typeof body !== 'object' || body === null) { - return null - } - - const record = body as Record - const paneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' - const rawPayload = record.payload - const hookPayload = - typeof rawPayload === 'string' - ? (() => { - try { - return JSON.parse(rawPayload) - } catch { - return null - } - })() - : rawPayload - // Why: paneKey comes from an authenticated-but-potentially-malicious local - // client; bound its size so pathological clients cannot blow up the - // per-pane caches (lastPromptByPaneKey / lastToolByPaneKey) with multi-MB - // keys. 200 chars is well above any legitimate `${tabId}:${paneId}` value. - const MAX_PANE_KEY_LEN = 200 - if ( - !paneKey || - paneKey.length > MAX_PANE_KEY_LEN || - typeof hookPayload !== 'object' || - hookPayload === null - ) { - return null - } - - // Why: scripts installed by an older app build may send a different shape. - // We accept the request (fail-open) but log once so stale installs are - // diagnosable instead of silently degrading. - const version = readStringField(record, 'version') - if ( - version && - version !== ORCA_HOOK_PROTOCOL_VERSION && - !warnedVersions.has(version) && - warnedVersions.size < MAX_WARNED_KEYS - ) { - warnedVersions.add(version) - console.warn( - `[agent-hooks] received hook v${version}; server expects v${ORCA_HOOK_PROTOCOL_VERSION}. ` + - 'Reinstall agent hooks from Settings to upgrade the managed script.' - ) - } - - // Why: detects dev-vs-prod cross-talk. A hook installed by a dev build but - // triggered inside a prod terminal (or vice versa) still points at whichever - // loopback port the shell env captured, so the *other* instance may receive - // it. Logging the mismatch lets a user know their terminals are wired to the - // wrong Orca. - const clientEnv = readStringField(record, 'env') - if (clientEnv && clientEnv !== expectedEnv) { - const key = `${clientEnv}->${expectedEnv}` - if (!warnedEnvs.has(key) && warnedEnvs.size < MAX_WARNED_KEYS) { - warnedEnvs.add(key) - console.warn( - `[agent-hooks] received ${clientEnv} hook on ${expectedEnv} server. ` + - 'Likely a stale terminal from another Orca install.' - ) - } - } - - const tabId = readStringField(record, 'tabId') - const worktreeId = readStringField(record, 'worktreeId') - - const eventName = (hookPayload as Record).hook_event_name - const promptText = extractPromptText(hookPayload as Record) - const hookPayloadRecord = hookPayload as Record - const payload = - source === 'claude' - ? normalizeClaudeEvent(eventName, promptText, paneKey, hookPayloadRecord) - : source === 'codex' - ? normalizeCodexEvent(eventName, promptText, paneKey, hookPayloadRecord) - : source === 'gemini' - ? normalizeGeminiEvent(eventName, promptText, paneKey, hookPayloadRecord) - : source === 'cursor' - ? normalizeCursorEvent(eventName, promptText, paneKey, hookPayloadRecord) - : source === 'pi' - ? normalizePiEvent(eventName, promptText, paneKey, hookPayloadRecord) - : normalizeOpenCodeEvent(eventName, promptText, paneKey, hookPayloadRecord) - - return payload ? { paneKey, tabId, worktreeId, payload } : null -} - -// Why: the endpoint file lives under userData so each Orca install (dev vs. -// packaged) has its own path and the two cannot clobber each other. Using a -// per-platform extension (`.env` on POSIX, `.cmd` on Windows) lets the hook -// scripts source the file with their platform-native syntax (`.` on POSIX, -// `call` on Windows); the OpenCode plugin's regex accepts both shapes so no -// platform detection is needed inside the plugin source either. -function getEndpointFileName(): string { - return process.platform === 'win32' ? 'endpoint.cmd' : 'endpoint.env' -} - -// Why: every value in the endpoint file is sourced as shell. Reject any -// value that contains shell/cmd metacharacters so a future field whose -// value is not shell-safe-by-construction cannot command-inject via the -// sourced file. Keep to a conservative allowlist of common printable -// chars plus hyphen/dot/slash/colon/underscore — sufficient for ports, -// UUIDs, version strings, and env names. -// Rejects empty values (`+` quantifier) as defense-in-depth for future -// callers — an empty sourced `KEY=` would silently clear the env var in -// the sourcing shell, masking whatever legitimate value was previously set. -function isShellSafeEndpointValue(value: string): boolean { - return /^[A-Za-z0-9._:/-]+$/.test(value) -} +export type { AgentHookSource } export class AgentHookServer { private server: ReturnType | null = null @@ -1194,19 +43,14 @@ export class AgentHookServer { private onAgentStatus: ((payload: AgentHookEventPayload) => void) | null = null // Why: directory that holds the on-disk endpoint file. Set via start()'s // `userDataPath` option so the class has no direct Electron dependency - // (keeps it mockable in the vitest node environment). When unset, we skip - // the endpoint-file write entirely — hooks still work via PTY env, just - // without survive-a-restart semantics. + // (keeps it mockable in the vitest node environment). private endpointDir: string | null = null private endpointFilePathCache: string | null = null - // Why: tracks whether writeEndpointFile() succeeded for the *current* - // start(). Without this flag, buildPtyEnv() would expose - // ORCA_AGENT_HOOK_ENDPOINT pointing at a path that may hold stale - // coordinates from a prior crashed instance — hook scripts would source - // those stale coords and silently post to a dead server. Gating the - // ENDPOINT env var on a successful write preserves the - // fail-open-to-fresh-env guarantee. private endpointFileWritten = false + // Why: per-instance caches (warn-once Sets, lastPrompt/lastTool/lastStatus + // by paneKey). Held on the instance instead of as module-level Maps so + // tests can spin up multiple servers without state cross-contamination. + private state: HookListenerState = createHookListenerState() setListener(listener: ((payload: AgentHookEventPayload) => void) | null): void { this.onAgentStatus = listener @@ -1215,7 +59,7 @@ export class AgentHookServer { } // Why: replay is best-effort per pane so one throwing listener call can't // starve subsequent panes from being replayed. - for (const payload of lastStatusByPaneKey.values()) { + for (const payload of this.state.lastStatusByPaneKey.values()) { try { listener(payload) } catch (err) { @@ -1224,6 +68,86 @@ export class AgentHookServer { } } + /** Ingest a payload that arrived over the relay JSON-RPC channel rather + * than the local HTTP server. `connectionId` is the SshChannelMultiplexer + * identity Orca holds (the wire envelope carries connectionId: null and + * Orca stamps the real value here). The relay pre-normalizes the inner + * payload via the shared listener module; we re-run the canonical + * normalizer here as a defense-in-depth check at the trust boundary + * before feeding the event into the same `onAgentStatus` fanout the HTTP + * path uses. See docs/design/agent-status-over-ssh.md §5. */ + ingestRemote( + envelope: { + paneKey: string + tabId?: string + worktreeId?: string + // Why: forwarded verbatim from the agent CLI POST body on the remote so + // the warn-once cross-build / dev-vs-prod diagnostics fire identically + // to the local HTTP path. Declared on the type now; consumed in PR2. + env?: string + version?: string + payload: unknown + }, + connectionId: string + ): void { + // Why: signature says non-empty, but the wire crosses a trust boundary — + // re-check at runtime (and trim) so a whitespace-only or empty + // connectionId can't poison caches. + if (typeof connectionId !== 'string') { + return + } + const trimmedConnectionId = connectionId.trim() + if (trimmedConnectionId.length === 0) { + return + } + if (!envelope || typeof envelope.paneKey !== 'string' || envelope.paneKey.length === 0) { + return + } + // Why: match the listener's HTTP path — `normalizeHookPayload` trims and + // length-caps paneKey before caching, so the cache key here must follow + // the same rule or remote-vs-local events for the same pane would diverge. + const paneKey = envelope.paneKey.trim() + if (paneKey.length === 0 || paneKey.length > MAX_PANE_KEY_LEN) { + return + } + if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { + return + } + if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { + return + } + // Why: mirror the HTTP path's `readStringField` behavior — trim and treat + // empty-after-trim as undefined rather than letting a literal "" leak + // into the event. + const tabId = + envelope.tabId !== undefined && envelope.tabId.trim().length > 0 + ? envelope.tabId.trim() + : undefined + const worktreeId = + envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 + ? envelope.worktreeId.trim() + : undefined + // Why: the relay is across a trust boundary; re-run the canonical + // normalizer on the inner payload so prompt/agentType/toolName/toolInput + // length caps, embedded-newline collapse, and the `interrupted`-only-on- + // done invariant are enforced here too. Returns null on malformed input + // (including invalid state), which subsumes the prior explicit state + // check. + const normalizedPayload = normalizeAgentStatusPayload(envelope.payload) + if (!normalizedPayload) { + return + } + const event: AgentHookEventPayload = { + paneKey, + tabId, + worktreeId, + connectionId: trimmedConnectionId, + payload: normalizedPayload + } + this.state.lastStatusByPaneKey.set(paneKey, event) + this.onAgentStatus?.(event) + } + async start(options?: { env?: string; userDataPath?: string }): Promise { if (this.server) { return @@ -1254,39 +178,23 @@ export class AgentHookServer { // Why: bound request time so a slow/stalled client cannot hold a socket // open indefinitely (slowloris-style). The hook endpoints are local and // should complete in well under a second. - req.setTimeout(5000, () => { + req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { req.destroy() }) try { const body = await readRequestBody(req) - // Why: match on pathname only so a future debugging addition of a - // query string or trailing slash from a hook sender does not silently - // 404 a valid, token-authenticated request. const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname - const source: AgentHookSource | null = - pathname === '/hook/claude' - ? 'claude' - : pathname === '/hook/codex' - ? 'codex' - : pathname === '/hook/gemini' - ? 'gemini' - : pathname === '/hook/opencode' - ? 'opencode' - : pathname === '/hook/cursor' - ? 'cursor' - : pathname === '/hook/pi' - ? 'pi' - : null + const source = resolveHookSource(pathname) if (!source) { res.writeHead(404) res.end() return } - const payload = normalizeHookPayload(source, body, this.env) + const payload = normalizeHookPayload(this.state, source, body, this.env) if (payload) { - lastStatusByPaneKey.set(payload.paneKey, payload) + this.state.lastStatusByPaneKey.set(payload.paneKey, payload) this.onAgentStatus?.(payload) } @@ -1301,12 +209,9 @@ export class AgentHookServer { }) await new Promise((resolve, reject) => { - // Why: the startup error handler must only reject the start() promise for - // errors that happen before 'listening'. Without swapping it out on - // success, any later runtime error (e.g. EADDRINUSE during rebind, - // socket errors) would call reject() on an already-settled promise and, - // more importantly, leaving it as the only 'error' listener means node - // treats runtime errors as unhandled and crashes the main process. + // Why: swap the startup error handler on success so a later runtime + // error (e.g. EADDRINUSE during rebind, socket errors) doesn't reject + // an already-settled promise or crash the main process as unhandled. const onStartupError = (err: Error): void => { this.server?.off('listening', onListening) reject(err) @@ -1320,11 +225,7 @@ export class AgentHookServer { if (address && typeof address === 'object') { this.port = address.port } - // Why: the endpoint file is the core of the survives-Orca-restart - // design. Write it *after* we have a concrete port — hooks that source - // the file must see a usable coordinate set, not a stale one left over - // from a previous process (e.g. one that crashed before getting here). - this.writeEndpointFile() + this.maybeWriteEndpointFile() resolve() } this.server!.once('error', onStartupError) @@ -1340,36 +241,16 @@ export class AgentHookServer { this.env = 'production' this.onAgentStatus = null // Why: intentionally do NOT delete the endpoint file on stop(). A stale - // file points at a dead port, which matches the fail-open policy (hook - // POSTs silently fail → same as pre-endpoint-file behavior). Attempting to unlink - // introduces a TOCTOU race: a concurrent Orca instance sharing userData - // could rewrite the file between our token check and unlink, and we'd - // delete their live endpoint file. The next successful start() overwrites - // the file atomically; the tmp-file sweep inside writeEndpointFile() - // handles orphan hygiene. + // file points at a dead port, which matches the fail-open policy. Unlink + // would introduce a TOCTOU race vs. a concurrent Orca instance. this.endpointDir = null this.endpointFilePathCache = null this.endpointFileWritten = false - // Why: drop all per-pane cache entries on shutdown so a subsequent start() - // in the same process (e.g. during tests or a settings-driven restart) - // does not inherit stale prompt/tool state from the previous run. - lastPromptByPaneKey.clear() - lastToolByPaneKey.clear() - lastStatusByPaneKey.clear() - // Why: across stop()/start() cycles the warn-once Sets would otherwise - // suppress legitimate new warnings after a restart. - warnedVersions.clear() - warnedEnvs.clear() + clearAllListenerCaches(this.state) } clearPaneState(paneKey: string): void { - // Why: callers invoke this on PTY teardown so the per-pane caches do not - // accumulate entries for dead panes over the process lifetime. Without - // this, every closed pane leaves its prompt + tool snapshot pinned in - // memory for the life of the main process. - lastPromptByPaneKey.delete(paneKey) - lastToolByPaneKey.delete(paneKey) - lastStatusByPaneKey.delete(paneKey) + clearPaneCacheState(this.state, paneKey) } buildPtyEnv(): Record { @@ -1377,12 +258,6 @@ export class AgentHookServer { return {} } - // Why: ORCA_AGENT_HOOK_ENDPOINT is the key that lets a surviving PTY reach - // the *current* Orca after a restart. The other four variables are retained - // for back-compat so pre-endpoint-file hook scripts (which do not know to - // source the endpoint file) continue to work on freshly spawned PTYs, and - // so the current script can fall through to env if the file is - // missing/unreadable for any reason. const env: Record = { ORCA_AGENT_HOOK_PORT: String(this.port), ORCA_AGENT_HOOK_TOKEN: this.token, @@ -1395,154 +270,49 @@ export class AgentHookServer { return env } - // Why: exposed as a read-only getter so tests (and any future main-process - // caller that needs the path for diagnostics) do not have to reconstruct - // the path convention. get endpointFilePath(): string | null { return this.endpointFilePathCache } - // Why: writes the four coordinates atomically via a tmp-then-rename so a - // hook reading concurrently either sees the old file or the new one, never - // a half-written one. Fail-open: on EACCES / ENOSPC / etc. we log and move - // on — start() remains usable via PTY env for freshly-spawned PTYs. Only - // survivors lose the endpoint-file path, matching the hook-payload - // fail-open policy already enforced on the receiving end. - private writeEndpointFile(): void { + private maybeWriteEndpointFile(): void { if (!this.endpointDir || !this.endpointFilePathCache) { return } - // Why: defensive reset — buildPtyEnv() must not see a stale `true` from - // a previous start() if this write fails before reaching the success - // assignment below. this.endpointFileWritten = false - const finalPath = this.endpointFilePathCache - // Why: unique-per-call tmp name (mirrors persistence.ts / installer-utils.ts); prevents cross-process collision if two writers race on the same endpoint dir. - const tmpPath = join(this.endpointDir, `.endpoint-${process.pid}-${randomUUID()}.tmp`) - const prefix = process.platform === 'win32' ? 'set ' : '' - // Why: every value written here is sourced as shell (`. "$file"` on - // POSIX, `call "%file%"` on Windows) — the file format IS shell, not - // key=value data. The current four inputs are shell-safe by - // construction: PORT is a number from listen(), TOKEN is randomUUID() - // output (hex + dashes only), VERSION is a compile-time string - // constant, and ENV is a fixed 'production' / 'development' literal - // passed from index.ts. Any future change that relaxes these - // invariants (user-supplied env name, persisted token, arbitrary - // free-form field) MUST add escaping or a safe-character validator - // before the write — otherwise a value like `foo&malicious` on Windows - // would command-inject via `call`, and a newline in any value would - // corrupt the POSIX sourceable output. The isShellSafeEndpointValue - // check below enforces this contract at runtime. - const valuesToWrite: [string, string][] = [ - ['ORCA_AGENT_HOOK_PORT', String(this.port)], - ['ORCA_AGENT_HOOK_TOKEN', this.token], - ['ORCA_AGENT_HOOK_ENV', this.env], - ['ORCA_AGENT_HOOK_VERSION', ORCA_HOOK_PROTOCOL_VERSION] - ] - for (const [key, value] of valuesToWrite) { - if (!isShellSafeEndpointValue(value)) { - console.error( - `[agent-hooks] refusing to write endpoint file: ${key} contains ` + - 'characters unsafe for shell sourcing. Falling back to PTY env.' - ) - return - } - } - const lines = [...valuesToWrite.map(([key, value]) => `${prefix}${key}=${value}`), ''] - let tmpWritten = false - try { - // Why: mode 0o700 — match the file's owner-only policy so the - // agent-hooks/ directory itself does not leak the existence of this - // Orca install (or the presence of the endpoint file) to other local - // users on a multi-user POSIX host. Default umask would otherwise - // leave the dir at 0o755 even though the file inside is 0o600. - mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 }) - if (process.platform !== 'win32') { - // Why: mkdirSync's `mode` only applies when the dir is newly created — - // a pre-existing agent-hooks/ dir (from an earlier build or user - // intervention) keeps its original permissions. Re-chmod on every - // start() so the directory matches the 0600 file inside it. POSIX - // only; chmod semantics differ on Windows and the filesystem-level - // ACL model makes this check meaningless there. - try { - chmodSync(this.endpointDir, 0o700) - } catch { - // Why: best-effort — a chmod failure (exotic fs, read-only mount) - // must not block the endpoint-file write itself. - } - } - // Why: a crash between writeFileSync and renameSync leaves stale - // `.endpoint--.tmp` in this directory. Sweep older-than-5-min - // orphans so the dir does not grow unboundedly. Fresh tmps are left - // alone so a legitimate concurrent instance is not disturbed. - try { - const entries = readdirSync(this.endpointDir) - const cutoff = Date.now() - 5 * 60 * 1000 - for (const entry of entries) { - if (!entry.startsWith('.endpoint-') || !entry.endsWith('.tmp')) { - continue - } - const entryPath = join(this.endpointDir, entry) - try { - if (statSync(entryPath).mtimeMs < cutoff) { - unlinkSync(entryPath) - } - } catch { - // best-effort sweep - } - } - } catch { - // readdirSync can fail on exotic filesystems; never block the write - } - // Why: 0o600 — the token is a loopback bearer credential and must not - // be readable by other local users. Parity with PTY env exposure via - // /proc//environ (owner-only on modern Linux). - // Why: `.cmd` files require CRLF for consistent `set` parsing across - // Windows versions — LF-only terminators are silently mis-parsed by - // some cmd.exe versions, which would break hook coord refresh (exactly - // the bug this file exists to fix). POSIX stays LF. - const separator = process.platform === 'win32' ? '\r\n' : '\n' - writeFileSync(tmpPath, lines.join(separator), { mode: 0o600 }) - tmpWritten = true - renameSync(tmpPath, finalPath) - this.endpointFileWritten = true - } catch (err) { - console.error('[agent-hooks] failed to write endpoint file:', err) - // Why: clean up tmp; never nuke the prior finalPath when we cannot - // guarantee we have replaced it. Stale finalPath → dead port → silent - // fail on hook POST matches the fail-open policy documented on the - // receiver side. Destroying the prior file would strand surviving PTYs - // that *could* have continued to fail silently against a dead port — - // strictly worse than leaving the prior coords in place until the next - // successful start() overwrites them. - if (tmpWritten) { - try { - unlinkSync(tmpPath) - } catch { - // Why: tmp may already be gone (rename partially succeeded, or an - // external process cleaned it). Nothing to do. - } - } - } + const ok = writeEndpointFile(this.endpointDir, this.endpointFilePathCache, { + port: this.port, + token: this.token, + env: this.env, + version: ORCA_HOOK_PROTOCOL_VERSION + }) + this.endpointFileWritten = ok } } export const agentHookServer = new AgentHookServer() -// Why: exported for test coverage of the per-agent field extractors. The -// `normalizeHookPayload` function wraps these with the cache + routing logic -// the tests need to exercise end-to-end; making it test-visible avoids -// having to spin up a real HTTP server just to assert field shaping. +// Why: exported for test coverage of the per-agent field extractors. export const _internals = { - normalizeHookPayload, + // Why: bind the test-helper to the singleton's state so existing tests keep + // exercising the same caches the live server uses. + normalizeHookPayload: ( + source: AgentHookSource, + body: unknown, + expectedEnv: string + ): AgentHookEventPayload | null => + normalizeHookPayload(_singletonState(), source, body, expectedEnv), parseFormEncodedBody, resetCachesForTests: (): void => { - lastPromptByPaneKey.clear() - lastToolByPaneKey.clear() - lastStatusByPaneKey.clear() - // Why: across test runs the warn-once Sets would otherwise suppress - // legitimate new warnings that a later test expects to observe. - warnedVersions.clear() - warnedEnvs.clear() + clearAllListenerCaches(_singletonState()) } } + +// Why: ergonomic accessor so the `_internals` shim can reach the singleton's +// per-instance state without exposing `state` on the public class surface. +function _singletonState(): HookListenerState { + // The runtime field is private, but tests access this module exclusively + // through `_internals`, which only fires after the module-level + // `agentHookServer` is constructed. The cast keeps the compile-time + // private invariant intact. + return (agentHookServer as unknown as { state: HookListenerState }).state +} diff --git a/src/main/index.ts b/src/main/index.ts index e9820c997e6..fa075da9d55 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -276,15 +276,16 @@ function openMainWindow(): BrowserWindow { } }) mainWindow = window - agentHookServer.setListener(({ paneKey, tabId, worktreeId, payload }) => { + agentHookServer.setListener(({ paneKey, tabId, worktreeId, connectionId, payload }) => { if (mainWindow?.isDestroyed()) { return } mainWindow?.webContents.send('agentStatus:set', { + ...payload, paneKey, tabId, worktreeId, - ...payload + connectionId }) // Why: cursor-agent's OSC title stays "Cursor Agent" for the whole turn, // and opencode's stays bare "OpenCode" — neither carries a working/idle diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3b330129099..80582f4c2e7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1236,6 +1236,11 @@ export type PreloadApi = { paneKey: string tabId?: string worktreeId?: string + // Why: stamped by main from the SshChannelMultiplexer the event + // arrived on (or null for local). The renderer uses it to drop + // in-flight events when an SSH connection tears down — see + // docs/design/agent-status-over-ssh.md §5. + connectionId: string | null state: AgentStatusState prompt?: string agentType?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 45539461eac..0250700d737 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2303,6 +2303,11 @@ const api = { paneKey: string tabId?: string worktreeId?: string + // Why: stamped by main from the SshChannelMultiplexer the event + // arrived on (or null for local). The renderer uses it to drop + // in-flight events when an SSH connection tears down — see + // docs/design/agent-status-over-ssh.md §5. + connectionId: string | null state: AgentStatusState prompt?: string agentType?: string @@ -2318,6 +2323,7 @@ const api = { paneKey: string tabId?: string worktreeId?: string + connectionId: string | null state: AgentStatusState prompt?: string agentType?: string diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts new file mode 100644 index 00000000000..700cb6426fa --- /dev/null +++ b/src/relay/agent-hook-server.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { RelayAgentHookServer } from './agent-hook-server' +import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' + +describe('RelayAgentHookServer', () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'relay-hook-server-')) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('forwards a parsed Claude UserPromptSubmit POST as a normalized envelope', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: 'tab-1:0', + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'remote', + version: '1', + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' } + }) + }) + expect(res.status).toBe(204) + expect(forward).toHaveBeenCalledTimes(1) + const envelope = forward.mock.calls[0][0] + expect(envelope.source).toBe('claude') + expect(envelope.paneKey).toBe('tab-1:0') + expect(envelope.tabId).toBe('tab-1') + expect(envelope.connectionId).toBeNull() + expect(envelope.payload.state).toBe('working') + expect(envelope.payload.prompt).toBe('hi') + // Why: the relay forwards body env/version verbatim so Orca's existing + // warn-once cross-build / dev-vs-prod diagnostics still fire on remote. + expect(envelope.env).toBe('remote') + expect(envelope.version).toBe('1') + } finally { + server.stop() + } + }) + + it('rejects requests with the wrong bearer token (403)', async () => { + const forward = vi.fn() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': 'wrong' + }, + body: '{}' + }) + expect(res.status).toBe(403) + expect(forward).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + + it('replays cached payloads on demand', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: 'tab-1:0', + tabId: 'tab-1', + env: 'remote', + version: '1', + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'cache me' } + }) + }) + forward.mockClear() + const replayed = server.replayCachedPayloadsForPanes() + expect(replayed).toBe(1) + expect(forward).toHaveBeenCalledTimes(1) + expect(forward.mock.calls[0][0].payload.prompt).toBe('cache me') + // Why: replay must preserve the wire envelope's env/version (and source) + // so Orca's warn-once cross-build / dev-vs-prod diagnostics fire on + // replayed events the same as on live POST events. + expect(forward.mock.calls[0][0].source).toBe('claude') + expect(forward.mock.calls[0][0].env).toBe('remote') + expect(forward.mock.calls[0][0].version).toBe('1') + } finally { + server.stop() + } + }) + + it('does not replay paneKeys after clearPaneState', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: 'tab-1:0', + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'gone' } + }) + }) + server.clearPaneState('tab-1:0') + forward.mockClear() + const replayed = server.replayCachedPayloadsForPanes() + expect(replayed).toBe(0) + expect(forward).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + + it('exposes ORCA_AGENT_HOOK_* env vars after start', async () => { + const forward = vi.fn() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const env = server.buildPtyEnv() + expect(env.ORCA_AGENT_HOOK_PORT).toMatch(/^\d+$/) + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() + expect(env.ORCA_AGENT_HOOK_ENV).toBe('remote') + expect(env.ORCA_AGENT_HOOK_VERSION).toBe('1') + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeTruthy() + } finally { + server.stop() + } + }) +}) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts new file mode 100644 index 00000000000..680e65fd01f --- /dev/null +++ b/src/relay/agent-hook-server.ts @@ -0,0 +1,266 @@ +// Why: relay-side adapter for the shared agent-hook listener pipeline. Hosts +// a loopback HTTP server (same shape as Orca's main-process server: bind +// 127.0.0.1:0, bearer-token auth, /hook/ routing) and forwards every +// parsed payload via a callback so `relay.ts` can re-emit it as an +// `agent.hook` JSON-RPC notification across the existing SSH channel. +// +// Per-instance state (warn-once Sets, last-status cache, last-prompt / +// last-tool caches) lives on `HookListenerState`. The cache is bounded to one +// entry per paneKey — see docs/design/agent-status-over-ssh.md §5 (Path 3, +// request-driven replay) for the rationale. +import { createServer, type IncomingMessage, type ServerResponse } from 'http' +import { randomUUID } from 'crypto' +import { join } from 'path' +import { homedir } from 'os' + +import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types' +import { + clearAllListenerCaches, + clearPaneCacheState, + createHookListenerState, + getEndpointFileName, + HOOK_REQUEST_SLOWLORIS_MS, + normalizeHookPayload, + readRequestBody, + resolveHookSource, + writeEndpointFile, + type AgentHookEventPayload, + type HookListenerState +} from '../shared/agent-hook-listener' +import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay' + +export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void + +// Why: relay's userData equivalent. Lives under $HOME so each user on a +// shared dev box gets their own dir, owned 0o700. Mirrors RELAY_REMOTE_DIR +// from `ssh-relay-deploy.ts` but stays local to this module — the hook +// server is the only consumer. +const RELAY_HOOKS_DIR_NAME = '.orca-relay' +const RELAY_HOOKS_SUBDIR = 'agent-hooks' + +function defaultEndpointDir(): string { + return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR) +} + +export type RelayHookServerOptions = { + /** Where to put endpoint.env / endpoint.cmd. Defaults to `$HOME/.orca-relay/agent-hooks`. */ + endpointDir?: string + /** Env tag forwarded into hook payloads (warn-once cross-build diagnostic). + * Defaults to "remote" — distinct from Orca's local 'production'/'development'. */ + env?: string + /** Called once per parsed payload. The relay wires this to + * `dispatcher.notify('agent.hook', envelope)`. */ + forward: RelayHookForward +} + +export class RelayAgentHookServer { + private server: ReturnType | null = null + private port = 0 + private token = '' + private env: string + private endpointDir: string + private endpointFilePath: string + private endpointFileWritten = false + private state: HookListenerState = createHookListenerState() + // Why: the shared `HookListenerState.lastStatusByPaneKey` cache only stores + // `AgentHookEventPayload` (no wire-envelope fields). Replay must still emit + // the original `source`/`env`/`version` so Orca's warn-once diagnostics fire + // identically to the live POST path. Keep this as a per-instance sidecar map + // so the shared listener type stays unchanged. Invariant: every key present + // in `state.lastStatusByPaneKey` must also be present here — populated and + // cleared in lockstep on the live POST path, clearPaneState, and stop(). + private lastEnvelopeMetaByPaneKey: Map< + string, + { source: AgentHookSource; env?: string; version?: string } + > = new Map() + private forward: RelayHookForward + + constructor(options: RelayHookServerOptions) { + this.env = options.env ?? 'remote' + this.endpointDir = options.endpointDir ?? defaultEndpointDir() + this.endpointFilePath = join(this.endpointDir, getEndpointFileName()) + this.forward = options.forward + } + + async start(): Promise { + if (this.server) { + return + } + this.token = randomUUID() + this.endpointFileWritten = false + this.server = createServer((req, res) => this.handleRequest(req, res)) + await new Promise((resolve, reject) => { + const onStartupError = (err: Error): void => { + this.server?.off('listening', onListening) + reject(err) + } + const onListening = (): void => { + this.server?.off('error', onStartupError) + this.server?.on('error', (err) => { + process.stderr.write(`[relay-hook-server] server error: ${err.message}\n`) + }) + const address = this.server!.address() + if (address && typeof address === 'object') { + this.port = address.port + } + this.endpointFileWritten = writeEndpointFile(this.endpointDir, this.endpointFilePath, { + port: this.port, + token: this.token, + env: this.env, + version: ORCA_HOOK_PROTOCOL_VERSION + }) + resolve() + } + this.server!.once('error', onStartupError) + // Why: bind 127.0.0.1:0 so the OS assigns a free port. Loopback only — + // the agent CLI inside the same remote box reaches us via curl + // 127.0.0.1:PORT; nobody outside the box can. + this.server!.listen(0, '127.0.0.1', onListening) + }) + } + + stop(): void { + this.server?.close() + this.server = null + this.port = 0 + this.token = '' + this.endpointFileWritten = false + clearAllListenerCaches(this.state) + this.lastEnvelopeMetaByPaneKey.clear() + } + + /** Request-driven replay: walks the per-paneKey last-payload cache and + * forwards each entry as a fresh notification. Called after Orca has + * re-wired its `agent.hook` handler on the new mux post-`--connect`. + * The relay-driver issues the replay forwards BEFORE returning from the + * request handler so the response strictly trails all replayed + * notifications on the dispatcher's single write callback. */ + replayCachedPayloadsForPanes(): number { + let count = 0 + for (const [paneKey, event] of this.state.lastStatusByPaneKey.entries()) { + const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey) + // Why: invariant — every paneKey in the shared status cache is populated + // in lockstep with `lastEnvelopeMetaByPaneKey`. If meta is missing, + // something has drifted; skip rather than fall back to a guessed source + // that would mis-tag the event downstream. + if (!meta) { + continue + } + this.forwardEvent(event, meta.source, meta.env, meta.version) + count++ + } + return count + } + + /** Drop a paneKey's cached entries on PTY exit so a terminated pane never + * resurfaces as a ghost event on a later reconnect. Symmetric with the + * local server's clearPaneState on PTY teardown. */ + clearPaneState(paneKey: string): void { + clearPaneCacheState(this.state, paneKey) + this.lastEnvelopeMetaByPaneKey.delete(paneKey) + } + + /** Env vars to inject into every relay-spawned PTY so the hook script / + * in-process plugin POSTs to this loopback server. */ + buildPtyEnv(): Record { + if (this.port <= 0 || !this.token) { + return {} + } + const env: Record = { + ORCA_AGENT_HOOK_PORT: String(this.port), + ORCA_AGENT_HOOK_TOKEN: this.token, + ORCA_AGENT_HOOK_ENV: this.env, + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION + } + if (this.endpointFileWritten) { + env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePath + } + return env + } + + /** Test-only / diagnostics accessor. */ + getCoordinates(): { port: number; token: string; endpointFilePath: string } { + return { port: this.port, token: this.token, endpointFilePath: this.endpointFilePath } + } + + // ─── Private ────────────────────────────────────────────────────── + + private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + if (req.method !== 'POST') { + res.writeHead(404) + res.end() + return + } + if (req.headers['x-orca-agent-hook-token'] !== this.token) { + res.writeHead(403) + res.end() + return + } + req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { + req.destroy() + }) + try { + const body = await readRequestBody(req) + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname + const source = resolveHookSource(pathname) + if (!source) { + res.writeHead(404) + res.end() + return + } + const event = normalizeHookPayload(this.state, source, body, this.env) + if (event) { + this.state.lastStatusByPaneKey.set(event.paneKey, event) + // TODO: once normalizeHookPayload returns validated env/version, drop + // bodyEnv/bodyVersion and source those from the listener result instead. + const env = this.bodyEnv(body) + const version = this.bodyVersion(body) + this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) + this.forwardEvent(event, source, env, version) + } + res.writeHead(204) + res.end() + } catch { + // Why: agent hooks must fail open — return success on parse / size / + // timeout errors so a buggy agent script never blocks the agent run. + res.writeHead(204) + res.end() + } + } + + private forwardEvent( + event: AgentHookEventPayload, + source: AgentHookSource, + env?: string, + version?: string + ): void { + const envelope: AgentHookRelayEnvelope = { + source, + paneKey: event.paneKey, + tabId: event.tabId, + worktreeId: event.worktreeId, + connectionId: null, + env, + version, + payload: event.payload + } + this.forward(envelope) + } + + private bodyEnv(body: unknown): string | undefined { + if (typeof body !== 'object' || body === null) { + return undefined + } + const v = (body as Record).env + return typeof v === 'string' && v.length > 0 ? v : undefined + } + + private bodyVersion(body: unknown): string | undefined { + if (typeof body !== 'object' || body === null) { + return undefined + } + const v = (body as Record).version + return typeof v === 'string' && v.length > 0 ? v : undefined + } +} + diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts new file mode 100644 index 00000000000..e0fa65dc713 --- /dev/null +++ b/src/shared/agent-hook-listener.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, statSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { + createHookListenerState, + getEndpointFileName, + isShellSafeEndpointValue, + normalizeHookPayload, + parseFormEncodedBody, + resolveHookSource, + writeEndpointFile, + type HookListenerState +} from './agent-hook-listener' + +describe('shared agent-hook-listener', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + it('parses form-encoded bodies', () => { + const decoded = parseFormEncodedBody('paneKey=tab-1%3A0&worktreeId=foo') + expect(decoded.paneKey).toBe('tab-1:0') + expect(decoded.worktreeId).toBe('foo') + }) + + it('routes pathnames to a known source or null', () => { + expect(resolveHookSource('/hook/claude')).toBe('claude') + expect(resolveHookSource('/hook/cursor')).toBe('cursor') + expect(resolveHookSource('/hook/unknown')).toBeNull() + expect(resolveHookSource('/')).toBeNull() + }) + + it('rejects shell-unsafe endpoint values', () => { + expect(isShellSafeEndpointValue('1234')).toBe(true) + expect(isShellSafeEndpointValue('abc-DEF.0_1')).toBe(true) + expect(isShellSafeEndpointValue('')).toBe(false) + expect(isShellSafeEndpointValue('foo&bar')).toBe(false) + expect(isShellSafeEndpointValue('foo bar')).toBe(false) + expect(isShellSafeEndpointValue('foo;bar')).toBe(false) + }) + + it('normalizes a Claude UserPromptSubmit body to a working state', () => { + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: 'tab-1:0', + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hello' } + }, + 'production' + ) + expect(event).not.toBeNull() + expect(event!.paneKey).toBe('tab-1:0') + expect(event!.connectionId).toBeNull() + expect(event!.payload.state).toBe('working') + expect(event!.payload.prompt).toBe('hello') + expect(event!.payload.agentType).toBe('claude') + }) + + it('trims surrounding whitespace from extracted prompt text', () => { + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: 'tab-1:0', + payload: { hook_event_name: 'UserPromptSubmit', prompt: ' hi ' } + }, + 'production' + ) + expect(event).not.toBeNull() + expect(event!.payload.prompt).toBe('hi') + }) + + it('rejects oversized paneKey', () => { + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: 'x'.repeat(300), + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' } + }, + 'production' + ) + expect(event).toBeNull() + }) + + it('isolates caches between listener instances', () => { + const a = createHookListenerState() + const b = createHookListenerState() + normalizeHookPayload( + a, + 'claude', + { paneKey: 'p', payload: { hook_event_name: 'UserPromptSubmit', prompt: 'first' } }, + 'production' + ) + // The second listener has no cached prompt for this paneKey, so a tool + // event without a fresh prompt should produce empty prompt string. + const event = normalizeHookPayload( + b, + 'claude', + { + paneKey: 'p', + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'Read', + tool_input: { file_path: '/etc/hosts' } + } + }, + 'production' + ) + expect(event).not.toBeNull() + expect(event!.payload.prompt).toBe('') + }) + + describe('writeEndpointFile', () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'agent-hook-listener-')) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('writes the endpoint file atomically with the right contents and mode', () => { + const finalPath = join(dir, getEndpointFileName()) + const ok = writeEndpointFile(dir, finalPath, { + port: 12345, + token: 'abcdef-0123', + env: 'production', + version: '1' + }) + expect(ok).toBe(true) + const text = readFileSync(finalPath, 'utf8') + expect(text).toContain('ORCA_AGENT_HOOK_PORT=12345') + expect(text).toContain('ORCA_AGENT_HOOK_TOKEN=abcdef-0123') + expect(text).toContain('ORCA_AGENT_HOOK_VERSION=1') + // POSIX 0o600 — owner read/write only. + if (process.platform !== 'win32') { + const mode = statSync(finalPath).mode & 0o777 + expect(mode).toBe(0o600) + } + }) + + it('refuses unsafe values', () => { + const finalPath = join(dir, getEndpointFileName()) + const ok = writeEndpointFile(dir, finalPath, { + port: 12345, + token: 'safe-token', + env: 'foo&bar', + version: '1' + }) + expect(ok).toBe(false) + }) + }) +}) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts new file mode 100644 index 00000000000..6193ce76102 --- /dev/null +++ b/src/shared/agent-hook-listener.ts @@ -0,0 +1,1155 @@ +/* eslint-disable max-lines -- Why: this module is the canonical, transport- + agnostic agent-hook listener. The HTTP request parser, payload normalizer, + per-CLI extractors, and on-disk endpoint-file writer all share invariants + (size caps, warn-once Sets, shell-safe value rules) that must not drift + between Orca's main process and the relay. Splitting by line count would + force the same invariants to be re-derived in two places. */ + +// Why: extracted from `src/main/agent-hooks/server.ts` so the relay can host +// the same listener pipeline on the remote without dragging Electron in. The +// module uses only Node builtins (http/fs/crypto/net/path/url/os) — none of +// which pull `electron` — so it is safe to import from `src/relay/`. See +// docs/design/agent-status-over-ssh.md §3 ("relay normalizes; Orca routes"). +import type { IncomingMessage } from 'http' +import { randomUUID } from 'crypto' +import { + chmodSync, + closeSync, + mkdirSync, + openSync, + readdirSync, + readSync, + renameSync, + statSync, + unlinkSync, + writeFileSync +} from 'fs' +import { join } from 'path' + +import { parseAgentStatusPayload, type ParsedAgentStatusPayload } from './agent-status-types' +import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' +import type { AgentHookSource } from './agent-hook-relay' + +/** Maximum request body size accepted by the listener (1 MB). */ +export const HOOK_REQUEST_MAX_BYTES = 1_000_000 + +/** Bound the warn-once Sets so a buggy/malicious local client that varies its + * `version` / `env` fields per request cannot grow them without bound for the + * process lifetime. */ +const MAX_WARNED_KEYS = 32 + +/** Slowloris cap: drop requests that have not finished sending after 5 s. */ +export const HOOK_REQUEST_SLOWLORIS_MS = 5_000 + +/** Bound paneKey size — `${tabId}:${paneId}` is well under 200 chars in + * practice; cap defends per-pane caches against pathological input. */ +export const MAX_PANE_KEY_LEN = 200 + +/** Per-listener-instance state that holds caches needing per-PTY teardown + * (last prompt, last tool snapshot, last status replay). Both Orca's main + * process and the relay get their own instance — they never share. */ +export type HookListenerState = { + warnedVersions: Set + warnedEnvs: Set + lastPromptByPaneKey: Map + lastToolByPaneKey: Map + lastStatusByPaneKey: Map +} + +export function createHookListenerState(): HookListenerState { + return { + warnedVersions: new Set(), + warnedEnvs: new Set(), + lastPromptByPaneKey: new Map(), + lastToolByPaneKey: new Map(), + lastStatusByPaneKey: new Map() + } +} + +export function clearPaneCacheState(state: HookListenerState, paneKey: string): void { + state.lastPromptByPaneKey.delete(paneKey) + state.lastToolByPaneKey.delete(paneKey) + state.lastStatusByPaneKey.delete(paneKey) +} + +export function clearAllListenerCaches(state: HookListenerState): void { + state.lastPromptByPaneKey.clear() + state.lastToolByPaneKey.clear() + state.lastStatusByPaneKey.clear() + state.warnedVersions.clear() + state.warnedEnvs.clear() +} + +export type AgentHookEventPayload = { + paneKey: string + tabId?: string + worktreeId?: string + /** Identifies the SSH connection the event arrived on, or null for local. + * Stamped only on the remote-ingest path (Orca's `ingestRemote`); the + * HTTP path always sets null because it cannot know which mux a request + * came from. See docs/design/agent-status-over-ssh.md §5. */ + connectionId: string | null + payload: ParsedAgentStatusPayload +} + +// ─── Body parsing ─────────────────────────────────────────────────── + +export function parseFormEncodedBody(body: string): Record { + const params = new URLSearchParams(body) + const parsed: Record = {} + for (const [key, value] of params.entries()) { + parsed[key] = value + } + return parsed +} + +export function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let byteLength = 0 + let settled = false + req.on('data', (chunk: Buffer) => { + if (settled) { + return + } + // Why: check size in bytes (not UTF-16 code units) and stop accumulating + // after rejection so a malicious client cannot push memory past the cap. + if (byteLength + chunk.length > HOOK_REQUEST_MAX_BYTES) { + settled = true + reject(new Error('payload too large')) + req.destroy() + return + } + byteLength += chunk.length + chunks.push(chunk) + }) + req.on('end', () => { + if (settled) { + return + } + settled = true + try { + // Why: decode once via Buffer.concat so multi-byte UTF-8 characters + // that straddle a chunk boundary are reassembled correctly. + const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf8') : '' + const contentType = req.headers['content-type'] ?? '' + if (typeof contentType === 'string' && contentType.includes('application/json')) { + resolve(body ? JSON.parse(body) : {}) + return + } + if ( + typeof contentType === 'string' && + contentType.includes('application/x-www-form-urlencoded') + ) { + resolve(parseFormEncodedBody(body)) + return + } + // Why: existing managed scripts POST JSON; updated POSIX scripts POST + // form-encoded. Default to JSON for unknown content types. + resolve(body ? JSON.parse(body) : {}) + } catch (error) { + reject(error) + } + }) + req.on('error', (err) => { + if (settled) { + return + } + settled = true + reject(err) + }) + // Why: req.destroy() (called by the slowloris timer) emits 'close' but + // not 'end'/'error'. Without this handler the promise would never settle + // and the chunk buffers would be retained for the process lifetime. + req.on('close', () => { + if (settled) { + return + } + settled = true + reject(new Error('aborted')) + }) + }) +} + +// ─── Per-pane field caches + extractors ───────────────────────────── + +function extractPromptText(hookPayload: Record): string { + const candidateKeys = ['prompt', 'user_prompt', 'userPrompt', 'message'] + for (const key of candidateKeys) { + const value = hookPayload[key] + if (typeof value === 'string' && value.trim().length > 0) { + // Why: trim so prompts match what readStringField produces elsewhere — + // surrounding whitespace would otherwise leak into UI and caches. + return value.trim() + } + } + // Why: OpenCode's plugin sends MessagePart events with { role, text }. When + // role === 'user', the text *is* the prompt — surface it even though + // OpenCode has no UserPromptSubmit-equivalent. + if (hookPayload.role === 'user' && typeof hookPayload.text === 'string') { + const trimmed = hookPayload.text.trim() + if (trimmed.length > 0) { + return trimmed + } + } + return '' +} + +function resolvePrompt( + state: HookListenerState, + paneKey: string, + promptText: string, + options?: { resetOnNewTurn?: boolean } +): string { + if (options?.resetOnNewTurn) { + state.lastPromptByPaneKey.delete(paneKey) + } + if (promptText) { + state.lastPromptByPaneKey.set(paneKey, promptText) + return promptText + } + return state.lastPromptByPaneKey.get(paneKey) ?? '' +} + +export type ToolSnapshot = { + toolName?: string + toolInput?: string + lastAssistantMessage?: string +} + +function resolveToolState( + state: HookListenerState, + paneKey: string, + update: ToolSnapshot, + options: { resetOnNewTurn: boolean } +): ToolSnapshot { + if (options.resetOnNewTurn) { + state.lastToolByPaneKey.delete(paneKey) + } + const previous = state.lastToolByPaneKey.get(paneKey) ?? {} + const merged: ToolSnapshot = { + toolName: update.toolName ?? previous.toolName, + toolInput: update.toolInput ?? previous.toolInput, + lastAssistantMessage: update.lastAssistantMessage ?? previous.lastAssistantMessage + } + state.lastToolByPaneKey.set(paneKey, merged) + return merged +} + +const TOOL_INPUT_KEYS_BY_TOOL: Record = { + Read: ['file_path', 'filePath', 'path'], + Write: ['file_path', 'filePath', 'path'], + Edit: ['file_path', 'filePath', 'path'], + MultiEdit: ['file_path', 'filePath', 'path'], + NotebookEdit: ['file_path', 'filePath', 'path'], + Bash: ['command'], + Glob: ['pattern'], + Grep: ['pattern'], + WebFetch: ['url'], + WebSearch: ['query'], + read_file: ['file_path', 'path'], + write_file: ['file_path', 'path'], + read_many_files: ['file_path', 'paths', 'path'], + edit_file: ['file_path', 'path'], + replace: ['file_path', 'path'], + run_shell_command: ['command'], + glob: ['pattern'], + search_file_content: ['pattern'], + web_fetch: ['url'], + google_web_search: ['query'], + exec_command: ['cmd', 'command'], + shell_command: ['cmd', 'command'], + apply_patch: ['path', 'file_path'], + view_image: ['path', 'file_path'], + bash: ['command'], + read: ['path', 'file_path'], + write: ['path', 'file_path'], + edit: ['path', 'file_path'], + grep: ['pattern'], + web_search: ['query'], + fetch_content: ['url'] +} + +function deriveToolInputPreview( + toolName: string | undefined, + toolInput: unknown +): string | undefined { + if (typeof toolInput === 'string') { + return toolInput + } + if (typeof toolInput !== 'object' || toolInput === null) { + return undefined + } + if (!toolName) { + return undefined + } + const keys = TOOL_INPUT_KEYS_BY_TOOL[toolName] + if (!keys) { + return undefined + } + const record = toolInput as Record + for (const key of keys) { + const value = record[key] + if (typeof value === 'string' && value.trim().length > 0) { + return value + } + } + return undefined +} + +function readString(record: Record, key: string): string | undefined { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function extractToolResponseText(toolResponse: unknown): string | undefined { + if (typeof toolResponse === 'string' && toolResponse.length > 0) { + return toolResponse + } + if (typeof toolResponse !== 'object' || toolResponse === null) { + return undefined + } + const record = toolResponse as Record + const content = record.content + if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === 'object' && part !== null) { + const text = (part as Record).text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + } + } + } + const text = record.text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + return undefined +} + +const TRANSCRIPT_CHUNK_BYTES = 64 * 1024 +const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 + +function extractAssistantTextFromLine(line: string): string | undefined { + let entry: unknown + try { + entry = JSON.parse(line) + } catch { + return undefined + } + if (typeof entry !== 'object' || entry === null) { + return undefined + } + const record = entry as Record + const nestedMessage = record.message as Record | undefined + const role = record.role ?? nestedMessage?.role + if (role !== 'assistant') { + return undefined + } + const content = (nestedMessage ?? record).content + if (typeof content === 'string' && content.trim().length > 0) { + return content + } + if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === 'object' && part !== null) { + const text = (part as Record).text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + } + } + } + return undefined +} + +function readLastAssistantFromTranscript(transcriptPath: unknown): string | undefined { + if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { + return undefined + } + try { + const stats = statSync(transcriptPath) + const size = stats.size + if (size <= 0) { + return undefined + } + const fd = openSync(transcriptPath, 'r') + try { + let carryBytes: Buffer = Buffer.alloc(0) + let bytesRead = 0 + while (bytesRead < size && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { + const chunkSize = Math.min(size - bytesRead, TRANSCRIPT_CHUNK_BYTES) + const position = size - bytesRead - chunkSize + const buffer = Buffer.alloc(chunkSize) + let filled = 0 + while (filled < chunkSize) { + const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) + if (n === 0) { + break + } + filled += n + } + const n = filled + bytesRead += n + if (n === 0) { + break + } + const combined = Buffer.concat([buffer.subarray(0, n), carryBytes]) + const atStart = bytesRead >= size + const firstNewline = combined.indexOf(0x0a) + let completeRegion: Buffer + let nextCarry: Buffer + if (atStart) { + completeRegion = combined + nextCarry = Buffer.alloc(0) + } else if (firstNewline === -1) { + completeRegion = Buffer.alloc(0) + nextCarry = combined + } else { + nextCarry = combined.subarray(0, firstNewline) + completeRegion = combined.subarray(firstNewline + 1) + } + if (completeRegion.length > 0) { + const lines = completeRegion.toString('utf8').split('\n') + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim() + if (line.length === 0) { + continue + } + const extracted = extractAssistantTextFromLine(line) + if (extracted !== undefined) { + return extracted + } + } + } + carryBytes = nextCarry + } + return undefined + } finally { + closeSync(fd) + } + } catch { + return undefined + } +} + +function extractClaudeToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + const update: ToolSnapshot = {} + if ( + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PostToolUseFailure' + ) { + const toolName = readString(hookPayload, 'tool_name') + update.toolName = toolName + update.toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) + } + if (eventName === 'PostToolUse') { + const responseText = extractToolResponseText(hookPayload.tool_response) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + if (eventName === 'PostToolUseFailure') { + const errorText = + extractToolResponseText(hookPayload.tool_response) ?? + readString(hookPayload, 'error') ?? + readString(hookPayload, 'message') + if (errorText) { + update.lastAssistantMessage = errorText + } + } + if (eventName === 'Stop') { + const direct = readString(hookPayload, 'last_assistant_message') + if (direct) { + update.lastAssistantMessage = direct + } else { + const lastFromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path) + if (lastFromTranscript) { + update.lastAssistantMessage = lastFromTranscript + } + } + } + return update +} + +function extractCodexToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { + const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.input) ?? + deriveToolInputPreview(toolName, hookPayload.arguments) + return { toolName, toolInput } + } + if (eventName === 'Stop') { + const message = readString(hookPayload, 'last_assistant_message') + if (message) { + return { lastAssistantMessage: message } + } + } + return {} +} + +function extractGeminiToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'PreToolUse' || eventName === 'PostToolUse' || eventName === 'AfterTool') { + const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.args) ?? + deriveToolInputPreview(toolName, hookPayload.input) + return { toolName, toolInput } + } + if (eventName === 'AfterAgent') { + const message = readString(hookPayload, 'prompt_response') + if (message) { + return { lastAssistantMessage: message } + } + } + return {} +} + +function extractOpenCodeToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'MessagePart' && hookPayload.role === 'assistant') { + const text = readString(hookPayload, 'text') + if (text) { + return { lastAssistantMessage: text } + } + } + return {} +} + +function extractCursorToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if ( + eventName === 'preToolUse' || + eventName === 'postToolUse' || + eventName === 'postToolUseFailure' + ) { + const toolName = readString(hookPayload, 'tool_name') + const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) + const update: ToolSnapshot = { toolName, toolInput } + if (eventName === 'postToolUse') { + const responseText = extractToolResponseText(hookPayload.tool_output) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + if (eventName === 'postToolUseFailure') { + const errorText = + extractToolResponseText(hookPayload.tool_output) ?? + readString(hookPayload, 'error_message') ?? + readString(hookPayload, 'error') + if (errorText) { + update.lastAssistantMessage = errorText + } + } + return update + } + if (eventName === 'beforeShellExecution') { + const command = readString(hookPayload, 'command') + return { toolName: 'Shell', toolInput: command } + } + if (eventName === 'beforeMCPExecution') { + const toolName = readString(hookPayload, 'tool_name') ?? 'MCP' + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + readString(hookPayload, 'command') ?? + readString(hookPayload, 'url') + return { toolName, toolInput } + } + if (eventName === 'afterAgentResponse') { + const text = readString(hookPayload, 'text') + if (text) { + return { lastAssistantMessage: text } + } + } + return {} +} + +function extractPiToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if ( + eventName === 'tool_call' || + eventName === 'tool_execution_start' || + eventName === 'tool_execution_end' + ) { + const toolName = readString(hookPayload, 'tool_name') + const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) + return { toolName, toolInput } + } + if (eventName === 'message_end' && hookPayload.role === 'assistant') { + const text = readString(hookPayload, 'text') + if (text) { + return { lastAssistantMessage: text } + } + } + return {} +} + +function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { + // Why: exhaustive switch so adding a 7th source to AgentHookSource fails + // typecheck here instead of silently falling through to `false`. + switch (source) { + case 'claude': + return eventName === 'UserPromptSubmit' + case 'codex': + return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' + case 'gemini': + return eventName === 'BeforeAgent' + case 'opencode': + return false + case 'cursor': + return eventName === 'beforeSubmitPrompt' || eventName === 'sessionStart' + case 'pi': + return eventName === 'before_agent_start' + default: { + const _exhaustive: never = source + void _exhaustive + return false + } + } +} + +function extractToolFields( + source: AgentHookSource, + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + // Why: exhaustive switch so adding a 7th source to AgentHookSource fails + // typecheck here instead of silently routing through OpenCode's extractor. + switch (source) { + case 'claude': + return extractClaudeToolFields(eventName, hookPayload) + case 'codex': + return extractCodexToolFields(eventName, hookPayload) + case 'gemini': + return extractGeminiToolFields(eventName, hookPayload) + case 'opencode': + return extractOpenCodeToolFields(eventName, hookPayload) + case 'cursor': + return extractCursorToolFields(eventName, hookPayload) + case 'pi': + return extractPiToolFields(eventName, hookPayload) + default: { + const _exhaustive: never = source + void _exhaustive + return {} + } + } +} + +function normalizeClaudeEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PostToolUseFailure' + ? 'working' + : eventName === 'PermissionRequest' + ? 'waiting' + : eventName === 'Stop' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('claude', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('claude', eventName) } + ) + + const interrupted = + eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('claude', eventName) + }), + agentType: 'claude', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) + ) +} + +function normalizeGeminiEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'BeforeAgent' || + eventName === 'AfterTool' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ? 'working' + : eventName === 'AfterAgent' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('gemini', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('gemini', eventName) } + ) + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('gemini', eventName) + }), + agentType: 'gemini', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage + }) + ) +} + +function normalizeCodexEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'SessionStart' || + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ? 'working' + : eventName === 'Stop' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('codex', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('codex', eventName) } + ) + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('codex', eventName) + }), + agentType: 'codex', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage + }) + ) +} + +function normalizeOpenCodeEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'SessionBusy' || eventName === 'MessagePart' + ? 'working' + : eventName === 'SessionIdle' + ? 'done' + : eventName === 'PermissionRequest' || eventName === 'AskUserQuestion' + ? 'waiting' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('opencode', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('opencode', eventName) } + ) + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('opencode', eventName) + }), + agentType: 'opencode', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage + }) + ) +} + +function normalizeCursorEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'beforeSubmitPrompt' || + eventName === 'sessionStart' || + eventName === 'preToolUse' || + eventName === 'postToolUse' || + eventName === 'postToolUseFailure' || + eventName === 'afterAgentResponse' + ? 'working' + : eventName === 'stop' || eventName === 'sessionEnd' + ? 'done' + : eventName === 'beforeShellExecution' || eventName === 'beforeMCPExecution' + ? 'waiting' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('cursor', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('cursor', eventName) } + ) + + const interrupted = + eventName === 'stop' && + typeof hookPayload.status === 'string' && + hookPayload.status !== 'completed' + ? true + : undefined + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('cursor', eventName) + }), + agentType: 'cursor', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) + ) +} + +function normalizePiEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'before_agent_start' || + eventName === 'agent_start' || + eventName === 'tool_call' || + eventName === 'tool_execution_start' || + eventName === 'tool_execution_end' || + eventName === 'message_end' + ? 'working' + : eventName === 'agent_end' || eventName === 'session_shutdown' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('pi', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('pi', eventName) } + ) + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('pi', eventName) + }), + agentType: 'pi', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage + }) + ) +} + +function readStringField(record: Record, key: string): string | undefined { + const value = record[key] + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +export function normalizeHookPayload( + state: HookListenerState, + source: AgentHookSource, + body: unknown, + expectedEnv: string +): AgentHookEventPayload | null { + if (typeof body !== 'object' || body === null) { + return null + } + + const record = body as Record + const paneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' + const rawPayload = record.payload + const hookPayload = + typeof rawPayload === 'string' + ? (() => { + try { + return JSON.parse(rawPayload) + } catch { + return null + } + })() + : rawPayload + if ( + !paneKey || + paneKey.length > MAX_PANE_KEY_LEN || + typeof hookPayload !== 'object' || + hookPayload === null + ) { + return null + } + + const version = readStringField(record, 'version') + if ( + version && + version !== ORCA_HOOK_PROTOCOL_VERSION && + !state.warnedVersions.has(version) && + state.warnedVersions.size < MAX_WARNED_KEYS + ) { + state.warnedVersions.add(version) + console.warn( + `[agent-hooks] received hook v${version}; server expects v${ORCA_HOOK_PROTOCOL_VERSION}. ` + + 'Reinstall agent hooks from Settings to upgrade the managed script.' + ) + } + + const clientEnv = readStringField(record, 'env') + if (clientEnv && clientEnv !== expectedEnv) { + const key = `${clientEnv}->${expectedEnv}` + if (!state.warnedEnvs.has(key) && state.warnedEnvs.size < MAX_WARNED_KEYS) { + state.warnedEnvs.add(key) + console.warn( + `[agent-hooks] received ${clientEnv} hook on ${expectedEnv} server. ` + + 'Likely a stale terminal from another Orca install.' + ) + } + } + + const tabId = readStringField(record, 'tabId') + const worktreeId = readStringField(record, 'worktreeId') + + const eventName = (hookPayload as Record).hook_event_name + const promptText = extractPromptText(hookPayload as Record) + const hookPayloadRecord = hookPayload as Record + // Why: exhaustive switch so adding a 7th source to AgentHookSource fails + // typecheck here instead of silently routing through OpenCode's normalizer. + let payload: ParsedAgentStatusPayload | null + switch (source) { + case 'claude': + payload = normalizeClaudeEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break + case 'codex': + payload = normalizeCodexEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break + case 'gemini': + payload = normalizeGeminiEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break + case 'opencode': + payload = normalizeOpenCodeEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break + case 'cursor': + payload = normalizeCursorEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break + case 'pi': + payload = normalizePiEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break + default: { + const _exhaustive: never = source + void _exhaustive + payload = null + } + } + + // Why: connectionId stays null at the listener layer. The local server keeps + // it null; the relay forwards null on the wire and Orca's `ingestRemote` + // stamps the real value from `mux` identity on receive. See + // docs/design/agent-status-over-ssh.md §5. + return payload ? { paneKey, tabId, worktreeId, connectionId: null, payload } : null +} + +// ─── URL routing ──────────────────────────────────────────────────── + +export const HOOK_SOURCE_BY_PATHNAME: Readonly> = Object.freeze({ + '/hook/claude': 'claude', + '/hook/codex': 'codex', + '/hook/gemini': 'gemini', + '/hook/opencode': 'opencode', + '/hook/cursor': 'cursor', + '/hook/pi': 'pi' +}) + +export function resolveHookSource(pathname: string): AgentHookSource | null { + return HOOK_SOURCE_BY_PATHNAME[pathname] ?? null +} + +// ─── Endpoint-file writing ────────────────────────────────────────── + +export function getEndpointFileName(): string { + // Why: per-platform extension lets hook scripts source the file natively + // (`. "$file"` POSIX, `call "%file%"` Windows). The OpenCode plugin's regex + // accepts both shapes already. + return process.platform === 'win32' ? 'endpoint.cmd' : 'endpoint.env' +} + +export function isShellSafeEndpointValue(value: string): boolean { + // Why: every value in the endpoint file is sourced as shell. The `+` + // quantifier rejects empty strings as defense-in-depth — a sourced empty + // `KEY=` would clear the env var in the sourcing shell. + return /^[A-Za-z0-9._:/-]+$/.test(value) +} + +export type EndpointFileFields = { + port: number + token: string + env: string + version: string +} + +/** Atomically write the endpoint file at `endpointDir/`. + * Returns true on success, false on any error (caller may fall back to PTY + * env). Mirrors `AgentHookServer.writeEndpointFile` and is shared verbatim by + * the relay's adapter. */ +export function writeEndpointFile( + endpointDir: string, + finalPath: string, + fields: EndpointFileFields +): boolean { + const tmpPath = join(endpointDir, `.endpoint-${process.pid}-${randomUUID()}.tmp`) + const prefix = process.platform === 'win32' ? 'set ' : '' + const valuesToWrite: [string, string][] = [ + ['ORCA_AGENT_HOOK_PORT', String(fields.port)], + ['ORCA_AGENT_HOOK_TOKEN', fields.token], + ['ORCA_AGENT_HOOK_ENV', fields.env], + ['ORCA_AGENT_HOOK_VERSION', fields.version] + ] + for (const [key, value] of valuesToWrite) { + if (!isShellSafeEndpointValue(value)) { + console.error( + `[agent-hooks] refusing to write endpoint file: ${key} contains ` + + 'characters unsafe for shell sourcing. Falling back to PTY env.' + ) + return false + } + } + const lines = [...valuesToWrite.map(([key, value]) => `${prefix}${key}=${value}`), ''] + let tmpWritten = false + try { + // Why: 0o700 — match the file's owner-only policy so the directory does + // not leak the existence of this Orca/relay install to other local users. + mkdirSync(endpointDir, { recursive: true, mode: 0o700 }) + if (process.platform !== 'win32') { + // Why: mkdirSync's mode only applies on creation — a pre-existing + // directory keeps its original perms. POSIX-only chmod fix. + try { + chmodSync(endpointDir, 0o700) + } catch { + // best-effort + } + } + // Why: sweep stale `.endpoint-*.tmp` orphans older than 5 min so a crash + // between writeFileSync and renameSync cannot grow the dir unboundedly. + try { + const entries = readdirSync(endpointDir) + const cutoff = Date.now() - 5 * 60 * 1000 + for (const entry of entries) { + if (!entry.startsWith('.endpoint-') || !entry.endsWith('.tmp')) { + continue + } + const entryPath = join(endpointDir, entry) + try { + if (statSync(entryPath).mtimeMs < cutoff) { + unlinkSync(entryPath) + } + } catch { + // best-effort sweep + } + } + } catch { + // readdirSync can fail on exotic filesystems + } + const separator = process.platform === 'win32' ? '\r\n' : '\n' + writeFileSync(tmpPath, lines.join(separator), { mode: 0o600 }) + tmpWritten = true + renameSync(tmpPath, finalPath) + return true + } catch (err) { + console.error('[agent-hooks] failed to write endpoint file:', err) + if (tmpWritten) { + try { + unlinkSync(tmpPath) + } catch { + // tmp may already be gone + } + } + return false + } +} diff --git a/src/shared/agent-hook-relay.test.ts b/src/shared/agent-hook-relay.test.ts new file mode 100644 index 00000000000..c89631caa66 --- /dev/null +++ b/src/shared/agent-hook-relay.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_HOOK_INSTALL_PLUGINS_METHOD, + AGENT_HOOK_NOTIFICATION_METHOD, + AGENT_HOOK_REQUEST_REPLAY_METHOD, + ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV, + isRemoteAgentHooksEnabled, + type AgentHookRelayEnvelope +} from './agent-hook-relay' + +describe('agent-hook-relay wire shape', () => { + it('encodes/decodes through JSON without losing fields', () => { + const envelope: AgentHookRelayEnvelope = { + source: 'claude', + paneKey: 'tab-1:0', + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + env: 'production', + version: '1', + payload: { + state: 'working', + prompt: 'roundtrip', + agentType: 'claude' + } + } + + const decoded = JSON.parse(JSON.stringify(envelope)) as AgentHookRelayEnvelope + expect(decoded).toEqual(envelope) + expect(decoded.connectionId).toBeNull() + expect(decoded.payload.prompt).toBe('roundtrip') + }) + + it('exposes stable JSON-RPC method names', () => { + expect(AGENT_HOOK_NOTIFICATION_METHOD).toBe('agent.hook') + expect(AGENT_HOOK_REQUEST_REPLAY_METHOD).toBe('agent_hook.requestReplay') + expect(AGENT_HOOK_INSTALL_PLUGINS_METHOD).toBe('agent_hook.installPlugins') + }) +}) + +describe('isRemoteAgentHooksEnabled', () => { + it('is off when the env var is absent', () => { + expect(isRemoteAgentHooksEnabled({})).toBe(false) + }) + + it('is off for empty / "0"', () => { + expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '' })).toBe(false) + expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '0' })).toBe(false) + expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: ' ' })).toBe(false) + }) + + it('is on for any other non-empty value', () => { + expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '1' })).toBe(true) + expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: 'on' })).toBe(true) + expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: 'true' })).toBe(true) + }) +}) diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts new file mode 100644 index 00000000000..4735ac9289c --- /dev/null +++ b/src/shared/agent-hook-relay.ts @@ -0,0 +1,83 @@ +// Why: defines the wire shape carried by the JSON-RPC `agent.hook` notification +// the relay sends to Orca. Consumed by `src/relay/agent-hook-server.ts` (which +// produces it after the shared listener parses an HTTP POST) and by +// `src/main/agent-hooks/server.ts` (which ingests it via `ingestRemote`). +// +// Lives in `shared/` because the relay deliberately has no Electron dependency +// (cf. `src/relay/protocol.ts` header). `agent-hook-types.ts` is reserved for +// the renderer-bound IPC + installer contract; this module is the wire envelope +// between Orca's main process and the remote relay. +// +// Per the design doc: +// - The relay normalizes; Orca routes. The envelope's `payload` field has +// already been through `normalizeHookPayload` on the relay side; Orca's +// ingestRemote re-runs the canonical normalizer at the trust boundary +// (defense-in-depth) before feeding the event into the same `onAgentStatus` +// fanout the local HTTP path uses. +// - The wire `connectionId` is **always `null`**: a `connectionId` is Orca's +// local handle on an `ssh2` connection, not a wire identity. Orca stamps the +// real value on receive from `mux` identity inside `ingestRemote`. +// - The wire `version` and `env` fields are forwarded verbatim from the agent +// CLI's POST body so Orca's existing warn-once cross-build / dev-vs-prod +// diagnostics still fire on remote-sourced events. + +import type { ParsedAgentStatusPayload } from './agent-status-types' + +// Why: the local hook server knows the discriminator from URL pathname routing +// (`/hook/`); the relay equally must tag each forwarded notification +// with the same value so Orca can attribute the event back to the right CLI. +// Promoted from `src/main/agent-hooks/server.ts` so the relay can import it +// without dragging Electron in (the shared listener module is the only place +// that consumes it from the relay side). +export type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' | 'pi' + +/** Wire envelope for a single hook event flowing relay → Orca. */ +export type AgentHookRelayEnvelope = { + source: AgentHookSource + paneKey: string + tabId?: string + worktreeId?: string + /** Always `null` on the wire — relay does not know Orca's local connectionId. */ + connectionId: null + /** Forwarded verbatim from the agent CLI POST body (e.g. 'production', + * 'development'). Lets Orca's warn-once env-mismatch diagnostic fire on + * remote events the same as on local. */ + env?: string + /** Forwarded verbatim from the agent CLI POST body. Lets Orca's warn-once + * protocol-version diagnostic fire on remote events the same as on local. */ + version?: string + /** Pre-normalized status payload from the relay's `normalizeHookPayload`. + * Orca's `ingestRemote` re-validates via `normalizeAgentStatusPayload` at + * the trust boundary as defense-in-depth. */ + payload: ParsedAgentStatusPayload +} + +/** JSON-RPC notification method name carried over the relay control channel. */ +export const AGENT_HOOK_NOTIFICATION_METHOD = 'agent.hook' as const + +/** JSON-RPC request method Orca issues after `--connect` reattach to ask the + * relay to replay its per-paneKey last-payload cache. See §5 Path 3 of the + * design doc for the race that ruled out push-on-`setWrite`. */ +export const AGENT_HOOK_REQUEST_REPLAY_METHOD = 'agent_hook.requestReplay' as const + +/** JSON-RPC request method Orca issues at session-ready to ship the + * OpenCode/Pi plugin source files to the relay so it can materialize the + * per-PTY overlay dirs on the remote. */ +export const AGENT_HOOK_INSTALL_PLUGINS_METHOD = 'agent_hook.installPlugins' as const + +/** Feature-flag env var. Read once at process start by Orca and the relay. + * Absent / empty / "0" = off; anything else = on. See §8 of the design doc + * for the gate locations. */ +export const ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV = 'ORCA_FEATURE_REMOTE_AGENT_HOOKS' as const + +export function isRemoteAgentHooksEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] + if (raw === undefined) { + return false + } + const trimmed = raw.trim() + if (trimmed.length === 0 || trimmed === '0') { + return false + } + return true +} From cc9f4bf08375bf2c7145ffc4cbc667327431d134 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 10 May 2026 21:58:16 -0700 Subject: [PATCH 08/15] feat(settings): list supported audio formats for Custom Sound (#1685) Updates the Custom Sound search entry description and keywords to include the supported formats (MP3, WAV, OGG, M4A, AAC, FLAC), and adds a small caption under the setting's description in NotificationsPane for clarity. Co-authored-by: Orca --- .../src/components/settings/NotificationsPane.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/settings/NotificationsPane.tsx b/src/renderer/src/components/settings/NotificationsPane.tsx index 9d292123628..8eaa7f8dbd2 100644 --- a/src/renderer/src/components/settings/NotificationsPane.tsx +++ b/src/renderer/src/components/settings/NotificationsPane.tsx @@ -31,8 +31,9 @@ export const NOTIFICATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ }, { title: 'Custom Sound', - description: 'Choose one local audio file for all delivered desktop notifications.', - keywords: ['notifications', 'sound', 'audio', 'ogg', 'mp3', 'wav'] + description: + 'Choose one local audio file (MP3, WAV, OGG, M4A, AAC, or FLAC) for all delivered desktop notifications.', + keywords: ['notifications', 'sound', 'audio', 'mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac'] }, { title: 'Send Test Notification', @@ -141,6 +142,9 @@ export function NotificationsPane({

One local audio file for all delivered desktop notifications.

+

+ Supported formats: MP3, WAV, OGG, M4A, AAC, FLAC. +

Date: Sun, 10 May 2026 23:07:28 -0700 Subject: [PATCH 09/15] feat(sidebar): allow manual drag-and-drop reordering of repos (#1686) * feat(sidebar): allow manual drag-and-drop reordering of repos Users can now drag repo headers in the sidebar to reorder them. The custom order is persisted to disk and survives restarts. Includes design doc at docs/manual-repo-reorder.md. Co-authored-by: Orca * fix: scope post-drag click swallow to dragged repo header Avoid silently eating unrelated clicks if one races between pointerup and the failsafe teardown. Co-authored-by: Orca --------- Co-authored-by: Orca --- .gitignore | 5 +- docs/manual-repo-reorder.md | 91 +++++ src/main/ipc/repos.ts | 17 + src/main/persistence.ts | 32 ++ src/preload/api-types.ts | 1 + src/preload/index.ts | 3 + .../src/components/sidebar/WorktreeList.tsx | 69 +++- .../components/sidebar/repo-header-drag.ts | 341 ++++++++++++++++++ .../sidebar/worktree-list-groups.test.ts | 34 ++ .../sidebar/worktree-list-groups.ts | 26 +- src/renderer/src/store/slices/repos.ts | 31 +- 11 files changed, 639 insertions(+), 11 deletions(-) create mode 100644 docs/manual-repo-reorder.md create mode 100644 src/renderer/src/components/sidebar/repo-header-drag.ts diff --git a/.gitignore b/.gitignore index a3f366a865f..58486129a44 100644 --- a/.gitignore +++ b/.gitignore @@ -67,9 +67,8 @@ design-docs/ .atl/ # Local-only design/planning docs (not checked in) -docs/*-design.md -docs/*-plan.md -docs/design-*.md +docs/*.md +!docs/README*.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/docs/manual-repo-reorder.md b/docs/manual-repo-reorder.md new file mode 100644 index 00000000000..e5fb57baf05 --- /dev/null +++ b/docs/manual-repo-reorder.md @@ -0,0 +1,91 @@ +# Manual repo reorder + +## Problem + +Current repo-header order in the sidebar is **not** `state.repos` insertion order. +`buildRows(..., groupBy='repo')` groups visible unpinned worktrees into a `Map` +and emits `Array.from(grouped.entries())`, so order follows first encounter in the +already-sorted visible worktree stream. + +Result: users cannot control repo-group order, and sorting/filtering side effects can +change which repo appears first. + +## Goal + +Allow users to reorder repo headers in the sidebar when grouped by repo, persist that +order, and use it anywhere the UI lists repos. + +## Non-goals + +- Reordering worktrees within a repo group. +- Reordering PR-status groups, pinned section, or `All` header. +- Dragging worktrees between repos. +- Mobile/touch drag support. + +## Design Decisions + +1. Persist canonical order in `state.repos` array order (no new schema field). +2. Add `Store.reorderRepos(orderedIds)` in `src/main/persistence.ts`. +3. Add IPC `repos:reorder` in `src/main/ipc/repos.ts` and preload/API typing. +4. Add renderer action `reorderRepos(orderedIds)` in repo slice. +5. In `buildRows`, when `groupBy==='repo'`, order repo groups by a `repoOrder` map + derived from `state.repos` (`repoId -> index`). Unknown ids sort last by label. + +## Interaction Model (Important) + +Do **not** use HTML5 DnD for this list. + +`WorktreeList` uses `@tanstack/react-virtual` with absolutely-positioned rows moved by +`translateY`. During drag, rows can unmount/remount as scroll changes; HTML5 DnD target +and hover state are brittle in this setup. + +Use pointer-driven drag state in React instead: + +- Start drag from a dedicated drag handle on repo headers only (not full header). +- Keep collapse click behavior on header body unchanged. +- While dragging, render insertion indicator from computed target repo index. +- On drop, compute new `orderedIds` and call renderer `reorderRepos`. +- Cancel cleanly on escape, pointer cancel, blur, or invalid target. + +Why handle-only: header already has click-to-toggle and nested buttons (`+`); dragging +from whole header conflicts with those interactions. + +## Consistency and Invalidation + +`repos:changed` already triggers `fetchRepos()` in `useIpcEvents`. + +Implications: + +- Renderer can optimistically reorder local `repos` immediately. +- Main persists reorder and emits `repos:changed`; renderer refetch should converge to + the same order (no semantic conflict). +- If reorder is rejected (stale permutation due to concurrent add/remove), renderer must + rollback by refetching. + +`repos:reorder` should return a status (`applied | rejected`) so renderer can avoid +extra fetch on success and force resync on rejection. + +## Edge Cases + +- Concurrent repo add/remove while dragging: permutation validation rejects stale order; + renderer refetches. +- Repo filtered out / no visible worktrees: repo stays in canonical `state.repos` order + and reappears in correct position when visible again. +- `groupBy !== 'repo'`: no drag affordance. +- Dragging near viewport edges: support auto-scroll while pointer-dragging. +- Virtualization: keep drop computation independent of mounted DOM targets; do not + require hovered row to stay mounted. +- Multi-window: last successful reorder wins via persisted array; each window resyncs on + `repos:changed`. + +## Implementation Plan + +1. Add `reorderRepos(orderedIds)` to Store with strict permutation validation and save. +2. Add `repos:reorder` IPC handler; on success emit `repos:changed`. +3. Extend preload `repos` API and `src/preload/api-types.ts`. +4. Add renderer repo-slice action with optimistic local reorder + rejection resync. +5. Thread `repoOrder` into `buildRows` and add tests for explicit repo ordering. +6. Add pointer-based drag handle + insertion indicator to repo header UI in + `WorktreeList.tsx`. +7. Manual validation: reorder, restart, filter/unfilter, collapse/expand, and + cross-window conflict behavior. diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 5c9074b5319..1e16236a81f 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -66,6 +66,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:list') ipcMain.removeHandler('repos:add') ipcMain.removeHandler('repos:remove') + ipcMain.removeHandler('repos:reorder') ipcMain.removeHandler('repos:update') ipcMain.removeHandler('repos:pickFolder') ipcMain.removeHandler('repos:pickDirectory') @@ -417,6 +418,22 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } ) + ipcMain.handle( + 'repos:reorder', + (_event, args: { orderedIds: string[] }): { status: 'applied' | 'rejected' } => { + // Why: validate at the IPC boundary — IPC input is untrusted and a + // permutation mismatch means the renderer's drag was stale relative to + // a concurrent add/remove. Reject so the renderer can resync. + const ids = Array.isArray(args?.orderedIds) ? args.orderedIds : [] + const applied = store.reorderRepos(ids) + if (applied) { + notifyReposChanged(mainWindow) + return { status: 'applied' } + } + return { status: 'rejected' } + } + ) + ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => { store.removeRepo(args.repoId) invalidateAuthorizedRootsCache() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 1a9cab0b38b..47a31c07a82 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -599,6 +599,38 @@ export class Store { this.scheduleSave() } + // Why: returns false on a stale permutation (concurrent add/remove races + // the renderer's drag) so the caller can tell the renderer to resync rather + // than persist an order that drops or duplicates ids. + reorderRepos(orderedIds: string[]): boolean { + const current = this.state.repos + if (orderedIds.length !== current.length) { + return false + } + const seen = new Set() + for (const id of orderedIds) { + if (typeof id !== 'string' || seen.has(id)) { + return false + } + seen.add(id) + } + const byId = new Map() + for (const r of current) { + byId.set(r.id, r) + } + const next: Repo[] = [] + for (const id of orderedIds) { + const repo = byId.get(id) + if (!repo) { + return false + } + next.push(repo) + } + this.state.repos = next + this.scheduleSave() + return true + } + removeRepo(id: string): void { this.state.repos = this.state.repos.filter((r) => r.id !== id) // Why: presets are repo-scoped, so removing the repo means the presets diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 80582f4c2e7..4bb4c8ce01d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -389,6 +389,7 @@ export type PreloadApi = { kind?: 'git' | 'folder' }) => Promise<{ repo: Repo } | { error: string }> remove: (args: { repoId: string }) => Promise + reorder: (args: { orderedIds: string[] }) => Promise<{ status: 'applied' | 'rejected' }> update: (args: { repoId: string updates: Partial< diff --git a/src/preload/index.ts b/src/preload/index.ts index 0250700d737..c5a0ddeda8e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -287,6 +287,9 @@ const api = { remove: (args: { repoId: string }): Promise => ipcRenderer.invoke('repos:remove', args), + reorder: (args: { orderedIds: string[] }): Promise<{ status: 'applied' | 'rejected' }> => + ipcRenderer.invoke('repos:reorder', args), + update: (args: { repoId: string; updates: Record }): Promise => ipcRenderer.invoke('repos:update', args), diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index d2b0fe0e1e4..f3e8dfac38c 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -36,6 +36,7 @@ import { sidebarHasActiveFilters } from './visible-worktrees' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { useRepoHeaderDrag } from './repo-header-drag' // How long to wait after a sortEpoch bump before actually re-sorting. // Prevents jarring position shifts when background events (AI starting work, @@ -79,6 +80,13 @@ type VirtualizedWorktreeViewportProps = { clearPendingRevealWorktreeId: () => void worktrees: Worktree[] repoMap: Map + repoOrder: Map + // The full canonical state.repos id ordering — the drag controller commits + // permutations of this list, even when some repos aren't currently visible + // (filtered out / collapsed-only). Visible-only ids would silently drop the + // hidden repos on reorder. + allRepoIds: string[] + reorderRepos: (orderedIds: string[]) => void prCache: Record | null // Why: the viewport remounts when the row structure changes (see // viewportResetKey) so the virtualizer's measurementsCache cannot hold @@ -101,10 +109,22 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp clearPendingRevealWorktreeId, worktrees, repoMap, + repoOrder, + allRepoIds, + reorderRepos, prCache, scrollOffsetRef }: VirtualizedWorktreeViewportProps) { const scrollRef = useRef(null) + + // Drag is only meaningful when the user is grouping by repo. When inert + // (groupBy !== 'repo'), the controller is still constructed for hook order + // stability but the handle is never rendered. + const repoDrag = useRepoHeaderDrag({ + orderedRepoIds: allRepoIds, + onCommit: reorderRepos, + getScrollContainer: () => scrollRef.current + }) const activeWorktreeRowIndex = useMemo( () => rows.findIndex((row) => row.type === 'item' && row.worktree.id === activeWorktreeId), [rows, activeWorktreeId] @@ -260,7 +280,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktrees, repoMap, prCache, - new Set() + new Set(), + repoOrder ).filter((r): r is Extract => r.type === 'item') if (worktreeRows.length === 0) { return @@ -293,7 +314,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp virtualizer.scrollToIndex(rowIndex, { align: 'auto' }) } }, - [rows, activeWorktreeId, virtualizer, groupBy, worktrees, repoMap, prCache] + [rows, activeWorktreeId, virtualizer, groupBy, worktrees, repoMap, prCache, repoOrder] ) useEffect(() => { @@ -368,10 +389,22 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }} > + {repoDrag.state.draggingRepoId !== null && repoDrag.state.dropIndicatorY !== null ? ( +
+ ) : null} {virtualItems.map((vItem) => { const row = rows[vItem.index] if (row.type === 'header') { + const isRepoHeader = groupBy === 'repo' && row.repo !== undefined + const repoIdForHeader = isRepoHeader ? row.repo!.id : undefined + const isDraggingThis = + repoDrag.state.draggingRepoId !== null && + repoDrag.state.draggingRepoId === repoIdForHeader return (
{row.icon ? (
repoDrag.onHandlePointerDown(e, repoIdForHeader) + : undefined + } className={cn( 'flex size-4 shrink-0 items-center justify-center rounded-[4px]', row.repo && 'text-muted-foreground' @@ -711,10 +753,22 @@ const WorktreeList = React.memo(function WorktreeList() { const collapsedGroups = useAppStore((s) => s.collapsedGroups) const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup) + // Why: header order in groupBy='repo' is bound to state.repos array order so + // manual reorder is the single source of truth. The Map lets buildRows do an + // O(1) rank lookup per group without depending on Repo identity. + const repos = useAppStore((s) => s.repos) + const repoOrder = useMemo(() => { + const map = new Map() + repos.forEach((r, i) => map.set(r.id, i)) + return map + }, [repos]) + const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) + const reorderReposAction = useAppStore((s) => s.reorderRepos) + // Build flat row list for rendering const rows: Row[] = useMemo( - () => buildRows(groupBy, worktrees, repoMap, prCache, collapsedGroups), - [groupBy, worktrees, repoMap, prCache, collapsedGroups] + () => buildRows(groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder), + [groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder] ) // Why: rows.length alone can stay the same when items migrate between // groups (e.g., PR cache loads on restart and a collapsed group absorbs @@ -824,6 +878,11 @@ const WorktreeList = React.memo(function WorktreeList() { clearPendingRevealWorktreeId={clearPendingRevealWorktreeId} worktrees={worktrees} repoMap={repoMap} + repoOrder={repoOrder} + allRepoIds={allRepoIds} + reorderRepos={(orderedIds) => { + void reorderReposAction(orderedIds) + }} prCache={prCache} scrollOffsetRef={sidebarScrollOffsetRef} /> diff --git a/src/renderer/src/components/sidebar/repo-header-drag.ts b/src/renderer/src/components/sidebar/repo-header-drag.ts new file mode 100644 index 00000000000..5e2effe4548 --- /dev/null +++ b/src/renderer/src/components/sidebar/repo-header-drag.ts @@ -0,0 +1,341 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +// Why pointer events instead of HTML5 DnD: rows are absolutely-positioned by +// react-virtual and unmount/remount as scroll changes, so DnD enter/leave fire +// against stale targets. With pointer events we cache the active set of repo +// header positions and compute the drop index from the live pointer Y. + +export type RepoDragState = { + draggingRepoId: string | null + // Insertion index in the orderedRepoIds array where the dragged repo would + // land if released now. null while not dragging. + dropIndex: number | null + // Y coordinate (in scrollContainer's local space, i.e. relative to its + // top-left content origin including current scrollTop offset) where the + // insertion bar should be drawn. null while not dragging. + dropIndicatorY: number | null +} + +const INITIAL_STATE: RepoDragState = { + draggingRepoId: null, + dropIndex: null, + dropIndicatorY: null +} + +export type UseRepoHeaderDragArgs = { + orderedRepoIds: string[] + onCommit: (orderedIds: string[]) => void + // Returns the scroll container that hosts the virtualized rows. Bounding + // rects are read from this element so insertion-bar Y values stay correct + // when the sidebar is resized. + getScrollContainer: () => HTMLElement | null +} + +type HeaderRect = { + repoId: string + // top/bottom in scrollContainer-local space (page-coord top minus container + // page-coord top, plus current scrollTop). + top: number + bottom: number +} + +export type RepoHeaderDragController = { + state: RepoDragState + // Call from the repo header's onPointerDown. The drag does NOT start + // immediately — it arms a pending session that promotes to an active drag + // only once the pointer moves past DRAG_THRESHOLD_PX. A pointerup before + // promotion releases without committing, so the surrounding click handler + // still fires and toggles the group's collapsed state. + onHandlePointerDown: (event: React.PointerEvent, repoId: string) => void +} + +// Pixels the pointer must travel before we promote a pending press into a +// real drag. Below this we treat the press as a normal click (toggle group). +const DRAG_THRESHOLD_PX = 4 + +export function useRepoHeaderDrag({ + orderedRepoIds, + onCommit, + getScrollContainer +}: UseRepoHeaderDragArgs): RepoHeaderDragController { + const [state, setState] = useState(INITIAL_STATE) + // Tracks whether a press has begun (armed) regardless of promotion. Used + // only to gate window listeners; visible drag state lives in `state`. + const [sessionArmed, setSessionArmed] = useState(false) + // Why: endDrag reads dropIndex on pointerup, but binding the listener with + // dropIndex in deps would re-add window listeners on every pointermove. + // The ref tracks the latest computed value without invalidating the effect. + const latestDropIndexRef = useRef(null) + latestDropIndexRef.current = state.dropIndex + // Keep callbacks stable: they read from refs so we don't re-bind window + // listeners every render. + const orderedIdsRef = useRef(orderedRepoIds) + orderedIdsRef.current = orderedRepoIds + const onCommitRef = useRef(onCommit) + onCommitRef.current = onCommit + const getContainerRef = useRef(getScrollContainer) + getContainerRef.current = getScrollContainer + + const dragSessionRef = useRef<{ + repoId: string + pointerId: number + headerRects: HeaderRect[] + handleEl: HTMLElement + startX: number + startY: number + // false until the pointer moves past DRAG_THRESHOLD_PX. While false the + // session exists but no drop indicator is shown and pointerup is treated + // as a click rather than a drop. + promoted: boolean + } | null>(null) + + const computeDrop = useCallback( + (pointerY: number): { dropIndex: number; dropIndicatorY: number } | null => { + const session = dragSessionRef.current + const container = getContainerRef.current() + if (!session || !container) { + return null + } + const containerRect = container.getBoundingClientRect() + // Translate pointer to container-local coords + scroll. + const localY = pointerY - containerRect.top + container.scrollTop + const rects = session.headerRects + if (rects.length === 0) { + return null + } + // Find the first header whose midpoint is below the pointer. + let insertBefore = rects.length + for (let i = 0; i < rects.length; i++) { + const mid = (rects[i].top + rects[i].bottom) / 2 + if (localY < mid) { + insertBefore = i + break + } + } + // Why anchor to the target header (not midpoint between headers): the + // space between two repo group headers is filled with worktree cards, + // so the midpoint falls *inside another repo's content*. Sitting the + // indicator just above the target header keeps it at the visual top of + // where the dragged group would land. + const INDICATOR_GAP_PX = 4 + const indicatorY = + insertBefore >= rects.length + ? rects.at(-1)!.bottom + INDICATOR_GAP_PX + : Math.max(0, rects[insertBefore].top - INDICATOR_GAP_PX) + return { dropIndex: insertBefore, dropIndicatorY: indicatorY } + }, + [] + ) + + const endDrag = useCallback((commit: boolean) => { + const session = dragSessionRef.current + if (!session) { + setState(INITIAL_STATE) + setSessionArmed(false) + return + } + try { + session.handleEl.releasePointerCapture(session.pointerId) + } catch { + // capture may already be released (pointercancel, element unmounted) + } + if (session.promoted) { + // After a real drag, the browser still fires a click on the header. + // Swallow exactly one click in capture phase so it doesn't toggle the + // group's collapsed state. Scope to the dragged handle (and ancestors) + // so an unrelated click that races between pointerup and the failsafe + // teardown isn't silently eaten. + const handleEl = session.handleEl + const swallow = (e: MouseEvent): void => { + const target = e.target as Node | null + if (target && handleEl.contains(target)) { + e.stopPropagation() + e.preventDefault() + } + window.removeEventListener('click', swallow, true) + } + window.addEventListener('click', swallow, true) + // Failsafe: if no click ever arrives (e.g. pointercancel), drop the + // listener after a tick so future clicks aren't silenced. + setTimeout(() => window.removeEventListener('click', swallow, true), 0) + } + // Only commit a reorder if the press was promoted into a real drag — + // otherwise the press was effectively a click, and the surrounding + // header onClick handler will toggle collapse. + const finalIndex = + commit && session.promoted && latestDropIndexRef.current !== null + ? latestDropIndexRef.current + : null + dragSessionRef.current = null + setState(INITIAL_STATE) + setSessionArmed(false) + if (finalIndex === null) { + return + } + const ids = orderedIdsRef.current + const fromIndex = ids.indexOf(session.repoId) + if (fromIndex === -1) { + return + } + // Splice fromIndex out, then insert at finalIndex (adjusting if the + // removal shifted indices). + const next = ids.slice() + next.splice(fromIndex, 1) + const insertAt = finalIndex > fromIndex ? finalIndex - 1 : finalIndex + if (insertAt === fromIndex) { + return + } + next.splice(insertAt, 0, session.repoId) + onCommitRef.current(next) + }, []) + + // Window-level listeners while a session is armed — pointer capture on the + // header element ensures the events still fire even if the pointer leaves + // it. The session may be unpromoted (waiting for a movement past the + // threshold to become a real drag) or promoted (drop indicator visible). + useEffect(() => { + if (!sessionArmed) { + return + } + const onPointerMove = (e: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || e.pointerId !== session.pointerId) { + return + } + if (!session.promoted) { + const dx = e.clientX - session.startX + const dy = e.clientY - session.startY + if (dx * dx + dy * dy < DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) { + return + } + session.promoted = true + setState({ draggingRepoId: session.repoId, dropIndex: null, dropIndicatorY: null }) + } + const drop = computeDrop(e.clientY) + if (!drop) { + return + } + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingRepoId: session.repoId, ...drop } + ) + } + const onPointerUp = (e: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || e.pointerId !== session.pointerId) { + return + } + endDrag(true) + } + const onPointerCancel = (e: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || e.pointerId !== session.pointerId) { + return + } + endDrag(false) + } + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + endDrag(false) + } + } + const onBlur = (): void => endDrag(false) + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('blur', onBlur) + return () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('blur', onBlur) + } + }, [sessionArmed, computeDrop, endDrag]) + + // From pointerdown onward (armed session, before/after promotion) force a + // grabbing cursor and disable text selection across the whole window so + // the user gets immediate feedback that the press registered, even before + // they've moved past DRAG_THRESHOLD_PX. + useEffect(() => { + if (!sessionArmed) { + return + } + const body = document.body + const prevCursor = body.style.cursor + const prevUserSelect = body.style.userSelect + body.style.cursor = 'grabbing' + body.style.userSelect = 'none' + return () => { + body.style.cursor = prevCursor + body.style.userSelect = prevUserSelect + } + }, [sessionArmed]) + + const onHandlePointerDown = useCallback( + (event: React.PointerEvent, repoId: string) => { + // Only react to primary button. Ignore right/middle clicks. + if (event.button !== 0) { + return + } + // Don't intercept presses that originated on a nested control (the + // `+` create-worktree button or the chevron). Those have their own + // click semantics and shouldn't arm a drag. + const target = event.target as HTMLElement | null + if (target && target !== event.currentTarget && target.closest('button')) { + return + } + const container = getContainerRef.current() + if (!container) { + return + } + // Snapshot every repo header's position in scrollContainer-local space. + // Using a snapshot (vs reading the DOM each pointermove) means the drop + // computation does not depend on those rows still being mounted — + // critical because react-virtual will unmount them as the user scrolls. + const containerRect = container.getBoundingClientRect() + const headerEls = container.querySelectorAll('[data-repo-header-id]') + const headerRects: HeaderRect[] = [] + headerEls.forEach((el) => { + const id = el.getAttribute('data-repo-header-id') + if (!id) { + return + } + const rect = el.getBoundingClientRect() + headerRects.push({ + repoId: id, + top: rect.top - containerRect.top + container.scrollTop, + bottom: rect.bottom - containerRect.top + container.scrollTop + }) + }) + headerRects.sort((a, b) => a.top - b.top) + + const handleEl = event.currentTarget + try { + handleEl.setPointerCapture(event.pointerId) + } catch { + // setPointerCapture can throw if the element is detached; the global + // pointer listeners still fire, so dragging keeps working. + } + dragSessionRef.current = { + repoId, + pointerId: event.pointerId, + headerRects, + handleEl, + startX: event.clientX, + startY: event.clientY, + promoted: false + } + // Don't show drag UI yet. Wait for movement past DRAG_THRESHOLD_PX so a + // simple click on the header still toggles collapse via the surrounding + // onClick handler. + setSessionArmed(true) + }, + [] + ) + + return { state, onHandlePointerDown } +} diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 071f0c2d24b..39d8a3d5b84 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -137,6 +137,40 @@ describe('buildRows with pinned worktrees', () => { }) }) +describe('buildRows repo grouping order', () => { + const repoA: Repo = { ...repo, id: 'repo-a', displayName: 'alpha' } + const repoB: Repo = { ...repo, id: 'repo-b', displayName: 'beta' } + const repoC: Repo = { ...repo, id: 'repo-c', displayName: 'gamma' } + const map = new Map([ + [repoA.id, repoA], + [repoB.id, repoB], + [repoC.id, repoC] + ]) + const wA: Worktree = { ...worktree, id: 'wt-a', repoId: repoA.id, displayName: 'a' } + const wB: Worktree = { ...worktree, id: 'wt-b', repoId: repoB.id, displayName: 'b' } + const wC: Worktree = { ...worktree, id: 'wt-c', repoId: repoC.id, displayName: 'c' } + + it('orders repo headers by explicit repoOrder, not first-encounter', () => { + // Worktree stream encounters in order C, A, B — but repoOrder says B, A, C. + const repoOrder = new Map([ + [repoB.id, 0], + [repoA.id, 1], + [repoC.id, 2] + ]) + const rows = buildRows('repo', [wC, wA, wB], map, null, new Set(), repoOrder) + const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key) + expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c']) + }) + + it('places unknown repo ids last and sorts them by label', () => { + // Only repoB is in repoOrder; repoA and repoC fall through to label sort. + const repoOrder = new Map([[repoB.id, 0]]) + const rows = buildRows('repo', [wC, wA, wB], map, null, new Set(), repoOrder) + const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key) + expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c']) + }) +}) + describe('WorktreeList header styles', () => { it('does not title-case workspace group labels', () => { const source = readFileSync( diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 85a5caae07a..088624082df 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -150,7 +150,8 @@ export function buildRows( worktrees: Worktree[], repoMap: Map, prCache: Record | null, - collapsedGroups: Set + collapsedGroups: Set, + repoOrder?: Map ): Row[] { const result: Row[] = [] @@ -210,7 +211,28 @@ export function buildRows( } } } else { - orderedGroups.push(...Array.from(grouped.entries())) + // Why: header order must follow the canonical state.repos array order, not + // first-encounter from the smart-sorted worktree stream — otherwise sorting + // or filtering side effects could shuffle which repo header appears first, + // and manual reorder would have nothing to bind to. Unknown ids (no entry + // in repoOrder) sort last by label so they remain deterministic. + const entries = Array.from(grouped.entries()) + if (repoOrder) { + const rankFor = (key: string): number => { + const repoId = key.startsWith('repo:') ? key.slice('repo:'.length) : key + const rank = repoOrder.get(repoId) + return rank === undefined ? Number.POSITIVE_INFINITY : rank + } + entries.sort((a, b) => { + const ra = rankFor(a[0]) + const rb = rankFor(b[0]) + if (ra !== rb) { + return ra - rb + } + return a[1].label.localeCompare(b[1].label) + }) + } + orderedGroups.push(...entries) } for (const [key, group] of orderedGroups) { diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 32f6ee82291..78977a17b7d 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -30,6 +30,7 @@ export type RepoSlice = { > ) => Promise setActiveRepo: (repoId: string | null) => void + reorderRepos: (orderedIds: string[]) => Promise } export const createRepoSlice: StateCreator = (set, get) => ({ @@ -267,5 +268,33 @@ export const createRepoSlice: StateCreator = (set, } }, - setActiveRepo: (repoId) => set({ activeRepoId: repoId }) + setActiveRepo: (repoId) => set({ activeRepoId: repoId }), + + reorderRepos: async (orderedIds) => { + // Optimistically apply the new order so the sidebar updates instantly; + // resync only if main rejects (stale permutation due to a racing add/remove). + const previous = get().repos + const byId = new Map(previous.map((r) => [r.id, r])) + const next: Repo[] = [] + for (const id of orderedIds) { + const repo = byId.get(id) + if (repo) { + next.push(repo) + } + } + if (next.length !== previous.length) { + // Caller passed a non-permutation — refuse to apply locally. + return + } + set({ repos: next }) + try { + const result = await window.api.repos.reorder({ orderedIds }) + if (result.status === 'rejected') { + await get().fetchRepos() + } + } catch (err) { + console.error('Failed to reorder repos:', err) + await get().fetchRepos() + } + } }) From 2247435c96922fff5d03328169be49d5e844a916 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 06:15:53 +0000 Subject: [PATCH 10/15] release: v1.3.48 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dbe4c7fc479..3d8735c0c3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.48-rc.3", + "version": "1.3.48", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 648f207281baecc48d7ee59ed357d3a35f79c958 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 10 May 2026 23:16:41 -0700 Subject: [PATCH 11/15] feat(checks-panel): auto-refresh on entering Checks tab (#1688) Force a freshness check each time the user enters the Checks tab (open sidebar, switch to Checks tab, or switch active worktree/branch) so stale PR metadata, cached-null "no PR" results, stale checks, and stale comments are surfaced immediately rather than waiting for the cache TTL. - Extracts entry-refresh logic into `checks-entry-refresh.ts` with a 30 s grace window to suppress rapid show/hide duplicate fetches. - Adds a `shouldEntryRefresh` effect in `ChecksPanel` keyed by `activeWorktreeId::repo.path::branch`; resets on panel hide so close-and-reopen re-evaluates freshness. - Fixes a stale-closure bug in `handleRefresh`: `fetchPRChecks` is now called directly with the freshly resolved `headSha` after PR refresh instead of reusing the pre-refresh closure's captured sha. - Adds 11 unit tests in `checks-entry-refresh.test.ts`. - Design doc: `docs/refresh-on-checks-tab.md`. Co-authored-by: Orca --- docs/refresh-on-checks-tab.md | 165 ++++++++++++++++++ .../components/right-sidebar/ChecksPanel.tsx | 96 +++++++++- .../checks-entry-refresh.test.ts | 140 +++++++++++++++ .../right-sidebar/checks-entry-refresh.ts | 46 +++++ 4 files changed, 444 insertions(+), 3 deletions(-) create mode 100644 docs/refresh-on-checks-tab.md create mode 100644 src/renderer/src/components/right-sidebar/checks-entry-refresh.test.ts create mode 100644 src/renderer/src/components/right-sidebar/checks-entry-refresh.ts diff --git a/docs/refresh-on-checks-tab.md b/docs/refresh-on-checks-tab.md new file mode 100644 index 00000000000..392b5de0609 --- /dev/null +++ b/docs/refresh-on-checks-tab.md @@ -0,0 +1,165 @@ +# Auto-refresh on entering the Checks tab + +## Problem + +The Checks panel fetches on visibility, but those fetches are cache-respecting: + +- `CACHE_TTL = 300_000` for PRs and comments. +- `CHECKS_CACHE_TTL = 60_000` for checks. +- `ChecksPanel.tsx` fetches PRs on repo/branch changes without `force`. +- Checks polling and the comments effect also call the store without `force`. + +That means entering the Checks tab can render stale PR metadata, a cached `null` +"no PR" result, stale comments, or stale checks. The checks path is partially +protected by a shorter renderer TTL, but the main process also uses `gh api +--cache 60s` for the REST checks endpoint unless `noCache` is set. Manual refresh +already bypasses these renderer and `gh api` caches for the requests it starts. + +## Goal + +When the user enters the Checks tab, run one freshness check for the active +worktree and force-refresh only when the relevant cache timestamps are older +than a small grace window. "Entering" means: + +- opening the right sidebar while the Checks tab is selected; +- switching from another right-sidebar tab to Checks; +- switching active worktree/repo/branch while Checks is already visible. + +The refresh must cover PR discovery too, including cached `null`, so a PR opened +outside Orca can appear without waiting up to 5 minutes. + +## Non-goals + +- Changing the existing polling cadence while the panel stays visible. +- Refreshing on window focus, network reconnect, or arbitrary background events. +- Changing the manual refresh button. +- Prefetching checks for non-active worktrees. +- Adding cross-renderer or cross-window request coordination. Orca currently has + one live main window; renderer in-flight maps are not a multi-window primitive. + +## Design + +1. **Entry trigger.** Add an effect in `ChecksPanel.tsx` keyed by: + + ```ts + const entryKey = isPanelVisible && repo && !isFolder && branch + ? `${activeWorktreeId ?? ''}::${repo.path}::${branch}` + : '' + ``` + + Track the last processed visible `entryKey` in a ref. When `entryKey` is empty, + reset the ref to `''`. When it becomes non-empty and differs from the ref, this + is an entry. This is required; a `prevKey !== currentKey` check that does not + reset on hide would miss closing and reopening the same PR. + +2. **Grace window.** Define `ENTRY_REFRESH_GRACE_MS = 15_000` in + `ChecksPanel.tsx`. Select only timestamps, not whole cache records: + + ```ts + const prFetchedAt = useAppStore( + (s) => (prCacheKey ? s.prCache[prCacheKey]?.fetchedAt : undefined) + ) + const checksFetchedAt = useAppStore( + (s) => prNumber + ? s.checksCache[`${repo?.path ?? ''}::pr-checks::${prNumber}`]?.fetchedAt + : undefined + ) + const commentsFetchedAt = useAppStore( + (s) => prNumber + ? s.commentsCache[`${repo?.path ?? ''}::pr-comments::${prNumber}`]?.fetchedAt + : undefined + ) + ``` + + Missing PR cache is stale. A cached PR value of `null` is still a PR cache + entry and should be refreshed on entry once outside the grace window. When a + PR number is known, missing checks/comments timestamps are stale. When no PR is + known, checks/comments are not relevant yet. + + Run `handleRefresh()` if the oldest relevant timestamp is missing or older + than `Date.now() - ENTRY_REFRESH_GRACE_MS`; otherwise skip. + +3. **Reuse `handleRefresh`, but pass the refreshed head SHA through.** The + manual refresh flow is the right shape: force `fetchPRForBranch`, then if a + PR is returned, force checks and comments for the returned PR. Extend + `fetchChecks()` to accept a `headShaOverride` (or call `fetchPRChecks` + directly from `handleRefresh`) so the checks request uses + `refreshedPR.headSha`, not the stale `pr?.headSha` captured before the PR + refresh completed. This handles PR number changes, cached `null`, and + external force-pushes correctly. + +4. **Reset polling attention state before refresh.** When the entry refresh runs, + set `pollIntervalRef.current = 30_000` and `prevChecksRef.current = ''` before + calling `handleRefresh()`. `fetchChecks()` will then write the new signature + from the forced result. + +5. **Do not overstate in-flight behavior.** Current store behavior is: + + - `fetchPRForBranch({ force: true })` bypasses a non-forced in-flight PR + request and uses a generation guard so the older result cannot overwrite the + newer cache entry. + - `fetchPRChecks({ force: true })` and `fetchPRComments({ force: true })` do + **not** bypass any in-flight request for the same key. If a non-forced poll + is already in flight, entry refresh will join it and may not pass + `noCache: true` to the main process. + + Accept that tradeoff for this feature. It avoids duplicate `gh` calls during a + visible polling race. If strict "entry always bypasses gh cache" semantics are + required later, change checks/comments in-flight maps to track `{ promise, + force, generation }` like PRs. + +## API cost and feasibility + +This is not free. A cold entry refresh can start: + +- one PR lookup (`gh:prForBranch`); +- one checks request (`gh:prChecks`); +- one comments request (`gh:prComments`). + +`gh:prChecks` usually calls `gh api repos/{owner}/{repo}/commits/{sha}/check-runs` +and uses `--cache 60s` unless forced; if that fails it falls back to `gh pr +checks`, which does not use the `--cache` flag. + +`gh:prComments` is heavier than "one GitHub API call": it runs issue comments +REST, review threads GraphQL, and reviews REST in parallel. `noCache` only +removes `--cache 60s` from the REST `gh api` calls; the GraphQL call is always +made. + +The 15 s grace window is therefore required, not cosmetic. + +## Edge cases + +- **Cached no-PR result.** Do not skip just because `prNumber` is null. Refresh + the PR cache entry on tab entry when its timestamp is outside the grace window. +- **First Checks entry after app start.** Only `prCache` and `issueCache` are + persisted. `checksCache` and `commentsCache` start empty, so a known PR should + force checks/comments on entry. +- **Worktree switch while Checks is visible.** Include `activeWorktreeId` in the + entry key so same repo/branch switches still count as a new entry. The existing + render-time local reset handles stale title/loading state. +- **Rapid tab toggles.** Hiding the panel resets the processed entry key; showing + it again re-evaluates timestamps. The grace window suppresses duplicate calls. +- **Concurrent polling tick.** Checks/comments may reuse the in-flight poll + request instead of forcing a new `noCache` request. This is intentional for now. +- **Conflicting PR refresh.** The existing `mergeable === 'CONFLICTING'` effect + can still force a PR refresh. Entry refresh may also run, but PR generation + guards prevent older PR responses from overwriting newer cache entries. +- **PR head changed externally.** `handleRefresh()` must use the refreshed PR + returned by `fetchPRForBranch` before fetching checks, so checks are requested + with the current `headSha`. +- **Component unmount mid-fetch.** The comments auto-fetch guards stale + responses; the checks fetch path and `handleRefresh()` do not cancel local + setters. This feature should keep that behavior unless tests expose a warning + or stale-state regression. +- **SSH / remote repo.** No path manipulation or platform-specific shortcut code + is needed. The same IPC-backed `gh` path is used. + +## Rollout + +1. Add timestamp selectors, `ENTRY_REFRESH_GRACE_MS`, and the entry effect in + `ChecksPanel.tsx`. +2. Keep `handleRefresh()` as the single refresh implementation. +3. Add `ChecksPanel.test.tsx` if absent. Cover stale PR/null cache refreshes, + fresh-within-grace skips, known PR with missing checks/comments refreshes, + hidden panel no-op, and hide/show same PR re-evaluation. +4. Run `pnpm typecheck && pnpm lint`. diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 47a86066972..1e644af9a83 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -15,6 +15,7 @@ import { ChecksList, PRCommentsList } from './checks-helpers' +import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh' import type { PRInfo, PRCheckDetail, PRComment } from '../../../../shared/types' export default function ChecksPanel(): React.JSX.Element { @@ -79,6 +80,21 @@ export default function ChecksPanel(): React.JSX.Element { ? (gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown' + // Why: select only timestamps (not whole cache records) so the entry-refresh + // effect doesn't re-run on every cache mutation. See + // docs/refresh-on-checks-tab.md. + const prFetchedAt = useAppStore((s) => + prCacheKey ? s.prCache[prCacheKey]?.fetchedAt : undefined + ) + const checksCacheKey = repo && prNumber ? `${repo.path}::pr-checks::${prNumber}` : '' + const commentsCacheKey = repo && prNumber ? `${repo.path}::pr-comments::${prNumber}` : '' + const checksFetchedAt = useAppStore((s) => + checksCacheKey ? s.checksCache[checksCacheKey]?.fetchedAt : undefined + ) + const commentsFetchedAt = useAppStore((s) => + commentsCacheKey ? s.commentsCache[commentsCacheKey]?.fetchedAt : undefined + ) + // Fetch PR data when the active worktree/branch changes. // Why: pass linkedPR so worktrees created from a PR (whose new local branch // differs from the PR's head ref) resolve via the number-based fallback. @@ -240,9 +256,41 @@ export default function ChecksPanel(): React.JSX.Element { linkedPRNumber: linkedPR }) if (refreshedPR) { + // Why: call fetchPRChecks directly with the refreshed PR's headSha so + // we don't pass the stale headSha captured by `fetchChecks`'s closure + // before the PR refresh completed (covers external force-pushes and + // PR-number changes). + const refreshedChecks = fetchPRChecks( + repo.path, + refreshedPR.number, + branch, + refreshedPR.headSha, + { force: true } + ).then( + (result) => { + setChecks(result) + const signature = JSON.stringify( + result.map((c) => `${c.name}:${c.status}:${c.conclusion}`) + ) + pollIntervalRef.current = + signature === prevChecksRef.current + ? Math.min(pollIntervalRef.current * 2, 120_000) + : 30_000 + prevChecksRef.current = signature + }, + (err) => { + console.warn('Failed to fetch PR checks:', err) + setChecks([]) + } + ) + setChecksLoading(true) + const refreshedComments = fetchComments({ + force: true, + prNumberOverride: refreshedPR.number + }) await Promise.all([ - fetchChecks({ force: true, prNumberOverride: refreshedPR.number }), - fetchComments({ force: true, prNumberOverride: refreshedPR.number }) + refreshedChecks.finally(() => setChecksLoading(false)), + refreshedComments ]) } else { setChecks([]) @@ -251,7 +299,49 @@ export default function ChecksPanel(): React.JSX.Element { } finally { setIsRefreshing(false) } - }, [repo, branch, linkedPR, fetchPRForBranch, fetchChecks, fetchComments]) + }, [repo, branch, linkedPR, fetchPRForBranch, fetchPRChecks, fetchComments]) + + // Why: force a freshness check on each "entry" into the Checks tab so PRs + // opened outside Orca, externally force-pushed heads, and stale checks/comments + // appear without waiting for the cache TTL. The grace window suppresses + // duplicate fetches from rapid show/hide toggles. See + // docs/refresh-on-checks-tab.md. + const entryKey = + isPanelVisible && repo && !isFolder && branch + ? `${activeWorktreeId ?? ''}::${repo.path}::${branch}` + : '' + const lastEntryKeyRef = useRef('') + useEffect(() => { + if (!entryKey) { + // Resetting on hide is required so reopening the panel on the same PR + // re-evaluates freshness (a prevKey !== currentKey check alone would miss + // close-and-reopen of the same PR). + lastEntryKeyRef.current = '' + return + } + if (lastEntryKeyRef.current === entryKey) { + return + } + lastEntryKeyRef.current = entryKey + + const stale = shouldEntryRefresh({ + prFetchedAt, + checksFetchedAt, + commentsFetchedAt, + prNumber, + now: Date.now(), + graceMs: ENTRY_REFRESH_GRACE_MS + }) + if (!stale) { + return + } + + // Reset polling attention state so the forced fetch's signature establishes + // a fresh baseline rather than colliding with the previous PR's backoff. + pollIntervalRef.current = 30_000 + prevChecksRef.current = '' + void handleRefresh() + }, [entryKey, prFetchedAt, checksFetchedAt, commentsFetchedAt, prNumber, handleRefresh]) const handleStartEdit = useCallback(() => { if (!pr) { diff --git a/src/renderer/src/components/right-sidebar/checks-entry-refresh.test.ts b/src/renderer/src/components/right-sidebar/checks-entry-refresh.test.ts new file mode 100644 index 00000000000..2b5cec8b92a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-entry-refresh.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh' + +const NOW = 1_700_000_000_000 + +describe('shouldEntryRefresh', () => { + it('refreshes when PR cache is missing (cold start, no PR known)', () => { + expect( + shouldEntryRefresh({ + prFetchedAt: undefined, + checksFetchedAt: undefined, + commentsFetchedAt: undefined, + prNumber: null, + now: NOW + }) + ).toBe(true) + }) + + it('refreshes when cached null PR result is older than the grace window', () => { + expect( + shouldEntryRefresh({ + prFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS - 1, + checksFetchedAt: undefined, + commentsFetchedAt: undefined, + prNumber: null, + now: NOW + }) + ).toBe(true) + }) + + it('skips when cached null PR result is within the grace window', () => { + expect( + shouldEntryRefresh({ + prFetchedAt: NOW - 1_000, + checksFetchedAt: undefined, + commentsFetchedAt: undefined, + prNumber: null, + now: NOW + }) + ).toBe(false) + }) + + it('refreshes when PR is known but checks cache is missing (first entry after app start)', () => { + expect( + shouldEntryRefresh({ + prFetchedAt: NOW - 1_000, + checksFetchedAt: undefined, + commentsFetchedAt: NOW - 1_000, + prNumber: 42, + now: NOW + }) + ).toBe(true) + }) + + it('refreshes when PR is known but comments cache is missing', () => { + expect( + shouldEntryRefresh({ + prFetchedAt: NOW - 1_000, + checksFetchedAt: NOW - 1_000, + commentsFetchedAt: undefined, + prNumber: 42, + now: NOW + }) + ).toBe(true) + }) + + it('refreshes when checks timestamp is older than the grace window', () => { + expect( + shouldEntryRefresh({ + prFetchedAt: NOW - 1_000, + checksFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS - 1, + commentsFetchedAt: NOW - 1_000, + prNumber: 42, + now: NOW + }) + ).toBe(true) + }) + + it('refreshes when comments timestamp is older than the grace window', () => { + expect( + shouldEntryRefresh({ + prFetchedAt: NOW - 1_000, + checksFetchedAt: NOW - 1_000, + commentsFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS - 1, + prNumber: 42, + now: NOW + }) + ).toBe(true) + }) + + it('skips when PR, checks, and comments are all fresh within the grace window', () => { + expect( + shouldEntryRefresh({ + prFetchedAt: NOW - 5_000, + checksFetchedAt: NOW - 5_000, + commentsFetchedAt: NOW - 5_000, + prNumber: 42, + now: NOW + }) + ).toBe(false) + }) + + it('ignores checks/comments freshness when no PR is known', () => { + expect( + shouldEntryRefresh({ + prFetchedAt: NOW - 1_000, + checksFetchedAt: undefined, + commentsFetchedAt: undefined, + prNumber: null, + now: NOW + }) + ).toBe(false) + }) + + it('treats a PR timestamp exactly at the cutoff as fresh', () => { + // Why: the rule is "older than now - grace", strict less-than. Equal is fresh. + expect( + shouldEntryRefresh({ + prFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS, + checksFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS, + commentsFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS, + prNumber: 42, + now: NOW + }) + ).toBe(false) + }) + + it('honors a custom graceMs override', () => { + expect( + shouldEntryRefresh({ + prFetchedAt: NOW - 2_000, + checksFetchedAt: NOW - 2_000, + commentsFetchedAt: NOW - 2_000, + prNumber: 42, + now: NOW, + graceMs: 1_000 + }) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-entry-refresh.ts b/src/renderer/src/components/right-sidebar/checks-entry-refresh.ts new file mode 100644 index 00000000000..9abb5e86919 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-entry-refresh.ts @@ -0,0 +1,46 @@ +// Why: extracted as a pure helper so the freshness decision is unit-testable +// without mounting ChecksPanel and its many store dependencies. The rules come +// from docs/refresh-on-checks-tab.md (Grace window + Edge cases sections). + +export const ENTRY_REFRESH_GRACE_MS = 15_000 + +export type EntryRefreshInput = { + prFetchedAt: number | undefined + checksFetchedAt: number | undefined + commentsFetchedAt: number | undefined + prNumber: number | null + now: number + graceMs?: number +} + +/** + * Decide whether entering the Checks tab should trigger a force refresh. + * + * Rules: + * - Missing PR cache timestamp is stale (cold start, never fetched). + * - A cached PR timestamp older than `now - graceMs` is stale; this also + * covers cached `null` PR results, which still have a fetchedAt. + * - When a PR number is known, missing checks/comments timestamps are stale + * (their caches are not persisted, so they restart empty). + * - When no PR number is known, checks/comments timestamps are not relevant. + */ +export function shouldEntryRefresh(input: EntryRefreshInput): boolean { + const { prFetchedAt, checksFetchedAt, commentsFetchedAt, prNumber, now } = input + const graceMs = input.graceMs ?? ENTRY_REFRESH_GRACE_MS + const cutoff = now - graceMs + + if (prFetchedAt === undefined || prFetchedAt < cutoff) { + return true + } + + if (prNumber !== null) { + if (checksFetchedAt === undefined || checksFetchedAt < cutoff) { + return true + } + if (commentsFetchedAt === undefined || commentsFetchedAt < cutoff) { + return true + } + } + + return false +} From 6616aa1577e7e9105278e2952512312c6f046832 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 10 May 2026 23:18:30 -0700 Subject: [PATCH 12/15] chore: remove local-only design doc from repo (#1689) docs/manual-repo-reorder.md is a planning doc that should not have been checked in; .gitignore now broadly excludes docs/*.md. Co-authored-by: Orca --- docs/manual-repo-reorder.md | 91 ------------------------------------- 1 file changed, 91 deletions(-) delete mode 100644 docs/manual-repo-reorder.md diff --git a/docs/manual-repo-reorder.md b/docs/manual-repo-reorder.md deleted file mode 100644 index e5fb57baf05..00000000000 --- a/docs/manual-repo-reorder.md +++ /dev/null @@ -1,91 +0,0 @@ -# Manual repo reorder - -## Problem - -Current repo-header order in the sidebar is **not** `state.repos` insertion order. -`buildRows(..., groupBy='repo')` groups visible unpinned worktrees into a `Map` -and emits `Array.from(grouped.entries())`, so order follows first encounter in the -already-sorted visible worktree stream. - -Result: users cannot control repo-group order, and sorting/filtering side effects can -change which repo appears first. - -## Goal - -Allow users to reorder repo headers in the sidebar when grouped by repo, persist that -order, and use it anywhere the UI lists repos. - -## Non-goals - -- Reordering worktrees within a repo group. -- Reordering PR-status groups, pinned section, or `All` header. -- Dragging worktrees between repos. -- Mobile/touch drag support. - -## Design Decisions - -1. Persist canonical order in `state.repos` array order (no new schema field). -2. Add `Store.reorderRepos(orderedIds)` in `src/main/persistence.ts`. -3. Add IPC `repos:reorder` in `src/main/ipc/repos.ts` and preload/API typing. -4. Add renderer action `reorderRepos(orderedIds)` in repo slice. -5. In `buildRows`, when `groupBy==='repo'`, order repo groups by a `repoOrder` map - derived from `state.repos` (`repoId -> index`). Unknown ids sort last by label. - -## Interaction Model (Important) - -Do **not** use HTML5 DnD for this list. - -`WorktreeList` uses `@tanstack/react-virtual` with absolutely-positioned rows moved by -`translateY`. During drag, rows can unmount/remount as scroll changes; HTML5 DnD target -and hover state are brittle in this setup. - -Use pointer-driven drag state in React instead: - -- Start drag from a dedicated drag handle on repo headers only (not full header). -- Keep collapse click behavior on header body unchanged. -- While dragging, render insertion indicator from computed target repo index. -- On drop, compute new `orderedIds` and call renderer `reorderRepos`. -- Cancel cleanly on escape, pointer cancel, blur, or invalid target. - -Why handle-only: header already has click-to-toggle and nested buttons (`+`); dragging -from whole header conflicts with those interactions. - -## Consistency and Invalidation - -`repos:changed` already triggers `fetchRepos()` in `useIpcEvents`. - -Implications: - -- Renderer can optimistically reorder local `repos` immediately. -- Main persists reorder and emits `repos:changed`; renderer refetch should converge to - the same order (no semantic conflict). -- If reorder is rejected (stale permutation due to concurrent add/remove), renderer must - rollback by refetching. - -`repos:reorder` should return a status (`applied | rejected`) so renderer can avoid -extra fetch on success and force resync on rejection. - -## Edge Cases - -- Concurrent repo add/remove while dragging: permutation validation rejects stale order; - renderer refetches. -- Repo filtered out / no visible worktrees: repo stays in canonical `state.repos` order - and reappears in correct position when visible again. -- `groupBy !== 'repo'`: no drag affordance. -- Dragging near viewport edges: support auto-scroll while pointer-dragging. -- Virtualization: keep drop computation independent of mounted DOM targets; do not - require hovered row to stay mounted. -- Multi-window: last successful reorder wins via persisted array; each window resyncs on - `repos:changed`. - -## Implementation Plan - -1. Add `reorderRepos(orderedIds)` to Store with strict permutation validation and save. -2. Add `repos:reorder` IPC handler; on success emit `repos:changed`. -3. Extend preload `repos` API and `src/preload/api-types.ts`. -4. Add renderer repo-slice action with optimistic local reorder + rejection resync. -5. Thread `repoOrder` into `buildRows` and add tests for explicit repo ordering. -6. Add pointer-based drag handle + insertion indicator to repo header UI in - `WorktreeList.tsx`. -7. Manual validation: reorder, restart, filter/unfilter, collapse/expand, and - cross-window conflict behavior. From 333cf6fd6c4ca40dea14a5d9acb0bbbe584ea1bf Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 10 May 2026 23:44:03 -0700 Subject: [PATCH 13/15] update (#1692) Co-authored-by: Orca --- src/shared/constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 17184820a89..bbce5b98eba 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -64,9 +64,9 @@ export const MAX_EDITOR_AUTO_SAVE_DELAY_MS = 10_000 // Why: initial threshold of agents spawned (since last update) before we show // the star-on-GitHub notification. Doubles each time the user dismisses -// without starring — e.g. 50 → 100 → 200 → 400. Past dismissals are encoded +// without starring — e.g. 35 → 70 → 140 → 280. Past dismissals are encoded // in starNagNextThreshold, so this constant is only the first-time seed. -export const STAR_NAG_INITIAL_THRESHOLD = 50 +export const STAR_NAG_INITIAL_THRESHOLD = 35 export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [ 'status', From 1f0caeb40af4f40303b57fd640275323d4b2db7d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 11 May 2026 00:10:51 -0700 Subject: [PATCH 14/15] fix(updater): simplify benign check failures (#1691) * WIP: Changes before auto-review fixes Co-authored-by: Orca * WIP: Changes before auto-review fixes Co-authored-by: Orca * fix(updater): repair retry-state correctness in release-transition fallback Address issues surfaced by automated multi-agent review on the 30s silent-retry + 1h backstop introduced in this branch: - forceLaunchUpdateCheck now OR-merges userInitiatedCheck instead of overwriting it, so a manual click during the 30s wait survives the timer's launch (the click's upgrade was being clobbered). - The .catch path mirrors the same OR-merge by reading the live module flag, so a synchronous throw or pinPrereleaseFeed rejection during the retry doesn't lose the click upgrade either. - checkForUpdatesFromMenu upgrades userInitiatedCheck = true before early-returning during the 30s wait, so the in-flight retry's result reflects the user's click. - Removed cross-cancellation between the 30s retry and 1h backstop callbacks: each callback only nulls its own handle, and both timers stay armed until a terminal event clears them centrally. The backstop is no longer destroyed at T+30s, restoring the app-nap recovery the design intended. - 'error' handler's non-'checking' branch now (a) clears retry state unconditionally so transitionRetryInFlight can never be stranded across a status-race, and (b) suppresses sendErrorStatus when status has already advanced to a good terminal (available/downloading/ downloaded), preventing a late backstop error from overwriting a successful retry result. - performQuitAndInstall now clears the retry timers and flag so the install-quit path (which bypasses the before-quit handler via markMacQuitAndInstallInFlight) doesn't leak a timer firing into the bundle-replacement window. Co-authored-by: Orca * fix(updater): simplify benign check failures Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/updater.check-failure.test.ts | 125 +++++++++++++------------ src/main/updater.test.ts | 2 +- src/main/updater.ts | 2 +- 3 files changed, 68 insertions(+), 61 deletions(-) diff --git a/src/main/updater.check-failure.test.ts b/src/main/updater.check-failure.test.ts index e12a3bab32a..53871a2e730 100644 --- a/src/main/updater.check-failure.test.ts +++ b/src/main/updater.check-failure.test.ts @@ -33,6 +33,7 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock, autoUpdaterMock.checkForUpdates.mockReset() autoUpdaterMock.downloadUpdate.mockReset() autoUpdaterMock.quitAndInstall.mockReset() + autoUpdaterMock.setFeedURL.mockClear() } const autoUpdaterMock = { @@ -91,6 +92,20 @@ vi.mock('./updater-nudge', () => ({ shouldApplyNudge: vi.fn().mockReturnValue(false) })) +const ONE_HOUR_MS = 60 * 60 * 1000 +const THIRTY_SECONDS_MS = 30 * 1000 +const FRIENDLY_MESSAGE = "Couldn't reach the update server. Try again in a few minutes." + +function makeBenignCheckFailure(message: string): void { + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('error', new Error(message)) + }) + return Promise.reject(new Error(message)) + }) +} + describe('updater check failure handling', () => { beforeEach(() => { vi.resetModules() @@ -105,63 +120,53 @@ describe('updater check failure handling', () => { isMock.dev = false killAllPtyMock.mockReset() vi.unstubAllGlobals() + vi.useRealTimers() }) - it('surfaces GitHub release transition errors to user-initiated checks', async () => { - autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => { - autoUpdaterMock.emit('checking-for-update') - queueMicrotask(() => { - autoUpdaterMock.emit('error', new Error('Unable to find latest version on GitHub')) - }) - return Promise.reject(new Error('Unable to find latest version on GitHub')) - }) + it('surfaces GitHub release-transition failures with calmer copy and no short retry', async () => { + vi.useFakeTimers() + makeBenignCheckFailure('Unable to find latest version on GitHub') const sendMock = vi.fn() const mainWindow = { webContents: { send: sendMock } } const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') - setupAutoUpdater(mainWindow as never) + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) checkForUpdatesFromMenu() - await vi.waitFor(() => { - const statuses = sendMock.mock.calls - .filter(([channel]) => channel === 'updater:status') - .map(([, status]) => status) - // Why: a user-initiated benign failure must show a visible error. Silently - // sending 'idle' (or 'not-available') makes the button look broken. - expect(statuses).toContainEqual( - expect.objectContaining({ - state: 'error', - userInitiated: true, - message: expect.stringContaining('GitHub may be temporarily unavailable') - }) - ) - expect(statuses).not.toContainEqual( - expect.objectContaining({ state: 'not-available', userInitiated: true }) - ) - }) + + await vi.advanceTimersByTimeAsync(0) + + const statuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + + expect(statuses).toContainEqual({ state: 'checking', userInitiated: true }) + expect(statuses).toContainEqual( + expect.objectContaining({ + state: 'error', + userInitiated: true, + message: FRIENDLY_MESSAGE + }) + ) + expect(statuses).not.toContainEqual( + expect.objectContaining({ message: expect.stringContaining('Unable to find latest version') }) + ) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(THIRTY_SECONDS_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) }) - it('surfaces missing latest-mac.yml to user-initiated checks', async () => { - autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => { - autoUpdaterMock.emit('checking-for-update') - queueMicrotask(() => { - autoUpdaterMock.emit( - 'error', - new Error('Cannot find channel "latest-mac.yml" update info: HttpError: 404') - ) - }) - return Promise.reject( - new Error('Cannot find channel "latest-mac.yml" update info: HttpError: 404') - ) - }) + it('surfaces missing latest-mac.yml to user-initiated checks with calmer copy', async () => { + makeBenignCheckFailure('Cannot find channel "latest-mac.yml" update info: HttpError: 404') const sendMock = vi.fn() const mainWindow = { webContents: { send: sendMock } } const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') - setupAutoUpdater(mainWindow as never) + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) checkForUpdatesFromMenu() await vi.waitFor(() => { const statuses = sendMock.mock.calls @@ -171,7 +176,7 @@ describe('updater check failure handling', () => { expect.objectContaining({ state: 'error', userInitiated: true, - message: expect.stringContaining('GitHub may be temporarily unavailable') + message: FRIENDLY_MESSAGE }) ) expect(statuses).not.toContainEqual( @@ -180,30 +185,32 @@ describe('updater check failure handling', () => { }) }) - it('silently drops background benign failures to idle', async () => { - // Why: background checks must stay quiet; only user-initiated clicks get - // an error card. This prevents noisy nag during a release transition. - autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => { - autoUpdaterMock.emit('checking-for-update') - queueMicrotask(() => { - autoUpdaterMock.emit('error', new Error('Unable to find latest version on GitHub')) - }) - return Promise.reject(new Error('Unable to find latest version on GitHub')) - }) + it('silently drops background benign failures to idle and waits for the hourly retry', async () => { + vi.useFakeTimers() + makeBenignCheckFailure('Unable to find latest version on GitHub') const sendMock = vi.fn() const mainWindow = { webContents: { send: sendMock } } const { setupAutoUpdater, checkForUpdates } = await import('./updater') - setupAutoUpdater(mainWindow as never) + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) checkForUpdates() - await vi.waitFor(() => { - const statuses = sendMock.mock.calls - .filter(([channel]) => channel === 'updater:status') - .map(([, status]) => status) - expect(statuses).toContainEqual({ state: 'idle' }) - expect(statuses).not.toContainEqual(expect.objectContaining({ state: 'error' })) - }) + + await vi.advanceTimersByTimeAsync(0) + + const statuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + + expect(statuses).toContainEqual({ state: 'idle' }) + expect(statuses).not.toContainEqual(expect.objectContaining({ state: 'error' })) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(THIRTY_SECONDS_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(ONE_HOUR_MS - THIRTY_SECONDS_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) }) }) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 773a849425a..3f488a729ce 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -206,7 +206,7 @@ describe('updater', () => { expect.objectContaining({ state: 'error', userInitiated: true, - message: expect.stringContaining('GitHub may be temporarily unavailable') + message: expect.stringContaining("Couldn't reach the update server") }) ) }) diff --git a/src/main/updater.ts b/src/main/updater.ts index d99f6521222..c0186a8dae6 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -217,7 +217,7 @@ async function sendCheckFailureStatus(message: string, userInitiated?: boolean): // prefixes "Could not check for updates." and Settings prefixes // "Update check failed.", so the message here only carries the // actionable cause. - sendErrorStatus('GitHub may be temporarily unavailable. Try again in a minute.', true) + sendErrorStatus("Couldn't reach the update server. Try again in a few minutes.", true) } else { sendStatus({ state: 'idle' }) } From 1f0e060c16b8aa525d801be8a4550f18714c0641 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 11 May 2026 00:16:37 -0700 Subject: [PATCH 15/15] fix(diff-comments): defer root.unmount() in editor cleanup (#1607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the editor is disposed during a parent render, the dispose listener's setState re-runs this effect and triggers a synchronous root.unmount() inside React's commit work loop, producing React 19's "Attempted to synchronously unmount a root while React was already rendering" warning. Snapshot the roots and clear bookkeeping synchronously, then unmount via queueMicrotask — matches the deferred-unmount pattern already used in the diff-pass effect. Co-authored-by: Orca --- src/main/agent-hooks/server.test.ts | 20 ++++------------- src/relay/agent-hook-server.ts | 1 - .../diff-comments/useDiffCommentDecorator.tsx | 22 ++++++++++++++++--- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 77606ff3d5b..b845fdf3c17 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -1217,10 +1217,7 @@ describe('AgentHookServer ingestRemote', () => { } const listener = vi.fn() server.setListener(listener) - server.ingestRemote( - { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, - 'conn-1' - ) + server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, 'conn-1') expect(listener).toHaveBeenCalledTimes(1) expect(listener).toHaveBeenCalledWith({ paneKey: PANE, @@ -1279,10 +1276,7 @@ describe('AgentHookServer ingestRemote', () => { } const listener = vi.fn() server.setListener(listener) - server.ingestRemote( - { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, - '' - ) + server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, '') expect(listener).not.toHaveBeenCalled() }) @@ -1296,10 +1290,7 @@ describe('AgentHookServer ingestRemote', () => { } const listener = vi.fn() server.setListener(listener) - server.ingestRemote( - { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, - ' ' - ) + server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, ' ') expect(listener).not.toHaveBeenCalled() }) @@ -1330,10 +1321,7 @@ describe('AgentHookServer ingestRemote', () => { } const listener = vi.fn() server.setListener(listener) - server.ingestRemote( - { paneKey: ' ', tabId: 'tab-1', worktreeId: 'wt-1', payload }, - 'conn-1' - ) + server.ingestRemote({ paneKey: ' ', tabId: 'tab-1', worktreeId: 'wt-1', payload }, 'conn-1') expect(listener).not.toHaveBeenCalled() }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 680e65fd01f..ca7e3ead63c 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -263,4 +263,3 @@ export class RelayAgentHookServer { return typeof v === 'string' && v.length > 0 ? v : undefined } } - diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx index 3c0bf6e2054..dcc8e527c12 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx @@ -287,10 +287,26 @@ export function useDiffCommentDecorator({ // stale zone ids from a dead editor. The diff effect below deliberately // has no cleanup so comment-only changes don't cause a full zone // rebuild; this cleanup is the single place we reset zone tracking. - for (const entry of zones.values()) { - entry.root.unmount() - } + // + // Why defer the unmount: this cleanup can run inside React's commit work + // loop (e.g. when an editor is disposed during a parent render and the + // dispose listener's setState triggers a re-render that re-runs this + // effect). A synchronous root.unmount() in that window produces React + // 19's "Attempted to synchronously unmount a root while React was + // already rendering" warning. queueMicrotask lands the unmount at the + // end of the current task, before any next render, with no visible + // delay. Clear `zones` synchronously so a subsequent editor mount sees + // empty bookkeeping immediately. This matches the deferred unmount in + // the diff-pass effect below. + const rootsToUnmount = Array.from(zones.values(), (z) => z.root) zones.clear() + if (rootsToUnmount.length > 0) { + queueMicrotask(() => { + for (const root of rootsToUnmount) { + root.unmount() + } + }) + } // Why: editor went away — drop both the in-flight scroll request and // the resolver closure (which captured the now-disposed editor). pendingScrollRef.current = null