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/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/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/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/package.json b/package.json index d91560e55e7..3d8735c0c3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.48-rc.1", + "version": "1.3.48", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 1d0eebbd631..e59b4bbdc76 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' @@ -78,6 +79,7 @@ describe('AgentHookServer listener replay', () => { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', + connectionId: null, receivedAt: expect.any(Number), stateStartedAt: expect.any(Number), payload: expect.objectContaining({ @@ -156,6 +158,7 @@ describe('AgentHookServer listener replay', () => { paneKey: PANE, tabId: 'tab-1', worktreeId: 'repo::/tmp/worktree with "quotes"', + connectionId: null, receivedAt: expect.any(Number), stateStartedAt: expect.any(Number), payload: expect.objectContaining({ @@ -1598,3 +1601,149 @@ describe('Last-status persistence', () => { expect(parsed.entries[PANE]?.payload?.prompt).toBe('flush me') }) }) + +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( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'conn-1', + receivedAt: expect.any(Number), + stateStartedAt: expect.any(Number), + 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 37f9a04b960..d0e0354d5c5 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1,1183 +1,57 @@ -/* 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. */ +/* eslint-disable max-lines -- Why: this file owns the loopback HTTP adapter, the on-disk last-status persistence layer (hydrate, sanitize, TTL, atomic write, drop), and the relay ingest path in one place so the cache lifecycle (set → schedule → drain) lives next to the surfaces that mutate it. Splitting would force mutual `private` accessor scaffolding for a single class. */ +// 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) +// - the on-disk last-status cache (`last-status.json`) that survives +// Orca restart so retained dashboard rows reappear on relaunch import { createServer, type IncomingMessage, type ServerResponse } from 'http' import { randomUUID } from 'crypto' -import { - chmodSync, - closeSync, - mkdirSync, - openSync, - readdirSync, - readFileSync, - readSync, - renameSync, - statSync, - unlinkSync, - writeFileSync -} from 'fs' +import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs' import { join } from 'path' + +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 { type AgentStatusIpcPayload, - normalizeAgentStatusPayload, - parseAgentStatusPayload, - type ParsedAgentStatusPayload + normalizeAgentStatusPayload } from '../../shared/agent-status-types' -import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-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' +export type { AgentHookSource } -type AgentHookEventBasePayload = { - paneKey: string - tabId?: string - worktreeId?: string - payload: ParsedAgentStatusPayload -} - -type AgentHookEventPayload = AgentHookEventBasePayload & { +// Why: server-process-only enrichment of the shared event payload. The shared +// listener emits `AgentHookEventPayload` (the bare event shape). For +// persistence and the dashboard's "did the agent transition since I last +// looked?" comparison, we attach `receivedAt` (when the latest event arrived +// for this pane) and `stateStartedAt` (when the current state first appeared). +// Stored in `state.lastStatusByPaneKey` via assignability — `AgentHookEventPayload` +// is the declared map value, and the extra fields ride along untouched because +// the shared module only writes/clears, never reads. +type EnrichedAgentHookEventPayload = AgentHookEventPayload & { receivedAt: number stateStartedAt: number } -// 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 -): AgentHookEventBasePayload | 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: name of the on-disk cache that survives Orca restart. Lives next to // the endpoint file in userData/agent-hooks/ so all hook-server-owned cross- // restart artifacts stay co-located. @@ -1187,9 +61,7 @@ const LAST_STATUS_FILE_NAME = 'last-status.json' // wrote a v1 shape with no receivedAt / stateStartedAt. Bumping to 2 means a // developer who upgrades from an in-flight branch sees an empty hydration // once instead of partially-typed legacy entries. New file format; never -// shipped to users at v1. Bumping this rejects on-disk files written by -// older shapes — see the "Stale file from a prior Orca version" edge case -// in the design doc. A mismatched version is treated as a corrupt file +// shipped to users at v1. A mismatched version is treated as a corrupt file // (silent empty hydration). const LAST_STATUS_FILE_VERSION = 2 @@ -1209,7 +81,7 @@ const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 type LastStatusFile = { version: number - entries: Record + entries: Record } // Why: paneKey is `${tabId}:${paneId}` — exactly one ':' with non-empty @@ -1223,13 +95,14 @@ export function isValidPaneKey(value: unknown): value is string { if (colon <= 0 || colon === value.length - 1) { return false } - // Why: exactly one colon. ParseAgentStatusPayload elsewhere is generous, - // but the file is downstream of internal callers — anything weirder is - // corruption. + // Why: exactly one colon. Anything weirder is corruption. return !value.includes(':', colon + 1) } -function sanitizeHydratedEntry(paneKey: string, rawEntry: unknown): AgentHookEventPayload | null { +function sanitizeHydratedEntry( + paneKey: string, + rawEntry: unknown +): EnrichedAgentHookEventPayload | null { if (!isValidPaneKey(paneKey)) { return null } @@ -1266,6 +139,17 @@ function sanitizeHydratedEntry(paneKey: string, rawEntry: unknown): AgentHookEve ) { return null } + // Why: connectionId is allowed to be null (local) or string (relay). Any + // other shape is rejected so the post-merge typed surface stays honest. + const connectionIdRaw = record.connectionId + let connectionId: string | null + if (connectionIdRaw === null || connectionIdRaw === undefined) { + connectionId = null + } else if (typeof connectionIdRaw === 'string') { + connectionId = connectionIdRaw + } else { + return null + } const payload = normalizeAgentStatusPayload(record.payload) if (!payload) { return null @@ -1274,36 +158,25 @@ function sanitizeHydratedEntry(paneKey: string, rawEntry: unknown): AgentHookEve paneKey, tabId: typeof tabId === 'string' ? tabId : undefined, worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, + connectionId, payload, receivedAt, stateStartedAt } } -function toAgentStatusIpcPayload(entry: AgentHookEventPayload): AgentStatusIpcPayload { +function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload { return { paneKey: entry.paneKey, tabId: entry.tabId, worktreeId: entry.worktreeId, + connectionId: entry.connectionId, receivedAt: entry.receivedAt, stateStartedAt: entry.stateStartedAt, ...entry.payload } } -// 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 class AgentHookServer { private server: ReturnType | null = null private port = 0 @@ -1312,28 +185,23 @@ export class AgentHookServer { // the server can detect dev vs. prod cross-talk. Set at start() from the // caller's knowledge of whether this is a packaged build. private env = 'production' - private onAgentStatus: ((payload: AgentHookEventPayload) => void) | null = null + private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => 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() // Why: full path to the on-disk last-status cache. Set in start() from // userDataPath. Null when the server runs without a userDataPath (e.g. // tests that skip the userDataPath option) — in that case, persistence is // a no-op and only in-memory replay applies. private lastStatusFilePath: string | null = null - // Why: trailing-edge debounce timer. captured per-instance so multiple + // Why: trailing-edge debounce timer. Captured per-instance so multiple // server instances in the same process (tests) don't share state. private statusPersistTimer: ReturnType | null = null // Why: identity check — skip writes when the JSON-stringified contents @@ -1341,29 +209,42 @@ export class AgentHookServer { // re-firing trailing timers when nothing changed. private lastWrittenJson: string | null = null - setListener(listener: ((payload: AgentHookEventPayload) => void) | null): void { + setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void { this.onAgentStatus = listener if (!listener) { return } // 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) + // Why: cache values are stored as enriched payloads (with receivedAt / + // stateStartedAt). The map's declared element type from the shared + // listener is the bare AgentHookEventPayload because the shared module + // never reads from this map; only this class does, and only enriched + // values are ever inserted. + listener(payload as EnrichedAgentHookEventPayload) } catch (err) { console.error('[agent-hooks] replay listener threw', err) } } } + /** Snapshot of the current cached statuses, in the IPC-shaped form the + * renderer consumes. Used by the `agentStatus:getSnapshot` IPC after + * workspace tabs have hydrated, so the dashboard catches up on any + * hook events that fired during startup. */ getStatusSnapshot(): AgentStatusIpcPayload[] { - return Array.from(lastStatusByPaneKey.values(), toAgentStatusIpcPayload) + return Array.from(this.state.lastStatusByPaneKey.values(), (entry) => + toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload) + ) } - private attachStatusTiming(payload: AgentHookEventBasePayload): AgentHookEventPayload { + private attachStatusTiming(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload { const now = Date.now() - const previous = lastStatusByPaneKey.get(payload.paneKey) + const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as + | EnrichedAgentHookEventPayload + | undefined const stateStartedAt = previous && previous.payload.state === payload.payload.state ? previous.stateStartedAt : now return { @@ -1373,6 +254,88 @@ 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 + } + const enriched = this.attachStatusTiming(event) + this.state.lastStatusByPaneKey.set(paneKey, enriched) + this.scheduleStatusPersist() + this.onAgentStatus?.(enriched) + } + async start(options?: { env?: string; userDataPath?: string }): Promise { if (this.server) { return @@ -1390,9 +353,9 @@ export class AgentHookServer { this.endpointFileWritten = false this.lastWrittenJson = null // Why: hydrate before binding the HTTP listener so any new hook POST - // (which goes through lastStatusByPaneKey.set) runs against an already- - // populated map. The renderer later pulls this map as a snapshot after - // workspace tabs are hydrated. + // (which goes through state.lastStatusByPaneKey.set) runs against an + // already-populated map. The renderer later pulls this map as a snapshot + // after workspace tabs are hydrated. if (this.lastStatusFilePath) { this.hydrateLastStatusFromDisk() } @@ -1412,42 +375,26 @@ 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 normalized = normalizeHookPayload(source, body, this.env) + const normalized = normalizeHookPayload(this.state, source, body, this.env) if (normalized) { - const payload = this.attachStatusTiming(normalized) - lastStatusByPaneKey.set(payload.paneKey, payload) + const enriched = this.attachStatusTiming(normalized) + this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) this.scheduleStatusPersist() - this.onAgentStatus?.(payload) + this.onAgentStatus?.(enriched) } res.writeHead(204) @@ -1461,12 +408,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) @@ -1480,11 +424,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) @@ -1506,58 +446,38 @@ 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 this.lastStatusFilePath = null this.lastWrittenJson = null - // 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) } - /* Why: invoked from the renderer-driven agentStatus:drop IPC when a user - * dismisses a still-active pane's status row. We must NOT wipe lastPromptByPaneKey - * or lastToolByPaneKey here — the pane's agent may still be alive, and the next - * hook event would otherwise arrive with an empty prompt and missing tool - * snapshot until a fresh UserPromptSubmit lands. clearPaneState (which wipes - * all three caches) is the right shape only for PTY-teardown. - */ + /** Why: invoked from the renderer-driven agentStatus:drop IPC when a user + * dismisses a still-active pane's status row. We must NOT wipe + * lastPromptByPaneKey or lastToolByPaneKey here — the pane's agent may + * still be alive, and the next hook event would otherwise arrive with an + * empty prompt and missing tool snapshot until a fresh UserPromptSubmit + * lands. clearPaneState (which wipes all three caches) is the right shape + * only for PTY-teardown. */ dropStatusEntry(paneKey: string): void { - if (!lastStatusByPaneKey.has(paneKey)) { + if (!this.state.lastStatusByPaneKey.has(paneKey)) { return } - lastStatusByPaneKey.delete(paneKey) + this.state.lastStatusByPaneKey.delete(paneKey) this.scheduleStatusPersist() } 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. - const hadStatus = lastStatusByPaneKey.has(paneKey) - lastPromptByPaneKey.delete(paneKey) - lastToolByPaneKey.delete(paneKey) - lastStatusByPaneKey.delete(paneKey) // Why: only schedule a write when we actually evicted a status entry — // dropping prompt/tool caches for a pane that never produced a hook // event does not change the on-disk file, and skipping the write avoids // re-stat'ing on every dead-pane teardown. + const hadStatus = this.state.lastStatusByPaneKey.has(paneKey) + clearPaneCacheState(this.state, paneKey) if (hadStatus) { this.scheduleStatusPersist() } @@ -1568,12 +488,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, @@ -1586,151 +500,37 @@ 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 { + /** Test/diagnostic accessor for the on-disk last-status file path. */ + get lastStatusPath(): string | null { + return this.lastStatusFilePath + } + + 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) { - // Why: sweep both endpoint-file and last-status-file orphan tmps — - // either writer can crash mid-rename. Matching the same prefix - // pattern keeps the dir bounded without needing a separate sweep - // pass per file type. - const isEndpointTmp = entry.startsWith('.endpoint-') && entry.endsWith('.tmp') - const isLastStatusTmp = entry.startsWith('.last-status-') && entry.endsWith('.tmp') - if (!isEndpointTmp && !isLastStatusTmp) { - 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 } private hydrateLastStatusFromDisk(): void { if (!this.lastStatusFilePath) { return } - // Why: defensive — keeps hydrate idempotent against repeated start() calls; - // production callers always have an empty map here, but a future re-start - // path must not silently merge prior-session state. - lastStatusByPaneKey.clear() + // Why: defensive — keeps hydrate idempotent against repeated start() + // calls; production callers always have an empty map here, but a future + // re-start path must not silently merge prior-session state. + this.state.lastStatusByPaneKey.clear() let raw: string try { raw = readFileSync(this.lastStatusFilePath, 'utf8') @@ -1778,7 +578,7 @@ export class AgentHookServer { for (const [paneKey, rawEntry] of Object.entries(entries)) { const entry = sanitizeHydratedEntry(paneKey, rawEntry) if (entry && entry.receivedAt >= ttlCutoff) { - lastStatusByPaneKey.set(paneKey, entry) + this.state.lastStatusByPaneKey.set(paneKey, entry) hydrated += 1 } else { dropped += 1 @@ -1805,15 +605,15 @@ export class AgentHookServer { } private serializeStatusFile(): string { - const entries: Record = {} - for (const [paneKey, payload] of lastStatusByPaneKey) { + const entries: Record = {} + for (const [paneKey, payload] of this.state.lastStatusByPaneKey) { // Why: defensive — never persist invalid keys even if they slipped // into the in-memory map somehow. Same invariant the hydrate path // enforces. if (!isValidPaneKey(paneKey)) { continue } - entries[paneKey] = payload + entries[paneKey] = payload as EnrichedAgentHookEventPayload } const file: LastStatusFile = { version: LAST_STATUS_FILE_VERSION, entries } return JSON.stringify(file) @@ -1889,20 +689,28 @@ export class AgentHookServer { 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/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/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' } 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/main/index.ts b/src/main/index.ts index 38ee7d30370..a0f3a86ca32 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -277,17 +277,18 @@ function openMainWindow(): BrowserWindow { }) mainWindow = window agentHookServer.setListener( - ({ paneKey, tabId, worktreeId, payload, receivedAt, stateStartedAt }) => { + ({ paneKey, tabId, worktreeId, connectionId, payload, receivedAt, stateStartedAt }) => { if (mainWindow?.isDestroyed()) { return } mainWindow?.webContents.send('agentStatus:set', { + ...payload, paneKey, tabId, worktreeId, + connectionId, receivedAt, - stateStartedAt, - ...payload + stateStartedAt }) // 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/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/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' }) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c9434b38f79..6ddb9f8a9bf 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 682788b9c20..2b2e20ec900 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/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..ca7e3ead63c --- /dev/null +++ b/src/relay/agent-hook-server.ts @@ -0,0 +1,265 @@ +// 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/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 63bde0578bf..6f449118a9f 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, @@ -2282,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) => { @@ -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,81 @@ 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: 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 + } + 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] } + // 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 + + // 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 +2596,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 +2628,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 +2650,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 +2681,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) { @@ -2665,11 +2696,17 @@ export default function GitHubItemDialog({ fetchedAt: 0, error: undefined }) + return } } } + // 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) }, - [workItem, detailsCacheKey] + [detailsCacheKey] ) return ( 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 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 +} 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. +

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 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() + } + } }) 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 +} diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 39ca6e2edee..9d3d7c486d4 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -114,6 +114,11 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { 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 /** Timestamp (ms) when the hook server received this latest status event. */ receivedAt: number /** Timestamp (ms) when the current state first appeared for this pane. */ diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 87e64297d4d..4ce0ad61765 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', diff --git a/src/shared/types.ts b/src/shared/types.ts index 8ae7a9f2668..a874acf137f 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