From d541982b9c6d55504512442580a9639f181b5eee Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:46:45 -0700 Subject: [PATCH 001/103] Improve cmd j search usefulness (#15551) * Show last active time for workspace tabs in cmd+j palette Adds session-age formatting and activity tracking to help users find recently-used tabs. Replaces host badge display with last-active timestamps that reflect either agent activity or worktree PTY activity, whichever is more recent. * Improve cmd+j search ranking with direct fields and recency Prioritize results matching direct fields (titles, content) over container fields (worktree, branch, repo). Use tab focus time to break ranking ties. Makes search more useful for quick navigation. * Extract path flavor logic to cross-platform-path utility - Remove local pathFlavor function in favor of shared cross-platform utilities - Simplify buildExcludePathPrefixes to use relativePathInsideRoot and resolveRuntimePath - Ensures consistent path handling for both local and remote roots * Improve cmd+j search ranking with recency-based tiebreaking Track lastFocusedAt on tab creation/focus and use it to break ties between equally-ranked search results. This surfaces recently-used items first, improving search utility. Also fixes hasDirectHit to check field matches directly rather than evidence metadata. --- .../components/WorktreeJumpPalette.test.tsx | 25 +++ .../src/components/WorktreeJumpPalette.tsx | 106 ++++----- .../cmd-j/palette-session-age.test.ts | 40 ++++ .../components/cmd-j/palette-session-age.ts | 30 +++ .../tab-bar/open-tab-search-entries.ts | 1 + .../tab-bar/open-tab-search-retention.test.ts | 9 +- .../tab-bar/open-tab-search.test.ts | 9 +- src/renderer/src/i18n/locales/en.json | 11 +- .../lib/browser-palette-page-entries.test.ts | 48 ++++ .../src/lib/browser-palette-page-entries.ts | 13 ++ .../src/lib/browser-palette-search.test.ts | 31 +++ .../src/lib/browser-palette-search.ts | 24 +- .../src/lib/cmd-j-section-leadership.test.ts | 13 +- .../src/lib/cmd-j-section-leadership.ts | 11 +- .../src/lib/palette-match/indexed-field.ts | 6 +- .../src/lib/palette-match/match-document.ts | 19 +- .../src/lib/palette-match/match-quality.ts | 6 +- .../src/lib/palette-match/palette-document.ts | 9 +- .../palette-match/palette-match-core.test.ts | 32 +++ .../src/lib/palette-match/tab-document.ts | 24 +- .../src/lib/palette-match/tab-match.ts | 31 +-- .../src/lib/simulator-palette-search.test.ts | 36 +++ .../src/lib/simulator-palette-search.ts | 25 ++- .../lib/workspace-tab-agent-metadata.test.ts | 48 +++- .../src/lib/workspace-tab-agent-metadata.ts | 20 +- .../lib/workspace-tab-agent-snippet-match.ts | 1 + .../workspace-tab-palette-activation.test.ts | 1 + .../workspace-tab-palette-entry-builder.ts | 5 +- .../lib/workspace-tab-palette-results.test.ts | 210 ++++++++++++++++++ .../src/lib/workspace-tab-palette-results.ts | 33 ++- .../lib/worktree-palette-task-url-result.ts | 1 + .../slices/tabs-unread-and-focus.test.ts | 29 +++ src/renderer/src/store/slices/tabs.ts | 32 ++- src/shared/tab-types.ts | 2 + 34 files changed, 828 insertions(+), 113 deletions(-) create mode 100644 src/renderer/src/components/cmd-j/palette-session-age.test.ts create mode 100644 src/renderer/src/components/cmd-j/palette-session-age.ts create mode 100644 src/renderer/src/lib/workspace-tab-palette-results.test.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index f42cdbb8d9c..5c57e6fad38 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -435,4 +435,29 @@ describe('WorktreeJumpPalette', () => { expect(input?.value).toBe('😉') }) + + it('renders last active timestamp when worktree has lastActivityAt', async () => { + const twentyThreeDaysAgo = Date.now() - 23 * 24 * 60 * 60 * 1000 + const activeWorktree = makeWorktree('active-wt', 'Active workspace', { + lastActivityAt: twentyThreeDaysAgo + }) + const noActivityWorktree = makeWorktree('no-activity-wt', 'No activity workspace', { + lastActivityAt: 0 + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [activeWorktree, noActivityWorktree] }, + showSleepingWorkspaces: true + }) + + const activeRow = testContainer.querySelector('[data-command-item="worktree:active-wt"]') + expect(activeRow?.textContent).toContain('23d') + const activeSpan = activeRow?.querySelector('span[aria-label="Last active 23d ago"]') + expect(activeSpan).not.toBeNull() + expect(activeSpan?.textContent).toBe('23d') + + const noActivityRow = testContainer.querySelector('[data-command-item="worktree:no-activity-wt"]') + expect(noActivityRow?.querySelector('span[aria-label*="Last active"]')).toBeNull() + }) }) + diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index a3333c3d2a2..61a190a8b2b 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -138,7 +138,7 @@ import { } from '@/components/browser-pane/host-guest/browser-focus' import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' import { buildSidebarHostOptions } from '@/components/sidebar/sidebar-host-options' -import { getPaletteHostBadge, type PaletteHostBadge } from '@/components/cmd-j/palette-host-badge' +import { getPaletteHostBadge } from '@/components/cmd-j/palette-host-badge' import { composeWorktreeHostIdentity, getWorktreeHostIdentity @@ -151,6 +151,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import { buildPaletteListEntryRenderKeys } from '@/components/cmd-j/palette-list-entry-render-keys' +import { formatPaletteSessionAge } from '@/components/cmd-j/palette-session-age' import PaletteFilterMenu from '@/components/cmd-j/PaletteFilterMenu' import PaletteFilterChips from '@/components/cmd-j/PaletteFilterChips' import { buildPaletteFilterModel } from '@/components/cmd-j/palette-filter-options' @@ -497,12 +498,14 @@ function PaletteOpenTabPrimaryLine({ titleRanges, secondaryText, secondaryRanges, + sessionAge, leadingBadges }: { title: string titleRanges: readonly MatchRange[] secondaryText: string secondaryRanges: readonly MatchRange[] + sessionAge?: string leadingBadges?: React.ReactNode }): React.JSX.Element { // Why gate on non-empty: empty secondaries (terminals/simulators) used to still @@ -517,6 +520,18 @@ function PaletteOpenTabPrimaryLine({ > + {sessionAge ? ( + + {sessionAge} + + ) : null} {leadingBadges} {showSecondary ? ( <> @@ -621,31 +636,6 @@ function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Eleme ) } -function PaletteHostBadgeChip({ - badge -}: { - badge: PaletteHostBadge | null -}): React.JSX.Element | null { - if (!badge) { - return null - } - // Host labels come from the registry and are intentionally not translated. - return ( - - {badge.label} - - ) -} - function getSettingsTargetFromSectionId(sectionId: string): { pane: SettingsNavTarget repoId: string | null @@ -694,6 +684,9 @@ function WorktreeJumpPaletteContent({ }): React.JSX.Element | null { // Why: subscribe to language changes so translated memos recompute without a fake i18n.language dependency. useTranslation() + // Why frozen on open: recomputing every keystroke would tick the session-age badges mid-session. + // oxlint-disable-next-line react-hooks/exhaustive-deps -- visible is the freeze trigger, not a read value. + const paletteNowMs = useMemo(() => Date.now(), [visible]) const closeModal = useAppStore((s) => s.closeModal) const openModal = useAppStore((s) => s.openModal) const openSettingsPage = useAppStore((s) => s.openSettingsPage) @@ -1203,6 +1196,7 @@ function WorktreeJumpPaletteContent({ worktreeOrder, browserTabsByWorktree, browserPagesByWorkspace, + unifiedTabsByWorktree, activeBrowserTabId, activeWorktreeId, activeWorkspaceExecutionHostId, @@ -1219,6 +1213,7 @@ function WorktreeJumpPaletteContent({ browserSortedWorktrees, repoByHostIdentity, repoMap, + unifiedTabsByWorktree, worktreeOrder ]) @@ -1392,8 +1387,18 @@ function WorktreeJumpPaletteContent({ // An empty query leaves every rank null, so ordering falls through to those scores. return items.sort((a, b) => comparePaletteRankedItems( - { rank: a.result.rank, order: a.result.score, id: a.id }, - { rank: b.result.rank, order: b.result.score, id: b.id } + { + rank: a.result.rank, + order: a.result.score, + id: a.id, + lastActiveAt: a.result.lastActiveAt ?? undefined + }, + { + rank: b.result.rank, + order: b.result.score, + id: b.id, + lastActiveAt: b.result.lastActiveAt ?? undefined + } ) ) }, [browserItems, simulatorItems, workspaceTabItems]) @@ -3231,8 +3236,7 @@ function WorktreeJumpPaletteContent({ ? (sshConnectionStates.get(sshConnectionId)?.status ?? 'disconnected') : null const isSshDisconnected = sshStatus != null && sshStatus !== 'connected' - const hostBadge = getPaletteHostBadge(repo, hostOptions, hostFilterActive) - + const sessionAge = formatPaletteSessionAge(worktree.lastActivityAt, paletteNowMs) return ( + {sessionAge ? ( + + {sessionAge} + + ) : null} {isCurrentWorktree && ( {translate( @@ -3329,7 +3345,6 @@ function WorktreeJumpPaletteContent({ )}
- {repoName && ( @@ -3351,9 +3366,6 @@ function WorktreeJumpPaletteContent({ if (entry.type === 'project-target') { const result = entry.result const isProject = result.kind === 'project' - const hostBadge = isProject - ? getPaletteHostBadge(result.repo, hostOptions, hostFilterActive) - : null const badgeLabel = isProject ? translate('auto.components.WorktreeJumpPalette.projectBadge', 'Project') : translate('auto.components.WorktreeJumpPalette.repoGroupBadge', 'Repo group') @@ -3381,7 +3393,6 @@ function WorktreeJumpPaletteContent({
{isProject ? (
- {result.repo.displayName} @@ -3430,6 +3441,7 @@ function WorktreeJumpPaletteContent({ if (entry.type === 'workspace-tab') { const result = entry.result + const sessionAge = formatPaletteSessionAge(result.lastActiveAt, paletteNowMs) const workspaceTabWorktree = resolveWorktree( result.worktreeId, result.executionHostId @@ -3438,11 +3450,6 @@ function WorktreeJumpPaletteContent({ ? resolveRepoForWorktree(workspaceTabWorktree) : undefined const workspaceTabRepoName = workspaceTabRepo?.displayName ?? result.repoName - const workspaceTabHostBadge = getPaletteHostBadge( - workspaceTabRepo, - hostOptions, - hostFilterActive - ) const workspaceTabFallback = result.contentType === 'terminal' && result.occupantAgent ? ( {result.isCurrentTab && ( @@ -3508,7 +3516,6 @@ function WorktreeJumpPaletteContent({ worktree={workspaceTabWorktree} className="max-w-[280px] truncate text-[12px] font-medium text-muted-foreground" /> - {workspaceTabRepoName && ( @@ -3538,10 +3545,9 @@ function WorktreeJumpPaletteContent({ ? resolveRepoForWorktree(simulatorWorktree) : undefined const simulatorRepoName = simulatorRepo?.displayName ?? result.repoName - const simulatorHostBadge = getPaletteHostBadge( - simulatorRepo, - hostOptions, - hostFilterActive + const sessionAge = formatPaletteSessionAge( + result.lastActiveAt ?? null, + paletteNowMs ) return ( @@ -3562,6 +3568,7 @@ function WorktreeJumpPaletteContent({ titleRanges={result.titleRanges} secondaryText={result.secondaryText} secondaryRanges={result.secondaryRanges} + sessionAge={sessionAge} leadingBadges={ <> {result.isCurrentTab && ( @@ -3591,7 +3598,6 @@ function WorktreeJumpPaletteContent({ worktree={simulatorWorktree} className="max-w-[280px] truncate text-[12px] font-medium text-muted-foreground" /> - {simulatorRepoName && ( @@ -3620,11 +3626,7 @@ function WorktreeJumpPaletteContent({ ? resolveRepoForWorktree(browserWorktree) : undefined const browserRepoName = browserRepo?.displayName ?? result.repoName - const browserHostBadge = getPaletteHostBadge( - browserRepo, - hostOptions, - hostFilterActive - ) + const sessionAge = formatPaletteSessionAge(result.lastActiveAt ?? null, paletteNowMs) return ( {result.isCurrentPage && ( @@ -3673,7 +3676,6 @@ function WorktreeJumpPaletteContent({ worktree={browserWorktree} className="max-w-[280px] truncate text-[12px] font-medium text-muted-foreground" /> - {browserRepoName && ( diff --git a/src/renderer/src/components/cmd-j/palette-session-age.test.ts b/src/renderer/src/components/cmd-j/palette-session-age.test.ts new file mode 100644 index 00000000000..8c0e96f69c7 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-session-age.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { formatPaletteSessionAge } from './palette-session-age' + +const NOW = 1_000_000_000 + +describe('formatPaletteSessionAge', () => { + it('returns undefined when there is no known activity', () => { + expect(formatPaletteSessionAge(null, NOW)).toBeUndefined() + }) + + it('buckets clock skew (negative delta) as "<1m"', () => { + expect(formatPaletteSessionAge(NOW + 5_000, NOW)).toBe('<1m') + }) + + it('stays "<1m" just under the one-minute boundary', () => { + expect(formatPaletteSessionAge(NOW - 59_000, NOW)).toBe('<1m') + }) + + it('switches to minutes at the one-minute boundary', () => { + expect(formatPaletteSessionAge(NOW - 60_000, NOW)).toBe('1m') + expect(formatPaletteSessionAge(NOW - 90_000, NOW)).toBe('1m') + }) + + it('stays in minutes just under the one-hour boundary', () => { + expect(formatPaletteSessionAge(NOW - 59 * 60_000, NOW)).toBe('59m') + }) + + it('switches to hours at the one-hour boundary', () => { + expect(formatPaletteSessionAge(NOW - 60 * 60_000, NOW)).toBe('1h') + }) + + it('stays in hours just under the two-day boundary', () => { + expect(formatPaletteSessionAge(NOW - 47 * 60 * 60_000, NOW)).toBe('47h') + }) + + it('switches to days at the two-day boundary', () => { + expect(formatPaletteSessionAge(NOW - 48 * 60 * 60_000, NOW)).toBe('2d') + expect(formatPaletteSessionAge(NOW - 72 * 60 * 60_000, NOW)).toBe('3d') + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-session-age.ts b/src/renderer/src/components/cmd-j/palette-session-age.ts new file mode 100644 index 00000000000..fc5924ea658 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-session-age.ts @@ -0,0 +1,30 @@ +import { translate } from '@/i18n/i18n' + +/** Bare compact "how long ago" token for the Cmd+J session-age badge, e.g. "3h"/"2d". */ +export function formatPaletteSessionAge( + lastActiveAt: number | null, + now: number +): string | undefined { + if (!lastActiveAt) { + return undefined + } + const deltaMs = now - lastActiveAt + // Why not "now": the badge feeds `aria-label="Last active {{value}} ago"`, and + // "Last active now ago" reads wrong — keep the token numeric like the other buckets. + if (deltaMs < 60_000) { + return translate('components.cmd-j.paletteSessionAge.underOneMinute', '<1m') + } + const minutes = Math.floor(deltaMs / 60_000) + if (minutes < 60) { + return translate('components.cmd-j.paletteSessionAge.minutes', '{{value0}}m', { + value0: minutes + }) + } + const hours = Math.floor(minutes / 60) + if (hours < 48) { + return translate('components.cmd-j.paletteSessionAge.hours', '{{value0}}h', { value0: hours }) + } + return translate('components.cmd-j.paletteSessionAge.days', '{{value0}}d', { + value0: Math.floor(hours / 24) + }) +} diff --git a/src/renderer/src/components/tab-bar/open-tab-search-entries.ts b/src/renderer/src/components/tab-bar/open-tab-search-entries.ts index 5d1ab2c8242..e62e1289328 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search-entries.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search-entries.ts @@ -160,6 +160,7 @@ export function buildOpenTabSearchEntries( ...scope, browserTabsByWorktree: state.browserTabsByWorktree, browserPagesByWorkspace: state.browserPagesByWorkspace, + unifiedTabsByWorktree: state.unifiedTabsByWorktree, activeBrowserTabId: state.activeBrowserTabId, activeWorktreeId: state.activeWorktreeId, activeTabType: state.activeTabType diff --git a/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts b/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts index 399a23b1942..390a9a8fa14 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts @@ -87,7 +87,14 @@ function makeWorkspaceTab({ typeAliases: aliases }), agentMetadata: agentSnippets.length - ? [{ paneKey: `${id}-pane`, textParts: [], snippetCandidates: agentSnippets }] + ? [ + { + paneKey: `${id}-pane`, + textParts: [], + snippetCandidates: agentSnippets, + lastActivityAt: 0 + } + ] : [], occupantAgent: null, isCurrentTab: false, diff --git a/src/renderer/src/components/tab-bar/open-tab-search.test.ts b/src/renderer/src/components/tab-bar/open-tab-search.test.ts index e5d9071e2da..e2029811625 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search.test.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search.test.ts @@ -105,7 +105,14 @@ function makeWorkspaceTab({ repoName: REPO_NAME }), agentMetadata: agentSnippets.length - ? [{ paneKey: `${id}-pane`, textParts: [], snippetCandidates: agentSnippets }] + ? [ + { + paneKey: `${id}-pane`, + textParts: [], + snippetCandidates: agentSnippets, + lastActivityAt: 0 + } + ] : [], occupantAgent, isCurrentTab, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1f16389c1fa..fd6362289a4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2198,7 +2198,8 @@ "27f10cca63": "Search chats, terminals, worktrees, settings, and actions...", "2770f02910": "Search chats, terminals, worktrees, settings, and actions", "paletteOpenTabBranch": "Branch name", - "paletteOpenTabWorkspace": "Workspace name" + "paletteOpenTabWorkspace": "Workspace name", + "lastActiveTime": "Last active {{value0}} ago" }, "github": { "pr": { @@ -16246,6 +16247,14 @@ "otherTopLevelItems": "Other top-level items ({{value0}})" } } + }, + "cmd-j": { + "paletteSessionAge": { + "minutes": "{{value0}}m", + "hours": "{{value0}}h", + "days": "{{value0}}d", + "underOneMinute": "<1m" + } } }, "dashboardPopout": { diff --git a/src/renderer/src/lib/browser-palette-page-entries.test.ts b/src/renderer/src/lib/browser-palette-page-entries.test.ts index f367ac85e73..8c985587500 100644 --- a/src/renderer/src/lib/browser-palette-page-entries.test.ts +++ b/src/renderer/src/lib/browser-palette-page-entries.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' +import type { Tab } from '../../../shared/tab-types' import type { Worktree } from '../../../shared/worktree/types' import { buildSearchableBrowserPages } from './browser-palette-page-entries' import { searchBrowserPages } from './browser-palette-search' @@ -191,6 +192,53 @@ describe('buildSearchableBrowserPages', () => { expect(entries[0].worktreeSortIndex).toBe(Number.MAX_SAFE_INTEGER) }) + it('reads page recency from the owning workspace unified tab, clamped to page age', () => { + function browserTab(overrides: Partial): Tab { + return { + id: 'tab-ws-1', + entityId: 'ws-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'browser', + label: 'Example', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...overrides + } + } + + const [unfocused] = buildSearchableBrowserPages({ + worktrees: [worktreeA], + repoMap, + worktreeOrder, + browserTabsByWorktree: { 'wt-1': [makeWorkspace()] }, + browserPagesByWorkspace: { 'ws-1': [makePage()] }, + unifiedTabsByWorktree: { 'wt-1': [browserTab({})] }, + activeBrowserTabId: null, + activeWorktreeId: null, + activeTabType: 'browser' + }) + expect(unfocused.lastActiveAt).toBeNull() + + const entries = buildSearchableBrowserPages({ + worktrees: [worktreeA], + repoMap, + worktreeOrder, + browserTabsByWorktree: { 'wt-1': [makeWorkspace({ pageIds: ['page-1', 'page-2'] })] }, + browserPagesByWorkspace: { + 'ws-1': [makePage(), makePage({ id: 'page-2', createdAt: 9000 })] + }, + unifiedTabsByWorktree: { 'wt-1': [browserTab({ lastFocusedAt: 4000 })] }, + activeBrowserTabId: null, + activeWorktreeId: null, + activeTabType: 'browser' + }) + + expect(entries.map((entry) => entry.lastActiveAt)).toEqual([4000, 9000]) + }) + it('feeds Cmd+J browser search the same ranking as the inline builder did', () => { const results = searchBrowserPages(buildFixture(), 'docs') diff --git a/src/renderer/src/lib/browser-palette-page-entries.ts b/src/renderer/src/lib/browser-palette-page-entries.ts index d252769e02c..795df372dc2 100644 --- a/src/renderer/src/lib/browser-palette-page-entries.ts +++ b/src/renderer/src/lib/browser-palette-page-entries.ts @@ -1,5 +1,6 @@ import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' +import type { Tab } from '../../../shared/tab-types' import type { Worktree } from '../../../shared/worktree/types' import type { ExecutionHostId } from '../../../shared/execution-host' import { isPaletteCurrentWorktree, resolvePaletteRepoForWorktree } from './palette-repo-resolution' @@ -17,6 +18,8 @@ export type BuildSearchableBrowserPagesOptions = { worktreeOrder: ReadonlyMap browserTabsByWorktree: Record browserPagesByWorkspace: Record + /** Source of browser recency: focus lives on the workspace's unified tab, not the page. */ + unifiedTabsByWorktree?: Record activeBrowserTabId: string | null activeWorktreeId: string | null activeWorkspaceExecutionHostId?: ExecutionHostId | null @@ -30,6 +33,7 @@ export function buildSearchableBrowserPages({ worktreeOrder, browserTabsByWorktree, browserPagesByWorkspace, + unifiedTabsByWorktree, activeBrowserTabId, activeWorktreeId, activeWorkspaceExecutionHostId, @@ -43,7 +47,14 @@ export function buildSearchableBrowserPages({ worktreeOrder.get(getWorktreeHostIdentity(worktree)) ?? worktreeOrder.get(worktree.id) ?? Number.MAX_SAFE_INTEGER + const focusedAtByWorkspaceId = new Map() + for (const tab of unifiedTabsByWorktree?.[worktree.id] ?? []) { + if (tab.contentType === 'browser' && tab.lastFocusedAt) { + focusedAtByWorkspaceId.set(tab.entityId, tab.lastFocusedAt) + } + } for (const workspace of browserTabsByWorktree[worktree.id] ?? []) { + const workspaceFocusedAt = focusedAtByWorkspaceId.get(workspace.id) for (const page of browserPagesByWorkspace[workspace.id] ?? []) { entries.push({ page, @@ -61,6 +72,8 @@ export function buildSearchableBrowserPages({ activeWorktreeId, activeWorkspaceExecutionHostId ), + // Never older than the page itself: it was opened while the workspace was focused. + lastActiveAt: workspaceFocusedAt ? Math.max(workspaceFocusedAt, page.createdAt) : null, document: buildSearchableBrowserPageDocument({ page, workspace, worktree, repoName }) }) } diff --git a/src/renderer/src/lib/browser-palette-search.test.ts b/src/renderer/src/lib/browser-palette-search.test.ts index 224f6dbcd4b..41cfd58f54d 100644 --- a/src/renderer/src/lib/browser-palette-search.test.ts +++ b/src/renderer/src/lib/browser-palette-search.test.ts @@ -363,6 +363,37 @@ describe('browser-palette-search', () => { expect(results[1].pageId).toBe('page-other') }) + it('breaks a rank tie between equally-matching pages by recency', () => { + // Ids are ordered so an id-only tiebreak would flip this expectation. + const entries = [ + makeEntry({ + page: makePage({ id: 'page-a-older', title: 'React Docs' }), + workspace: makeWorkspace({ id: 'ws-1' }), + worktree: makeWorktree(), + repoName: 'repo', + worktreeSortIndex: 0, + isCurrentPage: false, + isCurrentWorktree: false, + lastActiveAt: 1000 + }), + makeEntry({ + page: makePage({ id: 'page-z-newer', title: 'React Docs' }), + workspace: makeWorkspace({ id: 'ws-2' }), + worktree: makeWorktree(), + repoName: 'repo', + worktreeSortIndex: 0, + isCurrentPage: false, + isCurrentWorktree: false, + lastActiveAt: 5000 + }) + ] + + const results = searchBrowserPages(entries, 'react') + + expect(results.map((result) => result.pageId)).toEqual(['page-z-newer', 'page-a-older']) + expect(results[0].lastActiveAt).toBe(5000) + }) + it('matches the visible workspace label in browser search', () => { const results = searchBrowserPages( [ diff --git a/src/renderer/src/lib/browser-palette-search.ts b/src/renderer/src/lib/browser-palette-search.ts index ec1adeda66e..5540713d526 100644 --- a/src/renderer/src/lib/browser-palette-search.ts +++ b/src/renderer/src/lib/browser-palette-search.ts @@ -28,6 +28,8 @@ export type SearchableBrowserPage = { worktreeSortIndex: number isCurrentPage: boolean isCurrentWorktree: boolean + /** Last time the owning browser workspace was focused; null when never focused. */ + lastActiveAt?: number | null /** Normalized field index, built once per entry rather than per keystroke. */ document: PaletteDocument } @@ -55,6 +57,7 @@ export type BrowserPaletteSearchResult = { score: number qualityClass: PaletteResultQualityClass | null rank: PaletteDocumentRank | null + lastActiveAt?: number | null } export const BROWSER_PALETTE_QUERY_MAX_BYTES = 2 * 1024 @@ -129,8 +132,8 @@ function compareEmptyQueryResults( return compareText(a.title, b.title) } -// Why: empty-query browser ordering is intentionally deterministic and context-first. -// The palette should not invent hidden browser recency semantics. +// Why: empty-query browser ordering is intentionally deterministic and context-first; +// lastActiveAt only breaks ties between equally-ranked query matches. function positionScore(entry: SearchableBrowserPage): number { if (entry.isCurrentPage) { return entry.worktreeSortIndex * 100 - 4000 @@ -162,7 +165,8 @@ function baseResult(entry: SearchableBrowserPage): BrowserPaletteSearchResult { isCurrentWorktree: entry.isCurrentWorktree, score: positionScore(entry), qualityClass: null, - rank: null + rank: null, + lastActiveAt: entry.lastActiveAt ?? null } } @@ -208,8 +212,18 @@ export function searchBrowserPages( return results.sort((a, b) => a.rank && b.rank ? comparePaletteTabResults( - { rank: a.rank, positionScore: a.score, id: a.pageId }, - { rank: b.rank, positionScore: b.score, id: b.pageId } + { + rank: a.rank, + positionScore: a.score, + id: a.pageId, + lastActiveAt: a.lastActiveAt ?? undefined + }, + { + rank: b.rank, + positionScore: b.score, + id: b.pageId, + lastActiveAt: b.lastActiveAt ?? undefined + } ) : compareEmptyQueryResults(a, b) ) diff --git a/src/renderer/src/lib/cmd-j-section-leadership.test.ts b/src/renderer/src/lib/cmd-j-section-leadership.test.ts index bf0d082a501..620f32869a5 100644 --- a/src/renderer/src/lib/cmd-j-section-leadership.test.ts +++ b/src/renderer/src/lib/cmd-j-section-leadership.test.ts @@ -12,6 +12,7 @@ import type { PaletteDocumentRank } from './palette-match/palette-document' function rank(overrides: Partial = {}): PaletteDocumentRank { return { exactIntent: 1, + matchedDirectField: 0, wholeQuery: 3, worstQuality: 5, usesSupportingEvidence: 0, @@ -114,9 +115,15 @@ describe('ranked item comparison', () => { expect(comparePaletteRankedItems(strong, weak)).toBeLessThan(0) }) - it('falls back to the section order when match rank ties', () => { - const first = { rank: rank(), order: 1, id: 'z' } - const second = { rank: rank(), order: 2, id: 'a' } + it('prefers recently active item when match rank ties', () => { + const recent = { rank: rank(), order: 10, id: 'z', lastActiveAt: 2000 } + const older = { rank: rank(), order: 0, id: 'a', lastActiveAt: 1000 } + expect(comparePaletteRankedItems(recent, older)).toBeLessThan(0) + }) + + it('falls back to the section order when match rank and recency tie', () => { + const first = { rank: rank(), order: 1, id: 'z', lastActiveAt: 1000 } + const second = { rank: rank(), order: 2, id: 'a', lastActiveAt: 1000 } expect(comparePaletteRankedItems(first, second)).toBeLessThan(0) }) diff --git a/src/renderer/src/lib/cmd-j-section-leadership.ts b/src/renderer/src/lib/cmd-j-section-leadership.ts index 372eb2aebf6..093cacce567 100644 --- a/src/renderer/src/lib/cmd-j-section-leadership.ts +++ b/src/renderer/src/lib/cmd-j-section-leadership.ts @@ -31,9 +31,11 @@ export type PaletteRankedItem = { /** Existing smart-recency / list position, used only after match rank ties. */ order: number id: string + /** Timestamp of most recent activity (focus or agent interaction). */ + lastActiveAt?: number } -/** Match rank first, then the section's own recency order, then a stable id. */ +/** Match rank first, then recent activity, then positional order, then stable id. */ export function comparePaletteRankedItems(a: PaletteRankedItem, b: PaletteRankedItem): number { if (a.rank && b.rank) { const byRank = comparePaletteDocumentRank(a.rank, b.rank) @@ -43,6 +45,13 @@ export function comparePaletteRankedItems(a: PaletteRankedItem, b: PaletteRanked } else if (a.rank !== b.rank) { return a.rank ? -1 : 1 } + if (a.lastActiveAt !== b.lastActiveAt) { + const aTime = a.lastActiveAt ?? 0 + const bTime = b.lastActiveAt ?? 0 + if (aTime !== bTime) { + return bTime - aTime + } + } if (a.order !== b.order) { return a.order - b.order } diff --git a/src/renderer/src/lib/palette-match/indexed-field.ts b/src/renderer/src/lib/palette-match/indexed-field.ts index 5d87776bf47..0e87ae37895 100644 --- a/src/renderer/src/lib/palette-match/indexed-field.ts +++ b/src/renderer/src/lib/palette-match/indexed-field.ts @@ -30,6 +30,8 @@ export type PaletteFieldSource = { /** null marks a visible identity field; identity fields combine freely. */ evidenceId?: string | null identifier?: PaletteIdentifierOptions + /** Container-level fields (e.g. worktree/branch for tabs) demote when matched alone. */ + isContainer?: boolean } export type PaletteIndexedField = { @@ -40,6 +42,7 @@ export type PaletteIndexedField = { words: readonly PaletteWord[] evidenceId: string | null identifier: PaletteIdentifierOptions | null + isContainer: boolean } const IDENTIFIER_PREFIX_KINDS: ReadonlySet = new Set([ @@ -125,7 +128,8 @@ export function indexPaletteField(source: PaletteFieldSource): PaletteIndexedFie atoms: segments.atoms, words: segments.words, evidenceId: source.evidenceId ?? null, - identifier: source.identifier ?? null + identifier: source.identifier ?? null, + isContainer: Boolean(source.isContainer) } } diff --git a/src/renderer/src/lib/palette-match/match-document.ts b/src/renderer/src/lib/palette-match/match-document.ts index b5f7647ef28..9d19fe38220 100644 --- a/src/renderer/src/lib/palette-match/match-document.ts +++ b/src/renderer/src/lib/palette-match/match-document.ts @@ -154,14 +154,17 @@ function buildAssignments( } function rankAssignments(args: { + document: PaletteDocument assignments: readonly PaletteTokenAssignment[] usesEvidence: boolean wholeQuery: number exactIntent: boolean -}): { rank: PaletteDocumentRank; worstQuality: PaletteMatchQuality } { +}): { rank: PaletteDocumentRank; worstQuality: PaletteMatchQuality; isContainerOnly: boolean } { let worstQuality: PaletteMatchQuality = 'field-exact' let fuzzyTokenCount = 0 const fields = new Set() + // Derive from field metadata only: an evidence field can itself be a container. + let hasDirectHit = false for (const assignment of args.assignments) { if (paletteMatchQualityRank(assignment.quality) > paletteMatchQualityRank(worstQuality)) { @@ -171,12 +174,20 @@ function rankAssignments(args: { fuzzyTokenCount += 1 } fields.add(assignment.fieldId) + const field = args.document.fieldById.get(assignment.fieldId) + if (field && !field.isContainer) { + hasDirectHit = true + } } + const isContainerOnly = !hasDirectHit + return { worstQuality, + isContainerOnly, rank: { exactIntent: args.exactIntent ? 0 : 1, + matchedDirectField: isContainerOnly ? 1 : 0, wholeQuery: args.wholeQuery, worstQuality: paletteMatchQualityRank(worstQuality), usesSupportingEvidence: args.usesEvidence ? 1 : 0, @@ -274,7 +285,8 @@ export function matchPaletteDocument(args: { continue } const usedEvidenceId = built.usesEvidence ? evidenceId : null - const { rank, worstQuality } = rankAssignments({ + const { rank, worstQuality, isContainerOnly } = rankAssignments({ + document, assignments: built.assignments, usesEvidence: built.usesEvidence, wholeQuery, @@ -288,7 +300,8 @@ export function matchPaletteDocument(args: { ? 'exact-intent' : resolvePaletteResultQualityClass({ worstQuality, - usesSupportingEvidence: built.usesEvidence + usesSupportingEvidence: built.usesEvidence, + isContainerOnly }), rank, assignments: built.assignments, diff --git a/src/renderer/src/lib/palette-match/match-quality.ts b/src/renderer/src/lib/palette-match/match-quality.ts index 77f3c7aa8df..d400489b6be 100644 --- a/src/renderer/src/lib/palette-match/match-quality.ts +++ b/src/renderer/src/lib/palette-match/match-quality.ts @@ -59,11 +59,15 @@ export function paletteResultQualityClassRank(value: PaletteResultQualityClass): export function resolvePaletteResultQualityClass(args: { worstQuality: PaletteMatchQuality usesSupportingEvidence: boolean + isContainerOnly?: boolean }): PaletteResultQualityClass { - const { worstQuality, usesSupportingEvidence } = args + const { worstQuality, usesSupportingEvidence, isContainerOnly } = args if (isFuzzyPaletteMatchQuality(worstQuality)) { return 'fuzzy-evidence' } + if (isContainerOnly) { + return isExactPaletteMatchQuality(worstQuality) ? 'exact-evidence' : 'partial-evidence' + } if (usesSupportingEvidence) { return isExactPaletteMatchQuality(worstQuality) ? 'exact-evidence' : 'partial-evidence' } diff --git a/src/renderer/src/lib/palette-match/palette-document.ts b/src/renderer/src/lib/palette-match/palette-document.ts index 0e92875d493..2400921f6be 100644 --- a/src/renderer/src/lib/palette-match/palette-document.ts +++ b/src/renderer/src/lib/palette-match/palette-document.ts @@ -40,6 +40,7 @@ export type PaletteDocument = { /** Visible identity fields, cached because they carry the whole-query check. */ visibleFields: readonly PaletteIndexedField[] fieldsByEvidenceId: ReadonlyMap + fieldById: ReadonlyMap } export type PaletteDocumentInput = { @@ -80,7 +81,9 @@ export function buildPaletteDocument(input: PaletteDocumentInput): PaletteDocume const fields = indexPaletteFields([...input.visibleFields, ...evidenceSources]) const fieldsByEvidenceId = new Map() const visibleFields: PaletteIndexedField[] = [] + const fieldById = new Map() for (const field of fields) { + fieldById.set(field.id, field) if (!field.evidenceId) { visibleFields.push(field) continue @@ -103,7 +106,8 @@ export function buildPaletteDocument(input: PaletteDocumentInput): PaletteDocume evidenceUnits, renderOffsetByFieldId, visibleFields, - fieldsByEvidenceId + fieldsByEvidenceId, + fieldById } } @@ -125,6 +129,8 @@ export type PaletteSupportingEvidence = { export type PaletteDocumentRank = { /** 0 when a recognized exact intent (such as a task URL) produced this row. */ exactIntent: number + /** 0 when query matched at least one direct field; 1 when all matched tokens are container-only. */ + matchedDirectField: number /** 0 equality, 1 prefix, 2 word boundary, 3 none — whole query in visible text. */ wholeQuery: number worstQuality: number @@ -144,6 +150,7 @@ export type PaletteDocumentMatch = { const RANK_KEYS: readonly (keyof PaletteDocumentRank)[] = [ 'exactIntent', + 'matchedDirectField', 'wholeQuery', 'worstQuality', 'usesSupportingEvidence', diff --git a/src/renderer/src/lib/palette-match/palette-match-core.test.ts b/src/renderer/src/lib/palette-match/palette-match-core.test.ts index 6fe7878513f..8862165bc79 100644 --- a/src/renderer/src/lib/palette-match/palette-match-core.test.ts +++ b/src/renderer/src/lib/palette-match/palette-match-core.test.ts @@ -381,3 +381,35 @@ describe('typo distance', () => { expect(isWithinOnePaletteEdit(a, b)).toBe(expected) }) }) + +describe('container field matching', () => { + it('marks matchedDirectField as 1 and demotes quality class when all tokens land on container fields', () => { + const tabDoc: PaletteDocumentInput = { + id: 'tab-1', + visibleFields: [ + { id: 'title', profile: 'structured-label', text: 'README.md' }, + { id: 'worktree', profile: 'structured-label', text: 'STA-4360-feature', isContainer: true } + ], + evidence: [] + } + const match = run(tabDoc, '4360') + expect(match).not.toBeNull() + expect(match?.rank.matchedDirectField).toBe(1) + expect(match?.qualityClass).toBe('exact-evidence') + }) + + it('marks matchedDirectField as 0 when at least one token lands on a direct field', () => { + const tabDoc: PaletteDocumentInput = { + id: 'tab-1', + visibleFields: [ + { id: 'title', profile: 'structured-label', text: 'wsl-transcript-4360.ts' }, + { id: 'worktree', profile: 'structured-label', text: 'STA-4360-feature', isContainer: true } + ], + evidence: [] + } + const match = run(tabDoc, '4360') + expect(match).not.toBeNull() + expect(match?.rank.matchedDirectField).toBe(0) + expect(match?.qualityClass).toBe('exact-visible') + }) +}) diff --git a/src/renderer/src/lib/palette-match/tab-document.ts b/src/renderer/src/lib/palette-match/tab-document.ts index d03c09a7706..8c289931afc 100644 --- a/src/renderer/src/lib/palette-match/tab-document.ts +++ b/src/renderer/src/lib/palette-match/tab-document.ts @@ -72,13 +72,29 @@ function dedupeSecondaryTexts( export function buildPaletteTabDocument(input: PaletteTabDocumentInput): PaletteDocument { const fields: PaletteFieldSource[] = [ { id: PALETTE_TAB_TITLE_FIELD_ID, profile: 'structured-label', text: input.title }, - { id: PALETTE_TAB_WORKTREE_FIELD_ID, profile: 'structured-label', text: input.worktreeName }, - { id: PALETTE_TAB_BRANCH_FIELD_ID, profile: 'structured-label', text: input.branch }, - { id: PALETTE_TAB_REPO_FIELD_ID, profile: 'structured-label', text: input.repoName }, + { + id: PALETTE_TAB_WORKTREE_FIELD_ID, + profile: 'structured-label', + text: input.worktreeName, + isContainer: true + }, + { + id: PALETTE_TAB_BRANCH_FIELD_ID, + profile: 'structured-label', + text: input.branch, + isContainer: true + }, + { + id: PALETTE_TAB_REPO_FIELD_ID, + profile: 'structured-label', + text: input.repoName, + isContainer: true + }, { id: PALETTE_TAB_WORKSPACE_FIELD_ID, profile: 'structured-label', - text: input.workspaceLabel ?? '' + text: input.workspaceLabel ?? '', + isContainer: true } ] diff --git a/src/renderer/src/lib/palette-match/tab-match.ts b/src/renderer/src/lib/palette-match/tab-match.ts index d9c65000fa0..f40dd6de03d 100644 --- a/src/renderer/src/lib/palette-match/tab-match.ts +++ b/src/renderer/src/lib/palette-match/tab-match.ts @@ -12,7 +12,11 @@ import { } from './tab-document' import type { MatchRange } from './normalized-text' import type { PaletteResultQualityClass } from './match-quality' -import type { PaletteDocument, PaletteDocumentRank } from './palette-document' +import { + comparePaletteDocumentRank, + type PaletteDocument, + type PaletteDocumentRank +} from './palette-document' const NO_RANGES: readonly MatchRange[] = [] @@ -93,22 +97,21 @@ export type PaletteTabRankInputs = { /** Existing positional score: current tab, current worktree, then list order. */ positionScore: number id: string + /** Timestamp of most recent activity (focus or agent interaction). */ + lastActiveAt?: number } -const RANK_KEYS: readonly (keyof PaletteDocumentRank)[] = [ - 'exactIntent', - 'wholeQuery', - 'worstQuality', - 'usesSupportingEvidence', - 'fuzzyTokenCount', - 'fieldHopCount' -] - -/** Lexicographic match rank first, then the section's existing recency order. */ +/** Lexicographic match rank first, then recent activity, then positional order. */ export function comparePaletteTabResults(a: PaletteTabRankInputs, b: PaletteTabRankInputs): number { - for (const key of RANK_KEYS) { - if (a.rank[key] !== b.rank[key]) { - return a.rank[key] - b.rank[key] + const byRank = comparePaletteDocumentRank(a.rank, b.rank) + if (byRank !== 0) { + return byRank + } + if (a.lastActiveAt !== b.lastActiveAt) { + const aTime = a.lastActiveAt ?? 0 + const bTime = b.lastActiveAt ?? 0 + if (aTime !== bTime) { + return bTime - aTime } } if (a.positionScore !== b.positionScore) { diff --git a/src/renderer/src/lib/simulator-palette-search.test.ts b/src/renderer/src/lib/simulator-palette-search.test.ts index ccb4e469124..e1db78b0a64 100644 --- a/src/renderer/src/lib/simulator-palette-search.test.ts +++ b/src/renderer/src/lib/simulator-palette-search.test.ts @@ -82,6 +82,42 @@ function makeEntry(entry: Omit): SearchableS } } +describe('simulator-palette-search lastActiveAt', () => { + function entryFor(overrides: Partial): SearchableSimulatorTab { + return makeEntry({ + tab: makeTab(overrides), + worktree: makeWorktree(), + repoName: 'repo/mobile', + worktreeSortIndex: 0, + isCurrentTab: false, + isCurrentWorktree: true + }) + } + + it('is null when the tab was never focused', () => { + expect(searchSimulatorTabs([entryFor({})], '')[0].lastActiveAt).toBeNull() + }) + + it('carries the tab focus timestamp, never older than the tab itself', () => { + expect( + searchSimulatorTabs([entryFor({ createdAt: 5000, lastFocusedAt: 9000 })], '')[0].lastActiveAt + ).toBe(9000) + expect( + searchSimulatorTabs([entryFor({ createdAt: 5000, lastFocusedAt: 1000 })], '')[0].lastActiveAt + ).toBe(5000) + }) + + it('breaks a rank tie between equally-matching tabs by recency', () => { + // Ids are ordered so an id-only tiebreak would flip this expectation. + const older = entryFor({ id: 'sim-a-older', lastFocusedAt: 1000 }) + const newer = entryFor({ id: 'sim-z-newer', lastFocusedAt: 5000 }) + + const results = searchSimulatorTabs([older, newer], 'emulator') + + expect(results.map((result) => result.tabId)).toEqual(['sim-z-newer', 'sim-a-older']) + }) +}) + describe('simulator-palette-search', () => { it('keeps empty-query ordering deterministic and context-first', () => { const results = searchSimulatorTabs( diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index f5d1faecde1..b2a8594474a 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -54,6 +54,7 @@ export type SimulatorPaletteSearchResult = { score: number qualityClass: PaletteResultQualityClass | null rank: PaletteDocumentRank | null + lastActiveAt?: number | null } type SimulatorPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simulator' @@ -114,8 +115,8 @@ function compareEmptyQueryResults( return compareText(a.title, b.title) } -// Why: simulator tabs follow browser-tab Cmd+J ordering — deterministic and -// context-first until Orca tracks per-tab recency for this surface. +// Why: empty-query simulator ordering stays deterministic and context-first; +// lastActiveAt only breaks ties between equally-ranked query matches. function positionScore(entry: SearchableSimulatorTab): number { if (entry.isCurrentTab) { return entry.worktreeSortIndex * 100 - 4000 @@ -149,7 +150,11 @@ function baseResult(entry: SearchableSimulatorTab): SimulatorPaletteSearchResult isCurrentWorktree: entry.isCurrentWorktree, score: positionScore(entry), qualityClass: null, - rank: null + rank: null, + // Never older than the tab itself: creation is a focus event too. + lastActiveAt: entry.tab.lastFocusedAt + ? Math.max(entry.tab.lastFocusedAt, entry.tab.createdAt) + : null } } @@ -290,8 +295,18 @@ export function searchSimulatorTabs( return results.sort((a, b) => a.rank && b.rank ? comparePaletteTabResults( - { rank: a.rank, positionScore: a.score, id: a.tabId }, - { rank: b.rank, positionScore: b.score, id: b.tabId } + { + rank: a.rank, + positionScore: a.score, + id: a.tabId, + lastActiveAt: a.lastActiveAt ?? undefined + }, + { + rank: b.rank, + positionScore: b.score, + id: b.tabId, + lastActiveAt: b.lastActiveAt ?? undefined + } ) : compareEmptyQueryResults(a, b) ) diff --git a/src/renderer/src/lib/workspace-tab-agent-metadata.test.ts b/src/renderer/src/lib/workspace-tab-agent-metadata.test.ts index ab4658d5e20..7ad4526bd54 100644 --- a/src/renderer/src/lib/workspace-tab-agent-metadata.test.ts +++ b/src/renderer/src/lib/workspace-tab-agent-metadata.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../shared/agent-status-types' -import { collectAgentMetadataForTerminal } from './workspace-tab-agent-metadata' +import { + collectAgentMetadataForTerminal, + maxAgentActivityAt, + type AgentMetadata +} from './workspace-tab-agent-metadata' function makeEntry(overrides: Partial = {}): AgentStatusEntry { return { @@ -39,4 +43,46 @@ describe('collectAgentMetadataForTerminal', () => { expect(metadata?.snippetCandidates).toContain('Fix checkout race') expect(metadata?.snippetCandidates).toContain('Checkout race') }) + + it('carries the live entry updatedAt through as lastActivityAt', () => { + const [metadata] = collectAgentMetadataForTerminal({ + terminalTabId: 'tab-1', + worktreeId: 'wt-1', + agentStatusByPaneKey: { 'tab-1:leaf-1': makeEntry({ updatedAt: 5000 }) }, + retainedAgentsByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {} + }) + + expect(metadata?.lastActivityAt).toBe(5000) + }) +}) + +function makeMetadata(overrides: Partial = {}): AgentMetadata { + return { + paneKey: 'pane-1', + textParts: [], + snippetCandidates: [], + lastActivityAt: 0, + ...overrides + } +} + +describe('maxAgentActivityAt', () => { + it('returns null when there is no metadata', () => { + expect(maxAgentActivityAt([])).toBeNull() + }) + + it('returns the highest lastActivityAt across panes', () => { + const metadata = [ + makeMetadata({ paneKey: 'pane-1', lastActivityAt: 1000 }), + makeMetadata({ paneKey: 'pane-2', lastActivityAt: 3000 }), + makeMetadata({ paneKey: 'pane-3', lastActivityAt: 2000 }) + ] + + expect(maxAgentActivityAt(metadata)).toBe(3000) + }) + + it('ignores non-positive lastActivityAt values', () => { + expect(maxAgentActivityAt([makeMetadata({ lastActivityAt: 0 })])).toBeNull() + }) }) diff --git a/src/renderer/src/lib/workspace-tab-agent-metadata.ts b/src/renderer/src/lib/workspace-tab-agent-metadata.ts index c93c8d33a4e..a2a91838191 100644 --- a/src/renderer/src/lib/workspace-tab-agent-metadata.ts +++ b/src/renderer/src/lib/workspace-tab-agent-metadata.ts @@ -6,6 +6,18 @@ export type AgentMetadata = { paneKey: string textParts: string[] snippetCandidates: string[] + lastActivityAt: number +} + +/** Latest agent activity across a tab's panes, or null if it has none. */ +export function maxAgentActivityAt(metadata: readonly AgentMetadata[]): number | null { + let max: number | null = null + for (const entry of metadata) { + if (entry.lastActivityAt > 0 && (max === null || entry.lastActivityAt > max)) { + max = entry.lastActivityAt + } + } + return max } export type WorkspaceTabAgentMetadataState = { @@ -68,7 +80,7 @@ function agentRecordMatchesTab({ function collectLiveMetadata( entry: AgentStatusEntry -): Pick { +): Pick { const textParts: string[] = [] const snippetCandidates: string[] = [] addText(textParts, entry.orchestration?.displayName) @@ -86,12 +98,12 @@ function collectLiveMetadata( addText(textParts, historyEntry.prompt) addText(snippetCandidates, historyEntry.prompt) } - return { textParts, snippetCandidates } + return { textParts, snippetCandidates, lastActivityAt: entry.updatedAt } } function collectSleepingMetadata( record: SleepingAgentSessionRecord -): Pick { +): Pick { const textParts: string[] = [] const snippetCandidates: string[] = [] addText(textParts, record.prompt) @@ -101,7 +113,7 @@ function collectSleepingMetadata( addText(textParts, record.terminalTitle) addText(snippetCandidates, record.terminalTitle) addProviderSession(textParts, record.providerSession) - return { textParts, snippetCandidates } + return { textParts, snippetCandidates, lastActivityAt: record.updatedAt } } export function collectAgentMetadataForTerminal({ diff --git a/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts b/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts index f5b47aa1871..3a67ed6a11b 100644 --- a/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts +++ b/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts @@ -21,6 +21,7 @@ import type { AgentMetadata } from './workspace-tab-agent-metadata' */ const AGENT_SNIPPET_RANK: PaletteDocumentRank = { exactIntent: 1, + matchedDirectField: 0, wholeQuery: 3, worstQuality: paletteMatchQualityRank(PALETTE_MATCH_QUALITIES.at(-1) as PaletteMatchQuality) + 1, usesSupportingEvidence: 1, diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts index 59116fd8243..3ba4cf97afd 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts @@ -118,6 +118,7 @@ function makeResult( score: 0, qualityClass: null, rank: null, + lastActiveAt: null, ...overrides } } diff --git a/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts b/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts index 2b70345904d..4eea1c3b9d6 100644 --- a/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts +++ b/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts @@ -92,9 +92,12 @@ function isCurrentWorkspaceTab({ ) { return false } + if (activeUnifiedTabId) { + return activeUnifiedTabId === tab.id + } const visibleType = tab.contentType === 'terminal' ? 'terminal' : 'editor' const storedType = activeTabTypeByWorktree[tab.worktreeId] ?? activeTabType - if (storedType !== visibleType || activeUnifiedTabId !== tab.id) { + if (storedType !== visibleType) { return false } return visibleType === 'terminal' diff --git a/src/renderer/src/lib/workspace-tab-palette-results.test.ts b/src/renderer/src/lib/workspace-tab-palette-results.test.ts new file mode 100644 index 00000000000..a62c6870ae8 --- /dev/null +++ b/src/renderer/src/lib/workspace-tab-palette-results.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import type { Tab, TabContentType } from '../../../shared/tab-types' +import type { Worktree } from '../../../shared/worktree/types' +import { buildPaletteTabDocument } from './palette-match/tab-document' +import { searchWorkspaceTabs } from './workspace-tab-palette-results' +import type { SearchableWorkspaceTab } from './workspace-tab-palette-search' + +const REPO_NAME = 'octo/rocket' +const WORKTREE_NAME = 'Aurora Workspace' +const BRANCH_NAME = 'main' + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'wt-1', + repoId: 'repo-1', + path: '/tmp/wt-1', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: false, + displayName: WORKTREE_NAME, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +function makeTab(id: string, contentType: TabContentType, createdAt: number): Tab { + return { + id, + entityId: `${id}-entity`, + groupId: 'group-1', + worktreeId: 'wt-1', + contentType, + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt + } +} + +function makeEntry({ + id = 'tab-1', + contentType = 'terminal', + createdAt = 0, + worktree = makeWorktree(), + agentLastActivityAt +}: { + id?: string + contentType?: 'terminal' | 'editor' + createdAt?: number + worktree?: Worktree + agentLastActivityAt?: number +} = {}): SearchableWorkspaceTab { + const title = id + return { + tab: makeTab(id, contentType, createdAt) as SearchableWorkspaceTab['tab'], + worktree, + repoName: REPO_NAME, + worktreeSortIndex: 0, + groupSortIndex: 0, + tabSortIndex: 0, + occupantAgent: null, + title, + secondaryText: '', + titleSearchText: title, + secondarySearchTexts: [], + document: buildPaletteTabDocument({ + id, + title, + secondaryTexts: [], + worktreeName: WORKTREE_NAME, + branch: BRANCH_NAME, + repoName: REPO_NAME + }), + agentMetadata: + agentLastActivityAt === undefined + ? [] + : [ + { + paneKey: `${id}-pane`, + textParts: [], + snippetCandidates: [], + lastActivityAt: agentLastActivityAt + } + ], + isCurrentTab: false, + isCurrentWorktree: true + } +} + +describe('searchWorkspaceTabs lastActiveAt', () => { + it('is null when neither agent activity nor worktree activity is known', () => { + const [result] = searchWorkspaceTabs([makeEntry()], '') + expect(result.lastActiveAt).toBeNull() + }) + + it('falls back to worktree PTY activity for editor tabs with no agent metadata', () => { + const entry = makeEntry({ + contentType: 'editor', + worktree: makeWorktree({ lastActivityAt: 5000 }) + }) + const [result] = searchWorkspaceTabs([entry], '') + expect(result.lastActiveAt).toBe(5000) + }) + + it('prefers agent activity over worktree activity when agent activity is newer', () => { + const entry = makeEntry({ + worktree: makeWorktree({ lastActivityAt: 1000 }), + agentLastActivityAt: 9000 + }) + const [result] = searchWorkspaceTabs([entry], '') + expect(result.lastActiveAt).toBe(9000) + }) + + it('prefers agent activity even when it is older than worktree activity', () => { + const entry = makeEntry({ + worktree: makeWorktree({ lastActivityAt: 9000 }), + agentLastActivityAt: 1000 + }) + const [result] = searchWorkspaceTabs([entry], '') + expect(result.lastActiveAt).toBe(1000) + }) + + it('clamps to the tab creation time when the activity signal predates it', () => { + const entry = makeEntry({ + createdAt: 4000, + worktree: makeWorktree({ lastActivityAt: 1000 }) + }) + const [result] = searchWorkspaceTabs([entry], '') + expect(result.lastActiveAt).toBe(4000) + }) + + it('uses tab lastFocusedAt when no agent metadata is present', () => { + const entry = makeEntry({ + id: 'tab-focused', + createdAt: 1000 + }) + entry.tab.lastFocusedAt = 6000 + const [result] = searchWorkspaceTabs([entry], '') + expect(result.lastActiveAt).toBe(6000) + }) +}) + +describe('searchWorkspaceTabs ranking', () => { + it('ranks direct tab title matches ahead of container-only worktree matches', () => { + const directEntry = makeEntry({ id: 'README-4360' }) + const containerEntry = makeEntry({ id: 'unrelated-file' }) + // Both are in WORKTREE_NAME 'Aurora Workspace', but suppose worktree has 4360 + directEntry.document = buildPaletteTabDocument({ + id: 'tab-direct', + title: 'STA-4360-fix.ts', + secondaryTexts: [], + worktreeName: 'STA-4360', + branch: 'main', + repoName: 'repo' + }) + containerEntry.document = buildPaletteTabDocument({ + id: 'tab-container', + title: 'other-file.ts', + secondaryTexts: [], + worktreeName: 'STA-4360', + branch: 'main', + repoName: 'repo' + }) + + const results = searchWorkspaceTabs([containerEntry, directEntry], '4360') + expect(results).toHaveLength(2) + expect(results[0].tabId).toBe('README-4360') + expect(results[0].rank?.matchedDirectField).toBe(0) + expect(results[1].tabId).toBe('unrelated-file') + expect(results[1].rank?.matchedDirectField).toBe(1) + }) + + it('breaks tie between two container-matching tabs using lastActiveAt recency', () => { + const olderTab = makeEntry({ id: 'older-tab' }) + const newerTab = makeEntry({ id: 'newer-tab' }) + olderTab.document = buildPaletteTabDocument({ + id: 'older', + title: 'file-a.ts', + secondaryTexts: [], + worktreeName: 'STA-4360', + branch: 'main', + repoName: 'repo' + }) + newerTab.document = buildPaletteTabDocument({ + id: 'newer', + title: 'file-b.ts', + secondaryTexts: [], + worktreeName: 'STA-4360', + branch: 'main', + repoName: 'repo' + }) + olderTab.tab.lastFocusedAt = 1000 + newerTab.tab.lastFocusedAt = 5000 + + const results = searchWorkspaceTabs([olderTab, newerTab], '4360') + expect(results).toHaveLength(2) + expect(results[0].tabId).toBe('newer-tab') + expect(results[1].tabId).toBe('older-tab') + }) +}) diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts index 26ad64b16c5..8e6989b7754 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.ts @@ -10,6 +10,7 @@ import { resolveWorktreeDisplayName } from './worktree-default-display-name' import { matchWorkspaceTabAgentSnippet } from './workspace-tab-agent-snippet-match' +import { maxAgentActivityAt } from './workspace-tab-agent-metadata' import type { ExecutionHostId } from '../../../shared/execution-host' import type { MatchRange } from './palette-match/normalized-text' import type { PaletteDocumentRank } from './palette-match/palette-document' @@ -47,6 +48,8 @@ export type WorkspaceTabPaletteSearchResult = { score: number qualityClass: PaletteResultQualityClass | null rank: PaletteDocumentRank | null + /** Most recent activity for this tab, or null when nothing is known. */ + lastActiveAt: number | null } function compareText(a: string, b: string): number { @@ -82,6 +85,19 @@ function positionScore(entry: SearchableWorkspaceTab): number { return entry.isCurrentWorktree ? base - 1000 : base } +function resolveWorkspaceTabLastActiveAt(entry: SearchableWorkspaceTab): number | null { + // Agent activity wins when present; then explicit tab focus; otherwise fall back to worktree. + const candidate = + maxAgentActivityAt(entry.agentMetadata) ?? + entry.tab.lastFocusedAt ?? + (entry.worktree.lastActivityAt || null) + if (candidate == null) { + return null + } + // Never report activity older than the tab itself (e.g. a stale worktree signal). + return Math.max(candidate, entry.tab.createdAt) +} + function baseResult(entry: SearchableWorkspaceTab): WorkspaceTabPaletteSearchResult { return { ...(entry.worktree.hostId ? { executionHostId: entry.worktree.hostId } : {}), @@ -106,7 +122,8 @@ function baseResult(entry: SearchableWorkspaceTab): WorkspaceTabPaletteSearchRes isCurrentWorktree: entry.isCurrentWorktree, score: positionScore(entry), qualityClass: null, - rank: null + rank: null, + lastActiveAt: resolveWorkspaceTabLastActiveAt(entry) } } @@ -177,8 +194,18 @@ export function searchWorkspaceTabs( return results.sort((a, b) => a.rank && b.rank ? comparePaletteTabResults( - { rank: a.rank, positionScore: a.score, id: a.tabId }, - { rank: b.rank, positionScore: b.score, id: b.tabId } + { + rank: a.rank, + positionScore: a.score, + id: a.tabId, + lastActiveAt: a.lastActiveAt ?? undefined + }, + { + rank: b.rank, + positionScore: b.score, + id: b.tabId, + lastActiveAt: b.lastActiveAt ?? undefined + } ) : compareEmptyQueryResults(a, b) ) diff --git a/src/renderer/src/lib/worktree-palette-task-url-result.ts b/src/renderer/src/lib/worktree-palette-task-url-result.ts index 54c6e265c2d..303083dff92 100644 --- a/src/renderer/src/lib/worktree-palette-task-url-result.ts +++ b/src/renderer/src/lib/worktree-palette-task-url-result.ts @@ -38,6 +38,7 @@ export function buildWorktreePaletteTaskUrlResult(args: { qualityClass: 'exact-intent', rank: { exactIntent: 0, + matchedDirectField: 0, wholeQuery: 0, worstQuality: 0, usesSupportingEvidence: 1, diff --git a/src/renderer/src/store/slices/tabs-unread-and-focus.test.ts b/src/renderer/src/store/slices/tabs-unread-and-focus.test.ts index 09e03855e7f..f27a091e622 100644 --- a/src/renderer/src/store/slices/tabs-unread-and-focus.test.ts +++ b/src/renderer/src/store/slices/tabs-unread-and-focus.test.ts @@ -79,6 +79,35 @@ describe('TabsSlice', () => { }) // Ghostty "show until interact": BEL always marks unread (even focused/visible tabs); only user interaction via clearTerminalTabUnread dismisses it. + describe('lastFocusedAt', () => { + it('stamps a newly created tab that activates', () => { + const before = Date.now() + const tab = store.getState().createUnifiedTab(WT, 'terminal') + + const stored = store.getState().unifiedTabsByWorktree[WT].find((t) => t.id === tab.id) + expect(stored?.lastFocusedAt).toBeGreaterThanOrEqual(before) + }) + + it('leaves a background-created tab unstamped', () => { + const tab = store.getState().createUnifiedTab(WT, 'terminal', { activate: false }) + + const stored = store.getState().unifiedTabsByWorktree[WT].find((t) => t.id === tab.id) + expect(stored?.lastFocusedAt).toBeUndefined() + }) + + it('stamps a tab created into a new split group', () => { + const source = store.getState().createUnifiedTab(WT, 'terminal') + const before = Date.now() + const split = store.getState().createUnifiedTabInSplit(WT, 'terminal', { + sourceGroupId: source.groupId, + splitDirection: 'right' + }) + + const stored = store.getState().unifiedTabsByWorktree[WT].find((t) => t.id === split?.id) + expect(stored?.lastFocusedAt).toBeGreaterThanOrEqual(before) + }) + }) + describe('markTerminalTabUnread', () => { it('marks the tab even when it is active in a visible split group of the active worktree', () => { // Group A: the worktree's root group (implicit from createUnifiedTab), populated with tabA. diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index b1f1392d82f..9b81774a1df 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -854,6 +854,8 @@ export const createTabsSlice: StateCreator = (set, } } + const shouldActivate = init?.activate ?? true + const createdAt = Date.now() created = { id, entityId: init?.entityId ?? id, @@ -869,13 +871,14 @@ export const createTabsSlice: StateCreator = (set, customLabel: init?.customLabel ?? null, color: init?.color ?? null, sortOrder: nextOrder.length, - createdAt: Date.now(), + createdAt, + // Why: creating an active tab is a focus event; Cmd+J recency reads lastFocusedAt. + ...(shouldActivate ? { lastFocusedAt: createdAt } : {}), isPreview: init?.isPreview, isPinned: init?.isPinned } nextOrder = dedupeTabOrder([...nextOrder, created.id]) - const shouldActivate = init?.activate ?? true const nextActiveTabId = shouldActivate ? created.id : (group.activeTabId ?? created.id) const sanitizedRecent = sanitizeRecentTabIds(group.recentTabIds, nextOrder) // Why: automation-created browser tabs must paint without stealing the visible group selection from the user's current tab. @@ -925,6 +928,7 @@ export const createTabsSlice: StateCreator = (set, const currentLayout = state.layoutByWorktree[worktreeId] ?? ({ type: 'leaf', groupId: target.sourceGroupId } as const) + const createdAt = Date.now() const createdTab: Tab = { id, entityId: init?.entityId ?? id, @@ -940,7 +944,9 @@ export const createTabsSlice: StateCreator = (set, customLabel: init?.customLabel ?? null, color: init?.color ?? null, sortOrder: 0, - createdAt: Date.now(), + createdAt, + // Why: creating an active tab is a focus event; Cmd+J recency reads lastFocusedAt. + ...(shouldActivate ? { lastFocusedAt: createdAt } : {}), isPreview: init?.isPreview, isPinned: init?.isPinned } @@ -1059,14 +1065,18 @@ export const createTabsSlice: StateCreator = (set, })() : state.unreadTerminalTabs return { - unifiedTabsByWorktree: opts?.preservePreview - ? state.unifiedTabsByWorktree - : { - ...state.unifiedTabsByWorktree, - [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((item) => - item.id === tabId ? { ...item, isPreview: false } : item - ) - }, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((item) => + item.id === tabId + ? { + ...item, + isPreview: opts?.preservePreview ? item.isPreview : false, + lastFocusedAt: Date.now() + } + : item + ) + }, groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: (state.groupsByWorktree[worktreeId] ?? []).map((group) => diff --git a/src/shared/tab-types.ts b/src/shared/tab-types.ts index e456c1df412..2211668e615 100644 --- a/src/shared/tab-types.ts +++ b/src/shared/tab-types.ts @@ -58,6 +58,8 @@ export type Tab = { * underneath; `'terminal'` (the default for legacy/missing) shows the raw * xterm. Optional so sessions persisted before this field hydrate cleanly. */ viewMode?: 'terminal' | 'chat' + /** Timestamp when the tab was last focused / activated by the user. */ + lastFocusedAt?: number } export type TabGroup = { From d7a23c84a9f68c88167bc68736562686fa2a53b0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:46:12 -0700 Subject: [PATCH 002/103] fix(pty): keep a CPR reply from overtaking a deferred colour reply (#15559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A background-colour probe writes `OSC 11 ;? ST` then `CSI 6n` and reads exactly one response, using the CPR as its sentinel: a non-OSC first response means "unsupported" and it stops draining. #13309 routed live cooked-echo-risk replies through the ECHO-probe deferral while CPR kept the immediate path, so the CPR overtook the colour reply, the prober gave up, and the stray `ESC ]` was left in the tty for the next program — `gh auth login` died on it with an escape-sequence error. Queue a reply that needs no echo containment behind ones that do, FIFO, and only while something is actually deferred, so latency-critical replies stay immediate on every other path. Windows is unaffected: only posix-pty defers, so the queue is always empty there. Also: an in-flight echo probe is already the write continuation, so re-arming the timer for a queued reply would fork a second stty and throw away the first verdict; and teardown now hands queued uncontained writes to the pty best-effort instead of dropping bytes the caller was told were sent. Known scope limit, pinned by tests and tracked for follow-up: the guarantee is FIFO among recognised query replies, not over every byte — a reply coalesced with a keystroke, and ordinary typed input, still bypass the queue. Both were unordered before this change too. --- src/main/daemon/session.ts | 7 +- src/main/providers/local-pty-provider.ts | 12 +- src/relay/pty-handler.ts | 7 +- ...y-startup-ingress-live-query-reply.test.ts | 160 ++++++++++++++++++ src/shared/pty-startup-ingress.ts | 17 +- src/shared/pty-startup-reply-delivery.ts | 66 +++++++- src/shared/terminal-query-reply.test.ts | 103 ++++++++++- src/shared/terminal-query-reply.ts | 35 +++- 8 files changed, 368 insertions(+), 39 deletions(-) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index a6e5d4a3210..3d15af8c902 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -12,7 +12,7 @@ import type { SessionOptions } from './session-options' import type { TuiAgent } from '../../shared/tui-agent' import { randomUUID } from 'node:crypto' import { PtyStartupIngress } from '../../shared/pty-startup-ingress' -import { extractOnlyCookedEchoSafeQueryReplies } from '../../shared/terminal-query-reply' +import { takeLiveQueryReply } from '../../shared/terminal-query-reply' import type { SessionState, ShellReadyState, @@ -132,10 +132,7 @@ export class Session { } // Daemon POSIX PTYs need the local provider's cooked-echo containment (#13137). - if ( - extractOnlyCookedEchoSafeQueryReplies(data) && - this.startupIngress.answerLiveQueryReply(data) - ) { + if (takeLiveQueryReply(this.startupIngress, data)) { return } diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 0ed81da8293..f7da3438b8e 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -72,7 +72,7 @@ import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress' -import { extractOnlyCookedEchoSafeQueryReplies } from '../../shared/terminal-query-reply' +import { takeLiveQueryReply } from '../../shared/terminal-query-reply' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group' import { readPtsName } from '../pty/node-pty-pts-name' @@ -1192,12 +1192,10 @@ export class LocalPtyProvider implements IPtyProvider { return ptyProcesses.has(id) } write(id: string, data: string): boolean { - // Cooked PTYs echo private DSR/OSC replies; CPR/DA remain immediate (#13137, #7329). - if (extractOnlyCookedEchoSafeQueryReplies(data)) { - const ingress = startupIngressByPty.get(id) - if (ingress?.answerLiveQueryReply(data)) { - return true - } + // Cooked PTYs echo private DSR/OSC replies; CPR/DA stay immediate unless one of + // those is still deferred, which they must not overtake (#13137, #7329). + if (takeLiveQueryReply(startupIngressByPty.get(id), data)) { + return true } const proc = ptyProcesses.get(id) if (!proc) { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 52401a65476..de9cc799b3d 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -58,7 +58,7 @@ import { parsePtyStartupIngressIntent, type PtyIngressEmission } from '../shared/pty-startup-ingress' -import { extractOnlyCookedEchoSafeQueryReplies } from '../shared/terminal-query-reply' +import { takeLiveQueryReply } from '../shared/terminal-query-reply' import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend' import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' import { expandWindowsPathEnvironmentVariables } from '../shared/windows-environment-expansion' @@ -1881,10 +1881,7 @@ export class PtyHandler { this.lastInputAtByPty.set(id, performance.now()) this.interactiveOutputCharsByPty.set(id, 0) // Relay PTYs need the local provider's cooked-echo containment (#13137). - if ( - extractOnlyCookedEchoSafeQueryReplies(data) && - managed.startupIngress?.answerLiveQueryReply(data) - ) { + if (takeLiveQueryReply(managed.startupIngress, data)) { return } managed.pty.write(data) diff --git a/src/shared/pty-startup-ingress-live-query-reply.test.ts b/src/shared/pty-startup-ingress-live-query-reply.test.ts index 299bf73f214..779c1c57fb2 100644 --- a/src/shared/pty-startup-ingress-live-query-reply.test.ts +++ b/src/shared/pty-startup-ingress-live-query-reply.test.ts @@ -15,6 +15,8 @@ const COLOR_SCHEME_REPLY = mode2031SequenceFor('dark') // CSI has only a caret echo; OSC also has readline's rewritten echo. const POSIX_CSI_COOKED_ECHO = (reply: string): string => reply.replaceAll('\x1b', '^[') const OSC_COLOR_REPLY = '\x1b]11;rgb:00/00/00\x07' +// Latency-critical, so it stays off the contained path — but must not overtake one. +const CPR_REPLY = '\x1b[6;1R' const POSIX_OSC_COOKED_ECHOES = [ (reply: string): string => reply.replaceAll('\x1b', '^['), (reply: string): string => reply.replaceAll('\x1b]', '\x07').replaceAll('\x1b\\', '') @@ -162,6 +164,164 @@ describe('PtyStartupIngress live query replies (#13137)', () => { ingress.drainAndClose() }) + // termenv writes `OSC 11 ;? ST` then `CSI 6n` and stops reading at the CPR, so a + // CPR that overtakes the still-deferred color reply strands it for the next program + // (`gh auth login` -> "unexpected escape sequence from terminal: ['\x1b' ']']"). + it('holds a CPR reply behind a still-deferred color reply', async () => { + vi.useFakeTimers() + const writes: string[] = [] + const probe = scriptedEchoProbe('echoing', 'quiet') + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: probe, + write: (data) => writes.push(data), + onEmission: () => {} + }) + + expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(0) + expect(writes).toEqual([]) + expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(20) + expect(writes).toEqual([OSC_COLOR_REPLY, CPR_REPLY]) + ingress.drainAndClose() + }) + + it('leaves a CPR reply on the caller immediate path when nothing is deferred', async () => { + vi.useFakeTimers() + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: scriptedEchoProbe('quiet'), + write: (data) => writes.push(data), + onEmission: () => {} + }) + + expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(false) + expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(0) + expect(writes).toEqual([OSC_COLOR_REPLY]) + expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(false) + ingress.drainAndClose() + }) + + it('does not project an ordered CPR reply as an echo to swallow', async () => { + vi.useFakeTimers() + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + // Never settles, so the budget-expiry flush runs with kernelEchoImpossible + // false — the only verdict under which the caret shape IS armed, so this is + // what discriminates the uncontained branch from the contained one. + echoProbe: () => new Promise(() => {}), + write: () => {}, + onEmission: (emission) => emissions.push(emission) + }) + + expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(0) + expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(200) + // The CPR rode the queue for order only, so its caret shape is ordinary output. + ingress.accept(POSIX_CSI_COOKED_ECHO(CPR_REPLY)) + expect(visible(emissions)).toBe(POSIX_CSI_COOKED_ECHO(CPR_REPLY)) + ingress.drainAndClose() + }) + + // An ordered reply must not restart the ECHO probe: attemptPendingWrites nulls + // writeTimer on entry, so re-arming forks a second stty and discards the in-flight + // verdict, delaying the contained reply this queue exists to deliver first. + it('queues ordered replies without spawning extra echo probes', async () => { + vi.useFakeTimers() + const counts: number[] = [] + for (const orderedCount of [0, 8]) { + const writes: string[] = [] + let calls = 0 + // Never settles, so the probe stays in flight across the whole queueing window. + const probe = (): Promise => { + calls += 1 + return new Promise(() => {}) + } + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: probe, + write: (data) => writes.push(data), + onEmission: () => {} + }) + + expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(0) + for (let i = 0; i < orderedCount; i += 1) { + ingress.writeQueryReplyInOrder(CPR_REPLY) + } + await vi.advanceTimersByTimeAsync(200) + expect(writes[0]).toBe(OSC_COLOR_REPLY) + expect(writes).toHaveLength(1 + orderedCount) + counts.push(calls) + ingress.drainAndClose() + } + expect(counts).toEqual([1, 1]) + }) + + it('keeps holding a contained reply to its budget while the probe reports echoing', async () => { + vi.useFakeTimers() + const writes: string[] = [] + const probe = scriptedEchoProbe('echoing') + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: probe, + write: (data) => writes.push(data), + onEmission: () => {} + }) + + expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(0) + for (let i = 0; i < 8; i += 1) { + ingress.writeQueryReplyInOrder(CPR_REPLY) + } + // A burst of ordered replies must not exhaust the probe-start budget and force an + // early unprobed flush, which would paint the reply onto a still-cooked prompt. + await vi.advanceTimersByTimeAsync(199) + expect(writes).toEqual([]) + await vi.advanceTimersByTimeAsync(2) + expect(writes[0]).toBe(OSC_COLOR_REPLY) + ingress.drainAndClose() + }) + + it('hands a queued ordered reply to the pty at teardown instead of dropping it', async () => { + vi.useFakeTimers() + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: () => new Promise(() => {}), + write: (data) => writes.push(data), + onEmission: () => {} + }) + + expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(0) + expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(true) + // Daemon dispose drains before the kill, so the child can still be blocked on this. + ingress.drainAndClose() + expect(writes).toEqual([CPR_REPLY]) + }) + + it('never takes the ordered path on backends that do not defer', async () => { + vi.useFakeTimers() + for (const ownerBackend of ['windows-conpty', 'windows-wsl'] as const) { + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend, + write: (data) => writes.push(data), + onEmission: () => {} + }) + expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true) + expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(false) + expect(writes).toEqual([OSC_COLOR_REPLY]) + ingress.drainAndClose() + } + }) + it('does not route ordinary keystrokes through the echo-safe reply path', () => { expect(needsCookedEchoSafeQueryReply('y')).toBe(false) expect(needsCookedEchoSafeQueryReply('yes\r')).toBe(false) diff --git a/src/shared/pty-startup-ingress.ts b/src/shared/pty-startup-ingress.ts index fe1aa041752..bc6b055c8a2 100644 --- a/src/shared/pty-startup-ingress.ts +++ b/src/shared/pty-startup-ingress.ts @@ -100,6 +100,11 @@ export class PtyStartupIngress { : false } + /** Order-only: holds an uncontained reply behind a deferred one, else leaves it. */ + writeQueryReplyInOrder(reply: string): boolean { + return !this.closed && this.delivery.writeBehindDeferredReplies(reply) + } + drainAndClose(): number { this.enqueue({ kind: 'teardown' }) return this.rawHighWater @@ -322,12 +327,11 @@ export class PtyStartupIngress { } private releaseQueryPending(): void { - if (!this.queryPending) { - return - } const pending = this.queryPending this.queryPending = null - this.emit(pending, false) + if (pending) { + this.emit(pending, false) + } } /** @@ -377,10 +381,7 @@ export class PtyStartupIngress { } private clearDeadline(): void { - if (!this.deadlineTimer) { - return - } - clearTimeout(this.deadlineTimer) + clearTimeout(this.deadlineTimer ?? undefined) this.deadlineTimer = null } } diff --git a/src/shared/pty-startup-reply-delivery.ts b/src/shared/pty-startup-reply-delivery.ts index c8b38e055bd..0ec118ff800 100644 --- a/src/shared/pty-startup-reply-delivery.ts +++ b/src/shared/pty-startup-reply-delivery.ts @@ -74,7 +74,7 @@ const MAX_TRACKED_REPLIES = 64 const ECHO_PROBE_MAX_STARTS_PER_SECOND = 10 type ExpectedEcho = { projections: readonly string[]; remainingBytes: number } -type PendingWrite = { reply: string; onFailed: (() => void) | undefined } +type PendingWrite = { reply: string; onFailed: (() => void) | undefined; contained: boolean } type ActiveEchoProbe = { timer: ReturnType | null } /** Only a POSIX tty both echoes the reply and still delivers a deferred write. */ @@ -212,7 +212,31 @@ export class PtyStartupReplyDelivery { if (this.pendingWrites.length === 0) { this.echoPollDeadline = Date.now() + ECHO_POLL_BUDGET_MS } - this.pendingWrites.push({ reply, onFailed }) + this.pendingWrites.push({ reply, onFailed, contained: true }) + this.armWriteTimer() + return true + } + + /** + * Queues a reply that needs no echo containment behind ones that do, so it cannot + * overtake them. termenv writes `OSC 11 ;? ST` then `CSI 6n` and stops reading at + * the CPR, treating it as proof the terminal answered: a CPR that jumps a still- + * deferred color reply strands that reply in the tty for whatever runs next to read + * (`gh auth login` -> "unexpected escape sequence from terminal"). + * + * False means nothing is deferred and the caller owns the write, which keeps the + * latency-critical replies immediate on every path that is not mid-deferral. + */ + writeBehindDeferredReplies(reply: string): boolean { + if (this.closed || this.pendingWrites.length === 0) { + return false + } + if (this.pendingWrites.length >= MAX_TRACKED_REPLIES) { + // Flushing keeps order without growing the queue; the caller writes after it. + this.flushPendingWrites() + return false + } + this.pendingWrites.push({ reply, onFailed: undefined, contained: false }) this.armWriteTimer() return true } @@ -264,17 +288,32 @@ export class PtyStartupReplyDelivery { } } - /** Teardown: the pty is gone, so an unwritten reply has nowhere left to go. */ + /** + * Teardown. A contained reply has nowhere left to go — its reader is gone. An + * uncontained one is an ordinary write this queue only borrowed for ordering, and the + * caller was already told it was sent, so it is handed to the pty best-effort: the + * child can still be alive here (daemon dispose drains before the kill). + */ close(): void { this.closed = true this.clearWriteTimer() this.clearActiveEchoProbe() - this.pendingWrites.length = 0 + for (const pending of this.pendingWrites.splice(0)) { + if (!pending.contained) { + try { + this.writeProvider(pending.reply) + } catch { + /* pty already torn down */ + } + } + } this.expectedEchoes.length = 0 } private armWriteTimer(delayMs = 0): void { - if (this.writeTimer) { + // A probe in flight is already the continuation: re-arming forks a second stty and + // makes the first one's verdict unusable (the identity bail below discards it). + if (this.writeTimer || this.activeEchoProbe) { return } this.writeTimer = setTimeout(() => this.attemptPendingWrites(), delayMs) @@ -332,7 +371,7 @@ export class PtyStartupReplyDelivery { this.clearWriteTimer() this.clearActiveEchoProbe() for (const pending of this.pendingWrites.splice(0)) { - this.writeReply(pending.reply, pending.onFailed, kernelEchoImpossible) + this.writeReply(pending.reply, pending.onFailed, kernelEchoImpossible, pending.contained) } } @@ -371,11 +410,22 @@ export class PtyStartupReplyDelivery { return true } - private writeReply(reply: string, onFailed?: () => void, kernelEchoImpossible = false): boolean { + private writeReply( + reply: string, + onFailed?: () => void, + kernelEchoImpossible = false, + contained = true + ): boolean { if (this.closed) { return false } - const projections = replyEchoProjections(reply, this.ownerBackend, kernelEchoImpossible) + // Not a safety rule: the caret form of a short CSI reply (`^[[6;1R`) is far likelier + // to occur verbatim in ordinary output than a long OSC colour string, so arming it + // is not worth the false swallow. These replies were unprojected before they rode + // this queue, and stay that way. + const projections = contained + ? replyEchoProjections(reply, this.ownerBackend, kernelEchoImpossible) + : [] // Why: register before write because node-pty can synchronously re-enter onData. const expected: ExpectedEcho | null = projections.length > 0 ? { projections, remainingBytes: ECHO_SEARCH_BUDGET_BYTES } : null diff --git a/src/shared/terminal-query-reply.test.ts b/src/shared/terminal-query-reply.test.ts index c8bf50268ad..4b61e6dd696 100644 --- a/src/shared/terminal-query-reply.test.ts +++ b/src/shared/terminal-query-reply.test.ts @@ -1,10 +1,12 @@ import { Terminal } from '@xterm/headless' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { extractOnlyCookedEchoSafeQueryReplies, isTerminalQueryReply, - needsCookedEchoSafeQueryReply + needsCookedEchoSafeQueryReply, + takeLiveQueryReply } from './terminal-query-reply' +import { PtyStartupIngress } from './pty-startup-ingress' describe('isTerminalQueryReply', () => { it('matches synthetic query replies that must be sent immediately', () => { @@ -125,3 +127,100 @@ describe('isTerminalQueryReply', () => { expect(isTerminalQueryReply('\x1bP1$r2 q')).toBe(false) }) }) + +// Regression for the `gh auth login` report: termenv writes `OSC 11 ;? ST` then +// `CSI 6n` and reads exactly one response, treating a CPR-first answer as "no OSC +// support" without draining further. Orca defers the color reply behind an ECHO +// probe, so a CPR taken straight to the PTY overtakes it and leaves `ESC ]` in the +// tty for the next program — bubbletea then dies with +// "unexpected escape sequence from terminal: ['\x1b' ']']". +describe('takeLiveQueryReply ordering (termenv OSC-then-CPR)', () => { + const OSC_11_REPLY = '\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\' + const CPR_REPLY = '\x1b[1;1R' + + function hostWrites(ingress: PtyStartupIngress, pty: string[]) { + return (data: string): void => { + if (!takeLiveQueryReply(ingress, data)) { + pty.push(data) + } + } + } + + it('delivers the color reply before the CPR the querying program stops at', async () => { + vi.useFakeTimers() + const pty: string[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: async () => 'quiet', + write: (data) => pty.push(data), + onEmission: () => {} + }) + const write = hostWrites(ingress, pty) + + write(OSC_11_REPLY) + write(CPR_REPLY) + await vi.advanceTimersByTimeAsync(200) + + expect(pty).toEqual([OSC_11_REPLY, CPR_REPLY]) + // Nothing survives the CPR, so the next program's stdin opens clean. + expect(pty.slice(1).join('')).not.toContain('\x1b]') + ingress.drainAndClose() + vi.useRealTimers() + }) + + it('keeps a CPR immediate when no color reply is deferred', () => { + vi.useFakeTimers() + const pty: string[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: async () => 'quiet', + write: (data) => pty.push(data), + onEmission: () => {} + }) + + hostWrites(ingress, pty)(CPR_REPLY) + expect(pty).toEqual([CPR_REPLY]) + ingress.drainAndClose() + vi.useRealTimers() + }) + + it('never takes ordinary typed input', () => { + const pty: string[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + write: (data) => pty.push(data), + onEmission: () => {} + }) + + for (const keystroke of ['y', 'gh auth login\r', '\x1b[A', '\x1b', '\x03']) { + expect(takeLiveQueryReply(ingress, keystroke)).toBe(false) + } + ingress.drainAndClose() + }) +}) + +// Known limitation, tracked for follow-up: the guarantee is FIFO among recognised +// query replies, not over every byte written to the pty. Both branches of +// takeLiveQueryReply match whole strings, so a coalesced payload is not a reply, and +// ordinary input never rides the queue at all. Pinned so the boundary is explicit — +// if a change makes these take the ordered path, that is an improvement, not a break. +describe('takeLiveQueryReply: writes that still bypass the ordered queue', () => { + const BYPASSING = [ + { what: 'reply coalesced with a keystroke', data: '\x1b[6;1Ry' }, + { what: 'keystroke coalesced with a reply', data: 'y\x1b[6;1R' }, + { what: 'ordinary typed input', data: 'ls\r' } + ] + + it.each(BYPASSING)('does not take $what', ({ data }) => { + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: () => new Promise(() => {}), + write: () => {}, + onEmission: () => {} + }) + // Deferral is open, so an ordered write WOULD be queued here. + expect(ingress.answerLiveQueryReply('\x1b]11;rgb:00/00/00\x07')).toBe(true) + expect(takeLiveQueryReply(ingress, data)).toBe(false) + ingress.drainAndClose() + }) +}) diff --git a/src/shared/terminal-query-reply.ts b/src/shared/terminal-query-reply.ts index e83f0dbcc94..5d68acff9f5 100644 --- a/src/shared/terminal-query-reply.ts +++ b/src/shared/terminal-query-reply.ts @@ -23,10 +23,12 @@ const ESC = String.fromCharCode(0x1b) /* oxlint-disable no-control-regex -- grammars match terminal ESC/BEL sequences by definition */ // Known accepted collision: xterm.js encodes MODIFIED F3 (Shift/Ctrl/Alt+F3) as // `CSI 1 ; R`, which is indistinguishable from a CPR report (a classic -// VT ambiguity). Such a keystroke is sent immediately instead of debounced — -// byte order is still preserved (the immediate path flushes pending input -// first), it just skips input-intent/activity bookkeeping. Harmless, so we -// keep the reply grammar complete rather than special-casing it. +// VT ambiguity). Such a keystroke skips input-intent/activity bookkeeping, and +// takeLiveQueryReply will hold it behind a deferred reply like any CPR — so +// within that window (bounded by the host's echo budget) a keystroke typed +// after it can reach the pty first. Accepted: it needs the chord and a +// following keypress inside a live colour-query deferral, and we keep the reply +// grammar complete rather than special-casing an unresolvable ambiguity. const CPR_OR_DSR_RE = new RegExp('^\\u001b\\[\\??[0-9;]*[Rn]$') const DEVICE_ATTRIBUTES_RE = new RegExp('^\\u001b\\[[?>=]?[0-9;]*c$') // 4/6 = pixel-size reports, 8 = text-area size in characters (answer to CSI 18t). @@ -144,3 +146,28 @@ export function answerEachCookedEchoSafeQueryReply( } return accepted } + +/** Host-side ingress surface for replies the PTY owner may take ownership of. */ +export type LiveQueryReplyIngress = { + answerLiveQueryReply: (reply: string) => boolean + writeQueryReplyInOrder: (reply: string) => boolean +} + +/** + * True when `ingress` owns this write. A reply a cooked prompt would paint is deferred + * behind an ECHO probe (#13137); a latency-critical reply (CPR/DA) is taken only while + * one of those is still deferred, because termenv stops reading at the CPR it sends + * after its color query — an early CPR strands the color reply in the tty for whatever + * runs next (`gh auth login` -> "unexpected escape sequence from terminal"). + */ +export function takeLiveQueryReply( + ingress: LiveQueryReplyIngress | undefined, + data: string +): boolean { + if (!ingress) { + return false + } + return extractOnlyCookedEchoSafeQueryReplies(data) + ? ingress.answerLiveQueryReply(data) + : isTerminalQueryReply(data) && ingress.writeQueryReplyInOrder(data) +} From fdd4091ebd3daab948910947bee4aa18f638d10f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:37:02 -0700 Subject: [PATCH 003/103] fix(hooks): isolate lint-staged backups per worktree (#15388) --- config/patches/lint-staged@16.4.0.patch | 125 ++++++++++++++++++ .../lint-staged-worktree-backup.test.mjs | 97 ++++++++++++++ package.json | 3 +- pnpm-lock.yaml | 7 +- src/shared/git-binary-compatibility.test.ts | 32 +++++ 5 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 config/patches/lint-staged@16.4.0.patch create mode 100644 config/scripts/lint-staged-worktree-backup.test.mjs diff --git a/config/patches/lint-staged@16.4.0.patch b/config/patches/lint-staged@16.4.0.patch new file mode 100644 index 00000000000..cd5bcf57740 --- /dev/null +++ b/config/patches/lint-staged@16.4.0.patch @@ -0,0 +1,125 @@ +diff --git a/lib/gitWorkflow.js b/lib/gitWorkflow.js +index 3a32d6a9cd65c7ecb6b3cbccfce5a15c96530435..363513128bf7302eab73ea775102bb8a633a3b8b 100644 +--- a/lib/gitWorkflow.js ++++ b/lib/gitWorkflow.js +@@ -50,6 +50,9 @@ export const STASH = 'lint-staged automatic backup' + + const PATCH_UNSTAGED = 'lint-staged_unstaged.patch' + ++const BACKUP_REF_PREFIX = 'refs/worktree/lint-staged-backups' ++const ZERO_OID = '0000000000000000000000000000000000000000' ++ + const GIT_DIFF_ARGS = [ + '--binary', // support binary files + '--unified=0', // do not add lines around diff for consistent behaviour +@@ -100,6 +103,8 @@ export class GitWorkflow { + /** @type {import('./getStagedFiles.js').StagedFile[][]} */ + this.matchedFileChunks = matchedFileChunks + this.topLevelDir = topLevelDir ++ this.backupOid = null ++ this.backupRef = null + + /** + * These three files hold state about an ongoing git merge +@@ -122,6 +127,17 @@ export class GitWorkflow { + * Get name of backup stash + */ + async getBackupStash(ctx) { ++ if (this.backupRef) { ++ const backupOid = await this.execGit(['rev-parse', '--verify', this.backupRef]) ++ ++ if (backupOid !== this.backupOid) { ++ ctx.errors.add(GetBackupStashError) ++ throw new Error('lint-staged automatic backup is missing!') ++ } ++ ++ return this.backupRef ++ } ++ + /** Print stash list with short hash and subject */ + const stashes = await this.execGit(['stash', 'list', '--format="%h %s"', '-z']) + .then(parseGitZOutput) +@@ -270,11 +286,14 @@ export class GitWorkflow { + } else { + /** Save stash of all changes, keeping all files as-is */ + const stashHash = await this.execGit(['stash', 'create']) ++ this.backupOid = stashHash ++ this.backupRef = `${BACKUP_REF_PREFIX}/${crypto.randomUUID()}` + ctx.backupHash = await this.execGit(['rev-parse', '--short', stashHash]) +- await this.execGit(['stash', 'store', '--quiet', '--message', STASH, ctx.backupHash]) ++ await this.execGit(['update-ref', this.backupRef, this.backupOid, ZERO_OID]) ++ ctx.backupRef = this.backupRef + } + +- task.title = `Backed up original state in git stash (${ctx.backupHash})` ++ task.title = `Backed up original state (${ctx.backupHash})` + debugLog(task.title) + } + } catch (error) { +@@ -425,7 +444,11 @@ export class GitWorkflow { + async cleanup(ctx) { + try { + debugLog('Dropping backup stash...') +- await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)]) ++ if (this.backupRef) { ++ await this.execGit(['update-ref', '-d', this.backupRef, this.backupOid]) ++ } else { ++ await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)]) ++ } + debugLog('Done dropping backup stash!') + } catch (error) { + handleError(error, ctx) +diff --git a/lib/index.js b/lib/index.js +index 75eeacee48759ef90249df5524639d61bce10918..24b4852eba9b14e05e10c3df8a2b2dae75af19f3 100644 +--- a/lib/index.js ++++ b/lib/index.js +@@ -157,7 +157,7 @@ const lintStaged = async ( + logger.warn(PREVENTED_EMPTY_COMMIT) + } else if (ctx.errors.has(FailOnChangesError)) { + logger.warn(PREVENTED_TASK_MODIFICATIONS + '\n') +- logger.warn(restoreStashExample(ctx.backupHash)) ++ logger.warn(restoreStashExample(ctx.backupHash, ctx.backupRef)) + } else if (ctx.errors.has(RestoreUnstagedChangesError)) { + logger.warn(UNSTAGED_CHANGES_BACKUP_STASH_LOCATION) + logger.warn(ctx.unstagedPatch) +@@ -168,7 +168,7 @@ const lintStaged = async ( + logger.error(GIT_ERROR) + if (ctx.shouldBackup) { + // No sense to show this if the backup stash itself is missing. +- logger.error(restoreStashExample(ctx.backupHash) + '\n') ++ logger.error(restoreStashExample(ctx.backupHash, ctx.backupRef) + '\n') + } + } + +diff --git a/lib/messages.js b/lib/messages.js +index 993f8d81cac48132b5ae2ebdc8ca452c7217caad..2dc2291bc82451eacc5babfc8ce408051e1101e3 100644 +--- a/lib/messages.js ++++ b/lib/messages.js +@@ -66,9 +66,12 @@ export const PREVENTED_EMPTY_COMMIT = ` + Use the --allow-empty option to continue, or check your task configuration`)} + ` + +-export const restoreStashExample = ( +- hash = 'h0a0s0h0' +-) => `Any lost modifications can be restored from a git stash: ++export const restoreStashExample = (hash = 'h0a0s0h0', backupRef) => ++ backupRef ++ ? `Any lost modifications can be restored from the worktree backup: ++ ++ > git stash apply --index ${backupRef}` ++ : `Any lost modifications can be restored from a git stash: + + > git stash list --format="%h %s" + ${hash} On main: lint-staged automatic backup +diff --git a/lib/state.js b/lib/state.js +index da30e6f639d31307ebf503691aa991bb93948f8a..4a38799fa7773a8e8629c7c3a6b72287e6b22f4b 100644 +--- a/lib/state.js ++++ b/lib/state.js +@@ -15,6 +15,7 @@ export const getInitialState = ({ + revert = true, + } = {}) => ({ + backupHash: null, ++ backupRef: null, + errors: new Set([]), + shouldFailOnChanges: failOnChanges, + hasFilesToHide: null, diff --git a/config/scripts/lint-staged-worktree-backup.test.mjs b/config/scripts/lint-staged-worktree-backup.test.mjs new file mode 100644 index 00000000000..97ee56b7295 --- /dev/null +++ b/config/scripts/lint-staged-worktree-backup.test.mjs @@ -0,0 +1,97 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import lintStaged from 'lint-staged' +import { expect, it } from 'vitest' + +const BACKUP_REFS = 'refs/worktree/lint-staged-backups' +const silentLogger = { error() {}, log() {}, warn() {} } + +it('keeps lint-staged backups isolated to the current worktree', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-lint-staged-worktree-')) + + try { + const repo = join(root, 'repo') + const worktree = join(root, 'linked worktree') + const trackedFile = join(worktree, 'tracked.txt') + mkdirSync(repo) + initializeRepo(repo) + + writeFileSync(join(repo, 'tracked.txt'), 'base-one\nbase-two\n') + git(repo, ['add', 'tracked.txt']) + git(repo, ['commit', '--quiet', '-m', 'initial']) + writeFileSync(join(repo, 'tracked.txt'), 'user stash\nbase-two\n') + git(repo, ['stash', 'push', '--quiet', '--message', 'user backup']) + git(repo, ['worktree', 'add', '--quiet', '-b', 'linked', worktree]) + + writeFileSync(trackedFile, 'staged-change\nbase-two\n') + git(worktree, ['add', 'tracked.txt']) + writeFileSync(trackedFile, 'staged-change\nunstaged-change\n') + + const expectedStash = gitTrim(worktree, ['rev-parse', 'refs/stash']) + const stashBefore = git(worktree, ['stash', 'list', '--format=%H%x00%gs']) + const stagedBefore = git(worktree, ['diff', '--cached', '--binary']) + const unstagedBefore = git(worktree, ['diff', '--binary']) + const contentBefore = readFileSync(trackedFile, 'utf8') + const observation = join(root, 'task-observation.json') + const probe = join(root, 'failing-task.cjs') + writeProbe(probe) + + const task = [process.execPath, probe, expectedStash, observation].map(quote).join(' ') + const passed = await lintStaged( + { config: { '*.txt': task }, cwd: worktree, quiet: true }, + silentLogger + ) + + expect(passed).toBe(false) + expect(JSON.parse(readFileSync(observation, 'utf8'))).toEqual({ + backupRefs: [expect.stringMatching(`^${BACKUP_REFS}/`)], + sharedStash: expectedStash + }) + expect(git(worktree, ['for-each-ref', '--format=%(refname)', BACKUP_REFS])).toBe('') + expect(git(worktree, ['stash', 'list', '--format=%H%x00%gs'])).toBe(stashBefore) + expect(git(worktree, ['diff', '--cached', '--binary'])).toBe(stagedBefore) + expect(git(worktree, ['diff', '--binary'])).toBe(unstagedBefore) + expect(readFileSync(trackedFile, 'utf8')).toBe(contentBefore) + expect(git(worktree, ['ls-files', '--unmerged'])).toBe('') + } finally { + rmSync(root, { force: true, recursive: true }) + } +}) + +function initializeRepo(repo) { + git(repo, ['init', '--quiet']) + git(repo, ['config', 'user.email', 'test@example.invalid']) + git(repo, ['config', 'user.name', 'Test']) + git(repo, ['config', 'core.autocrlf', 'false']) + git(repo, ['config', 'core.hooksPath', join(repo, '.git', 'no-hooks')]) + git(repo, ['config', 'commit.gpgsign', 'false']) +} + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }) +} + +function gitTrim(cwd, args) { + return git(cwd, args).trim() +} + +function quote(value) { + return JSON.stringify(value) +} + +function writeProbe(path) { + writeFileSync( + path, + [ + "const { execFileSync } = require('node:child_process')", + "const { writeFileSync } = require('node:fs')", + "const git = (args) => execFileSync('git', args, { encoding: 'utf8' }).trim()", + "const backupRefs = git(['for-each-ref', '--format=%(refname)', 'refs/worktree/lint-staged-backups'])", + "writeFileSync(process.argv[3], JSON.stringify({ backupRefs: backupRefs.split('\\n').filter(Boolean), sharedStash: git(['rev-parse', 'refs/stash']) }))", + "writeFileSync(process.argv[4], 'task-output\\n')", + 'process.exit(1)' + ].join('\n') + ) +} diff --git a/package.json b/package.json index aabf6ab08d6..ceac925f0b2 100644 --- a/package.json +++ b/package.json @@ -313,7 +313,8 @@ "@xterm/addon-ligatures@0.11.0-beta.287": "config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch", "@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch", "@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch", - "@xterm/xterm@6.1.0-beta.287": "config/patches/@xterm__xterm@6.1.0-beta.287.patch" + "@xterm/xterm@6.1.0-beta.287": "config/patches/@xterm__xterm@6.1.0-beta.287.patch", + "lint-staged@16.4.0": "config/patches/lint-staged@16.4.0.patch" } }, "reactDoctor": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac78a681963..1c0d6b0d31b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ patchedDependencies: '@xterm/xterm@6.1.0-beta.287': hash: 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7 path: config/patches/@xterm__xterm@6.1.0-beta.287.patch + lint-staged@16.4.0: + hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 + path: config/patches/lint-staged@16.4.0.patch node-pty@1.1.0: hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8 path: config/patches/node-pty@1.1.0.patch @@ -274,7 +277,7 @@ importers: version: 0.16.45 lint-staged: specifier: ^16.4.0 - version: 16.4.0 + version: 16.4.0(patch_hash=7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673) lowlight: specifier: ^3.3.0 version: 3.3.0 @@ -11374,7 +11377,7 @@ snapshots: linkifyjs@4.3.2: {} - lint-staged@16.4.0: + lint-staged@16.4.0(patch_hash=7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673): dependencies: commander: 14.0.3 listr2: 9.0.5 diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index c8fe65d53bb..a33adcd7d76 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -200,6 +200,38 @@ describeBinaryCompatibility('real Git binary compatibility', () => { }) }) + it('supports isolated worktree backup refs', async () => { + const worktree = 'compat-lint-staged' + const backupRef = 'refs/worktree/lint-staged-backups/compat' + await runGit(['worktree', 'add', '-b', 'compat-lint-staged', worktree]) + await writeFile(join(repoPath, worktree, 'tracked.txt'), 'staged\n') + await runGit(['-C', worktree, 'add', 'tracked.txt']) + await writeFile(join(repoPath, worktree, 'tracked.txt'), 'staged\nunstaged\n') + + const backupOid = (await runGit(['-C', worktree, 'stash', 'create'])).stdout.trim() + await runGit([ + '-C', + worktree, + 'update-ref', + backupRef, + backupOid, + '0000000000000000000000000000000000000000' + ]) + await expect( + runGit(['-C', worktree, 'rev-parse', '--verify', backupRef]) + ).resolves.toMatchObject({ stdout: `${backupOid}\n` }) + await expect(runGit(['rev-parse', '--verify', backupRef])).rejects.toBeDefined() + + await runGit(['-C', worktree, 'reset', '--hard', 'HEAD']) + await expect( + runGit(['-C', worktree, 'stash', 'apply', '--quiet', '--index', backupRef]) + ).resolves.toBeDefined() + await expect(runGit(['-C', worktree, 'status', '--short'])).resolves.toMatchObject({ + stdout: 'MM tracked.txt\n' + }) + await runGit(['-C', worktree, 'update-ref', '-d', backupRef, backupOid]) + }) + it('degrades indexed credential config safely at the Git 2.31 boundary', async () => { const guardEnv = gitCredentialPromptGuardEnv({}, 'linux') await expect(runGit(['status', '--short'], guardEnv)).resolves.toBeDefined() From c72a4eecddeb1c0b575bef8643e8162255dbd4c2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:37:42 -0700 Subject: [PATCH 004/103] refactor(shell): collapse the zsh wrapper to one .zshenv and a precmd hook (STA-4786) (#15391) * refactor(shell): collapse the zsh wrapper to one .zshenv and a precmd hook (STA-4786) Orca needs to run code after the user's own zsh startup files. It bought that by keeping ZDOTDIR pointed at its own wrapper dir for the whole of startup and sourcing each user file by hand -- four generated files per transport, with a fake ZDOTDIR live while /etc/zshrc ran. That single decision is the root of a whole bug family: - /etc/zshrc assigns HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history unconditionally, so history landed inside Orca's own dir (#11044), and an epilogue had to repair it. - zsh's sourcehome() ignores ZDOTDIR once the shell is in sh/ksh emulation, so a user .zshenv or .zprofile ending in `emulate sh` hid every later wrapper file. The emulation degrade blocks and their forked $(emulate) probes exist for that. - One wrapper dir shared by two installed builds could mix files from both, so every generated file had to redefine the helpers it called. - The baked generation-time ZDOTDIR literal is unusable when a Windows-generated wrapper is sourced inside WSL via /mnt/c (#8003), so the runtime path had to be re-derived from %x. The wrapper now hands ZDOTDIR back on its first lines and defers Orca's work to a precmd hook that runs at the first prompt -- after .zprofile, /etc/zshrc, .zshrc and .zlogin, every one of which zsh reads from the user's own directory exactly as in an unwrapped shell. Each bug above stops being reachable rather than being repaired, and their machinery goes with them: eight of thirteen exported blocks in shell-templates.ts, both drifted discovery bodies (unifying them closes the "reconciling the two is a follow-up" note the file carried), and the relay's separate ORCA_USER_ZDOTDIR shape. Generated zsh drops from 819 lines across twelve files to 143 in three. Two things the design has to get right, both found by running it rather than reasoning about it: - Every function is defined ABOVE the source of the user's .zshenv. A user file ending in `emulate sh` puts the rest of the wrapper under sh parsing rules, and the first prototype died there with `parse error near '\n'` -- silently, leaving the pane unwrapped. Function bodies are parsed at definition time. - ORCA_ORIG_ZDOTDIR is vetted, not trusted. The launch config only sets it when it resolved a usable dir, but a pane inherits its parent's environment too, so a stale value from an older build can arrive on its own and would point ZDOTDIR back at a wrapper dir. The ownership check Node applies now also runs in the shell, where that route is visible. Orca also stops inventing a ZDOTDIR: where the user has none, ORCA_ORIG_ZDOTDIR is absent and the pane ends with ZDOTDIR unset, as an unwrapped login zsh does. Verified on real zsh over a real PTY -- necessary, because a precmd hook never runs in a shell started with -c, so the existing `zsh -i -c` probes could not have exercised this design at all. src/main/zsh-startup-hook-pty-harness.ts drives the shell to a prompt and reports through a file rather than stdout, which a PTY echoes. * test(shell): cover the relay variant of the zsh hook in a real shell The relay writes its own variant -- no OSC 133, remote CLI bin dir instead of the agent-teams shim -- and it had no live coverage. It used to carry a second ZDOTDIR shape as well, which is how it drifted from the desktop template in the first place; now the spec flags are the only difference, and this pins that. * fix(shell): rebase the single-file hook onto content-addressed wrapper trees #15285 landed content-addressed wrapper roots and a per-transport fileset module while this branch was in flight. The fileset modules are now the single place the tree is described, so 'only .zshenv' is stated once per transport and the required-paths check follows from it rather than repeating the list. * test(shell): point the mixed-build proof at the relay, the one fixed wrapper path #15285 content-addressed the desktop and daemon trees, so two builds can no longer write the same directory there and the scenario this file covers became unreachable on those paths. The relay still writes a fixed ~/.orca-relay/ shell-ready, so that is where the hazard survives and where the proof belongs. * fix(test): make the zsh PTY harness survive a startup that stops to ask Two CI-only failures, both from driving a real PTY where the old probes drove a pipe: - A host whose global zshrc runs `compinit` over directories it considers insecure stops startup and ASKS. A pipe-backed `zsh -i -c` never saw the question; a PTY sits at it until the timeout. The harness now answers it. ZSH_DISABLE_COMPFIX does not help -- that is an oh-my-zsh convention and plain compinit ignores it, which I confirmed by reproducing the prompt locally. - The PS1 line was typed at t=0, so on such a host the question consumed it as its answer. The harness now waits for the shell to fall quiet first, which also stops a slow prompt framework racing the same write. Also merges a duplicate vitest import the native code-quality audit flagged. * fix(test): stop the live-shell assertions assuming macOS host behaviour Two of them hardcoded what my machine does rather than what Orca owes: - LINEINIT was pinned to 'none'. A host whose global zsh config installs its own zle-line-init widget has one either way; the contract is that it looks the same wrapped as unwrapped, which the assertion beside it already states. - The dropped-precmd_functions case asserted HISTFILE was no longer the scoped path. Whether the scoped value survives at all is the host's call: macOS /etc/zshrc overwrites HISTFILE so it does not, and a host with no such assignment keeps whatever the spawn env set. Now compared against an unwrapped pane given the same env, which is the real contract on both. Also notes, where the emulation cases live, that they only discriminate on a host whose system zshrc clobbers HISTFILE -- on CI's Ubuntu the load-bearing assertion is ORCA_HISTFILE having been consumed. * test(shell): re-pin the fixes the four-file wrapper was built for Archaeology over the removed blocks: each existed for a bug, so each needs the bug shown to be unreachable rather than just the code gone. Six restored or added, each naming the change that introduced the behaviour. - #8003, twice: the wrapper sourced from a relocated root, and from a non-ASCII (token-range) one. The old file baked its generation-time path in and had to re-derive the runtime one from %x to avoid using it; this one bakes nothing. Both runs assert ORCA_SHELL_FEATURES came back consumed, so 'the user's .zshrc loaded' cannot pass on a pane that never read the wrapper at all. - #4667: user startup files must see their OWN ZDOTDIR while they run, or plugin and theme lookups resolve into Orca's dir. The old wrapper swapped ZDOTDIR around each source; this one never takes it away, and the values now have to match an unwrapped pane's. - #1947: a user .zshenv that returns early. - #15258: an inherited ZDOTDIR that is an Orca wrapper dir must be refused. CI proved this route is live -- the launch config only sets ORCA_ORIG_ZDOTDIR when it resolved a usable dir, but a pane inherits its parent's environment too. - #11044/#11146: a nested Orca inherits neither cross-process channel and no ZDOTDIR of Orca's, which is what makes #11044's plain shape unreachable rather than repaired. Verified the child-env probe detects a real leak before trusting it to report the absence of one. --- .github/workflows/pr.yml | 8 +- .../scripts/pr-workflow-parallelism.test.mjs | 13 +- .../daemon-zsh-zlogin.txt | 21 - .../daemon-zsh-zprofile.txt | 32 - .../daemon-zsh-zshenv.txt | 211 ++---- .../daemon-zsh-zshrc.txt | 23 - .../local-zsh-zlogin.txt | 21 - .../local-zsh-zprofile.txt | 32 - .../local-zsh-zshenv.txt | 211 ++---- .../local-zsh-zshrc.txt | 23 - .../relay-zsh-zlogin.txt | 21 - .../relay-zsh-zprofile.txt | 32 - .../relay-zsh-zshenv.txt | 111 ++-- .../relay-zsh-zshrc.txt | 23 - .../daemon-shell-ready-wrapper-fileset.ts | 11 +- .../daemon-zsh-shell-ready-wrapper-spec.ts | 7 +- src/main/daemon/shell-ready.test.ts | 94 +-- src/main/daemon/shell-ready.ts | 8 +- src/main/ipc/pty-wsl-cwd-validation.test.ts | 5 +- .../shell-ready-framework-example.test.ts | 3 +- .../local-pty-shell-ready-wrapper-fileset.ts | 19 +- ...pty-shell-ready-wrapper-generation.test.ts | 141 ++-- ...ocal-pty-shell-ready-wrapper-generation.ts | 9 +- ...shell-ready-zsh-launch-environment.test.ts | 28 +- ...ll-ready-zsh-startup-file-behavior.test.ts | 350 ---------- ...-shell-ready-zsh-zdotdir-discovery.test.ts | 335 ---------- ...ll-ready-zsh-zdotdir-normalization.test.ts | 344 ---------- src/main/providers/local-pty-shell-ready.ts | 8 +- .../shell-startup-feature-channel.test.ts | 139 ++-- src/main/shell-templates.ts | 327 ++------- ...ll-wrapper-generated-file-snapshot.test.ts | 9 +- .../zsh-scoped-histfile.live-shell.test.ts | 627 +++++++++--------- src/main/zsh-startup-hook-pty-harness.ts | 220 ++++++ ...user-config-equivalence.live-shell.test.ts | 492 ++++++++++++++ src/main/zsh-startup-wrapper-builder.ts | 258 +++---- src/main/zsh-wrapper-dir-ownership.ts | 21 +- ...rapper-version-mismatch.live-shell.test.ts | 122 ++-- src/relay/pty-shell-launch.test.ts | 38 +- src/relay/pty-shell-launch.ts | 4 +- src/relay/pty-shell-overlay-wrappers.ts | 32 +- 40 files changed, 1691 insertions(+), 2742 deletions(-) delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zlogin.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zlogin.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt delete mode 100644 src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts delete mode 100644 src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts delete mode 100644 src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts create mode 100644 src/main/zsh-startup-hook-pty-harness.ts create mode 100644 src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d0f6e1fbf61..4717da652fc 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -359,14 +359,12 @@ jobs: src/main/daemon/shell-ready.test.ts \ src/main/daemon/node-pty-fd-leak.test.ts \ src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ - src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \ - src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \ - src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/shell-startup-feature-channel.test.ts \ src/main/terminal-history-fish-session.node-pty.test.ts \ src/main/zsh-scoped-histfile.live-shell.test.ts \ + src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ src/shared/startup-shell-portability.live-shell.test.ts \ @@ -403,14 +401,12 @@ jobs: --exclude=src/main/daemon/shell-ready.test.ts \ --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \ --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ --exclude=src/main/shell-startup-feature-channel.test.ts \ --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ + --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 507b5396650..5b9ab226502 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -11,12 +11,10 @@ const shellContractFiles = [ 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts', 'src/main/daemon/shell-ready.test.ts', 'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts', - 'src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts', - 'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts', - 'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts', 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', 'src/main/shell-startup-feature-channel.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', + 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', 'src/shared/posix-command-path-lookup.test.ts' ] @@ -31,8 +29,12 @@ const testFilePatterns = [ 'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}', 'tests/tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}' ] +// Why the harness import counts: the zsh startup hook runs from a `precmd`, so +// its tests drive a real zsh through a PTY in zsh-startup-hook-pty-harness +// rather than calling spawnSync('zsh') themselves. Without this branch the rule +// silently stops noticing the very tests that need the lane's zsh install. const realZshUsage = - /(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath/ + /(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath|from '[^']*zsh-startup-hook-pty-harness'/ describe('PR workflow parallelism', () => { it('cancels superseded runs for the same pull request', () => { @@ -154,6 +156,9 @@ describe('PR workflow parallelism', () => { it('keeps every real-zsh test in the dedicated shell lane', () => { const discoveredFiles = globSync(testFilePatterns) + // Why this file is excluded: it carries the detector pattern as a literal + // and would otherwise match itself. + .filter((testFile) => testFile !== 'config/scripts/pr-workflow-parallelism.test.mjs') .filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8'))) .sort() diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt deleted file mode 100644 index 2c84163caa7..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt +++ /dev/null @@ -1,21 +0,0 @@ -# Orca daemon zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zlogin" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt deleted file mode 100644 index 7b275404b38..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt +++ /dev/null @@ -1,32 +0,0 @@ -# Orca daemon zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ -f "$_orca_home/.zprofile" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zprofile" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$_orca_home" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index fdd43798f72..1b89c740f2b 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -1,119 +1,65 @@ # Orca daemon zsh shell-ready wrapper -typeset -ga _orca_shell_features -_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) -builtin unset ORCA_SHELL_FEATURES -__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } -__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -# Why stricter for an inherited value: Orca can be launched from a terminal that -# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh -# startup file at all is not the user's config root whoever wrote it. -__orca_resolve_inherited_config_dir() { - __orca_resolve_user_config_dir "${1:-}" - [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 +__orca_usable_zdotdir() { + [[ -n "${1:-}" ]] || return 1 + # Orca's own dir, by marker file or by the shape older builds wrote. + [[ "$1" != */shell-ready/zsh ]] || return 1 + [[ ! -f "$1/.orca-shell-wrapper" ]] || return 1 + # A directory holding no zsh startup file at all is not a config root, + # whoever wrote it — and a stale value pointing at one would stop zsh from + # ever reading the user's real .zshenv. local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 + [[ -r "$1/$_orca_startup_file" ]] && return 0 done - _orca_resolved_config_dir="$HOME" + return 1 } -# Why: capture the runtime wrapper dir before it is unset below. On WSL this -# file is generated with a Windows path but sourced via /mnt/c, so the baked -# literal is unusable there and ZDOTDIR must be restored from this value. -# Derive it from the file being sourced (%x, zsh's internal script name) rather -# than the env-imported $ZDOTDIR: zsh corrupts environment values whose UTF-8 -# bytes fall in its 0x84-0x9D token range (e.g. a non-ASCII Windows username -# such as a Korean login), which would make the self-check below fail and fall -# back to the unusable baked literal, so the user's .zshrc never loads (#8003). -# %x is not subject to that corruption; keep $ZDOTDIR as a fallback for the -# rare shell where %x prompt expansion yields nothing. -_orca_wrapper_zdotdir_self="${${(%):-%x}:h}" -if [[ -z "${_orca_wrapper_zdotdir_self:-}" ]]; then - _orca_wrapper_zdotdir_self="${ZDOTDIR:-}" -fi -while [[ "${_orca_wrapper_zdotdir_self:-}" == */ ]]; do - _orca_wrapper_zdotdir_self="${_orca_wrapper_zdotdir_self%/}" -done -_orca_zshenv_path="" - -# Normalize fallback and source roots before reading user .zshenv so nested -# Orca PTYs never source another Orca wrapper recursively. -__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_user_zdotdir="$_orca_resolved_config_dir" -__orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" -_orca_zshenv_source_dir="$_orca_resolved_config_dir" -unset ORCA_ZSHENV_SOURCE_DIR - -# Why: source at wrapper top level, not in a function/subshell, so .zshenv -# exports, functions, path/fpath typesets, and zsh options keep normal scope. -unset ZDOTDIR -if [[ -n "${_orca_zshenv_source_dir:-}" && -f "${_orca_zshenv_source_dir}/.zshenv" ]]; then - _orca_zshenv_path="${_orca_zshenv_source_dir}/.zshenv" -fi -if [[ -n "${_orca_zshenv_path:-}" ]]; then - source "${_orca_zshenv_path}" -fi - -_orca_discovered_zdotdir="${ZDOTDIR:-}" - -while [[ "${_orca_discovered_zdotdir}" == */ ]]; do - _orca_discovered_zdotdir="${_orca_discovered_zdotdir%/}" -done - -case "${_orca_discovered_zdotdir}" in - *[![:space:]]*) ;; - *) _orca_discovered_zdotdir="" ;; -esac - -if [[ -n "${_orca_discovered_zdotdir}" && ! -d "${_orca_discovered_zdotdir}" ]]; then - [[ "${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '${_orca_discovered_zdotdir}' does not exist, falling back" >&2 - _orca_discovered_zdotdir="" -fi - -# Why only the ownership check here: a ZDOTDIR the user's own .zshenv just -# exported is the user's by construction, whatever it happens to contain. -__orca_resolve_user_config_dir "${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir - -if [[ -n "${_orca_zshenv_path:-}" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi -unset _orca_zshenv_path - -# Why: use :- after user .zshenv — a pathological unset under set -u must not -# abort the wrapper; empty falls through to the baked-literal branch. -if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then - export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}" +if __orca_usable_zdotdir "${ORCA_ORIG_ZDOTDIR:-}"; then + builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" else - export ZDOTDIR='/zsh' + builtin unset ZDOTDIR fi -unset _orca_wrapper_zdotdir_self - -__orca_shell_epilogue() { +builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR +builtin unfunction __orca_usable_zdotdir +builtin typeset -ga _orca_shell_features +_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) +builtin unset ORCA_SHELL_FEATURES +# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config +# that replaces precmd_functions wholesale drops the hook, and an exported value +# nothing will ever consume is then inherited by every child of this pane, +# including a nested Orca (#11146). Captured non-exported, it cannot escape. +builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" +builtin unset ORCA_HISTFILE +__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } +__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +__orca_osc133_precmd() { + local exit_code=$? + if [[ -n "${__orca_in_command:-}" ]]; then + builtin printf "\033]133;D;%s\007" "$exit_code" + builtin unset __orca_in_command + fi + builtin printf "\033]133;A\007" +} +__orca_osc133_preexec() { + builtin printf "\033]133;C\007" + # Why typeset -g: a plain assignment here creates a global inside a function, + # which prints a warning above every command under warn_create_global. + builtin typeset -g __orca_in_command=1 +} +__orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset - # precmd_functions is a fatal error that returns from the whole epilogue - # (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes - # the 1-based feature lookup drop the first selected feature. - emulate -L zsh - (( $+_orca_epilogue_done )) && return 0 - typeset -g _orca_epilogue_done=1 + # precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup + # drop whichever feature is listed first. + builtin emulate -L zsh + (( $+_orca_deferred_init_done )) && return 0 + builtin typeset -g _orca_deferred_init_done=1 + builtin typeset -g precmd_functions + if __orca_has_feature markers; then + precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) + else + precmd_functions=(${precmd_functions:#__orca_deferred_init}) + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -166,35 +112,8 @@ __orca_shell_epilogue() { fi unset __orca_codex_binary fi - if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$ORCA_HISTFILE" - builtin unset ORCA_HISTFILE - elif [[ "${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then - # Why also when Orca injected nothing: /etc/zshrc derived this from Orca's - # wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the - # user's real history would be invisible — the plain #11044 bug, with no - # per-worktree scoping involved. Matching the exact clobbered value means a - # HISTFILE the user set deliberately is never touched. - HISTFILE="${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history" - fi - if __orca_has_feature markers; then - __orca_osc133_precmd() { - local exit_code=$? - if [[ -n "${__orca_in_command:-}" ]]; then - printf "\033]133;D;%s\007" "$exit_code" - unset __orca_in_command - fi - printf "\033]133;A\007" - } - __orca_osc133_preexec() { - printf "\033]133;C\007" - # Why typeset -g: a plain assignment here creates a global inside a function, - # which prints a warning above every command under warn_create_global. - typeset -g __orca_in_command=1 - } - # Why: prepend so Orca captures $? before user prompt hooks can overwrite it. - precmd_functions=(__orca_osc133_precmd ${precmd_functions[@]}) - preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) + if [[ -n "${_orca_histfile:-}" ]]; then + HISTFILE="$_orca_histfile" fi if __orca_has_feature ready; then # Why: capture the prior zle-line-init so the marker chains to it. On a @@ -220,11 +139,17 @@ __orca_shell_epilogue() { } zle -N zle-line-init __orca_prompt_mark fi - __orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" - # Why: after Orca's last wrapper file has loaded, the interactive shell should - # expose the same ZDOTDIR a normal zsh startup would expose. - export ZDOTDIR="$_orca_resolved_config_dir" - unset _orca_home _orca_resolved_config_dir - unset _orca_shell_features - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir + # Why called here: we were appended during this prompt's own precmd sweep, so + # the permanent hook has not run yet and the first prompt would lose its mark. + __orca_has_feature markers && __orca_osc133_precmd + builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_init __orca_has_feature +} +{ + builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" + [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" +} always { + builtin unset _orca_user_zshenv + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt deleted file mode 100644 index 8a4e38c3aa3..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt +++ /dev/null @@ -1,23 +0,0 @@ -# Orca daemon zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ "$_orca_home" != "$ZDOTDIR" && -o interactive && -f "$_orca_home/.zshrc" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zshrc" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then - (( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zlogin.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zlogin.txt deleted file mode 100644 index a7556c7f006..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zlogin.txt +++ /dev/null @@ -1,21 +0,0 @@ -# Orca zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zlogin" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt deleted file mode 100644 index ee1befa1429..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt +++ /dev/null @@ -1,32 +0,0 @@ -# Orca zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ -f "$_orca_home/.zprofile" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zprofile" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$_orca_home" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 33d4d04d2a6..d73f2d4c4c9 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -1,119 +1,65 @@ # Orca zsh shell-ready wrapper -typeset -ga _orca_shell_features -_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) -builtin unset ORCA_SHELL_FEATURES -__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } -__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -# Why stricter for an inherited value: Orca can be launched from a terminal that -# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh -# startup file at all is not the user's config root whoever wrote it. -__orca_resolve_inherited_config_dir() { - __orca_resolve_user_config_dir "${1:-}" - [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 +__orca_usable_zdotdir() { + [[ -n "${1:-}" ]] || return 1 + # Orca's own dir, by marker file or by the shape older builds wrote. + [[ "$1" != */shell-ready/zsh ]] || return 1 + [[ ! -f "$1/.orca-shell-wrapper" ]] || return 1 + # A directory holding no zsh startup file at all is not a config root, + # whoever wrote it — and a stale value pointing at one would stop zsh from + # ever reading the user's real .zshenv. local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 + [[ -r "$1/$_orca_startup_file" ]] && return 0 done - _orca_resolved_config_dir="$HOME" + return 1 } -# Why: capture the runtime wrapper dir before it is unset below. On WSL this -# file is generated with a Windows path but sourced via /mnt/c, so the baked -# literal is unusable there and ZDOTDIR must be restored from this value. -# Derive it from the file being sourced (%x, zsh's internal script name) rather -# than the env-imported $ZDOTDIR: zsh corrupts environment values whose UTF-8 -# bytes fall in its 0x84-0x9D token range (e.g. a non-ASCII Windows username -# such as a Korean login), which would make the self-check below fail and fall -# back to the unusable baked literal, so the user's .zshrc never loads (#8003). -# %x is not subject to that corruption; keep $ZDOTDIR as a fallback for the -# rare shell where %x prompt expansion yields nothing. -_orca_wrapper_zdotdir_self="${${(%):-%x}:h}" -if [[ -z "${_orca_wrapper_zdotdir_self:-}" ]]; then - _orca_wrapper_zdotdir_self="${ZDOTDIR:-}" -fi -while [[ "${_orca_wrapper_zdotdir_self:-}" == */ ]]; do - _orca_wrapper_zdotdir_self="${_orca_wrapper_zdotdir_self%/}" -done -_orca_zshenv_path="" - -# Normalize fallback and source roots before reading user .zshenv so nested -# Orca PTYs never source another Orca wrapper recursively. -__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_user_zdotdir="$_orca_resolved_config_dir" -__orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" -_orca_zshenv_source_dir="$_orca_resolved_config_dir" -unset ORCA_ZSHENV_SOURCE_DIR - -# Why: source at wrapper top level, not in a function/subshell, so .zshenv -# exports, functions, path/fpath typesets, and zsh options keep normal scope. -unset ZDOTDIR -if [[ -n "${_orca_zshenv_source_dir:-}" && -f "${_orca_zshenv_source_dir}/.zshenv" ]]; then - _orca_zshenv_path="${_orca_zshenv_source_dir}/.zshenv" -fi -if [[ -n "${_orca_zshenv_path:-}" ]]; then - source "${_orca_zshenv_path}" -fi - -_orca_discovered_zdotdir="${ZDOTDIR:-}" - -while [[ "${_orca_discovered_zdotdir}" == */ ]]; do - _orca_discovered_zdotdir="${_orca_discovered_zdotdir%/}" -done - -case "${_orca_discovered_zdotdir}" in - *[![:space:]]*) ;; - *) _orca_discovered_zdotdir="" ;; -esac - -if [[ -n "${_orca_discovered_zdotdir}" && ! -d "${_orca_discovered_zdotdir}" ]]; then - [[ "${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '${_orca_discovered_zdotdir}' does not exist, falling back" >&2 - _orca_discovered_zdotdir="" -fi - -# Why only the ownership check here: a ZDOTDIR the user's own .zshenv just -# exported is the user's by construction, whatever it happens to contain. -__orca_resolve_user_config_dir "${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir - -if [[ -n "${_orca_zshenv_path:-}" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi -unset _orca_zshenv_path - -# Why: use :- after user .zshenv — a pathological unset under set -u must not -# abort the wrapper; empty falls through to the baked-literal branch. -if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then - export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}" +if __orca_usable_zdotdir "${ORCA_ORIG_ZDOTDIR:-}"; then + builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" else - export ZDOTDIR='/zsh' + builtin unset ZDOTDIR fi -unset _orca_wrapper_zdotdir_self - -__orca_shell_epilogue() { +builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR +builtin unfunction __orca_usable_zdotdir +builtin typeset -ga _orca_shell_features +_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) +builtin unset ORCA_SHELL_FEATURES +# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config +# that replaces precmd_functions wholesale drops the hook, and an exported value +# nothing will ever consume is then inherited by every child of this pane, +# including a nested Orca (#11146). Captured non-exported, it cannot escape. +builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" +builtin unset ORCA_HISTFILE +__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } +__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +__orca_osc133_precmd() { + local exit_code=$? + if [[ -n "${__orca_in_command:-}" ]]; then + builtin printf "\033]133;D;%s\007" "$exit_code" + builtin unset __orca_in_command + fi + builtin printf "\033]133;A\007" +} +__orca_osc133_preexec() { + builtin printf "\033]133;C\007" + # Why typeset -g: a plain assignment here creates a global inside a function, + # which prints a warning above every command under warn_create_global. + builtin typeset -g __orca_in_command=1 +} +__orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset - # precmd_functions is a fatal error that returns from the whole epilogue - # (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes - # the 1-based feature lookup drop the first selected feature. - emulate -L zsh - (( $+_orca_epilogue_done )) && return 0 - typeset -g _orca_epilogue_done=1 + # precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup + # drop whichever feature is listed first. + builtin emulate -L zsh + (( $+_orca_deferred_init_done )) && return 0 + builtin typeset -g _orca_deferred_init_done=1 + builtin typeset -g precmd_functions + if __orca_has_feature markers; then + precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) + else + precmd_functions=(${precmd_functions:#__orca_deferred_init}) + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -166,35 +112,8 @@ __orca_shell_epilogue() { fi unset __orca_codex_binary fi - if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$ORCA_HISTFILE" - builtin unset ORCA_HISTFILE - elif [[ "${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then - # Why also when Orca injected nothing: /etc/zshrc derived this from Orca's - # wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the - # user's real history would be invisible — the plain #11044 bug, with no - # per-worktree scoping involved. Matching the exact clobbered value means a - # HISTFILE the user set deliberately is never touched. - HISTFILE="${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history" - fi - if __orca_has_feature markers; then - __orca_osc133_precmd() { - local exit_code=$? - if [[ -n "${__orca_in_command:-}" ]]; then - printf "\033]133;D;%s\007" "$exit_code" - unset __orca_in_command - fi - printf "\033]133;A\007" - } - __orca_osc133_preexec() { - printf "\033]133;C\007" - # Why typeset -g: a plain assignment here creates a global inside a function, - # which prints a warning above every command under warn_create_global. - typeset -g __orca_in_command=1 - } - # Why: prepend so Orca captures $? before user prompt hooks can overwrite it. - precmd_functions=(__orca_osc133_precmd ${precmd_functions[@]}) - preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) + if [[ -n "${_orca_histfile:-}" ]]; then + HISTFILE="$_orca_histfile" fi if __orca_has_feature ready; then # Why: capture the prior zle-line-init so the marker chains to it. On a @@ -220,11 +139,17 @@ __orca_shell_epilogue() { } zle -N zle-line-init __orca_prompt_mark fi - __orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" - # Why: after Orca's last wrapper file has loaded, the interactive shell should - # expose the same ZDOTDIR a normal zsh startup would expose. - export ZDOTDIR="$_orca_resolved_config_dir" - unset _orca_home _orca_resolved_config_dir - unset _orca_shell_features - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir + # Why called here: we were appended during this prompt's own precmd sweep, so + # the permanent hook has not run yet and the first prompt would lose its mark. + __orca_has_feature markers && __orca_osc133_precmd + builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_init __orca_has_feature +} +{ + builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" + [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" +} always { + builtin unset _orca_user_zshenv + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt deleted file mode 100644 index 4776da936c3..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt +++ /dev/null @@ -1,23 +0,0 @@ -# Orca zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ "$_orca_home" != "$ZDOTDIR" && -o interactive && -f "$_orca_home/.zshrc" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zshrc" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then - (( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zlogin.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zlogin.txt deleted file mode 100644 index c76f4f6973d..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zlogin.txt +++ /dev/null @@ -1,21 +0,0 @@ -# Orca relay zsh overlay wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -_orca_home="$_orca_resolved_config_dir" -if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zlogin" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt deleted file mode 100644 index 79540246999..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt +++ /dev/null @@ -1,32 +0,0 @@ -# Orca relay zsh overlay wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -_orca_home="$_orca_resolved_config_dir" -if [[ -f "$_orca_home/.zprofile" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zprofile" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$_orca_home" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 750978b1cc9..40e10d17210 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -1,58 +1,46 @@ # Orca relay zsh overlay wrapper -typeset -ga _orca_shell_features -_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) -builtin unset ORCA_SHELL_FEATURES -__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } -__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -# Why stricter for an inherited value: Orca can be launched from a terminal that -# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh -# startup file at all is not the user's config root whoever wrote it. -__orca_resolve_inherited_config_dir() { - __orca_resolve_user_config_dir "${1:-}" - [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 +__orca_usable_zdotdir() { + [[ -n "${1:-}" ]] || return 1 + # Orca's own dir, by marker file or by the shape older builds wrote. + [[ "$1" != */shell-ready/zsh ]] || return 1 + [[ ! -f "$1/.orca-shell-wrapper" ]] || return 1 + # A directory holding no zsh startup file at all is not a config root, + # whoever wrote it — and a stale value pointing at one would stop zsh from + # ever reading the user's real .zshenv. local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 + [[ -r "$1/$_orca_startup_file" ]] && return 0 done - _orca_resolved_config_dir="$HOME" + return 1 } -__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" -__orca_resolve_user_config_dir "${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir" - -if [[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$ORCA_USER_ZDOTDIR" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac +if __orca_usable_zdotdir "${ORCA_ORIG_ZDOTDIR:-}"; then + builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" +else + builtin unset ZDOTDIR fi - -export ZDOTDIR='/zsh' - -__orca_shell_epilogue() { +builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR +builtin unfunction __orca_usable_zdotdir +builtin typeset -ga _orca_shell_features +_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) +builtin unset ORCA_SHELL_FEATURES +# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config +# that replaces precmd_functions wholesale drops the hook, and an exported value +# nothing will ever consume is then inherited by every child of this pane, +# including a nested Orca (#11146). Captured non-exported, it cannot escape. +builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" +builtin unset ORCA_HISTFILE +__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } +__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +__orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset - # precmd_functions is a fatal error that returns from the whole epilogue - # (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes - # the 1-based feature lookup drop the first selected feature. - emulate -L zsh - (( $+_orca_epilogue_done )) && return 0 - typeset -g _orca_epilogue_done=1 + # precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup + # drop whichever feature is listed first. + builtin emulate -L zsh + (( $+_orca_deferred_init_done )) && return 0 + builtin typeset -g _orca_deferred_init_done=1 + builtin typeset -g precmd_functions + precmd_functions=(${precmd_functions:#__orca_deferred_init}) if __orca_has_feature overlay; then # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" @@ -86,16 +74,8 @@ __orca_shell_epilogue() { omp() { __orca_omp "$@"; } fi fi - if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$ORCA_HISTFILE" - builtin unset ORCA_HISTFILE - elif [[ "${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then - # Why also when Orca injected nothing: /etc/zshrc derived this from Orca's - # wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the - # user's real history would be invisible — the plain #11044 bug, with no - # per-worktree scoping involved. Matching the exact clobbered value means a - # HISTFILE the user set deliberately is never touched. - HISTFILE="${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history" + if [[ -n "${_orca_histfile:-}" ]]; then + HISTFILE="$_orca_histfile" fi if __orca_has_feature ready; then # Why: capture the prior zle-line-init so the marker chains to it. On a @@ -121,11 +101,14 @@ __orca_shell_epilogue() { } zle -N zle-line-init __orca_prompt_mark fi - __orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" - # Why: after Orca's last wrapper file has loaded, the interactive shell should - # expose the same ZDOTDIR a normal zsh startup would expose. - export ZDOTDIR="$_orca_resolved_config_dir" - unset _orca_home _orca_resolved_config_dir - unset _orca_shell_features - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir + builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_init __orca_has_feature +} +{ + builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" + [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" +} always { + builtin unset _orca_user_zshenv + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt deleted file mode 100644 index 9c84f4df2ec..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt +++ /dev/null @@ -1,23 +0,0 @@ -# Orca relay zsh overlay wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -_orca_home="$_orca_resolved_config_dir" -if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zshrc" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then - (( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue -fi diff --git a/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts b/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts index 2e6b3e19613..43eda0f148b 100644 --- a/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts +++ b/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts @@ -6,18 +6,17 @@ import { join } from 'node:path' import { ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../shell-templates' import type { ShellWrapperFile } from '../shell-wrapper-file-writer' -import { buildZshStartupWrapperFiles } from '../zsh-startup-wrapper-builder' +import { buildZshStartupHook } from '../zsh-startup-wrapper-builder' import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile' import { getDaemonZshWrapperSpec } from './daemon-zsh-shell-ready-wrapper-spec' +// Why only .zshenv: the hook hands ZDOTDIR back on its first lines, so zsh reads +// .zprofile, .zshrc and .zlogin from the user's own directory. Nothing Orca +// writes is read after this file. export function buildDaemonShellReadyWrapperFiles(root: string): readonly ShellWrapperFile[] { const zshDir = join(root, 'zsh') - const zsh = buildZshStartupWrapperFiles(getDaemonZshWrapperSpec(zshDir)) return [ - [join(zshDir, '.zshenv'), zsh.zshenv], - [join(zshDir, '.zprofile'), zsh.zprofile], - [join(zshDir, '.zshrc'), zsh.zshrc], - [join(zshDir, '.zlogin'), zsh.zlogin], + [join(zshDir, '.zshenv'), buildZshStartupHook(getDaemonZshWrapperSpec())], [join(zshDir, ZSH_WRAPPER_DIR_MARKER_FILE), ZSH_WRAPPER_DIR_MARKER_CONTENT], [join(root, 'bash', 'rcfile'), getDaemonBashShellReadyRcfileContent()] ] diff --git a/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts b/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts index b23201ee04c..3d9d0e7c045 100644 --- a/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts +++ b/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts @@ -1,15 +1,12 @@ -import type { ZshStartupWrapperSpec } from '../zsh-startup-wrapper-builder' +import type { ZshStartupHookSpec } from '../zsh-startup-wrapper-builder' import { SHELL_READY_MARKER } from './daemon-shell-ready-marker' /** The zsh wrapper the daemon (local fork and SSH host) launches shells with. */ -export function getDaemonZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { +export function getDaemonZshWrapperSpec(): ZshStartupHookSpec { return { headerLabel: 'Orca daemon zsh shell-ready wrapper', - zshDir, - zshenvStrategy: 'discover-user-zdotdir', readyMarkerEscaped: SHELL_READY_MARKER, osc133CommandMarkers: true, - skipUserZshrcWhenHomeIsWrapperDir: true, overlayRestoreComment: "# Why: ~/.zshrc can export the user's default OpenCode config after spawn.", restores: { diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index b680cb06954..6772a52d46e 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -132,17 +132,6 @@ async function runInteractiveZshRc(args: { return output } -function expectZdotdirSourceContext(content: string, fileName: '.zprofile' | '.zshrc' | '.zlogin') { - expect(content).toContain('export ZDOTDIR="$_orca_home"') - expect(content).toContain(`source "$_orca_home/${fileName}"`) - expect(content).toContain('export ZDOTDIR="$_orca_wrapper_zdotdir"') -} - -function expectFinalZdotdirRestoreContext(content: string) { - expect(content).toContain("after Orca's last wrapper file has loaded") - expect(content).toContain('export ZDOTDIR="$_orca_resolved_config_dir"') -} - describePosix('daemon shell-ready launch config', () => { // Always runs, so the CI lane cannot report green with every live fish test skipped. it('has the fish the live tests need when CI requires one', () => { @@ -353,7 +342,7 @@ describePosix('daemon shell-ready launch config', () => { 15_000 ) - it('falls back to HOME for ORCA_ORIG_ZDOTDIR when inherited ZDOTDIR points at a wrapper dir', async () => { + it('sets no ORCA_ORIG_ZDOTDIR when the inherited ZDOTDIR points at a wrapper dir', async () => { // Why: an Orca-PTY parent has ZDOTDIR=.../shell-ready/zsh; propagating it makes the wrapper source itself (recursion loop). const previousZdotdir = process.env.ZDOTDIR const previousHome = process.env.HOME @@ -362,8 +351,7 @@ describePosix('daemon shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -390,7 +378,6 @@ describePosix('daemon shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(userDataPath) } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -410,7 +397,7 @@ describePosix('daemon shell-ready launch config', () => { } }) - it('falls back to HOME when inherited ORCA_ORIG_ZDOTDIR points at a wrapper dir', async () => { + it('sets no ORCA_ORIG_ZDOTDIR when the inherited one points at a wrapper dir', async () => { const previousZdotdir = process.env.ZDOTDIR const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR const previousHome = process.env.HOME @@ -420,8 +407,7 @@ describePosix('daemon shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -441,34 +427,23 @@ describePosix('daemon shell-ready launch config', () => { } }) - it('writes zsh wrappers that guard against ORCA_ORIG_ZDOTDIR self-loops', async () => { + it('writes a zsh hook that hands ZDOTDIR back before any user file loads', async () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - const zprofile = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zprofile'), 'utf8') - const zshrc = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshrc'), 'utf8') - const zlogin = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zlogin'), 'utf8') - expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"') + expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"') + expect(zshenv).toContain('builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR') expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - expect(zshenv).toContain('"$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then') - expect(zshenv).toContain('export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir"') - expectZdotdirSourceContext(zprofile, '.zprofile') - expectZdotdirSourceContext(zshrc, '.zshrc') - expectZdotdirSourceContext(zlogin, '.zlogin') - // Why .zshenv: the final restore is the last step of the single epilogue, - // which .zshrc (non-login) and .zlogin (login) each invoke once. - expectFinalZdotdirRestoreContext(zshenv) - // Why the emulation probe: sh/ksh emulation makes zsh read $HOME/.zlogin - // rather than the wrapper's, so the epilogue has to run from here instead. - // Why the option test in front of it: the probe forks, and all-off proves - // zsh emulation without one. - expect(zshrc).toContain( - 'if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && ' + - '[[ "$(emulate 2>/dev/null)" != zsh ]]; }; then' + expect(zshenv.indexOf('builtin export ZDOTDIR=')).toBeLessThan( + zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') ) - expect(zshrc).toContain('(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue') + // Why nothing else: zsh reads .zprofile, .zshrc and .zlogin through ZDOTDIR, + // which is the user's own again by the time it looks for them. + for (const name of ['.zprofile', '.zshrc', '.zlogin']) { + expect(existsSync(join(getShellReadyWrapperRoot(), 'zsh', name))).toBe(false) + } }) it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => { @@ -476,8 +451,8 @@ describePosix('daemon shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - // Why .zshenv: the widget registration lives in the epilogue, which .zlogin - // (login) and .zshrc (non-login) both call exactly once. + // Why .zshenv: the widget registration lives in the deferred hook, which the + // first prompt's precmd sweep calls exactly once. const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') expect(zshenv).toContain('zle -N zle-line-init __orca_prompt_mark') expect(zshenv).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"') @@ -652,7 +627,6 @@ describePosix('daemon shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(userZdotdir) } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -671,7 +645,7 @@ describePosix('daemon shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -695,7 +669,7 @@ describePosix('daemon shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -728,34 +702,20 @@ describePosix('daemon shell-ready launch config', () => { } }) - it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => { - // Why: PR #1737 sourced .zshenv in a wrapper function, breaking "typeset -U path"; keep it at zsh top level. + it('sources the user .zshenv at wrapper top level, not inside a function', async () => { + // Why: PR #1737 sourced .zshenv in a wrapper function, breaking `typeset -U + // path`. Top-level sourcing is still the contract. const { getShellReadyLaunchConfig } = await importFreshShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - expect(zshenv).toContain('unset ZDOTDIR') - expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ZSHENV') - expect(zshenv).toContain('source "${_orca_zshenv_path}"') - expect(zshenv).toContain('_orca_discovered_zdotdir="${ZDOTDIR:-}"') - expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') - expect(zshenv).toContain('export ZDOTDIR=') - }) - - it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => { - // Why: when user .zshenv sets no ZDOTDIR, the wrapper falls back to spawn-env ORCA_ORIG_ZDOTDIR, then HOME. - const { getShellReadyLaunchConfig } = await importFreshShellReady() - - getShellReadyLaunchConfig('/bin/zsh') - - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - - // Save spawn-env value before sourcing user .zshenv - expect(zshenv).toContain('_orca_user_zdotdir="$_orca_resolved_config_dir"') - - // Fallback chain: discovered → normalized spawn-env path → HOME - expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') + expect(zshenv).toContain('builtin source -- "$_orca_user_zshenv"') + // Every function the hook needs is defined above the source, so a user + // `emulate sh` cannot leave the rest of this file unparseable. + expect(zshenv.indexOf('__orca_deferred_init() {')).toBeLessThan( + zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') + ) }) }) diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 277817b9c61..dcc3aed3d75 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -16,10 +16,7 @@ import { import { resolveShellWrapperRoot } from '../shell-wrapper-content-address' import { writeShellWrapperFiles } from '../shell-wrapper-file-writer' import { buildDaemonShellReadyWrapperFiles } from './daemon-shell-ready-wrapper-fileset' -import { - resolveInheritedZdotdir, - resolveInheritedZshenvSourceDir -} from '../zsh-wrapper-dir-ownership' +import { inheritedZdotdirEnv, resolveInheritedZdotdir } from '../zsh-wrapper-dir-ownership' import { SHELL_READY_MARKER } from './daemon-shell-ready-marker' const ORCA_USER_DATA_PATH_ENV = 'ORCA_USER_DATA_PATH' @@ -149,8 +146,7 @@ export function getShellLaunchConfig( return { args: ['-l'], env: { - ORCA_ORIG_ZDOTDIR: resolveInheritedZdotdir(process.env), - ORCA_ZSHENV_SOURCE_DIR: resolveInheritedZshenvSourceDir(process.env), + ...inheritedZdotdirEnv(resolveInheritedZdotdir(process.env)), ZDOTDIR: join(getShellReadyWrapperRoot(), 'zsh'), [SHELL_STARTUP_FEATURE_ENV]: encodeShellStartupFeatures(features) }, diff --git a/src/main/ipc/pty-wsl-cwd-validation.test.ts b/src/main/ipc/pty-wsl-cwd-validation.test.ts index 93cd869bee0..cfdb693d228 100644 --- a/src/main/ipc/pty-wsl-cwd-validation.test.ts +++ b/src/main/ipc/pty-wsl-cwd-validation.test.ts @@ -355,7 +355,10 @@ describe('registerPtyHandlers', () => { expect(shell).toBe('/bin/zsh') expect(args).toEqual(['-l']) expect(options.env.ZDOTDIR).toBe(join(getShellReadyWrapperRoot(), 'zsh')) - expect(options.env.ORCA_ORIG_ZDOTDIR).toBe(process.env.HOME) + // Why absent: this HOME holds no zsh startup file, so there is no user + // config dir to hand back and Orca must not invent one — the wrapper + // leaves ZDOTDIR unset, exactly as an unwrapped login zsh would. + expect(options.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/providers/__tests__/shell-ready-framework-example.test.ts b/src/main/providers/__tests__/shell-ready-framework-example.test.ts index bf2a425ed7c..d130f2cb9de 100644 --- a/src/main/providers/__tests__/shell-ready-framework-example.test.ts +++ b/src/main/providers/__tests__/shell-ready-framework-example.test.ts @@ -85,8 +85,7 @@ EOF ) expect(stdout).toMatchInlineSnapshot(` - "ORCA_ORIG_ZDOTDIR= - ZDOTDIR=/.config/zsh-remote + "ZDOTDIR=/.config/zsh-remote " `) }) diff --git a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts index 1851a42e1a8..afad9569340 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts @@ -5,21 +5,15 @@ */ import { ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../shell-templates' import type { ShellWrapperFile } from '../shell-wrapper-file-writer' -import { - buildZshStartupWrapperFiles, - type ZshStartupWrapperSpec -} from '../zsh-startup-wrapper-builder' +import { buildZshStartupHook, type ZshStartupHookSpec } from '../zsh-startup-wrapper-builder' import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile' import { SHELL_READY_MARKER_ESCAPED } from './local-pty-shell-ready-marker' -export function getLocalZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { +export function getLocalZshWrapperSpec(): ZshStartupHookSpec { return { headerLabel: 'Orca zsh shell-ready wrapper', - zshDir, - zshenvStrategy: 'discover-user-zdotdir', readyMarkerEscaped: SHELL_READY_MARKER_ESCAPED, osc133CommandMarkers: true, - skipUserZshrcWhenHomeIsWrapperDir: true, overlayRestoreComment: "# Why: ~/.zshrc can export the user's default OpenCode config after spawn.", restores: { @@ -36,14 +30,13 @@ export function getLocalZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { // config builds the matching values the same way (local-pty-shell-ready.ts). // path.join would emit backslashes on Windows, where a shell literal reads them // as escapes -- and would desync the written path from the launched one. +// Why only .zshenv: the hook hands ZDOTDIR back on its first lines, so zsh reads +// .zprofile, .zshrc and .zlogin from the user's own directory. Nothing Orca +// writes is read after this file. export function buildLocalShellReadyWrapperFiles(root: string): readonly ShellWrapperFile[] { const zshDir = `${root}/zsh` - const zsh = buildZshStartupWrapperFiles(getLocalZshWrapperSpec(zshDir)) return [ - [`${zshDir}/.zshenv`, zsh.zshenv], - [`${zshDir}/.zprofile`, zsh.zprofile], - [`${zshDir}/.zshrc`, zsh.zshrc], - [`${zshDir}/.zlogin`, zsh.zlogin], + [`${zshDir}/.zshenv`, buildZshStartupHook(getLocalZshWrapperSpec())], [`${zshDir}/${ZSH_WRAPPER_DIR_MARKER_FILE}`, ZSH_WRAPPER_DIR_MARKER_CONTENT], [`${root}/bash/rcfile`, getBashShellReadyRcfileContent()] ] diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts index 96e8c901f29..62fd806a4e4 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts @@ -11,7 +11,7 @@ import { } from './local-pty-shell-ready-test-harness' // Why: rcfile content is pure, so a static import is equivalent to the fresh-module import used for wrapper writing. import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile' -import { getZshShellReadyWrapperFiles } from './local-pty-shell-ready-wrapper-generation' +import { getZshShellReadyWrapperFile } from './local-pty-shell-ready-wrapper-generation' import { makeUserZdotdir } from '../zsh-user-config-dir-fixture' // Why resolved rather than hardcoded: the wrapper tree is content-addressed. import { getShellReadyWrapperRoot } from './local-pty-shell-ready-wrapper-root' @@ -136,17 +136,6 @@ function expectBashOsc133Lifecycle(output: string): void { ]) } -function expectZdotdirSourceContext(content: string, fileName: '.zprofile' | '.zshrc' | '.zlogin') { - expect(content).toContain('export ZDOTDIR="$_orca_home"') - expect(content).toContain(`source "$_orca_home/${fileName}"`) - expect(content).toContain('export ZDOTDIR="$_orca_wrapper_zdotdir"') -} - -function expectFinalZdotdirRestoreContext(content: string) { - expect(content).toContain("after Orca's last wrapper file has loaded") - expect(content).toContain('export ZDOTDIR="$_orca_resolved_config_dir"') -} - describePosix('local PTY shell-ready launch config', () => { let userDataPath: string let previousOrcaOrigZdotdir: string | undefined @@ -204,8 +193,7 @@ describePosix('local PTY shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -232,7 +220,6 @@ describePosix('local PTY shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(userDataPath) } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -262,8 +249,7 @@ describePosix('local PTY shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -283,35 +269,28 @@ describePosix('local PTY shell-ready launch config', () => { } }) - it('writes zsh wrappers that guard against ORCA_ORIG_ZDOTDIR self-loops', async () => { + it('writes a zsh hook that hands ZDOTDIR back before any user file loads', async () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - const zprofile = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zprofile'), 'utf8') - const zshrc = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshrc'), 'utf8') - const zlogin = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zlogin'), 'utf8') - expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"') + expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"') expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - expect(zshenv).toContain('"$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then') - expect(zshenv).toContain('export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir"') - expectZdotdirSourceContext(zprofile, '.zprofile') - expectZdotdirSourceContext(zshrc, '.zshrc') - expectZdotdirSourceContext(zlogin, '.zlogin') - // Why .zshenv: the final restore is the last step of the single epilogue, - // which .zshrc (non-login) and .zlogin (login) each invoke once. - expectFinalZdotdirRestoreContext(zshenv) - // Why the emulation probe: sh/ksh emulation makes zsh read $HOME/.zlogin - // rather than the wrapper's, so the epilogue has to run from here instead. - // Why the option test in front of it: the probe forks, and all-off proves - // zsh emulation without one. - expect(zshrc).toContain( - 'if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && ' + - '[[ "$(emulate 2>/dev/null)" != zsh ]]; }; then' + // The handback is what makes a nested Orca unable to inherit this dir, and + // what stops /etc/zshrc deriving HISTFILE from it. + expect(zshenv).toContain('builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR') + expect(zshenv.indexOf('builtin export ZDOTDIR=')).toBeLessThan( + zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') ) - expect(zshrc).toContain('(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue') - expect(zlogin).toContain('(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue') + // Why nothing else is written: zsh reads .zprofile, .zshrc and .zlogin + // through ZDOTDIR, which is the user's again by the time it looks. + for (const name of ['.zprofile', '.zshrc', '.zlogin']) { + expect(existsSync(join(getShellReadyWrapperRoot(), 'zsh', name))).toBe(false) + } + // No emulation probe survives: nothing after this file is read via ZDOTDIR, + // so sh/ksh emulation entered by a user file can no longer hide anything. + expect(zshenv).not.toContain('$(emulate') }) it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => { @@ -319,8 +298,8 @@ describePosix('local PTY shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - // Why .zshenv: the widget registration lives in the epilogue, which .zlogin - // (login) and .zshrc (non-login) both call exactly once. + // Why .zshenv: the widget registration lives in the deferred hook, which the + // first prompt's precmd sweep calls exactly once. const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') expect(zshenv).toContain('zle -N zle-line-init __orca_prompt_mark') expect(zshenv).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"') @@ -329,9 +308,7 @@ describePosix('local PTY shell-ready launch config', () => { expect(zshenv).not.toContain('add-zle-hook-widget line-init') // Why: re-source guard — skip re-capturing when already the bound widget so the prior chain survives a second source. expect(zshenv).toContain('== "user:__orca_prompt_mark"') - expect(readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zlogin'), 'utf8')).toContain( - '__orca_shell_epilogue' - ) + expect(zshenv).toContain('__orca_deferred_init') }) it('writes wrappers without restoring Pi/OMP homes after user startup files', async () => { @@ -384,8 +361,8 @@ describePosix('local PTY shell-ready launch config', () => { // Why: issue #2422 — without OSC 133 C/D markers, bash sessions kept the worktree spinner "working" ~30min after the agent exited. it('emits OSC 133 C/D markers in the bash wrapper so agent exit cleanup fires', async () => { const bashRc = getBashShellReadyRcfileContent() - // Why .zshenv: the zsh markers live in the epilogue, behind `markers`. - const zshRc = getZshShellReadyWrapperFiles().zshenv + // Why .zshenv: the zsh markers live in the deferred hook, behind `markers`. + const zshRc = getZshShellReadyWrapperFile() // The exact escape sequences terminal-command-lifecycle parses (133;D = finished, 133;C = start). expect(bashRc).toContain('printf "\\033]133;D;%s\\007"') @@ -489,7 +466,6 @@ describePosix('local PTY shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(userZdotdir) } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -507,7 +483,7 @@ describePosix('local PTY shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -530,7 +506,7 @@ describePosix('local PTY shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -562,70 +538,37 @@ describePosix('local PTY shell-ready launch config', () => { } }) - it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => { - // Why: PR #1737 sourced .zshenv in a wrapper function, breaking "typeset -U path"; keep it at top level. + it('sources the user .zshenv at wrapper top level, not inside a function', async () => { + // Why: PR #1737 sourced .zshenv in a wrapper function, breaking `typeset -U + // path`. Top-level sourcing is still the contract; only the surrounding + // machinery went away. const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - expect(zshenv).toContain('unset ZDOTDIR') - expect(zshenv).toContain( - '__orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"' + expect(zshenv).toContain('builtin source -- "$_orca_user_zshenv"') + // Every function the hook needs is defined above the source, so a user + // `emulate sh` cannot leave the rest of this file unparseable. + expect(zshenv.indexOf('__orca_deferred_init() {')).toBeLessThan( + zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') ) - expect(zshenv).toContain('source "${_orca_zshenv_path}"') - expect(zshenv).toContain('_orca_discovered_zdotdir="${ZDOTDIR:-}"') - expect(zshenv).toContain( - '__orca_resolve_user_config_dir "${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}"' - ) - expect(zshenv).toContain('export ZDOTDIR=') }) - it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => { - // Why: if user .zshenv returns early or doesn't set ZDOTDIR, fall back to spawn-env ORCA_ORIG_ZDOTDIR, then HOME. + it('bakes no generation-time path into the zsh hook', async () => { + // Why: issue #8003 — a wrapper generated on Windows is sourced inside WSL + // via /mnt/c, where the generation-time path does not exist. The old file + // baked that path as a ZDOTDIR fallback and re-derived the runtime one from + // `%x` to avoid using it. Nothing re-points ZDOTDIR at the wrapper dir any + // more, so there is no path to bake and the whole class is gone. const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - // Save spawn-env value before sourcing user .zshenv - expect(zshenv.indexOf('_orca_user_zdotdir="$_orca_resolved_config_dir"')).toBeLessThan( - zshenv.indexOf('source "${_orca_zshenv_path}"') - ) - - // Fallback chain: discovered → normalized spawn-env path → HOME - expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') - }) - - it('restores wrapper ZDOTDIR from the runtime sourced path, not the baked literal', async () => { - // Why: issue #8003 — WSL sources Windows-generated wrappers via /mnt/c, so the baked generation-time path is absent. - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - - getShellReadyLaunchConfig('/bin/zsh') - - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - - // Why: derive wrapper dir from %x, not env $ZDOTDIR — zsh corrupts non-ASCII usernames in its 0x84-0x9D token range. - expect(zshenv).toContain('_orca_wrapper_zdotdir_self="${${(%):-%x}:h}"') - // Keep $ZDOTDIR only as a fallback when %x yields nothing; the final restore re-validates with -f, so no stat here. - expect(zshenv).toContain( - 'if [[ -z "${_orca_wrapper_zdotdir_self:-}" ]]; then\n' + - ' _orca_wrapper_zdotdir_self="${ZDOTDIR:-}"\n' + - 'fi' - ) - // Trust the runtime path only when it still holds a wrapper .zshenv; else fall back to the generation-time literal. - expect(zshenv).toContain( - 'if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then\n' + - ' export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}"\n' + - 'else\n' + - ` export ZDOTDIR='${join(getShellReadyWrapperRoot(), 'zsh')}'\n` + - 'fi' - ) - // Capture must happen before the wrapper unsets ZDOTDIR to source user files. - expect(zshenv.indexOf('_orca_wrapper_zdotdir_self="${${(%):-%x}:h}"')).toBeLessThan( - zshenv.indexOf('unset ZDOTDIR') - ) + expect(zshenv).not.toContain(getShellReadyWrapperRoot()) + expect(zshenv).not.toContain('%x') }) }) diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.ts index 8d39bcc028a..eb65e58437c 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.ts @@ -4,10 +4,7 @@ * Why: the wrappers emit an OSC 777 marker after startup files finish, which the * readiness scanner watches for before a startup command is written. */ -import { - buildZshStartupWrapperFiles, - type ZshStartupWrapperFiles -} from '../zsh-startup-wrapper-builder' +import { buildZshStartupHook } from '../zsh-startup-wrapper-builder' import { writeShellWrapperFiles } from '../shell-wrapper-file-writer' import { buildLocalShellReadyWrapperFiles, @@ -18,8 +15,8 @@ import { shellReadyWrappersExist } from './local-pty-shell-ready-wrapper-root' -export function getZshShellReadyWrapperFiles(): ZshStartupWrapperFiles { - return buildZshStartupWrapperFiles(getLocalZshWrapperSpec(`${getShellReadyWrapperRoot()}/zsh`)) +export function getZshShellReadyWrapperFile(): string { + return buildZshStartupHook(getLocalZshWrapperSpec()) } /** True when every wrapper file is present and non-empty afterwards. */ diff --git a/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts b/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts index 3d435b6731f..4c0237c19c5 100644 --- a/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts +++ b/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts @@ -48,13 +48,13 @@ describePosix('live zsh subprocess tests', () => { delete cleanEnv.ORCA_ORIG_ZDOTDIR cleanEnv.ZDOTDIR = config.env.ZDOTDIR - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + const result = spawnSync('zsh', ['-c', 'echo "ZDOTDIR=${ZDOTDIR}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' }) expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + expect(result.stdout).toContain(`ZDOTDIR=${xdgZshDir}`) }) it('discovers ZDOTDIR when launched from SSH session', async () => { @@ -76,13 +76,13 @@ describePosix('live zsh subprocess tests', () => { delete cleanEnv.ORCA_ORIG_ZDOTDIR cleanEnv.ZDOTDIR = config.env.ZDOTDIR - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + const result = spawnSync('zsh', ['-c', 'echo "ZDOTDIR=${ZDOTDIR}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' }) expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + expect(result.stdout).toContain(`ZDOTDIR=${xdgZshDir}`) }) it('handles sudo -E where HOME and ZDOTDIR mismatch', async () => { @@ -137,14 +137,14 @@ describePosix('live zsh subprocess tests', () => { delete cleanEnv.ZDOTDIR cleanEnv.ZDOTDIR = config.env.ZDOTDIR - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + const result = spawnSync('zsh', ['-c', 'echo "ZDOTDIR=${ZDOTDIR}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' }) expect(result.status).toBe(0) // Should discover fresh value from .zshenv, not use stale wrapper path - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${currentZdotdir}`) + expect(result.stdout).toContain(`ZDOTDIR=${currentZdotdir}`) } finally { if (previousOrcaZdotdir === undefined) { delete process.env.ORCA_ORIG_ZDOTDIR @@ -175,14 +175,14 @@ describePosix('live zsh subprocess tests', () => { delete cleanEnv.ZDOTDIR cleanEnv.ZDOTDIR = config.env.ZDOTDIR - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + const result = spawnSync('zsh', ['-c', 'echo "ZDOTDIR=${ZDOTDIR}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' }) expect(result.status).toBe(0) // Should use fresh discovery (user updated .zshenv) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${freshZdotdir}`) + expect(result.stdout).toContain(`ZDOTDIR=${freshZdotdir}`) } finally { if (previousOrcaZdotdir === undefined) { delete process.env.ORCA_ORIG_ZDOTDIR @@ -214,7 +214,10 @@ describePosix('live zsh subprocess tests', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(inheritedZdotdir) + // One channel now does what ORCA_ORIG_ZDOTDIR and ORCA_ZSHENV_SOURCE_DIR + // split between them: the wrapper hands this back as ZDOTDIR, and zsh + // reads .zshenv through it. + expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(inheritedZdotdir) const cleanEnv: Record = { ...process.env, @@ -224,10 +227,7 @@ describePosix('live zsh subprocess tests', () => { const result = spawnSync( 'zsh', - [ - '-c', - 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}" && echo "SOURCE_MARKER=${SOURCE_MARKER:-unset}"' - ], + ['-c', 'echo "ZDOTDIR=${ZDOTDIR}" && echo "SOURCE_MARKER=${SOURCE_MARKER:-unset}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' @@ -235,7 +235,7 @@ describePosix('live zsh subprocess tests', () => { ) expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${inheritedZdotdir}`) + expect(result.stdout).toContain(`ZDOTDIR=${inheritedZdotdir}`) expect(result.stdout).toContain('SOURCE_MARKER=inherited') } finally { if (previousZdotdir === undefined) { diff --git a/src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts b/src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts deleted file mode 100644 index fde7b49bf46..00000000000 --- a/src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { afterEach, beforeEach, expect, it } from 'vitest' -import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' -import { - describeIfZsh, - describePosix, - importFreshLocalPtyShellReady, - restoreUserDataPathAfterEach, - setTestUserDataPath -} from './local-pty-shell-ready-test-harness' - -restoreUserDataPathAfterEach() - -// End-to-end validation that wrapper ZDOTDIR discovery preserves top-level zsh semantics (spawns real zsh; gated on availability). -describePosix('live zsh subprocess tests', () => { - describeIfZsh('automation and edge cases', () => { - let testHome: string - let userDataPath: string - - beforeEach(async () => { - testHome = mkdtempSync(join(tmpdir(), 'orca-auto-')) - userDataPath = mkdtempSync(join(tmpdir(), 'orca-auto-userdata-')) - setTestUserDataPath(userDataPath) - }) - - afterEach(() => { - rmSync(testHome, { recursive: true, force: true }) - rmSync(userDataPath, { recursive: true, force: true }) - }) - - it('matches normal zsh when user .zshenv calls exit', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="$HOME/.config/zsh"\nexit 42\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "survived"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(42) - expect(result.stdout).not.toContain('survived') - }) - - it('survives user .zshenv with set -e and failing command', async () => { - writeFileSync( - join(testHome, '.zshenv'), - 'set -e\nfalse\nexport ZDOTDIR="$HOME/.config/zsh"\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // No ZDOTDIR was reached after the failing command, so we fall back. - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('survives user .zshenv with set -u before ZDOTDIR is set', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), 'set -u\nexport ZDOTDIR="$HOME/.config/zsh"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Should work because wrapper uses ${ZDOTDIR:-} which is safe with set -u - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('survives user .zshenv with nullglob set', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - 'setopt nullglob\nexport ZDOTDIR="$HOME/.config/zsh"\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('survives user .zshenv with extendedglob set', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - 'setopt extendedglob\nexport ZDOTDIR="$HOME/.config/zsh"\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('preserves exported .zshenv environment changes in the wrapper shell', async () => { - writeFileSync( - join(testHome, '.zshenv'), - 'export MY_VAR=from-zshenv\nexport ZDOTDIR="$HOME/.config/zsh"\n' - ) - - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.MY_VAR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "MY_VAR=${MY_VAR:-unset}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain('MY_VAR=from-zshenv') - }) - - it('handles empty HOME gracefully', async () => { - // When HOME is empty, wrapper should not attempt to source /.zshenv - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { HOME: '' } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Empty HOME falls back to empty ORCA_ORIG_ZDOTDIR - expect(result.stdout).toContain('ORCA_ORIG_ZDOTDIR=\n') - }) - - it('handles unset HOME gracefully', async () => { - // Why: zsh initializes HOME from /etc/passwd when unset at spawn, so the wrapper can still discover ZDOTDIR. - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = {} - delete cleanEnv.HOME - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // zsh initializes HOME from passwd, wrapper discovers ZDOTDIR normally - expect(result.stdout).toMatch(/ORCA_ORIG_ZDOTDIR=.+/) - }) - - it('handles ZDOTDIR containing only "/"', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="/"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Single slash normalizes to empty after %/, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles ZDOTDIR containing only slashes "///"', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="///"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Multiple slashes normalize to "/" then to empty after %/, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles user .zshenv that unsets HOME', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), `unset HOME\nexport ZDOTDIR="${xdgZshDir}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Subshell unsets HOME but wrapper HOME is in parent scope - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('handles user .zshenv that sets ZDOTDIR to empty string', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR=""\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Empty string should be normalized away, fall back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles conditional unset of ZDOTDIR', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - `export ZDOTDIR="${xdgZshDir}"\nif [[ "\${TERM}" == "dumb" ]]; then\n unset ZDOTDIR\nfi\n` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Test with TERM=dumb - let cleanEnv: Record = { - ...process.env, - HOME: testHome, - TERM: 'dumb' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - let result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // ZDOTDIR unset conditionally, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - - // Test with TERM=xterm - cleanEnv = { ...process.env, HOME: testHome, TERM: 'xterm-256color' } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // ZDOTDIR not unset, uses discovered value - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - }) -}) diff --git a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts b/src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts deleted file mode 100644 index 40ceb8333d7..00000000000 --- a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { afterEach, beforeEach, expect, it } from 'vitest' -import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' -import { join, dirname } from 'node:path' -import { mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' -import { - describeIfZsh, - describePosix, - importFreshLocalPtyShellReady, - restoreUserDataPathAfterEach, - setTestUserDataPath -} from './local-pty-shell-ready-test-harness' -// Why resolved rather than hardcoded: the wrapper tree is content-addressed. -import { getShellReadyWrapperRoot } from './local-pty-shell-ready-wrapper-root' - -restoreUserDataPathAfterEach() - -// End-to-end validation that wrapper ZDOTDIR discovery preserves top-level zsh semantics (spawns real zsh; gated on availability). -describePosix('live zsh subprocess tests', () => { - describeIfZsh('ZDOTDIR discovery with real zsh', () => { - let testHome: string - let userDataPath: string - - beforeEach(async () => { - testHome = mkdtempSync(join(tmpdir(), 'orca-zsh-test-home-')) - userDataPath = mkdtempSync(join(tmpdir(), 'orca-zsh-test-userdata-')) - setTestUserDataPath(userDataPath) - }) - - afterEach(() => { - rmSync(testHome, { recursive: true, force: true }) - rmSync(userDataPath, { recursive: true, force: true }) - }) - - it('preserves typeset -U path scoping when user .zshrc uses it', async () => { - // Why: PR #1737's function-wrapper made "typeset -U path" function-scoped; user rcfiles must source at top level. - - // Create XDG-style config: .zshenv sets ZDOTDIR, .zshrc modifies PATH - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - `export ZDOTDIR="$HOME/.config/zsh" -` - ) - writeFileSync( - join(xdgZshDir, '.zshrc'), - `typeset -U path -path=(/custom/bin $path) -` - ) - - // Generate the Orca wrapper - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Verify the wrapper discovered XDG ZDOTDIR, sourced user .zshrc, and kept typeset -U path (proves top-level scoping). - const cleanEnv: Record = { - ...process.env, - HOME: testHome, - PATH: '/usr/bin:/bin' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir - - const result = spawnSync( - 'zsh', - [ - '-i', - '-c', - 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}" && echo "PATH_HAS_CUSTOM=${PATH%%:*}"' - ], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status).toBe(0) - const output = result.stdout - expect(output).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - expect(output).toContain('PATH_HAS_CUSTOM=/custom/bin') - }) - - it('loads user .zshrc when wrappers are sourced from a different runtime path (WSL simulation)', async () => { - // Why: issue #8003 — WSL sources Windows-generated wrappers via /mnt/c where the baked path is absent; renaming userData reproduces that split. - writeFileSync(join(testHome, '.zshrc'), 'export USER_ZSHRC_LOADED=yes\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - getShellReadyLaunchConfig('/bin/zsh') - - const movedUserData = `${userDataPath}-wsl-view` - renameSync(userDataPath, movedUserData) - try { - const cleanEnv: Record = { - ...process.env, - HOME: testHome, - PATH: '/usr/bin:/bin' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.USER_ZSHRC_LOADED - // Why the prefix swap: the tree is content-addressed under the user data - // dir, so the relocated ZDOTDIR has to follow the resolved root. - cleanEnv.ZDOTDIR = join( - getShellReadyWrapperRoot().replace(userDataPath, movedUserData), - 'zsh' - ) - - // Cover both the WSL login shell (`exec zsh -l`) and the non-login local-pane flow so both restore paths stay pinned. - for (const args of [['-i'], ['-l', '-i']] as const) { - const result = spawnSync( - 'zsh', - [ - ...args, - '-c', - 'echo "USER_ZSHRC_LOADED=${USER_ZSHRC_LOADED:-no}" && echo "FINAL_ZDOTDIR=${ZDOTDIR:-unset}" && echo "IS_LOGIN=$([[ -o login ]] && echo yes || echo no)"' - ], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status, `zsh ${args.join(' ')} failed: ${result.stderr}`).toBe(0) - expect(result.stdout).toContain('USER_ZSHRC_LOADED=yes') - expect(result.stdout).toContain(`FINAL_ZDOTDIR=${testHome}`) - // Why: `as const` makes .includes('-l') reject the tuple union type; check by position instead. - expect(result.stdout).toContain(args[0] === '-l' ? 'IS_LOGIN=yes' : 'IS_LOGIN=no') - } - } finally { - rmSync(movedUserData, { recursive: true, force: true }) - } - }) - - it('loads user .zshrc when the wrapper dir contains a non-ASCII (token-range) path', async () => { - // Why: issue #8003 — non-ASCII usernames put UTF-8 bytes in zsh's 0x84-0x9D token range, corrupting env-imported $ZDOTDIR; derive from %x instead. - writeFileSync(join(testHome, '.zshrc'), 'export USER_ZSHRC_LOADED=yes\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - getShellReadyLaunchConfig('/bin/zsh') - - // Move wrappers under a non-ASCII root so the baked literal is unusable and runtime $ZDOTDIR corrupts on import. - const nonAsciiUserData = join(dirname(userDataPath), '홍길동-wsl-view') - renameSync(userDataPath, nonAsciiUserData) - try { - const cleanEnv: Record = { - ...process.env, - HOME: testHome, - PATH: '/usr/bin:/bin' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.USER_ZSHRC_LOADED - // Why the prefix swap: the tree is content-addressed under the user data - // dir, so the relocated ZDOTDIR has to follow the resolved root. - cleanEnv.ZDOTDIR = join( - getShellReadyWrapperRoot().replace(userDataPath, nonAsciiUserData), - 'zsh' - ) - - for (const args of [['-i'], ['-l', '-i']] as const) { - const result = spawnSync( - 'zsh', - [...args, '-c', 'echo "USER_ZSHRC_LOADED=${USER_ZSHRC_LOADED:-no}"'], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status, `zsh ${args.join(' ')} failed: ${result.stderr}`).toBe(0) - expect(result.stdout).toContain('USER_ZSHRC_LOADED=yes') - } - } finally { - rmSync(nonAsciiUserData, { recursive: true, force: true }) - } - }) - - it('preserves top-level .zshenv path and function side effects', async () => { - // Why: .zshenv is the normal place for always-on env/path setup; dropping side effects regresses zsh startup. - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - `typeset -U path -path=(/env/bin $path) -export MY_VAR=from-zshenv -orca_zshenv_func() { echo "from-zshenv-function"; } -export ZDOTDIR="$HOME/.config/zsh" -` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { - ...process.env, - HOME: testHome, - PATH: '/usr/bin:/bin' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.MY_VAR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync( - 'zsh', - [ - '-c', - 'echo "PATH_HEAD=${PATH%%:*}" && echo "MY_VAR=${MY_VAR:-unset}" && orca_zshenv_func' - ], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status).toBe(0) - expect(result.stdout).toContain('PATH_HEAD=/env/bin') - expect(result.stdout).toContain('MY_VAR=from-zshenv') - expect(result.stdout).toContain('from-zshenv-function') - }) - - it('sources user startup files with their own ZDOTDIR in scope', async () => { - // Why: plugin managers such as Antidote resolve files from $ZDOTDIR while startup files are sourced. - const xdgZshDir = join(testHome, '.config', 'zsh') - const zdotdirLog = join(testHome, 'zdotdir.log') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="$HOME/.config/zsh"\n') - writeFileSync( - join(xdgZshDir, '.zprofile'), - 'printf "zprofile=%s\\n" "$ZDOTDIR" >> "$HOME/zdotdir.log"\n' - ) - writeFileSync( - join(xdgZshDir, '.zshrc'), - 'printf "zshrc=%s\\n" "$ZDOTDIR" >> "$HOME/zdotdir.log"\n' - ) - writeFileSync( - join(xdgZshDir, '.zlogin'), - 'printf "zlogin=%s\\n" "$ZDOTDIR" >> "$HOME/zdotdir.log"\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync( - 'zsh', - ['-l', '-i', '-c', 'printf "command=%s\\n" "$ZDOTDIR" >> "$HOME/zdotdir.log"'], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8', - timeout: 5000 - } - ) - - expect(result.status).toBe(0) - expect(readFileSync(zdotdirLog, 'utf8')).toBe( - [ - `zprofile=${xdgZshDir}`, - `zshrc=${xdgZshDir}`, - `zlogin=${xdgZshDir}`, - `command=${xdgZshDir}`, - '' - ].join('\n') - ) - }) - - it('survives early return in user .zshenv without crashing', async () => { - // Why: early return is a common non-interactive-skip pattern; top-level sourcing must keep the wrapper running. - writeFileSync( - join(testHome, '.zshenv'), - `[[ -o interactive ]] || return 0 -export ZDOTDIR="$HOME/.config/zsh" -` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Build clean env: use wrapper ZDOTDIR but let wrapper discover ORCA_ORIG_ZDOTDIR at runtime - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir - - const result = spawnSync( - 'zsh', - ['-c', 'echo "survived" && echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status).toBe(0) - expect(result.stdout).toContain('survived') - // ZDOTDIR discovery yields nothing (early return before export), fallback to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('falls back to HOME when user .zshenv does not set ZDOTDIR', async () => { - // Why: vanilla zsh users don't set ZDOTDIR, so the fallback chain must land on HOME. - writeFileSync( - join(testHome, '.zshenv'), - `# Vanilla zsh config, no ZDOTDIR -export MY_VAR=foo -` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Build clean env: use wrapper ZDOTDIR but let wrapper discover ORCA_ORIG_ZDOTDIR at runtime - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - }) -}) diff --git a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts b/src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts deleted file mode 100644 index 32b162ae7a6..00000000000 --- a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { afterEach, beforeEach, expect, it } from 'vitest' -import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' -import { join, dirname } from 'node:path' -import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' -// Why resolved rather than hardcoded: the wrapper tree is content-addressed. -import { getShellReadyWrapperRoot } from './local-pty-shell-ready-wrapper-root' -import { - describeIfZsh, - describePosix, - importFreshLocalPtyShellReady, - restoreUserDataPathAfterEach, - setTestUserDataPath -} from './local-pty-shell-ready-test-harness' - -restoreUserDataPathAfterEach() - -// End-to-end validation that wrapper ZDOTDIR discovery preserves top-level zsh semantics (spawns real zsh; gated on availability). -describePosix('live zsh subprocess tests', () => { - describeIfZsh('high-priority edge cases', () => { - let testHome: string - let userDataPath: string - - beforeEach(async () => { - testHome = mkdtempSync(join(tmpdir(), 'orca-zsh-edge-')) - userDataPath = mkdtempSync(join(tmpdir(), 'orca-zsh-userdata-')) - setTestUserDataPath(userDataPath) - }) - - afterEach(() => { - rmSync(testHome, { recursive: true, force: true }) - rmSync(userDataPath, { recursive: true, force: true }) - }) - - it('discovers ZDOTDIR when .zshenv sources another file that sets it', async () => { - // Multi-file sourcing pattern - const commonSh = join(testHome, '.config', 'shell', 'common.sh') - mkdirSync(dirname(commonSh), { recursive: true }) - writeFileSync(commonSh, 'export ZDOTDIR="$HOME/.config/zsh"\n') - writeFileSync(join(testHome, '.zshenv'), 'source ~/.config/shell/common.sh\n') - - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('preserves ZDOTDIR with spaces in path', async () => { - const spacePath = join(testHome, 'My Config', 'zsh') - mkdirSync(spacePath, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${spacePath}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${spacePath}`) - }) - - it('falls back when .zshenv has syntax error', async () => { - writeFileSync(join(testHome, '.zshenv'), 'syntax error {{{\nexport ZDOTDIR=broken\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Syntax error causes discovery to fail, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles framework pattern with ${ZDOTDIR:-$HOME}', async () => { - writeFileSync( - join(testHome, '.zshenv'), - 'export ZDOTDIR="${ZDOTDIR:-$HOME}"\n# prezto-style pattern\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Framework pattern defaults to HOME when ZDOTDIR unset - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('captures last ZDOTDIR value when set multiple times', async () => { - const firstPath = join(testHome, '.config', 'zsh') - const lastPath = join(testHome, '.local', 'zsh') - mkdirSync(firstPath, { recursive: true }) - mkdirSync(lastPath, { recursive: true }) - - writeFileSync( - join(testHome, '.zshenv'), - `export ZDOTDIR="${firstPath}"\nexport ZDOTDIR="${lastPath}"\n` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${lastPath}`) - }) - - it('handles conditional ZDOTDIR based on environment', async () => { - const localPath = join(testHome, '.config', 'zsh') - const remotePath = join(testHome, '.config', 'zsh-remote') - mkdirSync(localPath, { recursive: true }) - mkdirSync(remotePath, { recursive: true }) - - writeFileSync( - join(testHome, '.zshenv'), - `if [[ -n "$SSH_CONNECTION" ]]; then\n export ZDOTDIR="${remotePath}"\nelse\n export ZDOTDIR="${localPath}"\nfi\n` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Test without SSH_CONNECTION - let cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.SSH_CONNECTION - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - let result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${localPath}`) - - // Test with SSH_CONNECTION - cleanEnv = { ...process.env, HOME: testHome, SSH_CONNECTION: '10.0.0.1 12345 10.0.0.2 22' } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${remotePath}`) - }) - - it('preserves explicit ZDOTDIR="$HOME" from user .zshenv', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="$HOME"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('falls back when discovered ZDOTDIR does not exist', async () => { - const nonexistent = join(testHome, '.config', 'zsh-missing') - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${nonexistent}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Validation rejects non-existent path, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('does not source /.zshenv when HOME is empty', async () => { - // Can't create /.zshenv in the test, so verify the wrapper logic guards against it. - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - getShellReadyLaunchConfig('/bin/zsh') - - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - - // Verify wrapper checks the resolved source root is non-empty before sourcing - expect(zshenv).toContain('if [[ -n "${_orca_zshenv_source_dir:-}"') - }) - - it('handles ZDOTDIR with single quote in path', async () => { - const quotePath = join(testHome, "config'zsh") - mkdirSync(quotePath, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${quotePath}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${quotePath}`) - }) - - it('does not evaluate command substitution in ZDOTDIR', async () => { - const safePath = join(testHome, '.config', 'zsh') - mkdirSync(safePath, { recursive: true }) - // Attempt command substitution - should be treated as literal path component - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${safePath}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Should contain the safe path, not any command-substituted value - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${safePath}`) - }) - - it('handles whitespace-only ZDOTDIR (tabs and newlines)', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="\t\t\n\n"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Whitespace-only should be normalized to empty, fall back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles ZDOTDIR with multiple trailing slashes', async () => { - const cleanPath = join(testHome, '.config', 'zsh') - mkdirSync(cleanPath, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${cleanPath}///"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Should normalize to path without trailing slashes - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${cleanPath}`) - }) - }) -}) diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 3deff780625..b6ae89b1a62 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -17,10 +17,7 @@ import { SHELL_STARTUP_FEATURE_ENV, type ShellStartupFeature } from '../shell-startup-features' -import { - resolveInheritedZdotdir, - resolveInheritedZshenvSourceDir -} from '../zsh-wrapper-dir-ownership' +import { inheritedZdotdirEnv, resolveInheritedZdotdir } from '../zsh-wrapper-dir-ownership' import { ensureShellReadyWrappers } from './local-pty-shell-ready-wrapper-generation' import { getShellReadyWrapperRoot, @@ -82,8 +79,7 @@ export function getShellLaunchConfig( return { args: ['-l'], env: { - ORCA_ORIG_ZDOTDIR: resolveInheritedZdotdir(process.env), - ORCA_ZSHENV_SOURCE_DIR: resolveInheritedZshenvSourceDir(process.env), + ...inheritedZdotdirEnv(resolveInheritedZdotdir(process.env)), ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh`, [SHELL_STARTUP_FEATURE_ENV]: encodeShellStartupFeatures(features) }, diff --git a/src/main/shell-startup-feature-channel.test.ts b/src/main/shell-startup-feature-channel.test.ts index 75aed3d2f47..97fee6a6fde 100644 --- a/src/main/shell-startup-feature-channel.test.ts +++ b/src/main/shell-startup-feature-channel.test.ts @@ -16,6 +16,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { selectShellStartupFeatures } from './shell-startup-features' +import { runZshPty } from './zsh-startup-hook-pty-harness' import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' import { importFreshLocalPtyShellReady, @@ -160,15 +161,19 @@ describePosix('zsh launch config', () => { try { const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() - // Empty dir: not a config root whoever wrote it. - expect(getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBe(home) + // Empty dir: not a config root whoever wrote it. Absent rather than $HOME + // because the wrapper hands this value straight back to the shell, and a + // user with no ZDOTDIR must end up with none. + expect(getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBeUndefined() // Stamped as Orca-owned: rejected by positive identification, even though // the path shape is not one of Orca's. writeFileSync(join(foreignWrapper, '.zshrc'), '') writeFileSync(join(foreignWrapper, ZSH_WRAPPER_DIR_MARKER_FILE), '') const stamped = await importFreshLocalPtyShellReady() - expect(stamped.getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBe(home) + expect( + stamped.getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR + ).toBeUndefined() // A real user config dir still round-trips. rmSync(join(foreignWrapper, ZSH_WRAPPER_DIR_MARKER_FILE)) @@ -238,35 +243,26 @@ describePosix('epilogue under hostile user shell options', () => { const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() const launch = getShellLaunchConfig(ZSH_PATH, features) - const output = execFileSync( - ZSH_PATH, - [ - ...(launch.args ?? ['-l']), - '-i', - '-c', - 'print -r -- "LINEINIT=${widgets[zle-line-init]:-none}"; ' + - 'print -r -- "PRECMD=${precmd_functions[*]:-none}"; ' + - 'print -r -- "OPENCODE=${OPENCODE_CONFIG_DIR:-none}"; ' + - 'print -r -- "ZDOTDIR=${ZDOTDIR:-}"; print -r -- "HISTFILE=${HISTFILE:-}"' - ], - { - encoding: 'utf8', - timeout: 20_000, - env: { - PATH: '/usr/bin:/bin', - ...spawnEnv, - ...launch.env, - ORCA_ORIG_ZDOTDIR: home, - ORCA_ZSHENV_SOURCE_DIR: home - } - } - ) + // Why a PTY: every feature is delivered from a precmd hook, and a shell + // started with -c never reaches a prompt to run one. + const { values } = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + ...spawnEnv, + ...launch.env, + ORCA_ORIG_ZDOTDIR: home + }, + report: ['LINEINIT', 'PRECMD', 'OPENCODE_CONFIG_DIR', 'ZDOTDIR', 'HISTFILE'], + commands: [ + 'LINEINIT="${widgets[zle-line-init]:-none}"; PRECMD="${precmd_functions[*]:-none}"' + ] + }) - expect(output).toContain('LINEINIT=user:__orca_prompt_mark') - expect(output).toContain('PRECMD=__orca_osc133_precmd') - expect(output).toContain(`OPENCODE=${opencodeDir}`) - expect(output).toContain(`ZDOTDIR=${home}`) - expect(output).toContain(`HISTFILE=${scoped}`) + expect(values.LINEINIT).toBe('user:__orca_prompt_mark') + expect(values.PRECMD).toContain('__orca_osc133_precmd') + expect(values.OPENCODE_CONFIG_DIR).toBe(opencodeDir) + expect(values.ZDOTDIR).toBe(home) + expect(values.HISTFILE).toBe(scoped) } ) }) @@ -295,13 +291,10 @@ describePosix('history-only pane in a real zsh', () => { rmSync(home, { recursive: true, force: true }) }) - const OBSERVABLES = - 'print -r -- "PRECMD=${precmd_functions[*]}"; ' + - 'print -r -- "PREEXEC=${preexec_functions[*]}"; ' + - 'print -r -- "LINEINIT=${widgets[zle-line-init]:-none}"; ' + - 'print -r -- "ZDOTDIR=$ZDOTDIR"; ' + - 'print -r -- "FEATURES=[${ORCA_SHELL_FEATURES:-}]"; ' + - 'print -r -- "ORCA_HISTFILE=[${ORCA_HISTFILE:-}]"' + function withoutInheritedZdotdir(env: Record): Record { + const { ORCA_ORIG_ZDOTDIR: _inherited, ...rest } = env + return rest + } async function launchHistoryOnly(): Promise<{ args: string[] @@ -327,11 +320,11 @@ describePosix('history-only pane in a real zsh', () => { env: { PATH: '/usr/bin:/bin', ...spawnEnv, - ...launch.env, - // Why override: the launch config reads the real process env, and this - // run must resolve the user's config against the sandbox home. - ORCA_ORIG_ZDOTDIR: home, - ORCA_ZSHENV_SOURCE_DIR: home + // Why ORCA_ORIG_ZDOTDIR is stripped: the launch config computes it from + // the real process env, which would leak the developer's own ZDOTDIR + // into the run. This sandbox home has none, so the pane must end up with + // none — which is also what makes it comparable to an unwrapped pane. + ...withoutInheritedZdotdir(launch.env) }, zdotdir: launch.env.ZDOTDIR } @@ -357,34 +350,38 @@ describePosix('history-only pane in a real zsh', () => { expect(output).not.toContain('history') }) - itWithZsh('emits no OSC 133 and matches an unwrapped pane', async () => { - const { args, env } = await launchHistoryOnly() + itWithZsh( + 'emits no OSC 133 and leaves a pane observably identical to an unwrapped one', + async () => { + // Why a PTY: the hook runs from the first prompt's precmd sweep, so a shell + // started with -c would report a pane Orca had not finished setting up. + const { env } = await launchHistoryOnly() + const capture = [ + 'PRECMD="${precmd_functions[*]}"; PREEXEC="${preexec_functions[*]}"', + 'LINEINIT="${widgets[zle-line-init]:-none}"' + ] + const report = ['PRECMD', 'PREEXEC', 'LINEINIT', 'ZDOTDIR', 'ORCA_SHELL_FEATURES'] - const wrapped = execFileSync(ZSH_PATH, [...args, '-i', '-c', OBSERVABLES], { - encoding: 'utf8', - timeout: 20_000, - env - }) - const unwrapped = execFileSync(ZSH_PATH, ['-l', '-i', '-c', OBSERVABLES], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', HOME: home } - }) + const wrapped = await runZshPty({ env, commands: capture, report }) + const unwrapped = await runZshPty({ + env: { PATH: '/usr/bin:/bin', HOME: home }, + commands: capture, + report + }) - expect(wrapped).not.toContain('\x1b]133;') - expect(wrapped).toContain('PRECMD=\n') - expect(wrapped).toContain('LINEINIT=none') - // Startup files run in the same order with the same hooks, so the pane is - // observably what it was before it got wrapped. - const comparable = (output: string): string[] => - output - .split('\n') - .filter((line) => !line.startsWith('ORCA_HISTFILE=') && !line.startsWith('ZDOTDIR=')) - expect(comparable(wrapped)).toEqual(comparable(unwrapped)) - // The one carried-over difference, unchanged from every pane Orca already - // wrapped: ZDOTDIR ends up explicitly set to the user's config dir, which - // is the value zsh itself defaults to when it is unset. - expect(wrapped).toContain(`ZDOTDIR=${home}`) - expect(unwrapped).toContain('ZDOTDIR=\n') - }) + expect(wrapped.output).not.toContain('\x1b]133;') + // The whole point of removing the hook rather than parking a no-op in its + // place: a history-only pane leaves no Orca name in the user's hook arrays. + expect(wrapped.values.PRECMD).toBe(unwrapped.values.PRECMD) + expect(wrapped.values.PREEXEC).toBe(unwrapped.values.PREEXEC) + // Why compared and not pinned to 'none': a host whose global zsh config + // installs its own zle-line-init widget has one either way, and what Orca + // owes is that it looks the same wrapped as unwrapped. + expect(wrapped.values.LINEINIT).toBe(unwrapped.values.LINEINIT) + expect(wrapped.values.PRECMD).not.toContain('orca') + // ZDOTDIR matches too, because the wrapper hands back exactly what it found. + expect(wrapped.values.ZDOTDIR).toBe(unwrapped.values.ZDOTDIR) + expect(wrapped.values.ORCA_SHELL_FEATURES).toBe('UNSET') + } + ) }) diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index 6bcfde6bfb7..8e498b11de1 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -2,10 +2,6 @@ // small drift here breaks different terminal transports in different ways. import { SHELL_STARTUP_FEATURE_ENV } from './shell-startup-features' -function quotePosixSingle(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} - /** Basename of the file every Orca-generated zsh wrapper dir is stamped with. */ export const ZSH_WRAPPER_DIR_MARKER_FILE = '.orca-shell-wrapper' @@ -24,9 +20,15 @@ export const ZSH_WRAPPER_DIR_MARKER_CONTENT = `# Orca-generated zsh startup wrap * .zshenv is sourced means nothing the user's config spawns can see or inherit * Orca's feature selection. */ -export const ZSH_FEATURE_CHANNEL_BLOCK = `typeset -ga _orca_shell_features +export const ZSH_FEATURE_CHANNEL_BLOCK = `builtin typeset -ga _orca_shell_features _orca_shell_features=(\${(s:,:)\${${SHELL_STARTUP_FEATURE_ENV}:-}}) builtin unset ${SHELL_STARTUP_FEATURE_ENV} +# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config +# that replaces precmd_functions wholesale drops the hook, and an exported value +# nothing will ever consume is then inherited by every child of this pane, +# including a nested Orca (#11146). Captured non-exported, it cannot escape. +builtin typeset -g _orca_histfile="\${ORCA_HISTFILE:-}" +builtin unset ORCA_HISTFILE __orca_has_feature() { (( \${_orca_shell_features[(Ie)$1]} )) }` /** The bash rcfile equivalent of ZSH_FEATURE_CHANNEL_BLOCK. */ @@ -39,238 +41,81 @@ __orca_has_feature() { [[ "$_orca_shell_features" == *",$1,"* ]]; }` export const SHELL_STARTUP_IDENTITY_MARKER_BLOCK = `__orca_has_feature identity && printf "\\033]777;orca-shell-start:%s\\007" "$$"` /** - * Resolves the directory Orca should treat as the user's zsh config root. + * The first executable lines of the wrapper: give ZDOTDIR back to the user. * - * Why positive identification: Orca may only reject a config dir it can prove is - * its own. A stamped marker file (or Orca's own wrapper path shape, for - * wrappers written by older builds) is that proof. Guessing at other - * terminals' wrapper dirs by name never can be, so this never tries. + * Why before anything else: every later startup file — the user's .zprofile, + * the system /etc/zshrc, .zshrc, .zlogin — is found through ZDOTDIR. Handing it + * back here means zsh reads all of them from the user's own directory, exactly + * as it would with no wrapper at all. In particular /etc/zshrc's unguarded + * `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` then derives the user's own path + * instead of one inside Orca's wrapper dir, so #11044 cannot happen rather than + * having to be repaired afterwards. * - * Why every generated file redefines it instead of relying on .zshenv: one - * wrapper dir can be rewritten by two concurrently installed builds, so a shell - * can read one build's .zshenv and another's .zshrc. A .zshrc that called a - * function only the newer .zshenv defines printed `command not found` AND left - * the out-parameter empty, which skipped sourcing the user's own startup file. + * ORCA_ORIG_ZDOTDIR is consumed: it has done its job, and leaving it exported + * would hand a stale value to everything this pane launches. * - * Why an Orca-private out-parameter and not `REPLY`: `REPLY` is zsh's shared - * scratch global, so a user config is entitled to constrain it. `typeset -r - * REPLY` made the very first assignment a fatal error and aborted the wrapper - * file; `typeset -i REPLY` silently turned every resolved path into `0`. Both - * left HISTFILE inside Orca's wrapper dir. Harmless while Orca wrapped a few - * percent of panes; not harmless now that it wraps every zsh pane. - * - * Why `typeset -g`: .zshrc/.zlogin call this AFTER the user's own config, so - * creating a plain global inside a function would print a warning per call - * under `setopt warn_create_global`. + * Why the value is vetted rather than trusted: the launch config only sets this + * when it resolved a usable dir, but a pane also inherits its parent's + * environment, so a stale ORCA_ORIG_ZDOTDIR written by an older build can arrive + * on its own. Handing that back would point ZDOTDIR at an Orca wrapper dir — the + * self-loop the Node-side ownership check exists to prevent, arriving by a route + * that check never sees. Identification stays positive, as it is in Node: a + * stamped marker file, or Orca's own path shape for wrappers older builds wrote. */ -export const ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK = `__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="\${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="\${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/${ZSH_WRAPPER_DIR_MARKER_FILE}" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -}` - -/** - * The stricter resolver .zshenv uses for a ZDOTDIR this shell INHERITED. - * - * Why only .zshenv needs it: nothing after .zshenv re-reads an inherited value — - * the later files resolve ORCA_ORIG_ZDOTDIR, which .zshenv already vetted. - */ -export const ZSH_INHERITED_CONFIG_DIR_RESOLVER_BLOCK = `# Why stricter for an inherited value: Orca can be launched from a terminal that -# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh -# startup file at all is not the user's config root whoever wrote it. -__orca_resolve_inherited_config_dir() { - __orca_resolve_user_config_dir "\${1:-}" - [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 +export const ZSH_ZDOTDIR_HANDBACK_BLOCK = `__orca_usable_zdotdir() { + [[ -n "\${1:-}" ]] || return 1 + # Orca's own dir, by marker file or by the shape older builds wrote. + [[ "$1" != */shell-ready/zsh ]] || return 1 + [[ ! -f "$1/${ZSH_WRAPPER_DIR_MARKER_FILE}" ]] || return 1 + # A directory holding no zsh startup file at all is not a config root, + # whoever wrote it — and a stale value pointing at one would stop zsh from + # ever reading the user's real .zshenv. local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 + [[ -r "$1/$_orca_startup_file" ]] && return 0 done - _orca_resolved_config_dir="$HOME" + return 1 +} +if __orca_usable_zdotdir "\${ORCA_ORIG_ZDOTDIR:-}"; then + builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" +else + builtin unset ZDOTDIR +fi +builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR +builtin unfunction __orca_usable_zdotdir` + +/** + * Sources the user's own .zshenv, then arms the deferred hook. + * + * Why `{ } always { }`: the whole compound command is parsed before any of it + * runs, so the registration below is already parsed as zsh even if the user's + * .zshenv switches the shell into sh emulation. Sourcing at wrapper top level + * (not in a function or subshell) keeps the user's exports, functions, fpath + * typesets and options in their normal scope. + * + * Why guarded on absence: the hook is idempotent, but appending it twice would + * still leave a dead name in the user's precmd_functions. + */ +export const ZSH_USER_ZSHENV_SOURCE_BLOCK = `{ + builtin typeset _orca_user_zshenv="\${ZDOTDIR-$HOME}/.zshenv" + [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" +} always { + builtin unset _orca_user_zshenv + builtin typeset -ag precmd_functions + (( \${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) }` // Why: daemon, local, and relay wrappers must preserve one Bash prompt-hook contract. export { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from './bash-prompt-command-composition' -/** - * Fork-free precondition for the `$(emulate)` probe: options that both - * `emulate sh` and `emulate ksh` turn on, so all-off proves zsh emulation. - * - * Why: the probe is a command substitution, which forks a zsh carrying every - * function, alias and completion the user's config has loaded by that point — - * the most expensive line in the wrapper, and one every zsh pane pays now that - * wrapping widened to all of them. Measured on zsh 5.9 / macOS, 150 login - * startups per arm, with a user .zshenv + .zprofile + .zshrc present: 9.97 - * ms/run unwrapped, 14.20 ms/run wrapped, 12.27 ms/run wrapped once these three - * probes are skipped — about half of what wrapping costs. - * - * Why a hint in front of the real probe and not a replacement for it: these - * options say nothing about `emulation`, which is what zsh's `sourcehome()` - * branches on, so a config that sets one by hand must still get the exact - * answer. OR, not AND, so the only way past it is to enter emulation and then - * unset all three; every real `emulate sh`/`emulate ksh` sets them. A false - * positive costs exactly the fork this saves. - * - * Why `2>/dev/null`: `[[ -o ]]` prints `no such option` to stderr and - * returns false rather than aborting, so on a zsh too old for one of these - * names the only symptom would be that text in the user's pane. All three - * predate every zsh Orca supports, so this is belt and braces, not a fallback. - */ -export const ZSH_BOURNE_EMULATION_OPTION_HINT = - '[[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null' - -/** - * Hands the pane back to the user unwrapped when zsh has entered sh/ksh - * emulation, and stops reading the rest of the current wrapper file. - * - * Why: zsh's `sourcehome()` ignores ZDOTDIR entirely once the shell is in sh or - * ksh emulation, so a user .zshenv (or .zprofile) ending in `emulate sh` means - * NO later wrapper file is ever read — the epilogue runs zero times, while the - * user's own $HOME startup files load normally. The pane looks fine and writes - * its history inside Orca's wrapper dir, where the user will never find it. - * - * Nothing can repair that from here (`/etc/zshrc` assigns HISTFILE after - * .zshenv and .zprofile both), so the wrapper does the next best thing: it - * restores the user's own ZDOTDIR and consumes Orca's variables, leaving - * exactly the shell an unwrapped pane would have produced. Degrading to the - * pre-wrapping behaviour is the bar; degrading to something worse is not. - * - * Why a positive `sh|ksh` match rather than `!= zsh`: a zsh too old for the - * query form of `emulate` prints nothing, and must not unwrap every pane. - * - * Why `sourcedUserFileTest` gates the probe rather than it running - * unconditionally: `$(emulate)` forks, and every zsh pane now pays for it. The - * gate loses no coverage — this wrapper file is itself read through ZDOTDIR, so - * anything that had already entered emulation (a system /etc/zshenv or - * /etc/zprofile) would have hidden this very file too. The only thing that can - * have entered emulation by this line is the user file this file just sourced. - * - * Why ZSH_BOURNE_EMULATION_OPTION_HINT gates it further: see that constant. - */ -export function getZshEmulationDegradeBlock(options: { - userZdotdirExpression: string - sourcedUserFileTest: string -}): string { - return `if [[ ${options.sourcedUserFileTest} ]] && ${ZSH_BOURNE_EMULATION_OPTION_HINT}; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR=${options.userZdotdirExpression} - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi` -} - -/** The ZDOTDIR-discovery body of the wrapper .zshenv (no header, no epilogue). */ -export function getZshEnvDiscoveryBody(zshDir: string): string { - return `# Why: capture the runtime wrapper dir before it is unset below. On WSL this -# file is generated with a Windows path but sourced via /mnt/c, so the baked -# literal is unusable there and ZDOTDIR must be restored from this value. -# Derive it from the file being sourced (%x, zsh's internal script name) rather -# than the env-imported $ZDOTDIR: zsh corrupts environment values whose UTF-8 -# bytes fall in its 0x84-0x9D token range (e.g. a non-ASCII Windows username -# such as a Korean login), which would make the self-check below fail and fall -# back to the unusable baked literal, so the user's .zshrc never loads (#8003). -# %x is not subject to that corruption; keep $ZDOTDIR as a fallback for the -# rare shell where %x prompt expansion yields nothing. -_orca_wrapper_zdotdir_self="\${\${(%):-%x}:h}" -if [[ -z "\${_orca_wrapper_zdotdir_self:-}" ]]; then - _orca_wrapper_zdotdir_self="\${ZDOTDIR:-}" -fi -while [[ "\${_orca_wrapper_zdotdir_self:-}" == */ ]]; do - _orca_wrapper_zdotdir_self="\${_orca_wrapper_zdotdir_self%/}" -done -_orca_zshenv_path="" - -# Normalize fallback and source roots before reading user .zshenv so nested -# Orca PTYs never source another Orca wrapper recursively. -__orca_resolve_inherited_config_dir "\${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_user_zdotdir="$_orca_resolved_config_dir" -__orca_resolve_inherited_config_dir "\${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" -_orca_zshenv_source_dir="$_orca_resolved_config_dir" -unset ORCA_ZSHENV_SOURCE_DIR - -# Why: source at wrapper top level, not in a function/subshell, so .zshenv -# exports, functions, path/fpath typesets, and zsh options keep normal scope. -unset ZDOTDIR -if [[ -n "\${_orca_zshenv_source_dir:-}" && -f "\${_orca_zshenv_source_dir}/.zshenv" ]]; then - _orca_zshenv_path="\${_orca_zshenv_source_dir}/.zshenv" -fi -if [[ -n "\${_orca_zshenv_path:-}" ]]; then - source "\${_orca_zshenv_path}" -fi - -_orca_discovered_zdotdir="\${ZDOTDIR:-}" - -while [[ "\${_orca_discovered_zdotdir}" == */ ]]; do - _orca_discovered_zdotdir="\${_orca_discovered_zdotdir%/}" -done - -case "\${_orca_discovered_zdotdir}" in - *[![:space:]]*) ;; - *) _orca_discovered_zdotdir="" ;; -esac - -if [[ -n "\${_orca_discovered_zdotdir}" && ! -d "\${_orca_discovered_zdotdir}" ]]; then - [[ "\${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '\${_orca_discovered_zdotdir}' does not exist, falling back" >&2 - _orca_discovered_zdotdir="" -fi - -# Why only the ownership check here: a ZDOTDIR the user's own .zshenv just -# exported is the user's by construction, whatever it happens to contain. -__orca_resolve_user_config_dir "\${_orca_discovered_zdotdir:-\${_orca_user_zdotdir:-$HOME}}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir - -${getZshEmulationDegradeBlock({ - userZdotdirExpression: '"$ORCA_ORIG_ZDOTDIR"', - sourcedUserFileTest: '-n "${_orca_zshenv_path:-}"' -})} -unset _orca_zshenv_path - -# Why: use :- after user .zshenv — a pathological unset under set -u must not -# abort the wrapper; empty falls through to the baked-literal branch. -if [[ -n "\${_orca_wrapper_zdotdir_self:-}" && -f "\${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then - export ZDOTDIR="\${_orca_wrapper_zdotdir_self:-}" -else - export ZDOTDIR=${quotePosixSingle(zshDir)} -fi -unset _orca_wrapper_zdotdir_self -` -} - -/** - * The relay variant of the discovery body: it trusts the ZDOTDIR the remote - * shell already inherited instead of re-deriving one, and republishes it as - * ORCA_USER_ZDOTDIR for the later wrapper files. - * - * Why separate: this diverged from the discovery template before unification - * and is preserved here — reconciling the two is a follow-up. - */ -export function getZshOverlayEnvBody(zshDir: string): string { - return `__orca_resolve_inherited_config_dir "\${ORCA_ORIG_ZDOTDIR:-$HOME}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" -__orca_resolve_user_config_dir "\${ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}" -export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir" - -${getZshEmulationDegradeBlock({ - userZdotdirExpression: '"$ORCA_USER_ZDOTDIR"', - sourcedUserFileTest: '-f "$ORCA_ORIG_ZDOTDIR/.zshenv"' -})} - -export ZDOTDIR=${quotePosixSingle(zshDir)} -` -} - /** * Restores the worktree-scoped HISTFILE that macOS `/etc/zshrc` destroys. * + * Bash only. The zsh wrapper no longer needs this: it hands ZDOTDIR back before + * /etc/zshrc runs, so the value that file derives is already the user's own, and + * the scoped path is re-applied from a non-exported variable in the deferred + * hook. The `elif` below is inert under bash, where ZDOTDIR is normally unset. + * * That file assigns `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` with no * check-before-set, and it runs before any wrapper file Orca controls — so the * injected value is already gone, and because ZDOTDIR still points at Orca's @@ -281,7 +126,7 @@ export ZDOTDIR=${quotePosixSingle(zshDir)} * cannot be inherited by anything the shell later spawns if it no longer exists * once it has been consumed. HISTFILE itself stays exported. */ -export const ZSH_HISTFILE_RESTORE_BLOCK = `if [[ -n "\${ORCA_HISTFILE:-}" ]]; then +export const BASH_HISTFILE_RESTORE_BLOCK = `if [[ -n "\${ORCA_HISTFILE:-}" ]]; then HISTFILE="$ORCA_HISTFILE" builtin unset ORCA_HISTFILE elif [[ "\${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then @@ -293,33 +138,6 @@ elif [[ "\${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then HISTFILE="\${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history" fi` -export function getZshStartupFileSourceBlock(options: { - fileName: '.zprofile' | '.zshrc' | '.zlogin' - homeExpression?: string - interactiveOnly?: boolean - skipWhenHomeIsCurrentZdotdir?: boolean -}): string { - const homeExpression = options.homeExpression ?? '"${ORCA_ORIG_ZDOTDIR:-$HOME}"' - const checks = [ - options.skipWhenHomeIsCurrentZdotdir ? '"$_orca_home" != "$ZDOTDIR"' : null, - options.interactiveOnly ? '-o interactive' : null, - `-f "$_orca_home/${options.fileName}"` - ].filter(Boolean) - - return `__orca_resolve_user_config_dir ${homeExpression} -_orca_home="$_orca_resolved_config_dir" -if [[ ${checks.join(' && ')} ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/${options.fileName}" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi -` -} - // Why: zsh precmd fires before zle switches the PTY into line-editing mode, // so the marker must be emitted from zle-line-init. Registering it through // add-zle-hook-widget is unsafe: the azhw dispatcher aborts its hook chain @@ -369,12 +187,3 @@ export function getFishShellReadyInitCommand(escapedMarker: string): string { functions -e __orca_shell_ready_marker end` } - -export function getZshFinalZdotdirRestoreBlock(homeExpression = '"${ORCA_ORIG_ZDOTDIR:-$HOME}"') { - return `__orca_resolve_user_config_dir ${homeExpression} -# Why: after Orca's last wrapper file has loaded, the interactive shell should -# expose the same ZDOTDIR a normal zsh startup would expose. -export ZDOTDIR="$_orca_resolved_config_dir" -unset _orca_home _orca_resolved_config_dir -` -} diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index 6e6d10ab0ff..0fa1eadbe14 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -31,18 +31,17 @@ const STARTUP_COMMAND_FEATURES = selectShellStartupFeatures({ emitsStartupIdentity: true }) +// Why one zsh file: the wrapper hands ZDOTDIR back on its first lines, so zsh +// reads .zprofile, .zshrc and .zlogin from the user's own directory. const WRAPPER_FILES = [ ['zsh-zshenv', join('zsh', '.zshenv')], - ['zsh-zprofile', join('zsh', '.zprofile')], - ['zsh-zshrc', join('zsh', '.zshrc')], - ['zsh-zlogin', join('zsh', '.zlogin')], ['bash-rcfile', join('bash', 'rcfile')] ] as const const SNAPSHOT_DIR = join(__dirname, '__fixtures__', 'shell-wrapper-snapshots') -// Why: the wrapper root is a temp dir per run, and the baked ZDOTDIR literal is -// the only path-dependent byte in the output; pin it to a stable placeholder. +// Why still normalized: the zsh hook no longer bakes a wrapper path at all, but +// the bash rcfile can still carry one, and a temp root differs per run. function withStableRoot(content: string, root: string): string { return content.split(root).join('') } diff --git a/src/main/zsh-scoped-histfile.live-shell.test.ts b/src/main/zsh-scoped-histfile.live-shell.test.ts index 741f892d734..566ce8ae4b1 100644 --- a/src/main/zsh-scoped-histfile.live-shell.test.ts +++ b/src/main/zsh-scoped-histfile.live-shell.test.ts @@ -1,63 +1,34 @@ /** - * Real-zsh proof that a worktree-scoped HISTFILE survives shell startup. + * Real-zsh proof that a worktree-scoped HISTFILE survives shell startup, and + * that the rest of Orca's startup features arrive with it. * * macOS `/etc/zshrc` assigns `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` with no - * check-before-set, and it runs before every wrapper file Orca controls. So the - * value `injectHistoryEnv` put in the spawn env is already gone by the time the - * user reaches a prompt — and because ZDOTDIR still points at Orca's wrapper - * dir, the replacement lands inside it. Per-worktree history was therefore a - * silent no-op on the primary platform (#11044). + * check-before-set. Orca used to fight that by keeping its own ZDOTDIR in place + * across `/etc/zshrc` and repairing the damage afterwards (#11044). It now hands + * ZDOTDIR back before that file runs, so the value `/etc/zshrc` derives is the + * user's own path and the scoped one is re-applied from the deferred hook. * - * Only a real zsh can show this: the string the wrapper emits looks correct - * either way, and the whole bug lives in what /etc/zshrc does between the spawn - * env and the first prompt. + * Only a real zsh on a real PTY can show any of this: the wrapper text looks + * correct either way, the whole question is what `/etc/zshrc` does between the + * spawn env and the first prompt, and the hook is a `precmd` — which a shell + * started with `-c` never reaches. * * These tests drive the REAL launch decision (`selectShellStartupFeatures` + * `getShellLaunchConfig`) rather than an inline copy of the gate, so a pane that * Orca would not wrap cannot pass here by construction. */ -import { execFileSync, spawnSync } from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers' import { getShellLaunchConfig } from './providers/local-pty-shell-ready' import { selectShellStartupFeatures } from './shell-startup-features' +import { hasZsh, makeZshHome, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' -// Why probe and execute the same binary: guarding on `zsh` from PATH but then -// running a hardcoded `/bin/zsh` lets the guard pass on a host that installs zsh -// elsewhere, and the test fails for a missing binary rather than a wrapper -// defect. The absolute path is resolved once so the sandboxed PATH below cannot -// lose it. -const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 -const ZSH_PATH = hasZsh - ? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim() - : '' const itWithZsh = hasZsh ? it : it.skip -function runZsh( - args: string[], - env: Record, - probe = 'echo "RESULT=$HISTFILE"' -): string { - // -o noglobalrcs is deliberately NOT passed: /etc/zshrc is the thing under test. - return execFileSync(ZSH_PATH, [...args, '-i', '-c', probe], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', ...env } - }) -} - -/** Both streams, because a wrapper that breaks does it on stderr. */ -function runZshCapturingStderr(args: string[], env: Record, probe: string): string { - const result = spawnSync(ZSH_PATH, [...args, '-i', '-c', probe], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', ...env } - }) - return `${result.stdout ?? ''}${result.stderr ?? ''}` -} - /** * True when this host's system zshrc is the thing that destroys HISTFILE. * @@ -75,11 +46,7 @@ const systemZshrcClobbersHistfile = (() => { const output = execFileSync(ZSH_PATH, ['-l', '-i', '-c', 'echo "RESULT=$HISTFILE"'], { encoding: 'utf8', timeout: 20_000, - env: { - PATH: '/usr/bin:/bin', - HOME: home, - HISTFILE: join(home, 'injected-history') - } + env: { PATH: '/usr/bin:/bin', HOME: home, HISTFILE: join(home, 'injected-history') } }) return !output.includes(join(home, 'injected-history')) } catch { @@ -91,314 +58,372 @@ const systemZshrcClobbersHistfile = (() => { const itWithClobber = systemZshrcClobbersHistfile ? itWithZsh : it.skip -/** The launch config Orca produces for a plain pane whose only Orca-owned - * concern is a worktree-scoped HISTFILE. */ -function launchPlainHistoryPane(home: string, scopedHistfile: string) { +/** The launch config Orca produces for a pane with exactly these features. */ +function launchPane( + home: string, + scopedHistfile: string | null, + overrides: Partial[0]> = {} +) { const env: Record = { HOME: home, - HISTFILE: scopedHistfile, - ORCA_HISTFILE: scopedHistfile + ...(scopedHistfile ? { HISTFILE: scopedHistfile, ORCA_HISTFILE: scopedHistfile } : {}) } const features = selectShellStartupFeatures({ shellPath: ZSH_PATH, env, hasStartupCommand: false, waitsForShellReady: false, - emitsStartupIdentity: false + emitsStartupIdentity: false, + ...overrides }) const launch = getShellLaunchConfig(ZSH_PATH, features) return { features, launch, - env: { ...env, ...launch.env, ...sandboxConfigDir(home) } + // Why ORCA_ORIG_ZDOTDIR overridden: the launch config resolves the user's + // config dir from the real process env, and these runs must resolve against + // the sandbox home instead. + env: { PATH: '/usr/bin:/bin', ...env, ...launch.env, ORCA_ORIG_ZDOTDIR: home } } } -// Why override: the launch config resolves the user's config dir from the real -// process env, and these runs must resolve against the sandbox home instead. -function sandboxConfigDir(home: string): Record { - return { ORCA_ORIG_ZDOTDIR: home, ORCA_ZSHENV_SOURCE_DIR: home } +const USER_FILES = { + '.zshenv': 'export ORCA_TEST_USER_ZSHENV=1\n', + '.zprofile': 'export ORCA_TEST_USER_ZPROFILE=1\n', + '.zshrc': 'export ORCA_TEST_USER_ZSHRC=1\n' } -function withTempHome(run: (home: string) => void): void { - const home = mkdtempSync(join(tmpdir(), 'orca-scoped-histfile-')) - try { - run(home) - } finally { - rmSync(home, { recursive: true, force: true }) +function withHome(files: Record, run: (home: string) => Promise) { + return async () => { + const home = makeZshHome(files) + try { + await run(home) + } finally { + rmSync(home, { recursive: true, force: true }) + } } } -const histfileOf = (output: string): string => - /^RESULT=(.*)$/m.exec(output)?.[1]?.trim() ?? '' +describe.skipIf(process.platform === 'win32')( + 'worktree-scoped HISTFILE survives zsh startup', + () => { + itWithZsh( + 'wraps a plain pane once Orca injected a worktree HISTFILE', + withHome(USER_FILES, async (home) => { + const { features, launch } = launchPane(home, join(home, 'zsh_history')) -describe('worktree-scoped HISTFILE survives zsh startup', () => { - itWithZsh('wraps a plain pane once Orca injected a worktree HISTFILE', () => { - withTempHome((home) => { - const { features, launch } = launchPlainHistoryPane(home, join(home, 'zsh_history')) + // The whole reason wrapping widened: this pane has no overlay env and no + // startup command, so before #15258 nothing pointed it at a wrapper. + expect(features).toEqual(['history']) + expect(launch.env.ZDOTDIR).toBeTruthy() + expect(launch.env.ORCA_SHELL_FEATURES).toBe('history') + }) + ) - // The whole bug: this pane has no overlay env and no startup command, so - // before this change nothing pointed it at a wrapper at all. - expect(features).toEqual(['history']) - expect(launch.env.ZDOTDIR).toBeTruthy() - expect(launch.env.ORCA_SHELL_FEATURES).toBe('history') - }) - }) + itWithClobber( + 'keeps the injected path that the system zshrc would otherwise clobber', + withHome(USER_FILES, async (home) => { + const scoped = join(home, 'orca-history', 'zsh_history') + const { env } = launchPane(home, scoped) - itWithClobber('keeps the injected path that the system zshrc would otherwise clobber', () => { - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) + const { values } = await runZshPty({ env, report: ['HISTFILE'] }) - const output = runZsh(launch.args ?? ['-l'], env) + expect(values.HISTFILE).toBe(scoped) + }) + ) - expect(histfileOf(output)).toBe(scoped) - }) - }) + itWithZsh( + 'hands ZDOTDIR back before the user’s own startup files load', + withHome(USER_FILES, async (home) => { + const { env, launch } = launchPane(home, join(home, 'orca-history', 'zsh_history')) - itWithClobber('never leaves history inside Orca’s own wrapper directory', () => { - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) - - const output = runZsh(launch.args ?? ['-l'], env) - - // The exact failure mode of #11044: history written into shell-ready/zsh. - expect(output).not.toContain(launch.env.ZDOTDIR) - }) - }) - - itWithClobber( - 'repairs the clobber for a shell that re-enters the wrapper with no features', - () => { - // Why: a non-interactive zsh runs .zshenv (which exports Orca's wrapper - // ZDOTDIR) but never the epilogue that restores it, so an interactive zsh - // started from there re-enters the wrapper with the feature channel already - // consumed. /etc/zshrc still lands HISTFILE inside the wrapper dir — #11044 - // with no per-worktree scoping involved — so the repair cannot be gated on - // a feature that no longer exists by then. - withTempHome((home) => { - const { launch } = launchPlainHistoryPane(home, join(home, 'orca-history', 'zsh_history')) - - const output = runZsh(launch.args ?? ['-l'], { - HOME: home, - ZDOTDIR: launch.env.ZDOTDIR, - ORCA_ORIG_ZDOTDIR: home + const { values } = await runZshPty({ + env, + report: [ + 'ZDOTDIR', + 'ORCA_TEST_USER_ZSHENV', + 'ORCA_TEST_USER_ZPROFILE', + 'ORCA_TEST_USER_ZSHRC' + ] }) - expect(histfileOf(output)).toBe(join(home, '.zsh_history')) + // Each user file is read from the user's own dir, exactly as unwrapped. + expect(values.ORCA_TEST_USER_ZSHENV).toBe('1') + expect(values.ORCA_TEST_USER_ZPROFILE).toBe('1') + expect(values.ORCA_TEST_USER_ZSHRC).toBe('1') + expect(values.ZDOTDIR).toBe(home) + expect(values.ZDOTDIR).not.toBe(launch.env.ZDOTDIR) }) - } - ) + ) - itWithZsh('runs the epilogue for a login shell whose .zshrc ends in `emulate sh`', () => { - // Why: zsh's sourcehome() ignores ZDOTDIR once the shell is in sh/ksh - // emulation, so such a login shell reads $HOME/.zlogin instead of the - // wrapper's. Everything the epilogue owns — the HISTFILE repair, the OSC 133 - // hooks, the readiness widget every startup command waits on — would be - // skipped entirely. - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const env: Record = { - HOME: home, - HISTFILE: scoped, - ORCA_HISTFILE: scoped - } - const launch = getShellLaunchConfig( - ZSH_PATH, - selectShellStartupFeatures({ - shellPath: ZSH_PATH, + itWithZsh( + 'never leaves history inside Orca’s own wrapper directory', + withHome(USER_FILES, async (home) => { + const { env, launch } = launchPane(home, join(home, 'orca-history', 'zsh_history')) + + const { values } = await runZshPty({ env, report: ['HISTFILE'] }) + + // #11044's exact failure mode. It is now unreachable rather than repaired: + // /etc/zshrc derives HISTFILE from a ZDOTDIR that is already the user's. + expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR) + }) + ) + + itWithZsh( + 'consumes ORCA_HISTFILE so nothing the shell spawns can inherit it', + withHome(USER_FILES, async (home) => { + const scoped = join(home, 'orca-history', 'zsh_history') + const { env } = launchPane(home, scoped) + + const { values } = await runZshPty({ env, - hasStartupCommand: true, - waitsForShellReady: true, - // Why off: the identity marker is printed with no trailing newline, so - // it would prefix the first probe line and defeat the parser. + report: ['ORCA_HISTFILE', 'ORCA_SHELL_FEATURES', 'HISTFILE'] + }) + + // Root-cause fix for #11146: the variables no longer exist after use. + expect(values.ORCA_HISTFILE).toBe('UNSET') + expect(values.ORCA_SHELL_FEATURES).toBe('UNSET') + expect(values.HISTFILE).toBe(scoped) + }) + ) + + itWithZsh( + 'leaves HISTFILE exactly as an unwrapped zsh would when Orca injects nothing', + withHome(USER_FILES, async (home) => { + // Why compared against an unwrapped run rather than asserted non-empty: + // what zsh defaults to is platform-specific. macOS /etc/zshrc assigns + // HISTFILE, so it is always set there; a stock Ubuntu zsh leaves it EMPTY. + // The contract is that Orca's wrapper does not change it either way. + const overlayEnv = { ORCA_CODEX_HOME: join(home, 'codex') } + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env: { HOME: home, ...overlayEnv }, + hasStartupCommand: false, + waitsForShellReady: false, emitsStartupIdentity: false }) - ) - writeFileSync(join(home, '.zshrc'), 'emulate sh\n') + const launch = getShellLaunchConfig(ZSH_PATH, features) - const output = runZsh( - launch.args ?? ['-l'], - { ...env, ...launch.env, ...sandboxConfigDir(home) }, - 'echo "RESULT=$HISTFILE"; echo "WIDGET=[${widgets[zle-line-init]:-none}]"' - ) + const wrapped = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + HOME: home, + ...overlayEnv, + ...launch.env, + ORCA_ORIG_ZDOTDIR: home + }, + report: ['HISTFILE'] + }) + const unwrapped = await runZshPty({ + env: { PATH: '/usr/bin:/bin', HOME: home }, + report: ['HISTFILE'] + }) - expect(histfileOf(output)).toBe(scoped) - expect(output).toContain('WIDGET=[user:__orca_prompt_mark]') - }) - }) + expect(wrapped.values.HISTFILE).toBe(unwrapped.values.HISTFILE) + }) + ) + } +) - itWithZsh('consumes ORCA_HISTFILE so nothing the shell spawns can inherit it', () => { - withTempHome((home) => { +describe.skipIf(process.platform === 'win32')('the deferred hook delivers every feature', () => { + itWithZsh( + 'emits the identity, readiness and OSC 133 markers a startup command waits on', + withHome(USER_FILES, async (home) => { const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) + const { env, features } = launchPane(home, scoped, { + hasStartupCommand: true, + waitsForShellReady: true, + emitsStartupIdentity: true + }) + expect(features).toEqual(expect.arrayContaining(['markers', 'ready', 'identity'])) - const output = execFileSync( - ZSH_PATH, - [...(launch.args ?? ['-l']), '-i', '-c', 'echo "LEAK=[${ORCA_HISTFILE:-}]"'], - { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', ...env } + const { output, values } = await runZshPty({ + env, + commands: ['true'], + report: ['HISTFILE'] + }) + + expect(output).toMatch(MARKERS.identity) + expect(output).toContain(MARKERS.ready) + expect(output).toContain(MARKERS.promptStart) + expect(output).toContain(MARKERS.commandStart) + expect(output).toMatch(MARKERS.commandDone) + expect(values.HISTFILE).toBe(scoped) + }) + ) + + itWithZsh( + 'restores Orca’s overlay values after the user’s config overwrites them', + withHome( + { + ...USER_FILES, + '.zshrc': 'export CODEX_HOME=/user/codex\nexport PATH=/user/bin:$PATH\n' + }, + async (home) => { + const overlayEnv = { + ORCA_CODEX_HOME: '/orca/codex', + ORCA_AGENT_TEAMS_SHIM_DIR: '/orca/shim' } - ) + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env: { HOME: home, ...overlayEnv }, + hasStartupCommand: false, + waitsForShellReady: false, + emitsStartupIdentity: false + }) + const launch = getShellLaunchConfig(ZSH_PATH, features) - // Root-cause fix for #11146: the variable no longer exists after use. - expect(output).toContain('LEAK=[]') - }) - }) + const { values } = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + HOME: home, + ...overlayEnv, + ...launch.env, + ORCA_ORIG_ZDOTDIR: home + }, + report: ['CODEX_HOME', 'PATH'] + }) - itWithZsh('leaves HISTFILE exactly as an unwrapped zsh would when Orca injects nothing', () => { - // Why compared against an unwrapped run rather than asserted non-empty: what - // zsh defaults to is platform-specific. macOS `/etc/zshrc` assigns HISTFILE, - // so it is always set there; a stock Ubuntu zsh has no such file and leaves - // it EMPTY. The contract is that Orca's wrapper does not change it either - // way, which is the same assertion on both. - withTempHome((home) => { - const features = selectShellStartupFeatures({ - shellPath: ZSH_PATH, - env: { HOME: home, ORCA_CODEX_HOME: join(home, 'codex') }, - hasStartupCommand: false, - waitsForShellReady: false, - emitsStartupIdentity: false - }) - const launch = getShellLaunchConfig(ZSH_PATH, features) - const wrapped = runZsh(launch.args ?? ['-l'], { - HOME: home, - ORCA_CODEX_HOME: join(home, 'codex'), - ...launch.env, - ...sandboxConfigDir(home) - }) - const unwrapped = runZsh(['-l'], { HOME: home }) - - expect(histfileOf(wrapped)).toBe(histfileOf(unwrapped)) - expect(wrapped).not.toContain('ORCA_HISTFILE') - }) - }) + // The point of running last: the user's .zshrc set both of these after + // the spawn env did, and Orca's values still win. + expect(values.CODEX_HOME).toBe('/orca/codex') + expect(values.PATH.startsWith('/orca/shim:')).toBe(true) + } + ) + ) }) -const PROBE = - 'echo "RESULT=$HISTFILE"; print -r -- "ZDOTDIR=[$ZDOTDIR]"; ' + - 'print -r -- "ORCA_HISTFILE=[${ORCA_HISTFILE:-unset}]"' - -describe('the wrapper survives a hostile user zsh config', () => { - // Why both forms: `REPLY` is zsh's shared scratch global, and the two ways a - // user config constrains it fail differently. `typeset -r` makes the - // wrapper's first assignment fatal and prints into the pane; `typeset -i` - // turns every resolved path into 0 with no error text at all. Both aborted - // the wrapper before the epilogue, leaving history inside Orca's own wrapper - // dir — on 100% of zsh panes, not the few percent wrapped before. - it.each(['typeset -r REPLY', 'typeset -i REPLY'])( - 'resolves the user config dir when the user .zshrc runs `%s`', - (declaration) => { +describe.skipIf(process.platform === 'win32')( + 'the wrapper survives a hostile user zsh config', + () => { + /** + * Why these three cases and not the old degrade matrix: zsh's `sourcehome()` + * ignores ZDOTDIR once the shell is in sh/ksh emulation, which used to hide + * every wrapper file after the one that entered it — so emulation from + * `.zshenv` or `.zprofile` cost the pane all of Orca's features. Only one + * wrapper file is read now, and it is read before any user file can change + * modes, so these are wins rather than degradations. + */ + it.each([ + ['.zshenv', 'emulate sh'], + ['.zshenv', 'emulate ksh'], + ['.zprofile', 'emulate sh'], + ['.zshrc', 'emulate sh'], + ['.zshrc', 'setopt no_unset'], + ['.zshrc', 'setopt ksharrays'] + ])('still scopes history when the user %s runs `%s`', async (file, statement) => { if (!hasZsh) { return } - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) - writeFileSync(join(home, '.zshrc'), `${declaration}\n`) - - const output = runZshCapturingStderr(launch.args ?? ['-l'], env, PROBE) - - expect(histfileOf(output)).toBe(scoped) - expect(output).toContain(`ZDOTDIR=[${home}]`) - expect(output).toContain('ORCA_HISTFILE=[unset]') - expect(output).not.toContain('__orca_resolve_user_config_dir:') + const home = makeZshHome({ + ...USER_FILES, + [file]: `${USER_FILES[file as keyof typeof USER_FILES]}${statement}\n` }) - } - ) + try { + const scoped = join(home, 'orca-history', 'zsh_history') + const { env } = launchPane(home, scoped) - // Why these two files and not .zshrc: zsh's sourcehome() ignores ZDOTDIR once - // the shell is in sh/ksh emulation, so emulation entered from .zshenv or - // .zprofile hides EVERY later wrapper file — the epilogue runs zero times and - // can repair nothing. A .zshrc that does it is already covered by running the - // epilogue from the wrapper .zshrc, which /etc/zshrc has run before. - it.each([ - ['.zshenv', 'emulate sh'], - ['.zshenv', 'emulate ksh'], - ['.zprofile', 'emulate sh'] - ])('degrades to an unwrapped pane when the user %s runs `%s`', (file, statement) => { - if (!hasZsh) { - return - } - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) - writeFileSync(join(home, file), `${statement}\n`) + const { values } = await runZshPty({ env, report: ['HISTFILE', 'ORCA_HISTFILE'] }) - const wrapped = runZshCapturingStderr(launch.args ?? ['-l'], env, PROBE) - const unwrapped = runZsh(['-l'], { HOME: home, HISTFILE: scoped }) - - // The bar: never worse off than the pane Orca did not wrap. What that is - // differs per host (macOS /etc/zshrc clobbers HISTFILE, stock Ubuntu does - // not), so it is read from an unwrapped run rather than hardcoded. - expect(histfileOf(wrapped)).toBe(histfileOf(unwrapped)) - expect(histfileOf(wrapped)).not.toContain(launch.env.ZDOTDIR) - expect(wrapped).toContain(`ZDOTDIR=[${home}]`) - // Nothing this pane spawns may inherit a history path no wrapper file - // will ever consume. - expect(wrapped).toContain('ORCA_HISTFILE=[unset]') + expect(values.HISTFILE).toBe(scoped) + expect(values.ORCA_HISTFILE).toBe('UNSET') + } finally { + rmSync(home, { recursive: true, force: true }) + } }) - }) -}) + + itWithZsh( + 'degrades to an unwrapped pane, leaking nothing, when a config drops precmd_functions', + withHome({ ...USER_FILES, '.zshrc': 'precmd_functions=()\n' }, async (home) => { + const scoped = join(home, 'orca-history', 'zsh_history') + const { env, launch } = launchPane(home, scoped) + + const report = ['HISTFILE', 'ORCA_HISTFILE', 'ZDOTDIR'] + const { values } = await runZshPty({ env, report }) + // Why compared against an unwrapped run rather than asserted to differ + // from the scoped path: whether the scoped value survives at all is the + // host's call, not Orca's. macOS /etc/zshrc overwrites HISTFILE, so it + // does not; a host with no such assignment keeps whatever the spawn env + // set. The contract on both is the same — this pane is the pane the user + // would have had unwrapped. + const unwrapped = await runZshPty({ + env: { PATH: '/usr/bin:/bin', HOME: home, HISTFILE: scoped }, + report + }) + + expect(values.HISTFILE).toBe(unwrapped.values.HISTFILE) + expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR) + // ORCA_HISTFILE was consumed in .zshenv precisely so a dropped hook + // leaks nothing to the pane's children. + expect(values.ORCA_HISTFILE).toBe('UNSET') + expect(values.ZDOTDIR).toBe(home) + }) + ) + + itWithZsh( + 'loads the user config even when a startup file writes an unrelated ZDOTDIR', + withHome( + { ...USER_FILES, '.zshenv': `${USER_FILES['.zshenv']}export ZDOTDIR="$HOME"\n` }, + async (home) => { + const { env } = launchPane(home, join(home, 'orca-history', 'zsh_history')) + + const { values } = await runZshPty({ + env, + report: ['ZDOTDIR', 'ORCA_TEST_USER_ZSHRC'] + }) + + // A ZDOTDIR the user's own .zshenv exports is theirs by construction and + // needs no discovery machinery: zsh reads .zshrc through it directly. + expect(values.ZDOTDIR).toBe(home) + expect(values.ORCA_TEST_USER_ZSHRC).toBe('1') + } + ) + ) + } +) /** - * Records every argument-less `emulate` — i.e. exactly the wrapper's emulation - * probe, and not the epilogue's `emulate -L zsh` — into a file the test reads. - * - * Why a shadowing function and not a timing assertion: the cost being removed - * is a fork, and a fork is countable where milliseconds are flaky. Placed in - * the user .zshenv so it is defined before all three probe sites. + * The relay writes its own variant of the hook: no OSC 133 (its bash rcfile owns + * those on remote hosts) and a remote CLI bin dir on PATH instead of the + * agent-teams shim. It used to have a whole second ZDOTDIR shape too, which is + * why it drifted from the desktop template; now the only differences are the + * spec flags, and this pins that the variant still works in a real shell. */ -function probeCounterConfig(logPath: string): string { - return [ - 'emulate() {', - ` (( $# == 0 )) && print -r -- probe >> ${JSON.stringify(logPath)}`, - ' builtin emulate "$@"', - '}' - ].join('\n') -} +describe.skipIf(process.platform === 'win32')('the relay variant of the hook', () => { + itWithZsh( + 'scopes history and restores the remote CLI path without emitting OSC 133', + withHome(USER_FILES, async (home) => { + const relayRoot = mkdtempSync(join(tmpdir(), 'orca-relay-wrapper-')) + try { + expect(ensureOverlayRestoreWrappers(relayRoot)).toBe(true) + const scoped = join(home, 'orca-history', 'zsh_history') -const probeCount = (logPath: string): number => - existsSync(logPath) ? readFileSync(logPath, 'utf8').split('\n').filter(Boolean).length : 0 + const { output, values } = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + HOME: home, + ZDOTDIR: join(relayRoot, 'zsh'), + ORCA_ORIG_ZDOTDIR: home, + ORCA_SHELL_FEATURES: 'overlay,history,ready', + ORCA_HISTFILE: scoped, + ORCA_REMOTE_CLI_BIN_DIR: '/orca/remote-bin' + }, + commands: ['true'], + report: ['HISTFILE', 'ZDOTDIR', 'PATH', 'ORCA_HISTFILE'] + }) -describe('the emulation probe forks only when it can change the answer', () => { - itWithZsh('never forks for a pane whose config stays in zsh emulation', () => { - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const log = join(home, 'probe.log') - const { launch, env } = launchPlainHistoryPane(home, scoped) - // All three probe sites are gated on having sourced the matching user - // file, so every one of them has to exist for this to mean anything. - writeFileSync(join(home, '.zshenv'), probeCounterConfig(log)) - writeFileSync(join(home, '.zprofile'), '') - writeFileSync(join(home, '.zshrc'), '') - - const output = runZshCapturingStderr(launch.args ?? ['-l'], env, PROBE) - - expect(probeCount(log)).toBe(0) - // The pane still has to work: the saving is a fork, not a feature. - expect(histfileOf(output)).toBe(scoped) - expect(output).toContain(`ZDOTDIR=[${home}]`) + expect(values.HISTFILE).toBe(scoped) + expect(values.ZDOTDIR).toBe(home) + expect(values.ORCA_HISTFILE).toBe('UNSET') + expect(values.PATH.startsWith('/orca/remote-bin:')).toBe(true) + expect(output).toContain(MARKERS.ready) + // Remote panes get their command lifecycle from the bash rcfile, so the + // zsh variant must stay silent here. + expect(output).not.toContain(MARKERS.promptStart) + expect(output).not.toContain(MARKERS.commandStart) + } finally { + rmSync(relayRoot, { recursive: true, force: true }) + } }) - }) - - // Why kept exact rather than replaced by the option test: those options say - // nothing about `emulation`, which is what zsh's sourcehome() branches on. - itWithZsh('still forks to get the exact answer once a Bourne option is set', () => { - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const log = join(home, 'probe.log') - const { launch, env } = launchPlainHistoryPane(home, scoped) - writeFileSync(join(home, '.zshenv'), `${probeCounterConfig(log)}\nsetopt shwordsplit\n`) - - const output = runZshCapturingStderr(launch.args ?? ['-l'], env, PROBE) - - expect(probeCount(log)).toBeGreaterThan(0) - // shwordsplit alone is not emulation, so the pane stays wrapped. - expect(histfileOf(output)).toBe(scoped) - }) - }) + ) }) diff --git a/src/main/zsh-startup-hook-pty-harness.ts b/src/main/zsh-startup-hook-pty-harness.ts new file mode 100644 index 00000000000..a4de4e3bba9 --- /dev/null +++ b/src/main/zsh-startup-hook-pty-harness.ts @@ -0,0 +1,220 @@ +/** + * Drives a real zsh through a real PTY, to the first prompt and beyond. + * + * Why a PTY and not `zsh -i -c ''`: everything Orca owns now runs from a + * `precmd` hook, and `-c` never reaches a prompt, so `precmd` never fires. A + * probe run that way would report the wrapper doing nothing at all — for the + * right reason, at the wrong question. These tests have to reach a prompt to + * mean anything. + * + * Results come back through a file rather than stdout because a PTY echoes the + * command being typed, so matching on stdout matches the echo of the probe as + * readily as its output. + */ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' + +export const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 + +export const ZSH_PATH = hasZsh + ? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim() + : '' + +/** OSC sequences the wrapper emits, as the terminal would receive them. */ +export const MARKERS = { + identity: /\]777;orca-shell-start:\d+/, + ready: ']777;orca-shell-ready', + promptStart: ']133;A', + commandStart: ']133;C', + commandDone: /\]133;D;\d+/ +} as const + +export type ZshPtyRun = { + /** Everything the terminal received, escape sequences intact. */ + output: string + /** `name=` pairs the probe wrote, parsed. */ + values: Record + /** + * True when the shell exited before reaching a prompt — what a user `.zshenv` + * that calls `exit` produces. A real outcome to compare, not a failure. + */ + exitedBeforePrompt: boolean +} + +export type ZshPtyOptions = { + env: Record + /** Shell variables to report, e.g. `['HISTFILE', 'ZDOTDIR']`. */ + report?: readonly string[] + /** Commands to run at the prompt before reporting. */ + commands?: readonly string[] + cwd?: string + timeoutMs?: number +} + +/** + * Why the value syntax is `NAME=<...>`: an unset variable and an empty one must + * be distinguishable, and the delimiters survive a terminal's line wrapping. + * + * Why the trailing `;` before `}`: a config that leaves the shell in sh + * emulation needs it, and without it zsh sits in a `cursh>` continuation + * waiting for the group to close — which reads exactly like a hung wrapper. + */ +function buildProbe(report: readonly string[], resultPath: string): string { + const prints = report.map((name) => `print -r -- "${name}=<\${${name}:-UNSET}>";`).join(' ') + return `{ ${prints} } > ${JSON.stringify(resultPath)}` +} + +function parseValues(resultPath: string): Record { + if (!existsSync(resultPath)) { + return {} + } + const values: Record = {} + for (const line of readFileSync(resultPath, 'utf8').split('\n')) { + const match = /^(\w+)=<(.*)>$/.exec(line.trim()) + if (match) { + values[match[1]] = match[2] + } + } + return values +} + +/** + * Runs an interactive login zsh under a PTY, waits for its first prompt, runs + * the requested commands, and reports the requested shell variables. + * + * Readiness is detected by a sentinel baked into PS1 rather than a fixed sleep, + * so a slow prompt framework makes the run slower, never flaky. + */ +export async function runZshPty(options: ZshPtyOptions): Promise { + const sentinel = '@@ORCA-PTY-READY@@' + const workDir = mkdtempSync(join(tmpdir(), 'orca-zsh-pty-')) + const resultPath = join(workDir, 'probe.txt') + const timeoutMs = options.timeoutMs ?? 20_000 + + const proc = pty.spawn(ZSH_PATH, ['-l', '-i'], { + name: 'xterm-256color', + cols: 200, + rows: 40, + cwd: options.cwd ?? workDir, + env: { + ...options.env, + // Why the sentinel is env-borne: the prompt is overwritten from the PTY + // below, and it has to survive whatever prompt the user's config installs. + ORCA_PTY_SENTINEL: sentinel + } + }) + + let output = '' + let answeredCompinit = false + let lastDataAt = Date.now() + let resolveReady: (() => void) | undefined + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + proc.onData((data) => { + output += data + lastDataAt = Date.now() + // Why this is answered rather than configured away: a host whose global + // zshrc runs `compinit` over directories it considers insecure — CI runners + // do — stops startup and ASKS, and a real PTY will sit at that question + // until the timeout. A pipe-backed `zsh -i -c` never saw it. ZSH_DISABLE_COMPFIX + // does not help: that is an oh-my-zsh convention, and plain `compinit` (which + // is what asks) ignores it. Answering keeps the shell on the path a user + // pressing `y` would take, and both arms of a comparison get the same + // treatment, so it cannot tilt one against the other. + if (!answeredCompinit && output.includes('Ignore insecure directories')) { + answeredCompinit = true + proc.write('y\r') + } + if (resolveReady && output.includes(sentinel)) { + resolveReady() + resolveReady = undefined + } + }) + let hasExited = false + const exited = new Promise((resolve) => { + proc.onExit(() => { + hasExited = true + resolve() + }) + }) + + let timer: ReturnType | null = null + const timedOut = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`timed out waiting for the zsh prompt:\n${output}`)), + timeoutMs + ) + }) + + /** + * Resolves once the shell has produced nothing for `quietMs`, or exited. + * + * Why quiescence and not a fixed sleep before typing: startup output has to + * finish before the PS1 line is typed, or a shell still asking a question + * (compinit, above) eats it as the answer. A fast host waits milliseconds; a + * slow prompt framework waits as long as it needs. + */ + async function waitForQuiet(quietMs: number): Promise { + while (!hasExited) { + const idleFor = Date.now() - lastDataAt + if (idleFor >= quietMs) { + return + } + await new Promise((resolve) => setTimeout(resolve, quietMs - idleFor)) + } + } + + try { + await Promise.race([waitForQuiet(250), exited, timedOut]) + if (hasExited) { + return { output, values: parseValues(resultPath), exitedBeforePrompt: true } + } + // Why the prompt is replaced rather than parsed: it only has to carry the + // sentinel from the SECOND prompt on — the first is where the deferred hook + // does its work, and that has already happened by now. + // + // Why PS1 and not PROMPT: a config that leaves the shell in sh emulation + // renders PS1, where PROMPT is just an ordinary variable. In zsh's own mode + // the two name the same parameter, so PS1 covers both. + proc.write(`PS1="$ORCA_PTY_SENTINEL"\r`) + // Why `exited` is raced here too: a user .zshenv that calls `exit` never + // reaches a prompt, and that is an outcome worth comparing rather than a + // twenty-second timeout. + await Promise.race([ready, exited, timedOut]) + if (hasExited) { + return { output, values: parseValues(resultPath), exitedBeforePrompt: true } + } + for (const command of options.commands ?? []) { + proc.write(`${command}\r`) + } + if (options.report?.length) { + proc.write(`${buildProbe(options.report, resultPath)}\r`) + } + proc.write('exit\r') + await Promise.race([exited, timedOut]) + return { output, values: parseValues(resultPath), exitedBeforePrompt: false } + } finally { + if (timer !== null) { + clearTimeout(timer) + } + try { + proc.kill() + } catch { + // Already exited normally. + } + rmSync(workDir, { recursive: true, force: true }) + } +} + +/** Writes a throwaway $HOME with the given zsh startup files. */ +export function makeZshHome(files: Record): string { + const home = mkdtempSync(join(tmpdir(), 'orca-zsh-home-')) + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(home, name), content) + } + return home +} diff --git a/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts b/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts new file mode 100644 index 00000000000..e5e45d2c22a --- /dev/null +++ b/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts @@ -0,0 +1,492 @@ +/** + * Real-zsh proof that a wrapped pane resolves the user's zsh config to exactly + * what an unwrapped pane would, for every odd or hostile `.zshenv` shape. + * + * These cases were previously asserted one expected value at a time against + * `ORCA_ORIG_ZDOTDIR` — the output of Orca's own shell-side ZDOTDIR discovery. + * That discovery is gone: the wrapper hands ZDOTDIR back on its first lines and + * zsh resolves the rest natively, so there is no Orca-computed value left to + * assert on. The contract those tests were really protecting is the one below, + * and stated as an equivalence it is stricter — it pins the wrapped pane to + * whatever the host's own zsh does, including on hosts where that differs, + * rather than to a value hardcoded here. + * + * Each case runs twice on a real PTY, once wrapped and once not, and the two + * must agree on where the config came from and what it exported. + */ +import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { getShellLaunchConfig } from './providers/local-pty-shell-ready' +import { selectShellStartupFeatures } from './shell-startup-features' +import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' +import { hasZsh, makeZshHome, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' + +const itWithZsh = hasZsh ? it : it.skip + +/** What both arms must agree on: where config came from, and what it exported. */ +const REPORTED = ['ZDOTDIR', 'ORCA_TEST_MARK', 'ORCA_TEST_FROM_ZSHRC', 'PATH'] as const + +/** + * Every case writes `$HOME/.zshenv`. `.zshrc` is written into whichever dir the + * case points ZDOTDIR at, so "did the right .zshrc load" is observable. + */ +type ConfigCase = { + name: string + /** Builds `$HOME/.zshenv` and any extra files. Returns the dir holding .zshrc. */ + setup: (home: string) => string +} + +const CASES: ConfigCase[] = [ + { + name: 'no ZDOTDIR at all', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=plain\n') + return home + } + }, + { + name: 'ZDOTDIR exported to an XDG dir', + setup: (home) => { + const dir = join(home, '.config', 'zsh') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(home, '.zshenv'), `export ORCA_TEST_MARK=xdg\nexport ZDOTDIR="${dir}"\n`) + return dir + } + }, + { + name: 'ZDOTDIR set by a file the .zshenv sources', + setup: (home) => { + const dir = join(home, '.config', 'zsh') + mkdirSync(dir, { recursive: true }) + const common = join(home, '.config', 'shell', 'common.sh') + mkdirSync(dirname(common), { recursive: true }) + writeFileSync(common, `export ZDOTDIR="${dir}"\n`) + writeFileSync(join(home, '.zshenv'), `export ORCA_TEST_MARK=sourced\nsource "${common}"\n`) + return dir + } + }, + { + name: 'ZDOTDIR with spaces in the path', + setup: (home) => { + const dir = join(home, 'My Config', 'zsh') + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=spaces\nexport ZDOTDIR="${dir}"\n` + ) + return dir + } + }, + { + name: 'ZDOTDIR set more than once', + setup: (home) => { + const first = join(home, 'first') + const dir = join(home, 'second') + mkdirSync(first, { recursive: true }) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=twice\nexport ZDOTDIR="${first}"\nexport ZDOTDIR="${dir}"\n` + ) + return dir + } + }, + { + name: 'ZDOTDIR written with a trailing slash', + setup: (home) => { + const dir = join(home, 'trailing') + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=trailing\nexport ZDOTDIR="${dir}/"\n` + ) + return dir + } + }, + { + name: 'ZDOTDIR pointing at a directory that does not exist', + setup: (home) => { + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=missing\nexport ZDOTDIR="${join(home, 'nope')}"\n` + ) + return home + } + }, + { + name: 'ZDOTDIR set to the empty string', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=empty\nexport ZDOTDIR=""\n') + return home + } + }, + { + name: 'ZDOTDIR explicitly set to $HOME', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=home\nexport ZDOTDIR="$HOME"\n') + return home + } + }, + { + name: 'a .zshenv with a syntax error', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=broken\nif [ ; then\n') + return home + } + }, + { + name: 'a .zshenv running set -u before anything else', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'set -u\nexport ORCA_TEST_MARK=nounset\n') + return home + } + }, + { + name: 'a .zshenv running set -e with a failing command', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'set -e\nexport ORCA_TEST_MARK=errexit\nfalse\n') + return home + } + }, + { + name: 'a .zshenv setting extendedglob and nullglob', + setup: (home) => { + writeFileSync( + join(home, '.zshenv'), + 'setopt extendedglob nullglob\nexport ORCA_TEST_MARK=globs\n' + ) + return home + } + }, + { + name: 'a .zshenv that unsets HOME', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=nohome\nunset HOME\n') + return home + } + }, + { + name: 'ZDOTDIR containing only slashes', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=slashes\nexport ZDOTDIR="///"\n') + return home + } + }, + { + name: 'a whitespace-only ZDOTDIR', + setup: (home) => { + writeFileSync( + join(home, '.zshenv'), + 'export ORCA_TEST_MARK=blank\nexport ZDOTDIR="$(printf \'\\t\\n\')"\n' + ) + return home + } + }, + { + name: 'ZDOTDIR with a single quote in the path', + setup: (home) => { + const dir = join(home, "it's zsh") + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=quote\nexport ZDOTDIR=${JSON.stringify(dir)}\n` + ) + return dir + } + }, + { + name: 'a .zshenv that conditionally unsets ZDOTDIR', + setup: (home) => { + writeFileSync( + join(home, '.zshenv'), + 'export ORCA_TEST_MARK=conditional\nexport ZDOTDIR="$HOME/x"\nunset ZDOTDIR\n' + ) + return home + } + }, + { + name: 'a .zshenv using typeset -U path at top level', + setup: (home) => { + // Why this one matters: `path` is a top-level-only construct, so it also + // proves the user's .zshenv is sourced in the wrapper's own scope rather + // than inside a function or subshell. + writeFileSync( + join(home, '.zshenv'), + 'typeset -U path\npath=(/usr/bin /bin /usr/bin)\nexport ORCA_TEST_MARK=uniqpath\n' + ) + return home + } + }, + { + name: 'a .zshenv defining a function and extending fpath', + setup: (home) => { + const fns = join(home, 'fns') + mkdirSync(fns, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `fpath=(${JSON.stringify(fns)} $fpath)\norca_test_fn() { : }\nexport ORCA_TEST_MARK=fnscope\n` + ) + return home + } + }, + { + name: 'a .zshenv that calls exit', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=exiting\nexit 0\n') + return home + } + } +] + +function wrappedEnv(home: string): Record { + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env: { HOME: home, ORCA_HISTFILE: join(home, 'scoped_history') }, + hasStartupCommand: false, + waitsForShellReady: false, + emitsStartupIdentity: false + }) + const launch = getShellLaunchConfig(ZSH_PATH, features) + // Why ORCA_ORIG_ZDOTDIR is dropped rather than pinned to the sandbox home: + // these cases are about a user who has no inherited ZDOTDIR, so the pane must + // resolve purely from HOME — and Orca must not invent a ZDOTDIR for it. The + // launch config computes this one from the real process env, which would + // otherwise leak the developer's own ZDOTDIR into the run. + const { ORCA_ORIG_ZDOTDIR: _dropped, ...env } = launch.env + return { + PATH: '/usr/bin:/bin', + HOME: home, + ORCA_HISTFILE: join(home, 'scoped_history'), + ...env + } +} + +describe.skipIf(process.platform === 'win32')( + 'a wrapped pane resolves the user config exactly as an unwrapped one does', + () => { + it.each(CASES.map((testCase) => [testCase.name, testCase] as const))( + 'matches unwrapped zsh for %s', + async (_name, testCase) => { + if (!hasZsh) { + return + } + const home = makeZshHome({}) + try { + const zshrcDir = testCase.setup(home) + mkdirSync(zshrcDir, { recursive: true }) + writeFileSync(join(zshrcDir, '.zshrc'), 'export ORCA_TEST_FROM_ZSHRC=1\n') + + const wrapped = await runZshPty({ env: wrappedEnv(home), report: REPORTED }) + const unwrapped = await runZshPty({ + env: { PATH: '/usr/bin:/bin', HOME: home }, + report: REPORTED + }) + + expect(wrapped.exitedBeforePrompt).toBe(unwrapped.exitedBeforePrompt) + for (const key of REPORTED) { + expect( + wrapped.values[key], + `${key} differs between a wrapped and an unwrapped pane` + ).toBe(unwrapped.values[key]) + } + } finally { + rmSync(home, { recursive: true, force: true }) + } + } + ) + } +) + +/** + * Regressions the four-file wrapper was built to fix, re-pinned against the one + * that replaced it. Each names the change that introduced the behaviour, because + * "the machinery is gone" is only a good answer if the reason it existed is gone + * with it. + */ +describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was built for', () => { + let userDataPath = '' + let previousUserDataPath: string | undefined + + beforeAll(() => { + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + userDataPath = mkdtempSync(join(tmpdir(), 'orca-hook-regression-')) + process.env.ORCA_USER_DATA_PATH = userDataPath + }) + + afterAll(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(userDataPath, { recursive: true, force: true }) + }) + + /** + * Generates the tree, then points ZDOTDIR at it under a different root. + * + * Why the full spawn env and not a bare ZDOTDIR: "the user's .zshrc loaded" is + * equally true of a pane that never read the wrapper at all, so the run has to + * be able to show the wrapper ran. ORCA_SHELL_FEATURES coming back consumed is + * that proof — only the wrapper's own .zshenv unsets it. + */ + async function runFromRelocatedRoot(home: string, movedRoot: string) { + const env = wrappedEnv(home) + const relocated = env.ZDOTDIR.replace(userDataPath, movedRoot) + expect(relocated, 'the relocated ZDOTDIR should differ from the generated one').not.toBe( + env.ZDOTDIR + ) + renameSync(userDataPath, movedRoot) + try { + return await runZshPty({ + env: { ...env, ZDOTDIR: relocated }, + report: ['ORCA_TEST_FROM_ZSHRC', 'ORCA_SHELL_FEATURES', 'HISTFILE'] + }) + } finally { + if (existsSync(movedRoot)) { + renameSync(movedRoot, userDataPath) + } + } + } + + itWithZsh( + 'loads the user .zshrc when the wrapper is sourced from a relocated path (#8003)', + async () => { + // Why relocation: on Windows+WSL the wrappers are generated with a Windows + // path but sourced via /mnt/c, so the generation-time path is absent at + // runtime. The old wrapper baked that path in as a ZDOTDIR fallback and had + // to re-derive the real one from `%x` to avoid using it; this one bakes no + // path, so the split cannot arise. Renaming the root reproduces it. + const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) + try { + const { values } = await runFromRelocatedRoot(home, `${userDataPath}-wsl-view`) + + expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1') + // The wrapper really was read from the relocated path. + expect(values.ORCA_SHELL_FEATURES).toBe('UNSET') + expect(values.HISTFILE).toBe(join(home, 'scoped_history')) + } finally { + rmSync(home, { recursive: true, force: true }) + } + } + ) + + itWithZsh('loads the user .zshrc from a non-ASCII wrapper path (#8003)', async () => { + // Why non-ASCII: a Korean Windows login puts UTF-8 bytes in zsh's 0x84-0x9D + // token range, and zsh corrupts environment values containing them while + // processing startup files. That corrupted the env-imported $ZDOTDIR, the + // wrapper's self-check failed, and it fell back to the unusable baked path — + // a bare prompt with none of the user's config. Nothing is baked now, and a + // value this wrapper cannot use degrades to $HOME, where zsh itself looks. + const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) + try { + const { values } = await runFromRelocatedRoot( + home, + join(dirname(userDataPath), '홍길동-wsl-view') + ) + + expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1') + expect(values.ORCA_SHELL_FEATURES).toBe('UNSET') + expect(values.HISTFILE).toBe(join(home, 'scoped_history')) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + itWithZsh('gives the user’s startup files their own ZDOTDIR while they run (#4667)', async () => { + // Why it mattered: user startup files resolve plugin and theme paths from + // $ZDOTDIR, so sourcing them with Orca's dir in place sent those lookups into + // the wrapper. The old wrapper swapped ZDOTDIR around each source; this one + // never takes it away, so each file sees what it would see unwrapped. + const home = makeZshHome({}) + const xdg = join(home, '.config', 'zsh') + mkdirSync(xdg, { recursive: true }) + writeFileSync(join(home, '.zshenv'), `export ZDOTDIR=${JSON.stringify(xdg)}\n`) + writeFileSync(join(xdg, '.zshrc'), 'export ORCA_TEST_IN_ZSHRC="$ZDOTDIR"\n') + writeFileSync(join(xdg, '.zprofile'), 'export ORCA_TEST_IN_ZPROFILE="$ZDOTDIR"\n') + try { + const report = ['ORCA_TEST_IN_ZSHRC', 'ORCA_TEST_IN_ZPROFILE'] + const wrapped = await runZshPty({ env: wrappedEnv(home), report }) + const unwrapped = await runZshPty({ env: { PATH: '/usr/bin:/bin', HOME: home }, report }) + + expect(wrapped.values.ORCA_TEST_IN_ZSHRC).toBe(xdg) + expect(wrapped.values.ORCA_TEST_IN_ZPROFILE).toBe(xdg) + expect(wrapped.values).toEqual(unwrapped.values) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + itWithZsh('refuses an inherited ZDOTDIR that is an Orca wrapper dir (#15258)', async () => { + // Why the shell checks this and not only Node: the launch config sets + // ORCA_ORIG_ZDOTDIR when it resolved a usable dir, but a pane also inherits + // its parent's environment, so a stale value written by an older build can + // arrive on its own — a route the Node-side check never sees. Handing that + // back would point ZDOTDIR at a wrapper dir, which is the self-loop the + // ownership check exists to prevent. Identification stays positive: a stamped + // marker file, or Orca's own path shape for wrappers older builds wrote. + const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) + const foreign = join(home, 'other-terminal', 'zsh') + mkdirSync(foreign, { recursive: true }) + writeFileSync(join(foreign, '.zshrc'), 'export ORCA_TEST_FROM_WRAPPER_DIR=1\n') + writeFileSync(join(foreign, ZSH_WRAPPER_DIR_MARKER_FILE), '') + try { + const { values } = await runZshPty({ + env: { ...wrappedEnv(home), ORCA_ORIG_ZDOTDIR: foreign }, + report: ['ZDOTDIR', 'ORCA_TEST_FROM_ZSHRC', 'ORCA_TEST_FROM_WRAPPER_DIR'] + }) + + // Rejected, so zsh falls back to $HOME and the user's own config loads. + expect(values.ZDOTDIR).toBe('UNSET') + expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1') + expect(values.ORCA_TEST_FROM_WRAPPER_DIR).toBe('UNSET') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + itWithZsh('leaves a nested Orca nothing of its own to inherit (#11044, #11146)', async () => { + // Why this closes #11044's plain shape rather than repairing it: that bug was + // a nested zsh inheriting Orca's ZDOTDIR, so /etc/zshrc derived HISTFILE + // inside the wrapper dir. A pane can no longer hand any child a ZDOTDIR that + // is Orca's, because it does not have one itself past the first few lines. + const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) + try { + const { values } = await runZshPty({ + env: wrappedEnv(home), + commands: [ + 'ORCA_CHILD_ENV="$(env | grep -cE \'^(ORCA_SHELL_FEATURES|ORCA_HISTFILE)=\' || true)"', + 'ORCA_CHILD_ZDOTDIR="$(env | sed -n \'s/^ZDOTDIR=//p\')"' + ], + report: ['ORCA_CHILD_ENV', 'ORCA_CHILD_ZDOTDIR'] + }) + + // Neither channel survives into a child, and no ZDOTDIR of Orca's does. + // `UNSET` here is the probe's rendering of an empty capture, i.e. `env` + // printed no ZDOTDIR line at all. + expect(values.ORCA_CHILD_ENV).toBe('0') + expect(values.ORCA_CHILD_ZDOTDIR).toBe('UNSET') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + itWithZsh('survives a user .zshenv that returns early (#1947)', async () => { + const home = makeZshHome({ + '.zshenv': 'export ORCA_TEST_MARK=early\nreturn 0\nexport ORCA_TEST_MARK=late\n', + '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' + }) + try { + const report = ['ORCA_TEST_MARK', 'ORCA_TEST_FROM_ZSHRC', 'ZDOTDIR'] + const wrapped = await runZshPty({ env: wrappedEnv(home), report }) + const unwrapped = await runZshPty({ env: { PATH: '/usr/bin:/bin', HOME: home }, report }) + + expect(wrapped.values).toEqual(unwrapped.values) + expect(wrapped.values.ORCA_TEST_FROM_ZSHRC).toBe('1') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index a337b186a4d..0a2bb9c8026 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -1,41 +1,42 @@ /** - * The single source of the zsh startup wrapper files (.zshenv/.zprofile/.zshrc/ - * .zlogin) Orca writes for every transport: local PTY, daemon/SSH, and relay. + * The single `.zshenv` Orca writes for every transport: local PTY, daemon/SSH, + * and relay. * - * Why: the three generators were copies that drifted apart, so a fix landed in - * one transport and silently missed the other two. Everything they genuinely - * disagree on is a field on ZshStartupWrapperSpec, so the disagreements are - * visible in one place instead of spread across three template literals. + * Orca needs to run code AFTER the user's own zsh startup files. The old shape + * bought that by keeping ZDOTDIR pointed at Orca's wrapper dir for the whole of + * startup and sourcing each user file by hand — four generated files, and a + * fake ZDOTDIR live while `/etc/zshrc` ran. That one decision was the root of a + * whole bug family: `/etc/zshrc` assigns `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` + * unconditionally, so history landed inside Orca's own dir (#11044); zsh's + * `sourcehome()` ignores ZDOTDIR once the shell enters sh/ksh emulation, so a + * user file ending in `emulate sh` hid every later wrapper file; and one wrapper + * dir shared by two installed builds could mix files from both. * - * Every generated file redefines the helpers it calls, so the epilogue is the - * only thing one file needs another to have defined — see EPILOGUE_CALL. + * This shape gives ZDOTDIR back before anything else can observe it, then defers + * Orca's work to a `precmd` hook that runs at the first prompt — after + * `.zprofile`, `/etc/zshrc`, `.zshrc` and `.zlogin`, all of which zsh now reads + * from the user's own directory exactly as in an unwrapped shell. #11044 becomes + * unreachable rather than repaired, and the emulation and mixed-build classes + * stop existing. * - * All order-sensitive Orca work lives in ONE `__orca_shell_epilogue` defined in - * .zshenv and invoked exactly once — from .zshrc for a non-login shell, from - * .zlogin for a login shell. Each feature inside it is an independent guard on - * the allowlist snapshotted (and destroyed) at the top of .zshenv, so a pane - * wrapped only for one feature runs only that feature's code — except the - * HISTFILE repair, which undoes damage the wrapper's own ZDOTDIR caused and so - * must also run for a shell that re-entered the wrapper with no allowlist. + * ORDER IS LOAD-BEARING: every function is defined ABOVE the `source` of the + * user's `.zshenv`. A user `.zshenv` ending in `emulate sh` puts the rest of + * this file under sh parsing rules, and zsh-only syntax then fails to parse, + * taking the whole wrapper with it. Function bodies are parsed at definition + * time, so defining them first makes them immune; `emulate -L zsh` inside the + * hook restores zsh option semantics for the body at call time. */ import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from './pty/codex-shell-launch-preflight' import { - getZshEnvDiscoveryBody, - getZshEmulationDegradeBlock, - getZshFinalZdotdirRestoreBlock, - getZshOverlayEnvBody, getZshShellReadyMarkerRegistrationBlock, - getZshStartupFileSourceBlock, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, - ZSH_BOURNE_EMULATION_OPTION_HINT, ZSH_FEATURE_CHANNEL_BLOCK, - ZSH_HISTFILE_RESTORE_BLOCK, - ZSH_INHERITED_CONFIG_DIR_RESOLVER_BLOCK, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK + ZSH_USER_ZSHENV_SOURCE_BLOCK, + ZSH_ZDOTDIR_HANDBACK_BLOCK } from './shell-templates' -/** Runtime values the wrapper re-exports after the user's own startup files ran. */ +/** Runtime values the hook re-exports after the user's own startup files ran. */ export type ZshWrapperRestoreSpec = { /** Orca's agent-teams shim dir back onto PATH. */ agentTeamsPath: boolean @@ -47,36 +48,17 @@ export type ZshWrapperRestoreSpec = { codexLaunchPreflight: boolean } -export type ZshStartupWrapperSpec = { - /** First line of every generated file, e.g. `# Orca zsh shell-ready wrapper`. */ +export type ZshStartupHookSpec = { + /** First line of the generated file, e.g. `# Orca zsh shell-ready wrapper`. */ headerLabel: string - /** Wrapper ZDOTDIR baked into .zshenv as the fallback literal. */ - zshDir: string - /** - * How .zshenv finds the user's real ZDOTDIR. `discover-user-zdotdir` sources - * the user .zshenv and reads what it exported; `overlay-user-zdotdir` trusts - * the inherited ZDOTDIR and republishes it as ORCA_USER_ZDOTDIR. - */ - zshenvStrategy: 'discover-user-zdotdir' | 'overlay-user-zdotdir' - /** zsh expression the wrapper resolves the user's startup-file dir from. */ - homeExpression?: string readyMarkerEscaped: string /** OSC 133 command-lifecycle hooks (behind the `markers` feature). */ osc133CommandMarkers: boolean - /** Skip the user .zshrc when its dir is already the wrapper ZDOTDIR. */ - skipUserZshrcWhenHomeIsWrapperDir: boolean - /** Comment heading the overlay restores inside the epilogue. */ + /** Comment heading the overlay restores inside the hook. */ overlayRestoreComment: string restores: ZshWrapperRestoreSpec } -export type ZshStartupWrapperFiles = { - zshenv: string - zprofile: string - zshrc: string - zlogin: string -} - const AGENT_TEAMS_PATH_RESTORE_BLOCK = `__orca_restore_agent_teams_path() { [[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 case "$PATH" in @@ -92,29 +74,25 @@ const REMOTE_CLI_BIN_DIR_RESTORE = `[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && const CODEX_HOME_RESTORE = `# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"` -const ZSH_OSC133_COMMAND_MARKER_BLOCK = `__orca_osc133_precmd() { +/** + * The OSC 133 hooks, defined at top level so their bodies are parsed before the + * user's `.zshenv` can change the parsing mode. + */ +const ZSH_OSC133_FUNCTION_BLOCK = `__orca_osc133_precmd() { local exit_code=$? if [[ -n "\${__orca_in_command:-}" ]]; then - printf "\\033]133;D;%s\\007" "$exit_code" - unset __orca_in_command + builtin printf "\\033]133;D;%s\\007" "$exit_code" + builtin unset __orca_in_command fi - printf "\\033]133;A\\007" + builtin printf "\\033]133;A\\007" } __orca_osc133_preexec() { - printf "\\033]133;C\\007" + builtin printf "\\033]133;C\\007" # Why typeset -g: a plain assignment here creates a global inside a function, # which prints a warning above every command under warn_create_global. - typeset -g __orca_in_command=1 -} -# Why: prepend so Orca captures $? before user prompt hooks can overwrite it. -precmd_functions=(__orca_osc133_precmd \${precmd_functions[@]}) -preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]})` + builtin typeset -g __orca_in_command=1 +}` -/** - * Blocks already carrying a trailing newline (the omp wrapper, the codex - * preflight, the shared source/restore templates) keep it, so joining on a - * single newline reproduces the blank-line spacing of the originals. - */ function joinBlocks(blocks: (string | null)[]): string { return blocks.filter((block): block is string => block !== null).join('\n') } @@ -126,7 +104,7 @@ function indentBlock(block: string, indent: string): string { .join('\n') } -/** One epilogue feature: `if __orca_has_feature ; then ... fi`. */ +/** One hook feature: `if __orca_has_feature ; then ... fi`. */ function featureGuard(name: string, body: (string | null)[]): string | null { const blocks = body.filter((block): block is string => block !== null) if (blocks.length === 0) { @@ -136,7 +114,7 @@ function featureGuard(name: string, body: (string | null)[]): string | null { } /** The env/PATH restores that must outlast the user's own startup files. */ -function getOverlayRestoreBlocks(spec: ZshStartupWrapperSpec): (string | null)[] { +function getOverlayRestoreBlocks(spec: ZshStartupHookSpec): (string | null)[] { return [ spec.overlayRestoreComment, spec.restores.agentTeamsPath ? AGENT_TEAMS_PATH_RESTORE_BLOCK : null, @@ -150,122 +128,66 @@ function getOverlayRestoreBlocks(spec: ZshStartupWrapperSpec): (string | null)[] } /** - * Every Orca-owned, order-sensitive step, in one function called once. - * - * Why a function in .zshenv rather than inline code in .zshrc/.zlogin: login and - * non-login shells load a different last file, and duplicating the body in both - * is how the two copies drifted before. The once-flag makes a double invocation - * (re-sourced rc files) a no-op rather than a double-registered prompt hook. + * Everything Orca owns that must run after the user's config, in one function + * invoked from the first prompt's precmd sweep and then retired. */ -function buildEpilogue(spec: ZshStartupWrapperSpec): string { - return `__orca_shell_epilogue() { +function buildDeferredInit(spec: ZshStartupHookSpec): string { + // Why substitute in the markers case and remove otherwise: OSC 133 needs a + // permanent precmd, so swapping this hook for it keeps the array position the + // user's own hooks were registered around. With no permanent hook to leave + // behind, removing is what keeps a history-only pane observably identical to + // the unwrapped pane it was — no stray Orca name in `precmd_functions`. + // Verified on zsh 5.9 that self-removal mid-sweep skips no later hook, from + // the head, the middle and the tail of the array. + const permanentPrecmd = spec.osc133CommandMarkers + ? ` if __orca_has_feature markers; then + precmd_functions=(\${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]}) + else + precmd_functions=(\${precmd_functions:#__orca_deferred_init}) + fi` + : ` precmd_functions=(\${precmd_functions:#__orca_deferred_init})` + + return `__orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset - # precmd_functions is a fatal error that returns from the whole epilogue - # (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes - # the 1-based feature lookup drop the first selected feature. - emulate -L zsh - (( $+_orca_epilogue_done )) && return 0 - typeset -g _orca_epilogue_done=1 + # precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup + # drop whichever feature is listed first. + builtin emulate -L zsh + (( $+_orca_deferred_init_done )) && return 0 + builtin typeset -g _orca_deferred_init_done=1 + builtin typeset -g precmd_functions +${permanentPrecmd} ${joinBlocks([ featureGuard('overlay', getOverlayRestoreBlocks(spec)), - // Why ungated: this repairs damage Orca's own ZDOTDIR caused, so it must also - // run for a shell that re-enters the wrapper with no feature channel left - // (a nested zsh under an inherited wrapper ZDOTDIR) — the plain #11044 shape. - indentBlock(ZSH_HISTFILE_RESTORE_BLOCK, ' '), - featureGuard('markers', [spec.osc133CommandMarkers ? ZSH_OSC133_COMMAND_MARKER_BLOCK : null]), - featureGuard('ready', [getZshShellReadyMarkerRegistrationBlock(spec.readyMarkerEscaped)]) + // Why no /etc/zshrc repair branch: ZDOTDIR was handed back before that file + // ran, so the value it derives is the user's own path. #11044 is unreachable. + ` if [[ -n "\${_orca_histfile:-}" ]]; then + HISTFILE="$_orca_histfile" + fi`, + featureGuard('ready', [ + indentBlock(getZshShellReadyMarkerRegistrationBlock(spec.readyMarkerEscaped), '') + ]) ])} -${indentBlock(getZshFinalZdotdirRestoreBlock(spec.homeExpression).replace(/\n$/, ''), ' ')} - unset _orca_shell_features - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir +${ + spec.osc133CommandMarkers + ? ` # Why called here: we were appended during this prompt's own precmd sweep, so + # the permanent hook has not run yet and the first prompt would lose its mark. + __orca_has_feature markers && __orca_osc133_precmd\n` + : '' +} builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_init __orca_has_feature }` } -function buildZshenv(spec: ZshStartupWrapperSpec): string { +export function buildZshStartupHook(spec: ZshStartupHookSpec): string { return `${joinBlocks([ `# ${spec.headerLabel}`, + ZSH_ZDOTDIR_HANDBACK_BLOCK, ZSH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, - ZSH_INHERITED_CONFIG_DIR_RESOLVER_BLOCK, - spec.zshenvStrategy === 'discover-user-zdotdir' - ? getZshEnvDiscoveryBody(spec.zshDir) - : getZshOverlayEnvBody(spec.zshDir), - buildEpilogue(spec) + spec.osc133CommandMarkers ? ZSH_OSC133_FUNCTION_BLOCK : null, + buildDeferredInit(spec), + ZSH_USER_ZSHENV_SOURCE_BLOCK ])}\n` } - -/** - * Why guarded: one wrapper dir can be shared by two concurrently installed Orca - * builds, so a shell can read one build's .zshenv and another's .zshrc/.zlogin. - * The epilogue is the only cross-file dependency left — every other function - * these files call is redefined at the top of each of them — so an older .zshenv - * costs this shell the epilogue's features and nothing else, without printing - * "command not found" into the user's pane. Braced subscript because sh/ksh - * emulation (KSH_ARRAYS) rejects the unbraced `$+functions[...]` form outright. - */ -const EPILOGUE_CALL = '(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue' - -/** - * A login shell normally runs the epilogue from .zlogin, after the user's own - * .zlogin. The exception: zsh's `sourcehome()` ignores ZDOTDIR once the shell is - * in sh/ksh emulation, so a user .zshrc ending in `emulate sh` makes zsh read - * $HOME/.zlogin and never the wrapper's — no OSC 133 hooks, no ready widget, and - * HISTFILE left pointing inside the wrapper dir. Running the epilogue here - * instead means the user's own .zlogin can undo its overlay restores, which - * beats not running it at all. An older zsh whose `emulate` has no query form - * prints nothing, fails the comparison, and runs it here too; the epilogue's - * once-flag makes the later .zlogin call a no-op. - * - * Of the wrapper's three emulation probes this is the costliest — it forks a - * zsh that has just finished loading the user's whole .zshrc — so - * ZSH_BOURNE_EMULATION_OPTION_HINT skips it for any shell not in Bourne - * emulation. Braces because `A || B && C` groups as `(A || B) && C` in a shell. - */ -const ZSHRC_EPILOGUE_INVOCATION = `if [[ ! -o login ]] || { ${ZSH_BOURNE_EMULATION_OPTION_HINT} && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then - ${EPILOGUE_CALL} -fi` - -export function buildZshStartupWrapperFiles(spec: ZshStartupWrapperSpec): ZshStartupWrapperFiles { - return { - zshenv: buildZshenv(spec), - zprofile: `${joinBlocks([ - `# ${spec.headerLabel}`, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, - getZshStartupFileSourceBlock({ - fileName: '.zprofile', - homeExpression: spec.homeExpression - }), - // Why here too: a user .zprofile ending in `emulate sh` hides .zshrc and - // .zlogin from the wrapper exactly as a user .zshenv does, and .zprofile - // is the last wrapper file that still runs before /etc/zshrc clobbers - // HISTFILE — so this is the last point where degrading cleanly is possible. - getZshEmulationDegradeBlock({ - userZdotdirExpression: '"$_orca_home"', - sourcedUserFileTest: '-f "$_orca_home/.zprofile"' - }) - ])}\n`, - zshrc: `${joinBlocks([ - `# ${spec.headerLabel}`, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, - getZshStartupFileSourceBlock({ - fileName: '.zshrc', - homeExpression: spec.homeExpression, - interactiveOnly: true, - skipWhenHomeIsCurrentZdotdir: spec.skipUserZshrcWhenHomeIsWrapperDir - }), - ZSHRC_EPILOGUE_INVOCATION - ])}\n`, - zlogin: `${joinBlocks([ - `# ${spec.headerLabel}`, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, - getZshStartupFileSourceBlock({ - fileName: '.zlogin', - homeExpression: spec.homeExpression, - interactiveOnly: true - }), - EPILOGUE_CALL - ])}\n` - } -} diff --git a/src/main/zsh-wrapper-dir-ownership.ts b/src/main/zsh-wrapper-dir-ownership.ts index 6eebe5b6132..0567212d516 100644 --- a/src/main/zsh-wrapper-dir-ownership.ts +++ b/src/main/zsh-wrapper-dir-ownership.ts @@ -43,15 +43,22 @@ function usableInheritedZdotdir(value: string | undefined): string | null { return value } -export function resolveInheritedZdotdir(env: EnvLike, homeFallback = ''): string { +/** + * The ZDOTDIR the user genuinely has, or null. + * + * Why null rather than a $HOME fallback: the wrapper hands this value straight + * back to the shell, and a user with no ZDOTDIR must end up with none — not with + * one Orca invented. `ZDOTDIR=$HOME` and an unset ZDOTDIR look identical to zsh + * when it reads startup files, but they are different environments for + * everything the pane goes on to launch. + */ +export function resolveInheritedZdotdir(env: EnvLike): string | null { return ( - usableInheritedZdotdir(env.ZDOTDIR) ?? - usableInheritedZdotdir(env.ORCA_ORIG_ZDOTDIR) ?? - env.HOME ?? - homeFallback + usableInheritedZdotdir(env.ZDOTDIR) ?? usableInheritedZdotdir(env.ORCA_ORIG_ZDOTDIR) ?? null ) } -export function resolveInheritedZshenvSourceDir(env: EnvLike, homeFallback = ''): string { - return usableInheritedZdotdir(env.ZDOTDIR) ?? env.HOME ?? homeFallback +/** Spawn-env entry for the wrapper's ZDOTDIR handback; absent when there is none. */ +export function inheritedZdotdirEnv(inherited: string | null): Record { + return inherited ? { ORCA_ORIG_ZDOTDIR: inherited } : {} } diff --git a/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts b/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts index af88295c7e6..796dc290b37 100644 --- a/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts +++ b/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts @@ -1,67 +1,107 @@ /** - * Real-zsh proof that a wrapper dir written by two different Orca builds still + * Real-zsh proof that a wrapper dir holding files from two Orca builds still * loads the user's own zsh config. * - * One wrapper dir (`/shell-ready/zsh`, or `~/.orca-relay/shell-ready/ - * zsh` for a remote host) is shared by every concurrently installed build, and - * each rewrites it on spawn. A shell can therefore read one build's `.zshenv` - * and another's `.zprofile`/`.zshrc`/`.zlogin`. Only a real zsh shows the cost: - * a helper the newer files call but the older `.zshenv` never defined both - * prints `command not found` into the pane AND leaves `$REPLY` empty, which - * silently skips sourcing the user's own startup files. + * A shared dir used to mean a shell could read one build's `.zshenv` and + * another's `.zprofile`/`.zshrc`/`.zlogin`, which is why every generated file + * redefined the helpers it called. #15285 removed the hazard for the desktop and + * daemon trees by naming each one after a hash of its contents, so two builds + * never write the same directory. + * + * The relay is the one writer left on a fixed path — `~/.orca-relay/shell-ready` + * — so this is where the scenario is still reachable, and it is now much smaller: + * Orca writes one file, and that file hands ZDOTDIR back before anything else + * runs. Both halves are pinned here: + * + * 1. Files an older build left beside the hook are inert — zsh reads .zprofile, + * .zshrc and .zlogin from the user's own directory and never from here. + * 2. Generation does not delete them. An older build checks for all four before + * calling its tree complete; deleting them makes it rewrite its own `.zshenv` + * over ours, and an older `.zshenv` points ZDOTDIR at a directory that would + * then hold no `.zshrc` at all — the user losing their entire config, which is + * the failure this file exists to prevent. */ -import { spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { ensureShellReadyWrappersAt } from './providers/local-pty-shell-ready-wrapper-generation' +import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers' +import { hasZsh, makeZshHome, runZshPty } from './zsh-startup-hook-pty-harness' -const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 -const ZSH_PATH = hasZsh - ? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim() - : '' const itWithZsh = hasZsh ? it : it.skip -/** - * A `.zshenv` from a build that predates everything the other three files now - * call: it exports the wrapper ZDOTDIR and nothing else. - */ -function olderBuildZshenv(zshDir: string): string { - return `# Orca zsh shell-ready wrapper -export ORCA_ORIG_ZDOTDIR="$HOME" -export ZDOTDIR=${JSON.stringify(zshDir)} -` +/** The three files an older build wrote alongside its own `.zshenv`. */ +const OLDER_BUILD_FILES = { + '.zprofile': 'export ORCA_TEST_STALE_ZPROFILE=1\n', + '.zshrc': 'export ORCA_TEST_STALE_ZSHRC=1\n', + '.zlogin': 'export ORCA_TEST_STALE_ZLOGIN=1\n' } describe.skipIf(process.platform === 'win32')('zsh wrapper dir written by mixed builds', () => { - itWithZsh('still sources the user config when the .zshenv is from an older build', () => { + itWithZsh('ignores an older build’s files and loads the user’s config instead', async () => { const root = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-')) - const home = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-home-')) + const home = makeZshHome({ + '.zshenv': 'export ORCA_TEST_USER_ZSHENV=1\n', + '.zprofile': 'export ORCA_TEST_USER_ZPROFILE=1\n', + '.zshrc': 'export ORCA_TEST_USER_ZSHRC=1\n' + }) try { - expect(ensureShellReadyWrappersAt(root)).toBe(true) + expect(ensureOverlayRestoreWrappers(root)).toBe(true) const zshDir = join(root, 'zsh') - writeFileSync(join(zshDir, '.zshenv'), olderBuildZshenv(zshDir)) - for (const file of ['.zprofile', '.zshrc', '.zlogin']) { - writeFileSync(join(home, file), `echo "RAN ${file}"\n`) + for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) { + writeFileSync(join(zshDir, name), content) } - const result = spawnSync(ZSH_PATH, ['-l', '-i', '-c', 'exit 0'], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', HOME: home, ZDOTDIR: zshDir } + const { values } = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + HOME: home, + ZDOTDIR: zshDir, + ORCA_ORIG_ZDOTDIR: home, + ORCA_SHELL_FEATURES: 'history', + ORCA_HISTFILE: join(home, 'scoped_history') + }, + report: [ + 'ORCA_TEST_USER_ZPROFILE', + 'ORCA_TEST_USER_ZSHRC', + 'ORCA_TEST_STALE_ZPROFILE', + 'ORCA_TEST_STALE_ZSHRC', + 'ORCA_TEST_STALE_ZLOGIN', + 'HISTFILE' + ] }) - // Why both streams: zsh prints `command not found` on stderr and the - // fixture files echo on stdout, and this asserts about each. - const output = `${result.stdout}${result.stderr}` - expect(output).not.toContain('command not found') - expect(output).toContain('RAN .zprofile') - expect(output).toContain('RAN .zshrc') - expect(output).toContain('RAN .zlogin') + // The user's own files loaded; the older build's leftovers did not. + expect(values.ORCA_TEST_USER_ZPROFILE).toBe('1') + expect(values.ORCA_TEST_USER_ZSHRC).toBe('1') + expect(values.ORCA_TEST_STALE_ZPROFILE).toBe('UNSET') + expect(values.ORCA_TEST_STALE_ZSHRC).toBe('UNSET') + expect(values.ORCA_TEST_STALE_ZLOGIN).toBe('UNSET') + expect(values.HISTFILE).toBe(join(home, 'scoped_history')) } finally { rmSync(root, { recursive: true, force: true }) rmSync(home, { recursive: true, force: true }) } }) + + it('leaves an older build’s files in place so that build can still use them', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-keep-')) + try { + expect(ensureOverlayRestoreWrappers(root)).toBe(true) + const zshDir = join(root, 'zsh') + for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) { + writeFileSync(join(zshDir, name), content) + } + + // A second generation pass is what an older build's launch would race. + expect(ensureOverlayRestoreWrappers(root)).toBe(true) + + for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) { + expect(existsSync(join(zshDir, name))).toBe(true) + expect(readFileSync(join(zshDir, name), 'utf8')).toBe(content) + } + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) }) diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index c1bd4365a50..92ae2437f76 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' @@ -59,17 +59,6 @@ function expectBashOsc133Lifecycle(output: string): void { ]) } -function expectZdotdirSourceContext(content: string, fileName: '.zprofile' | '.zshrc' | '.zlogin') { - expect(content).toContain('export ZDOTDIR="$_orca_home"') - expect(content).toContain(`source "$_orca_home/${fileName}"`) - expect(content).toContain('export ZDOTDIR="$_orca_wrapper_zdotdir"') -} - -function expectFinalZdotdirRestoreContext(content: string) { - expect(content).toContain("after Orca's last wrapper file has loaded") - expect(content).toContain('export ZDOTDIR="$_orca_resolved_config_dir"') -} - describe('isRelayWslShell', () => { it.each(['wsl.exe', 'WSL.EXE', 'C:\\Windows\\System32\\wsl.exe', 'wsl'])( 'recognizes %s on a Windows relay', @@ -111,20 +100,17 @@ describe('getRelayShellLaunchConfig', () => { expect(config.args).toEqual(['-l']) expect(config.env.ZDOTDIR).toBe(zshRoot) const zshenv = readFileSync(join(zshRoot, '.zshenv'), 'utf8') - const userZdotdirResolution = - '__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' - expect(zshenv).toContain('export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir"') - const zprofile = readFileSync(join(zshRoot, '.zprofile'), 'utf8') - const zshrc = readFileSync(join(zshRoot, '.zshrc'), 'utf8') - const zlogin = readFileSync(join(zshRoot, '.zlogin'), 'utf8') - expect(zprofile).toContain(userZdotdirResolution) - expect(zshrc).toContain(userZdotdirResolution) - expect(zlogin).toContain(userZdotdirResolution) - expectZdotdirSourceContext(zprofile, '.zprofile') - expectZdotdirSourceContext(zshrc, '.zshrc') - expectZdotdirSourceContext(zlogin, '.zlogin') + // Why no ORCA_USER_ZDOTDIR: the relay used to republish the inherited + // ZDOTDIR under that name so its three later wrapper files could prefer it + // over the spawn-time value. There are no later wrapper files, and a + // ZDOTDIR the user's own .zshenv exports simply stands. + expect(zshenv).not.toContain('ORCA_USER_ZDOTDIR') + expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"') + expect(zshenv).toContain('builtin source -- "$_orca_user_zshenv"') + for (const name of ['.zprofile', '.zshrc', '.zlogin']) { + expect(existsSync(join(zshRoot, name))).toBe(false) + } // Why .zshenv: the final restore is the last step of the one epilogue. - expectFinalZdotdirRestoreContext(zshenv) } ) @@ -222,7 +208,7 @@ describe('getRelayShellLaunchConfig', () => { }) expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain( - 'export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir"' + 'builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"' ) }) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 58ab9ab75e4..f617903056b 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -6,7 +6,7 @@ import { SHELL_STARTUP_FEATURE_ENV, type ShellStartupFeature } from '../main/shell-startup-features' -import { resolveInheritedZdotdir } from '../main/zsh-wrapper-dir-ownership' +import { inheritedZdotdirEnv, resolveInheritedZdotdir } from '../main/zsh-wrapper-dir-ownership' import { ensureOverlayRestoreWrappers } from './pty-shell-overlay-wrappers' const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready' const POSIX_LOGIN_ARGS = ['-l'] @@ -134,7 +134,7 @@ export function getRelayShellLaunchConfig( return { args: POSIX_LOGIN_ARGS, env: { - ORCA_ORIG_ZDOTDIR: resolveInheritedZdotdir(env, process.env.HOME ?? ''), + ...inheritedZdotdirEnv(resolveInheritedZdotdir(env)), ZDOTDIR: join(root, 'zsh'), ...featureEnv }, diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index 197f900268b..ca7867c98e2 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -5,15 +5,12 @@ import { BASH_FEATURE_CHANNEL_BLOCK, BASH_PROMPT_COMMAND_COMPOSITION_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, - ZSH_HISTFILE_RESTORE_BLOCK, + BASH_HISTFILE_RESTORE_BLOCK, ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../main/shell-templates' import { writeShellWrapperFiles } from '../main/shell-wrapper-file-writer' -import { - buildZshStartupWrapperFiles, - type ZshStartupWrapperSpec -} from '../main/zsh-startup-wrapper-builder' +import { buildZshStartupHook, type ZshStartupHookSpec } from '../main/zsh-startup-wrapper-builder' /** Writes the zsh/bash overlay wrapper files a relay-spawned shell sources. * Split from pty-shell-launch.ts so the launch-config decisions stay readable @@ -21,19 +18,16 @@ import { const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' -// Why: the relay .zshenv republishes the inherited ZDOTDIR as ORCA_USER_ZDOTDIR, -// so later wrapper files prefer it over the spawn-time ORCA_ORIG_ZDOTDIR. -const RELAY_HOME_EXPRESSION = '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' - -function getRelayZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { +// Why the relay no longer needs its own ZDOTDIR shape: it used to republish the +// inherited value as ORCA_USER_ZDOTDIR so the later wrapper files could prefer +// it over the spawn-time ORCA_ORIG_ZDOTDIR. There are no later wrapper files +// now, and ZDOTDIR itself carries the answer, so the relay and desktop bodies +// are one template again. +function getRelayZshWrapperSpec(): ZshStartupHookSpec { return { headerLabel: 'Orca relay zsh overlay wrapper', - zshDir, - zshenvStrategy: 'overlay-user-zdotdir', - homeExpression: RELAY_HOME_EXPRESSION, readyMarkerEscaped: SHELL_READY_MARKER_ESCAPED, osc133CommandMarkers: false, - skipUserZshrcWhenHomeIsWrapperDir: false, overlayRestoreComment: '# Why: remote startup files can re-export user defaults after relay spawn.', restores: { @@ -50,7 +44,7 @@ export function ensureOverlayRestoreWrappers(root: string): boolean { const zshDir = join(root, 'zsh') const bashDir = join(root, 'bash') - const zsh = buildZshStartupWrapperFiles(getRelayZshWrapperSpec(zshDir)) + const zshenv = buildZshStartupHook(getRelayZshWrapperSpec()) const bashRc = `# Orca relay bash overlay wrapper ${BASH_FEATURE_CHANNEL_BLOCK} ${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} @@ -78,7 +72,7 @@ fi [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac ${getPosixOmpShellWrapper()} -${ZSH_HISTFILE_RESTORE_BLOCK} +${BASH_HISTFILE_RESTORE_BLOCK} # Why: SSH bash sessions need the same command lifecycle markers as local # bash so agent rows stop showing "working" when the foreground command exits. __orca_initializing_wrapper=1 @@ -162,11 +156,9 @@ trap '__orca_osc133_preexec' DEBUG unset __orca_initializing_wrapper ` + // Only .zshenv: see local-pty-shell-ready-wrapper-generation.ts. const files = [ - [join(zshDir, '.zshenv'), zsh.zshenv], - [join(zshDir, '.zprofile'), zsh.zprofile], - [join(zshDir, '.zshrc'), zsh.zshrc], - [join(zshDir, '.zlogin'), zsh.zlogin], + [join(zshDir, '.zshenv'), zshenv], [join(zshDir, ZSH_WRAPPER_DIR_MARKER_FILE), ZSH_WRAPPER_DIR_MARKER_CONTENT], [join(bashDir, 'rcfile'), bashRc] ] as const From acbcb477a1d7880e99cb924c5035e3f165761f9c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:39:48 -0700 Subject: [PATCH 005/103] Auto e2e tests autofix scheduled ci 1h run 1 20260818T2143 (#15379) * fix: update E2E tests for API changes and selector robustness - Improve source control file locator specificity to avoid flakiness - Fix board test to use correct worktree ID attribute - Update removeWorktree calls to pass host ID parameter - Simplify git status polling with timeout expectation * fix: increase packaged-watchdog launch timeout and await git-status rows Extract hardcoded 15s launch timeout to a 30s constant for better reliability under load. E2E test now waits for all git-status rows to render before asserting absence of status messages, preventing flaky passes when the list is still loading. --- ...ged-hang-watchdog-worker-contract.test.mjs | 2 ++ .../smoke-packaged-hang-watchdog-worker.mjs | 3 +- ...ource-control-pr-generation-switch.spec.ts | 8 +++-- ...orkspace-board-lane-virtualization.spec.ts | 2 +- tests/e2e/workspace-space-git-status.spec.ts | 29 +++++++++---------- tests/e2e/worktree-lifecycle.spec.ts | 12 ++++++-- 6 files changed, 33 insertions(+), 23 deletions(-) diff --git a/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs b/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs index 1ae558c7691..a4998760b67 100644 --- a/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs +++ b/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs @@ -19,6 +19,8 @@ describe('packaged hang watchdog worker contract', () => { expect(smokeSource).toContain( "process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]" ) + expect(smokeSource).toContain('const LAUNCH_TIMEOUT_MS = 30_000') + expect(smokeSource).toContain('timeout: LAUNCH_TIMEOUT_MS') }) // Why: Electron ignores process.exitCode, so the gate needs app.exit plus a stdout assertion. diff --git a/config/scripts/smoke-packaged-hang-watchdog-worker.mjs b/config/scripts/smoke-packaged-hang-watchdog-worker.mjs index ae9b340785a..a8c4e578a8c 100644 --- a/config/scripts/smoke-packaged-hang-watchdog-worker.mjs +++ b/config/scripts/smoke-packaged-hang-watchdog-worker.mjs @@ -11,6 +11,7 @@ const ASAR_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_ASAR' const TIMEOUT_MS = 100 const CHECK_INTERVAL_MS = 20 const POLL_TIMEOUT_MS = 5_000 +const LAUNCH_TIMEOUT_MS = 30_000 const SUCCESS_LINE = '[packaged-watchdog-smoke] app.asar worker detected and recovered a stall' function sleep(ms) { @@ -144,7 +145,7 @@ function runSmoke() { const result = spawnSync(executable, electronArgs, { env, encoding: 'utf8', - timeout: 15_000 + timeout: LAUNCH_TIMEOUT_MS }) if (result.status !== 0) { throw new Error( diff --git a/tests/e2e/source-control-pr-generation-switch.spec.ts b/tests/e2e/source-control-pr-generation-switch.spec.ts index 283b47416eb..58091cd3cf8 100644 --- a/tests/e2e/source-control-pr-generation-switch.spec.ts +++ b/tests/e2e/source-control-pr-generation-switch.spec.ts @@ -401,9 +401,11 @@ test.describe('Source Control AI PR generation worktree switching', () => { await installDelayedCommitMessageGenerator(orcaPage, generatorScriptPath, callLogPath) await openSourceControl(orcaPage, commitWorktreeId) - await expect(orcaPage.getByText('e2e-commit-message-generation.txt')).toBeVisible({ - timeout: 10_000 - }) + await expect( + orcaPage + .getByTestId('source-control-entry') + .getByText('e2e-commit-message-generation.txt', { exact: true }) + ).toBeVisible({ timeout: 10_000 }) const generate = orcaPage.getByRole('button', { name: 'Generate commit message with AI' }) diff --git a/tests/e2e/workspace-board-lane-virtualization.spec.ts b/tests/e2e/workspace-board-lane-virtualization.spec.ts index a6c847a5504..69a18e06937 100644 --- a/tests/e2e/workspace-board-lane-virtualization.spec.ts +++ b/tests/e2e/workspace-board-lane-virtualization.spec.ts @@ -278,7 +278,7 @@ test.describe('Workspace board lane virtualization', () => { const sourceCard = board .locator('[data-workspace-status="state-20"] [data-workspace-board-card-id]') .first() - const sourceId = await sourceCard.getAttribute('data-workspace-board-card-id') + const sourceId = await sourceCard.getAttribute('data-workspace-board-worktree-id') const sourceBox = await sourceCard.boundingBox() const targetBox = await finalLane .locator('[data-workspace-board-lane-scroll]') diff --git a/tests/e2e/workspace-space-git-status.spec.ts b/tests/e2e/workspace-space-git-status.spec.ts index 45b756a662f..57b0e6dc136 100644 --- a/tests/e2e/workspace-space-git-status.spec.ts +++ b/tests/e2e/workspace-space-git-status.spec.ts @@ -47,7 +47,7 @@ test.describe('Workspace Space git status checks', () => { // cache TTL until every path registers before deriving the space rows. await loadWorktreesUntilPathsPresent(orcaPage, repoId, registeredWorktreePaths) - await orcaPage.evaluate( + const rowDisplayNames = await orcaPage.evaluate( async ({ testRepoPath, worktreePaths }) => { const store = window.__store if (!store) { @@ -129,27 +129,24 @@ test.describe('Workspace Space git status checks', () => { } }) store.getState().openSpacePage() + return rows.map((row) => row.displayName) }, { testRepoPath, worktreePaths: registeredWorktreePaths } ) - await expect - .poll( - () => - orcaPage.evaluate(() => { - const state = window.__store?.getState() - if (!state?.workspaceSpaceAnalysis) { - return 60 - } - return state.workspaceSpaceAnalysis.worktrees.filter( - (row) => state.gitStatusByWorktree[row.worktreeId] === undefined - ).length - }), - { timeout: 30_000 } + // Why: `toHaveCount(0)` passes trivially while the list is still empty, so + // every row has to be on screen before the absent-status assertion means + // anything. + const rowCheckboxes = orcaPage.getByRole('checkbox', { + name: new RegExp( + `^Select (?:${rowDisplayNames + .map((displayName) => displayName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|')})$` ) - .toBe(0) + }) + await expect(rowCheckboxes).toHaveCount(rowDisplayNames.length, { timeout: 30_000 }) - await expect(orcaPage.getByText('Keep: git not checked')).toHaveCount(0) + await expect(orcaPage.getByText('Keep: git not checked')).toHaveCount(0, { timeout: 30_000 }) } finally { for (const worktreePath of worktreePaths) { try { diff --git a/tests/e2e/worktree-lifecycle.spec.ts b/tests/e2e/worktree-lifecycle.spec.ts index 1f5bed38ac6..07f4da9fbd9 100644 --- a/tests/e2e/worktree-lifecycle.spec.ts +++ b/tests/e2e/worktree-lifecycle.spec.ts @@ -73,7 +73,11 @@ async function removeWorktreeViaStore( return { ok: false as const, error: 'store unavailable' } } - const result = await store.getState().removeWorktree(id, true) + const state = store.getState() + const result = await state.removeWorktree( + { id, executionHostId: state.getKnownWorktreeById(id)?.hostId ?? null }, + true + ) return result }, worktreeId) } @@ -101,7 +105,11 @@ test.describe('Worktree Lifecycle', () => { await orcaPage .evaluate(async (id) => { try { - await window.__store?.getState().removeWorktree(id, true) + const state = window.__store?.getState() + await state?.removeWorktree( + { id, executionHostId: state.getKnownWorktreeById(id)?.hostId ?? null }, + true + ) } catch { /* best-effort cleanup */ } From ef096d539db1260d858dfb350b792983c93c0779 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:42:39 -0700 Subject: [PATCH 006/103] fix(terminal): refuse a cursor on a screen read, and correct the source docs (#15563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #15380. The RPC accepted `cursor` and `screen` together. The CLI refuses the pair, but terminal.read is reachable without it, and honoring both answered with rendered lines carrying the stream's pagination metadata — two frames of reference in one payload, which is the confusion `source` exists to remove. The guard beside it, withVisibleSnapshotFallback, already declines to substitute rendered lines when a cursor is present; the screen path now agrees, at the RPC boundary where every remote caller passes. Nothing could previously send both, since `screen` did not exist, so rejecting breaks no existing caller. The command notes and the runtime comment both still described the fallback as `source: stream`, left over from renaming that value to `screen-unavailable` during implementation. The spec text is surfaced through `orca help` and the agent-context schema, so a caller following it would test for a value the code never emits. Both now describe all four states, including that an absent source means the host predates the field. --- src/cli/specs/core.ts | 2 +- src/main/runtime/orca-runtime.ts | 7 ++- .../terminal-read-screen-cursor.test.ts | 43 +++++++++++++++++++ src/main/runtime/rpc/methods/terminal.ts | 6 +++ 4 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 7aa503fad36..a3cab5dfe70 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -204,7 +204,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ 'Omit --terminal to target the active terminal in the current worktree.', 'By default this returns accumulated terminal output with escape sequences stripped, not the rendered screen. Any program that repaints a line — shells, progress bars, TUIs — comes back as stacked fragments, so one `clear` keystroke by keystroke reads as `cclclecleaclear`, and spaces a prompt draws by moving the cursor are absent.', 'Use --screen to read what the terminal actually renders. Prefer it whenever the answer depends on how output looks rather than what was emitted over time; the default is unsuitable for verifying rendered output.', - 'The result reports source: stream or screen, so a caller can tell which question was answered. A --screen read falls back to source: stream when no rendered state exists rather than passing the stream off as a screen.', + 'The result reports source: stream when it is accumulated output, screen when it is the rendered screen, and screen-unavailable when a screen was asked for but none could be rendered and the accumulated output is being returned instead. An absent source means the host predates the field.', '--screen and --cursor are mutually exclusive: a screen read is the current frame and has no history to page.', 'Use --cursor with the nextCursor value from a previous read to get only new output since that read.', 'Use --limit to request more retained lines for long agent responses; output reports oldestCursor when older lines were dropped.', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 62a1c9c96e0..5e48785e74e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -18480,8 +18480,11 @@ export class OrcaRuntimeService { // ("cclclecleaclear" for one `clear`) and drops spaces a prompt draws with cursor-forward. // That is the right answer for "what happened over time" and the wrong one for "what is on // screen", so an explicit screen read goes to the emulator state instead. When no rendered - // state exists the stream is still returned, but labelled `stream` rather than passed off as - // a screen — silently answering the other question is the defect this exists to stop. + // state exists the stream is still returned, but labelled `screen-unavailable` rather than + // passed off as a screen — silently answering the other question is the defect this exists to + // stop, and that label is what separates it from a stream the caller actually asked for. + // A cursor cannot reach here: pairing one with a screen read is refused at the RPC boundary, + // because rendered lines carrying the stream's pagination metadata would mix both frames. private async readRenderedScreen( ptyId: string, read: RuntimeTerminalRead, diff --git a/src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts b/src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts new file mode 100644 index 00000000000..bd870e0f886 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { TERMINAL_METHODS } from './terminal' + +function readParams() { + const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.read') + if (!method?.params) { + throw new Error('Missing params schema for terminal.read') + } + return method.params +} + +describe('terminal.read screen and cursor', () => { + // Why: the CLI refuses this pair, but terminal.read is an RPC and any other caller can send + // both. Honoring them would answer with rendered lines carrying the stream's pagination + // metadata — two frames of reference in one payload. + it('rejects a cursor combined with a screen read', () => { + expect(() => readParams().parse({ terminal: 'term_abc', screen: true, cursor: 42 })).toThrow( + /Cursor cannot be combined with a screen read/ + ) + }) + + it('still accepts each on its own', () => { + expect(readParams().parse({ terminal: 'term_abc', screen: true })).toMatchObject({ + screen: true + }) + expect(readParams().parse({ terminal: 'term_abc', cursor: 42 })).toMatchObject({ cursor: 42 }) + }) + + // Why: cursor 0 is a real cursor, not an absent one — an off-by-one here would let the + // combination through on the first page. + it('rejects cursor zero with a screen read', () => { + expect(() => readParams().parse({ terminal: 'term_abc', screen: true, cursor: 0 })).toThrow( + /Cursor cannot be combined with a screen read/ + ) + }) + + it('leaves an ordinary paginated read untouched', () => { + expect(readParams().parse({ terminal: 'term_abc', cursor: 0, limit: 100 })).toMatchObject({ + cursor: 0, + limit: 100 + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index bdbeb53cd6b..5dee92a7676 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -925,6 +925,12 @@ const TerminalRead = TerminalHandle.extend({ // Why: optional so an older host that does not understand it simply drops the key and answers // with its usual stream read; the response's `source` is what tells the caller which it got. screen: z.literal(true).optional() +}).refine((params) => !(params.screen === true && params.cursor !== undefined), { + // Why: a cursor pages through accumulated output; a screen is the current frame with nothing + // behind it. Honoring both would answer with rendered lines carrying the stream's pagination + // metadata — two frames of reference in one payload, which is the confusion `source` exists to + // remove. The CLI already refuses the pair, but the RPC is reachable without it. + message: 'Cursor cannot be combined with a screen read' }) // Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct. From 4daace62511c15cb8ba87686e1b8faf7142c7344 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:54:03 -0700 Subject: [PATCH 007/103] fix(tabs): stop split workspaces multiplying columns on return (#15482) --- ...-tabs-sync-layout-duplicate-groups.test.ts | 113 ++++++++++++++++++ .../src/runtime/web-session-tabs-sync.ts | 27 ++++- .../tabs-hydration-group-validation.test.ts | 36 ++++++ .../src/store/slices/tabs-hydration.ts | 21 +++- .../slices/tabs-model-reconciliation.test.ts | 63 ++++++++++ 5 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts diff --git a/src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts new file mode 100644 index 00000000000..b0045bd658b --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import type { Tab, TabGroup } from '../../../shared/tab-types' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' +import { + ENV, + NOW, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from './web-session-tabs-sync-test-harness' + +vi.mock('../store', () => ({ + useAppStore: { + setState: vi.fn() + } +})) + +const LOCAL_GROUP_ID = 'local-group-1' + +function localEditorTab(): Tab { + return { + id: 'local-editor-tab', + entityId: '/repo/NOTES.md', + groupId: LOCAL_GROUP_ID, + worktreeId: WT, + contentType: 'editor', + label: 'NOTES.md', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW, + isPreview: false, + isPinned: false + } +} + +function localGroup(): TabGroup { + return { + id: LOCAL_GROUP_ID, + worktreeId: WT, + activeTabId: 'local-editor-tab', + tabOrder: ['local-editor-tab'], + recentTabIds: ['local-editor-tab'] + } +} + +function stateWithLocalGroup(): WebSessionTabsSyncState { + return makeState({ + unifiedTabsByWorktree: { [WT]: [localEditorTab()] }, + groupsByWorktree: { [WT]: [localGroup()] }, + activeGroupIdByWorktree: { [WT]: LOCAL_GROUP_ID }, + layoutByWorktree: { [WT]: { type: 'leaf', groupId: LOCAL_GROUP_ID } } + }) +} + +// Host group whose tabs are absent from this snapshot: the host names groups but +// publishes no layout for them, which is the shape that used to duplicate leaves. +function snapshotWithUnmappedHostGroup(): ReturnType { + return makeSnapshot([], { + activeGroupId: 'host-group-1', + activeTabId: null, + tabGroups: [{ id: 'host-group-1', activeTabId: 'host-tab-1', tabOrder: ['host-tab-1'] }] + }) +} + +function collectLeafGroupIds(layout: unknown, out: string[] = []): string[] { + const node = layout as { type: string; groupId?: string; first?: unknown; second?: unknown } + if (!node) { + return out + } + if (node.type === 'leaf') { + out.push(node.groupId!) + return out + } + collectLeafGroupIds(node.first, out) + collectLeafGroupIds(node.second, out) + return out +} + +describe('applyWebSessionTabsSnapshot layout composition', () => { + beforeEach(resetWebSessionTabsSyncTestState) + + it('never places one tab group in two layout leaves', () => { + const patch = applyWebSessionTabsSnapshot( + stateWithLocalGroup(), + snapshotWithUnmappedHostGroup(), + ENV, + NOW + ) as Partial + + const leaves = collectLeafGroupIds( + patch.layoutByWorktree?.[WT] ?? { type: 'leaf', groupId: LOCAL_GROUP_ID } + ) + expect(leaves).toEqual([LOCAL_GROUP_ID]) + }) + + it('does not grow the layout when the same snapshot is applied twice', () => { + let state = stateWithLocalGroup() + for (let i = 0; i < 3; i++) { + resetWebSessionTabsSyncTestState() + const patch = applyWebSessionTabsSnapshot( + state, + snapshotWithUnmappedHostGroup(), + ENV, + NOW + ) as Partial + state = { ...state, ...patch } + } + + expect(collectLeafGroupIds(state.layoutByWorktree[WT])).toEqual([LOCAL_GROUP_ID]) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 14028c25c38..3cbd2243027 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -1762,6 +1762,24 @@ function pruneTabGroupLayout( return first ?? second } +function dropTabGroupLayoutGroups( + layout: TabGroupLayoutNode | null, + excludedGroupIds: ReadonlySet +): TabGroupLayoutNode | null { + if (!layout) { + return null + } + if (layout.type === 'leaf') { + return excludedGroupIds.has(layout.groupId) ? null : layout + } + const first = dropTabGroupLayoutGroups(layout.first, excludedGroupIds) + const second = dropTabGroupLayoutGroups(layout.second, excludedGroupIds) + if (first && second) { + return { ...layout, first, second } + } + return first ?? second +} + function appendTabGroupLayout( first: TabGroupLayoutNode | null, second: TabGroupLayoutNode | null @@ -1772,11 +1790,18 @@ function appendTabGroupLayout( if (!second) { return first } + // Why: a group already placed by `first` must not gain a second leaf — two + // leaves for one group render the same tab strip in two columns, and each + // later snapshot appends another copy. + const appended = dropTabGroupLayoutGroups(second, collectLayoutGroupIds(first)) + if (!appended) { + return first + } return { type: 'split', direction: 'horizontal', first, - second + second: appended } } diff --git a/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts b/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts index 70d81deb851..7a9b88ac78e 100644 --- a/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts +++ b/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts @@ -213,4 +213,40 @@ describe('buildHydratedTabState referential repair', () => { expect(leafGroupIds(result.layoutByWorktree.w1)).toEqual(['g1', 'g2']) }) + + it('collapses a persisted layout that repeats one group across leaves', () => { + const result = buildHydratedTabState( + { + ...makeBaseSession(), + unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g2', 1)] }, + tabGroups: { + w1: [ + { id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }, + { id: 'g2', worktreeId: 'w1', activeTabId: 't2', tabOrder: ['t2'] } + ] + }, + tabGroupLayouts: { + w1: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'g1' }, + second: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'g1' }, + second: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'g2' }, + second: { type: 'leaf', groupId: 'g1' } + } + } + } + } + }, + new Set(['w1']) + ) + + expect(leafGroupIds(result.layoutByWorktree.w1)).toEqual(['g1', 'g2']) + }) }) diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index 753286e1142..c115c431539 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -19,16 +19,31 @@ type HydratedTabState = { layoutByWorktree: Record } +/** Drops leaves whose group is gone, and repeat leaves for a group already + * placed earlier in the tree — one column per group is the render invariant, + * and a repeated leaf paints the same tab strip in every one of its columns. */ export function pruneTabGroupLayoutForGroups( root: TabGroupLayoutNode, validGroupIds: Set +): TabGroupLayoutNode | null { + return pruneLayoutNodeForGroups(root, validGroupIds, new Set()) +} + +function pruneLayoutNodeForGroups( + root: TabGroupLayoutNode, + validGroupIds: Set, + placedGroupIds: Set ): TabGroupLayoutNode | null { if (root.type === 'leaf') { - return validGroupIds.has(root.groupId) ? root : null + if (!validGroupIds.has(root.groupId) || placedGroupIds.has(root.groupId)) { + return null + } + placedGroupIds.add(root.groupId) + return root } - const first = pruneTabGroupLayoutForGroups(root.first, validGroupIds) - const second = pruneTabGroupLayoutForGroups(root.second, validGroupIds) + const first = pruneLayoutNodeForGroups(root.first, validGroupIds, placedGroupIds) + const second = pruneLayoutNodeForGroups(root.second, validGroupIds, placedGroupIds) if (first === null) { return second diff --git a/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts b/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts index e943f2a20f0..0869b720ec0 100644 --- a/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts +++ b/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts @@ -108,6 +108,69 @@ describe('TabsSlice', () => { expect(result.renderableTabCount).toBe(1) }) + // A session mirrored from a runtime host used to append a fresh leaf for a + // group the layout already held, so returning to a split workspace showed the + // same tab strip in several columns. Reconciliation collapses the repeats. + it('collapses a layout that repeats one group across leaves', () => { + const groupId = 'g-1' + store.setState({ + unifiedTabsByWorktree: { + [WT]: [ + { + id: 'terminal-1', + entityId: 'terminal-1', + groupId, + worktreeId: WT, + contentType: 'terminal', + label: 'Terminal 1', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + tabsByWorktree: { + [WT]: [ + { + id: 'terminal-1', + ptyId: 'pty-1', + worktreeId: WT, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'terminal-1': ['pty-1'] }, + groupsByWorktree: { + [WT]: [ + { id: groupId, worktreeId: WT, activeTabId: 'terminal-1', tabOrder: ['terminal-1'] } + ] + }, + activeGroupIdByWorktree: { [WT]: groupId }, + layoutByWorktree: { + [WT]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId }, + second: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId }, + second: { type: 'leaf', groupId } + } + } + } + }) + + store.getState().reconcileWorktreeTabModel(WT) + + expect(store.getState().layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId }) + }) + it('keeps simulator tabs because they reconnect their own backing stream', () => { const terminalGroupId = 'g-terminal' const simulatorGroupId = 'g-simulator' From 9a898a84ccfb2b1d6aebd1cd915750c396a61e26 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:25:47 -0700 Subject: [PATCH 008/103] fix(terminal): keep xterm's render pause latched for a pane with no layout box (#15555) * fix(terminal): keep xterm's render pause latched for a pane with no layout box resetWebglTextureAtlas() released xterm's paused-render gate for every pane of a visible manager, including panes that are display:none (a collapsed sibling of an expanded pane, a restore that stays display:none for its whole reattach). forceRepaintThroughRenderPause exists for a pane that is already DOM-visible while xterm's IntersectionObserver lags a frame. On a pane with no box it paints the freshly cleared render model into nothing, and because the observer only fires on a state change it never re-pauses the service. It also clears _needsFullRefresh, which is the only thing that makes _handleIntersectionChange repaint on reveal and flush the deferred _pausedResizeTask. Latch instead for those panes: terminal.refresh() re-arms _needsFullRefresh and xterm repaints from it on reveal. * test(terminal): type the render-service repaint mock --- .../pane-manager/pane-webgl-renderer.test.ts | 68 ++++++++++++++++++- .../lib/pane-manager/pane-webgl-renderer.ts | 13 +++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts index b3990f20400..11076608027 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' import { @@ -63,6 +63,42 @@ function createFittablePane(): ManagedPaneInternal { return pane } +type FakeRenderService = { + _isPaused: boolean + _needsFullRefresh: boolean + refreshRows: Mock<(start: number, end: number, sync?: boolean) => void> +} + +/** A pane whose xterm render service is paused, as it is whenever the pane has + * no layout box: `display` decides whether the element is one xterm's + * IntersectionObserver would report as intersecting. */ +function createPausedPane(display: 'block' | 'none'): { + pane: ManagedPaneInternal + renderService: FakeRenderService +} { + const pane = createPane() + const renderService: FakeRenderService = { + _isPaused: true, + _needsFullRefresh: false, + // Mirrors xterm: a refresh while paused only latches the pending full repaint. + refreshRows: vi.fn<(start: number, end: number, sync?: boolean) => void>(() => { + if (renderService._isPaused) { + renderService._needsFullRefresh = true + } + }) + } + const view = { getComputedStyle: () => ({ display }) } + const element = { ownerDocument: { defaultView: view }, parentElement: null } + pane.container = element as never + pane.xtermContainer = element as never + pane.terminal = { + ...pane.terminal, + refresh: vi.fn(() => renderService.refreshRows(0, pane.terminal.rows - 1)), + _core: { _renderService: renderService } + } as never + return { pane, renderService } +} + describe('terminal WebGL addon lifecycle', () => { beforeEach(() => { resetTerminalWebglSuggestion() @@ -132,6 +168,36 @@ describe('terminal WebGL addon lifecycle', () => { expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) }) + it('keeps the render pause latched when resetting a pane that has no layout box', () => { + // Regression: the atlas reset released xterm's pause for every pane of a + // visible manager, including a collapsed sibling of an expanded pane. That + // painted the freshly cleared model into an element with no box, and left + // the service unpaused for good — the IntersectionObserver only fires on a + // change, so it never re-pauses. Clearing _needsFullRefresh with it drops + // the repaint the observer owes the pane on reveal, and the deferred + // _pausedResizeTask that rides along with it. + const { pane, renderService } = createPausedPane('none') + + resetWebglTextureAtlas(pane) + + expect(renderService._isPaused).toBe(true) + expect(renderService._needsFullRefresh).toBe(true) + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('still forces the paused render through for a pane that has a layout box', () => { + // The reveal case the release exists for: DOM-visible, but xterm's observer + // has not caught up, so a plain refresh() would be swallowed. + const { pane, renderService } = createPausedPane('block') + + resetWebglTextureAtlas(pane) + + expect(renderService._isPaused).toBe(false) + expect(renderService._needsFullRefresh).toBe(false) + expect(renderService.refreshRows).toHaveBeenCalledWith(0, 23, true) + expect(pane.terminal.refresh).not.toHaveBeenCalled() + }) + it('skips the reset while WebGL is latched off after a context loss', () => { const pane = createPane() pane.webglDisabledAfterContextLoss = true diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index f162052cebe..9d05e6b876b 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -2,6 +2,7 @@ import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' import { getLivePaneCensus } from './pane-manager-registry' +import { isManagedPaneDisplayNone } from './pane-display-visibility' import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' import { getTerminalWebglAutoDecision, @@ -151,7 +152,17 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { // paused-render gate and the cleared model never repaints (stale bottom rows // until a drag-select forces a redraw). Force the paused render through // first; only fall back to refresh() when the terminal was not gated. - if (!forceRepaintThroughRenderPause(pane.terminal)) { + // + // Why the display check: that release is only right for a pane that is + // DOM-visible. A pane with no box at all (collapsed sibling of an expanded + // pane, a restore that stays display:none for its whole reattach) is + // legitimately paused, and releasing it paints the just-cleared model into + // nothing and then leaves the service unpaused for good — the observer only + // fires on a change, so it never re-pauses. Clearing _needsFullRefresh with + // it also drops the full repaint the observer owes the pane on reveal, and + // the deferred _pausedResizeTask that flushes alongside it. Latching is what + // xterm's own gate does, and the reveal repaints from the latch. + if (isManagedPaneDisplayNone(pane) || !forceRepaintThroughRenderPause(pane.terminal)) { // Why: refresh even without a WebGL addon so recovery never silently // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. pane.terminal.refresh(0, pane.terminal.rows - 1) From 9d06b3ba938d3c5467de5b82b0bec322af2620a8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:33:05 -0700 Subject: [PATCH 009/103] ci: stop docs-only commits from starting the skill-roundtrip matrix (#15474) A cancelled Skill update round trip on a README merge painted main red because push to main had no path filter. Share the PR path list on push, and skip expensive PR Checks when every changed file is docs. --- .github/workflows/pr.yml | 88 +++++++++++++- .github/workflows/skill-update-roundtrip.yml | 10 +- config/scripts/pr-code-change-scope.mjs | 41 +++++++ config/scripts/pr-code-change-scope.test.mjs | 107 ++++++++++++++++++ config/scripts/pr-e2e-gate-contract.test.mjs | 10 +- .../scripts/pr-workflow-parallelism.test.mjs | 1 + .../skill-update-roundtrip-workflow.test.mjs | 35 ++++++ 7 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 config/scripts/pr-code-change-scope.mjs create mode 100644 config/scripts/pr-code-change-scope.test.mjs create mode 100644 config/scripts/skill-update-roundtrip-workflow.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4717da652fc..48b545ef3ea 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,8 +16,46 @@ permissions: contents: read jobs: + # Why: a README/docs-only PR used to start the full matrix (32 test shards, + # two package jobs, typecheck, git compat, xterm, shell contracts). Path + # filters on `on.pull_request` would drop the `verify` check entirely; this + # detector keeps verify as the required aggregate and skips the expensive jobs. + code_paths: + name: detect code-relevant changes + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.filter.outputs.should_run }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # Why blob:none: full history is needed for the merge-base diff, but historical + # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the + # few this job actually reads on demand. + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + - name: Classify changed paths + id: filter + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + # Why --no-renames: name-only rename detection can report only the destination. + # A code file moved under docs/ must still expose its code-side deletion. + CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")" + echo "Changed paths:" + printf '%s\n' "$CHANGED" + SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs)" + echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT" + echo "should_run=$SHOULD_RUN" + static_analysis: name: static analysis + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest steps: @@ -140,6 +178,8 @@ jobs: run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA" typecheck: + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest steps: @@ -166,6 +206,8 @@ jobs: git_compatibility: name: Git compatibility + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest steps: @@ -231,6 +273,8 @@ jobs: xterm_patch_sync: name: xterm patch sync + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest steps: @@ -259,6 +303,8 @@ jobs: shell_contracts: name: shell contracts + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest # Why: this job's cost is almost entirely package download, and a stalled mirror has # no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this @@ -372,6 +418,8 @@ jobs: test: name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest strategy: fail-fast: false @@ -416,6 +464,8 @@ jobs: cross-version-wire: name: cross-version wire compatibility + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest steps: @@ -443,6 +493,8 @@ jobs: managed_hook_node18: name: managed hooks on Node 18 + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest steps: @@ -466,6 +518,8 @@ jobs: package: name: package + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest steps: @@ -527,6 +581,8 @@ jobs: package_windows: name: package (windows) + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: windows-2022 timeout-minutes: 30 @@ -592,8 +648,9 @@ jobs: # release runs retain full-suite coverage. e2e-paths: name: detect changed e2e specs + needs: [code_paths] runs-on: ubuntu-latest - if: github.event.pull_request.draft != true + if: github.event.pull_request.draft != true && needs.code_paths.outputs.should_run == 'true' # Why: detector only needs to read the checkout; do not inherit repo defaults. permissions: contents: read @@ -673,6 +730,7 @@ jobs: verify: if: always() needs: + - code_paths - static_analysis - root_directory_guard - typecheck @@ -696,6 +754,8 @@ jobs: # checked outside the loop or it would excuse the jobs above. - name: Require successful checks env: + CODE_PATHS: ${{ needs.code_paths.result }} + SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }} STATIC_ANALYSIS: ${{ needs.static_analysis.result }} ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }} TYPECHECK: ${{ needs.typecheck.result }} @@ -707,7 +767,33 @@ jobs: PACKAGE: ${{ needs.package.result }} PACKAGE_WINDOWS: ${{ needs.package_windows.result }} run: | + if [ "$CODE_PATHS" != "success" ]; then + exit 1 + fi + if [ "$SHOULD_RUN" != "true" ]; then + echo "Docs-only change; expensive PR checks skipped." + if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then + exit 1 + fi + for result in \ + "$STATIC_ANALYSIS" \ + "$TYPECHECK" \ + "$GIT_COMPATIBILITY" \ + "$XTERM_PATCH_SYNC" \ + "$SHELL_CONTRACTS" \ + "$TEST" \ + "$MANAGED_HOOK_NODE18" \ + "$PACKAGE" \ + "$PACKAGE_WINDOWS"; do + if [ "$result" != "skipped" ]; then + exit 1 + fi + done + exit 0 + fi + # Require success when the PR has code-relevant changes for result in \ + "$CODE_PATHS" \ "$STATIC_ANALYSIS" \ "$ROOT_DIRECTORY_GUARD" \ "$TYPECHECK" \ diff --git a/.github/workflows/skill-update-roundtrip.yml b/.github/workflows/skill-update-roundtrip.yml index 6c9418e1f64..71fcf264f69 100644 --- a/.github/workflows/skill-update-roundtrip.yml +++ b/.github/workflows/skill-update-roundtrip.yml @@ -1,18 +1,26 @@ name: Skill update round trip +# Why the same paths on push as pull_request: GitHub evaluates each event's +# filters independently. The PR already skipped README-only changes, but the +# unfiltered `push` to main still started the 13-job matrix. Cancelling that +# run marks the default-branch tip failed, so the repo shows a red X. on: pull_request: - paths: + paths: &skill-roundtrip-paths - 'skills/**' - 'resources/skills/**' - 'config/scripts/verify-skill-update-roundtrip.mjs' + - 'config/scripts/skill-update-roundtrip-workflow.test.mjs' - 'src/main/skills/skill-freshness-eligibility.ts' - 'src/shared/skill-freshness.ts' - '.github/workflows/skill-update-roundtrip.yml' + # Why unfiltered: GitHub ignores `paths` on merge_group. We do not run a merge + # queue today; if one is added, gate the matrix on a path job first. merge_group: push: branches: - main + paths: *skill-roundtrip-paths jobs: roundtrip: diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs new file mode 100644 index 00000000000..d19eee52454 --- /dev/null +++ b/config/scripts/pr-code-change-scope.mjs @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +const DOCS_ONLY_FILES = new Set([ + 'README.md', + 'LICENSE', + 'AGENTS.md', + 'CLAUDE.md', + 'Agents.md', + 'Claude.md', + '.github/CONTRIBUTING.md', + '.github/pull_request_template.md', + '.github/CODEOWNERS' +]) + +const DOCS_ONLY_PREFIXES = ['docs/', '.github/ISSUE_TEMPLATE/'] + +export function isDocsOnlyPath(file) { + if (DOCS_ONLY_FILES.has(file)) { + return true + } + if (DOCS_ONLY_PREFIXES.some((prefix) => file.startsWith(prefix))) { + return true + } + return /^README\.[^/]+\.md$/.test(file) +} + +export function shouldRunPrChecks(changedFiles) { + // Why empty-run: a silent empty diff is more likely a detector bug than a + // genuine no-op PR, so fail closed and keep the expensive jobs. + if (changedFiles.length === 0) { + return true + } + return changedFiles.some((file) => !isDocsOnlyPath(file)) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const files = readFileSync(0, 'utf8').split('\n').filter(Boolean) + process.stdout.write(shouldRunPrChecks(files) ? 'true\n' : 'false\n') +} diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs new file mode 100644 index 00000000000..f71d6c42934 --- /dev/null +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -0,0 +1,107 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { isDocsOnlyPath, shouldRunPrChecks } from './pr-code-change-scope.mjs' + +const projectDir = resolve(import.meta.dirname, '../..') +const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) + +const gatedIf = "needs.code_paths.outputs.should_run == 'true'" +const expensiveJobs = [ + 'static_analysis', + 'typecheck', + 'git_compatibility', + 'xterm_patch_sync', + 'shell_contracts', + 'test', + 'cross-version-wire', + 'managed_hook_node18', + 'package', + 'package_windows' +] + +describe('docs-only path classification', () => { + it('treats the WeChat README PR files as docs-only', () => { + expect( + shouldRunPrChecks([ + 'README.md', + 'docs/assets/wechat-qr-group8.jpg', + 'docs/readme/README.zh-CN.md' + ]) + ).toBe(false) + }) + + it('skips root instruction files and GitHub markdown templates', () => { + expect(isDocsOnlyPath('AGENTS.md')).toBe(true) + expect(isDocsOnlyPath('CLAUDE.md')).toBe(true) + expect(isDocsOnlyPath('LICENSE')).toBe(true) + expect(isDocsOnlyPath('.github/CONTRIBUTING.md')).toBe(true) + expect(isDocsOnlyPath('.github/pull_request_template.md')).toBe(true) + expect(isDocsOnlyPath('.github/ISSUE_TEMPLATE/bug_report.yml')).toBe(true) + expect(isDocsOnlyPath('.github/CODEOWNERS')).toBe(true) + }) + + it('still runs PR Checks for product markdown and CI', () => { + expect(isDocsOnlyPath('skills/computer-use/SKILL.md')).toBe(false) + expect(isDocsOnlyPath('skill-guides/orca-cli.md')).toBe(false) + expect(isDocsOnlyPath('.github/workflows/pr.yml')).toBe(false) + expect(isDocsOnlyPath('src/main/index.ts')).toBe(false) + expect(isDocsOnlyPath('config/scripts/pr-code-change-scope.mjs')).toBe(false) + expect(shouldRunPrChecks(['README.md', 'src/main/index.ts'])).toBe(true) + }) + + it('runs PR Checks when the diff is empty rather than skipping by accident', () => { + expect(shouldRunPrChecks([])).toBe(true) + }) +}) + +describe('PR Checks docs-only skip wiring', () => { + it('classifies the PR range with a tested script and expands renames', () => { + const classify = prWorkflow.jobs.code_paths.steps.find( + (step) => step.name === 'Classify changed paths' + ) + expect(classify.run).toContain('--diff-filter=ACDMR') + expect(classify.run).toContain('--no-renames') + expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"') + expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs') + expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe( + '${{ steps.filter.outputs.should_run }}' + ) + }) + + it('keeps the cheap root-directory guard on docs-only PRs', () => { + expect(prWorkflow.jobs.root_directory_guard.if).toBeUndefined() + expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined() + }) + + it('skips expensive jobs unless the detector says the PR has code', () => { + for (const jobName of expensiveJobs) { + expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths']) + expect(prWorkflow.jobs[jobName].if, jobName).toBe(gatedIf) + } + }) + + it('skips e2e detection on docs-only PRs without dropping the draft gate', () => { + expect(prWorkflow.jobs['e2e-paths'].needs).toEqual(['code_paths']) + expect(prWorkflow.jobs['e2e-paths'].if).toBe( + "github.event.pull_request.draft != true && needs.code_paths.outputs.should_run == 'true'" + ) + }) + + it('lets verify pass when expensive jobs are skipped for docs-only PRs', () => { + const verifyStep = prWorkflow.jobs.verify.steps.find( + (step) => step.name === 'Require successful checks' + ) + expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths') + expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}') + expect(verifyStep.run).toContain('"$SHOULD_RUN" != "true"') + const docsOnlyBranch = verifyStep.run.slice( + 0, + verifyStep.run.indexOf('# Require success when the PR has code-relevant changes') + ) + expect(docsOnlyBranch).toContain('if [ "$result" != "skipped" ]') + expect(docsOnlyBranch).not.toContain('"$result" != "success"') + expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"') + }) +}) diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 46de7b39fcc..92faae907a1 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -53,11 +53,17 @@ describe('PR E2E gate contract', () => { // job without adding it to the strict loop fails here instead of silently // leaving that job unenforced. This is what caught GIT_COMPATIBILITY and // SHELL_CONTRACTS being absent from an earlier hardcoded list. - const strictLoop = verifyStep.run.slice(0, verifyStep.run.indexOf('done')) + // Why lastIndexOf: the docs-only branch has its own loop that allows skipped. + const successMarker = '# Require success when the PR has code-relevant changes' + const successLoop = verifyStep.run.slice( + verifyStep.run.indexOf(successMarker), + verifyStep.run.lastIndexOf('done') + ) + expect(successLoop.length).toBeGreaterThan(0) for (const job of prWorkflow.jobs.verify.needs) { const envVar = job.toUpperCase() expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`) - expect(strictLoop).toContain(`"$${envVar}"`) + expect(successLoop).toContain(`"$${envVar}"`) } }) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 5b9ab226502..59ea62b1fc2 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -334,6 +334,7 @@ describe('PR workflow parallelism', () => { it('keeps verify as the aggregate required check', () => { expect(workflow.jobs.verify.needs).toEqual([ + 'code_paths', 'static_analysis', 'root_directory_guard', 'typecheck', diff --git a/config/scripts/skill-update-roundtrip-workflow.test.mjs b/config/scripts/skill-update-roundtrip-workflow.test.mjs new file mode 100644 index 00000000000..f7bea277ed9 --- /dev/null +++ b/config/scripts/skill-update-roundtrip-workflow.test.mjs @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') +const workflow = parse( + readFileSync(join(projectDir, '.github/workflows/skill-update-roundtrip.yml'), 'utf8') +) + +const expectedPaths = [ + 'skills/**', + 'resources/skills/**', + 'config/scripts/verify-skill-update-roundtrip.mjs', + 'config/scripts/skill-update-roundtrip-workflow.test.mjs', + 'src/main/skills/skill-freshness-eligibility.ts', + 'src/shared/skill-freshness.ts', + '.github/workflows/skill-update-roundtrip.yml' +] + +describe('skill-update-roundtrip workflow triggers', () => { + it('uses the same path filter on push to main as on pull_request', () => { + // Why: GitHub evaluates each event independently. An unfiltered `push` to + // main started the 13-job matrix on README-only merges; cancelling that + // run paints the default-branch tip red. + expect(workflow.on.pull_request.paths).toEqual(expectedPaths) + expect(workflow.on.push.branches).toEqual(['main']) + expect(workflow.on.push.paths).toEqual(workflow.on.pull_request.paths) + }) + + it('does not claim a path filter on merge_group that GitHub would ignore', () => { + expect(workflow.on.merge_group).toBeDefined() + expect(workflow.on.merge_group?.paths).toBeUndefined() + }) +}) From 471bc9d8ceec7f84070a169fcac9268a46907c40 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 20 Aug 2026 00:16:04 -0700 Subject: [PATCH 010/103] Ship the WSL transcript helper with the Windows relay (STA-4831) (#15529) --- config/scripts/build-relay.mjs | 102 +++++++++++++----- ...package-electron-runtime-contract.test.mjs | 14 +-- .../scripts/relay-artifact-manifest.test.mjs | 81 ++++++++++++++ package.json | 2 +- src/main/ssh/ssh-remote-commands.test.ts | 66 ++++++++++++ src/main/ssh/ssh-remote-commands.ts | 40 +++---- .../ssh-system-transport.integration.test.ts | 11 +- src/shared/relay-artifacts.ts | 57 ++++++++++ 8 files changed, 315 insertions(+), 58 deletions(-) create mode 100644 config/scripts/relay-artifact-manifest.test.mjs create mode 100644 src/shared/relay-artifacts.ts diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 23ad74ab47a..125538a7236 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -10,8 +10,22 @@ */ import { build } from 'esbuild' import { createHash } from 'node:crypto' -import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { join } from 'node:path' +import { + RELAY_BUILD_PLATFORMS, + RELAY_VERSION_FILENAME, + isWindowsRelayPlatform, + relayArtifactFilenames +} from '../../src/shared/relay-artifacts.ts' const __dirname = import.meta.dirname // Why: the script lives under config/scripts, so go two levels up to reach the repo root. @@ -19,6 +33,13 @@ const ROOT = join(__dirname, '..', '..') const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts') const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts') const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts') +const WSL_TRANSCRIPT_FS_PROCESS_ENTRY = join( + ROOT, + 'src', + 'main', + 'native-chat', + 'wsl-transcript-fs-process-entry.ts' +) const MANAGED_HOOK_RUNTIME_ENTRY = join( ROOT, 'src', @@ -35,19 +56,17 @@ const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join( NODE_PTY_CONSOLE_LIST_PATCH_FILENAME ) -const PLATFORMS = [ - 'linux-x64', - 'linux-arm64', - 'darwin-x64', - 'darwin-arm64', - 'win32-x64', - 'win32-arm64' -] +// Why: lets the packaging contract test build into a temp tree instead of +// clobbering a developer's out/relay or racing tests that read it. +const OUT_ROOT = process.env.ORCA_RELAY_OUT_ROOT ?? join(ROOT, 'out', 'relay') const RELAY_VERSION = '0.1.0' -for (const platform of PLATFORMS) { - const outDir = join(ROOT, 'out', 'relay', platform) +for (const platform of RELAY_BUILD_PLATFORMS) { + const outDir = join(OUT_ROOT, platform) + // Why: a stale companion left by an earlier build would otherwise satisfy the + // manifest check and be hashed into .version, shipping mixed-generation bytes. + rmSync(outDir, { recursive: true, force: true }) mkdirSync(outDir, { recursive: true }) await build({ @@ -67,7 +86,7 @@ for (const platform of PLATFORMS) { } }) - if (platform.startsWith('win32-')) { + if (isWindowsRelayPlatform(platform)) { copyFileSync( NODE_PTY_CONSOLE_LIST_PATCH_SOURCE, join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME) @@ -104,6 +123,23 @@ for (const platform of PLATFORMS) { } }) + // Why beside the service: the spawn resolves this child next to its own + // bundle, and a relay host has no desktop out/main to fall back to. + await build({ + entryPoints: [WSL_TRANSCRIPT_FS_PROCESS_ENTRY], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: join(outDir, 'wsl-transcript-fs-process-entry.js'), + external: ['electron'], + sourcemap: false, + minify: true, + define: { + 'process.env.NODE_ENV': '"production"' + } + }) + await build({ entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY], bundle: true, @@ -121,24 +157,34 @@ for (const platform of PLATFORMS) { } }) - // Why: include a content hash so the deploy check detects code changes - // even when RELAY_VERSION hasn't been bumped. Hash every executable module - // so a companion-only change always deploys beside the matching relay host. - const relayContent = readFileSync(join(outDir, 'relay.js')) - const watcherContent = readFileSync(join(outDir, 'relay-watcher.js')) - const aiVaultServiceContent = readFileSync(join(outDir, 'relay-ai-vault-service.js')) - const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js')) + // Why: include a content hash so the deploy check detects code changes even + // when RELAY_VERSION hasn't been bumped. Hashing the whole manifest means a + // companion-only change still selects a fresh immutable relay directory. + const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform)) const hash = createHash('sha256') - .update(relayContent) - .update(watcherContent) - .update(aiVaultServiceContent) - .update(managedHookRuntimeContent) - // Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory. - if (platform.startsWith('win32-')) { - hash.update(readFileSync(join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME))) + for (const filename of expected) { + const artifactPath = join(outDir, filename) + if (!existsSync(artifactPath)) { + throw new Error( + `Relay ${platform} declares ${filename} in RELAY_ARTIFACTS but never emitted it. ` + + 'Add the build step, or drop it from src/shared/relay-artifacts.ts.' + ) + } + hash.update(readFileSync(artifactPath)) } const contentHash = hash.digest('hex').slice(0, 12) - writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${contentHash}`) + + // Close the loop: an artifact emitted here but absent from the manifest would + // ship unhashed and unprobed — exactly how the WSL helper went missing. + const emitted = readdirSync(outDir).filter((name) => name !== RELAY_VERSION_FILENAME) + const undeclared = emitted.filter((name) => !expected.includes(name)) + if (undeclared.length > 0) { + throw new Error( + `Relay ${platform} emitted undeclared artifacts: ${undeclared.join(', ')}. ` + + 'Add them to RELAY_ARTIFACTS in src/shared/relay-artifacts.ts.' + ) + } + writeFileSync(join(outDir, RELAY_VERSION_FILENAME), `${RELAY_VERSION}+${contentHash}`) console.log(`Built relay for ${platform} → ${outDir}/relay.js`) } @@ -149,7 +195,7 @@ for (const platform of PLATFORMS) { // Windows app via the same out/relay extraResources mapping. { const wslEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts') - const outDir = join(ROOT, 'out', 'relay', 'wsl') + const outDir = join(OUT_ROOT, 'wsl') mkdirSync(outDir, { recursive: true }) await build({ entryPoints: [wslEntry], diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index cb54f25031e..a539dc2b717 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -3,6 +3,7 @@ import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' +import { relayArtifactFilenames } from '../../src/shared/relay-artifacts.ts' const projectDir = resolve(import.meta.dirname, '../..') const require = createRequire(import.meta.url) @@ -201,14 +202,15 @@ describe('Electron runtime package contract', () => { expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'") expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')") - expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))") expect(relayBuild).toContain("outfile: join(outDir, 'relay-ai-vault-service.js')") - expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-ai-vault-service.js'))") expect(builderConfig).toContain("from: 'out/relay'") - expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')") - expect(remoteCommands).toContain( - "joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')" - ) + + // Hashing and remote install probing are manifest-driven, so the contract + // is that both companions are declared once and that both sites read it. + expect(relayArtifactFilenames(true)).toContain('relay-watcher.js') + expect(relayArtifactFilenames(true)).toContain('relay-ai-vault-service.js') + expect(relayBuild).toContain('relayArtifactFilenames(') + expect(remoteCommands).toContain('relayArtifactFilenames(') const assertRelayGate = (steps, publishStepName) => { const names = steps.map((step) => step.name) diff --git a/config/scripts/relay-artifact-manifest.test.mjs b/config/scripts/relay-artifact-manifest.test.mjs new file mode 100644 index 00000000000..2796a361778 --- /dev/null +++ b/config/scripts/relay-artifact-manifest.test.mjs @@ -0,0 +1,81 @@ +// Packaged-relay contract: what `build:relay` actually writes to disk. +// +// Asserts against a real build, not the source tree — the unit suites cannot see +// this gap, because the WSL transcript dispatcher runs in-process under vitest +// and never forks. +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + RELAY_BUILD_PLATFORMS, + RELAY_VERSION_FILENAME, + isWindowsRelayPlatform, + relayArtifactFilenames +} from '../../src/shared/relay-artifacts.ts' + +const projectDir = resolve(import.meta.dirname, '../..') +// Its own tree: building into out/relay would clobber a developer's build and +// race the suites that read it. +const relayOutDir = mkdtempSync(join(tmpdir(), 'orca-relay-contract-')) + +beforeAll(() => { + execFileSync('node', [join(projectDir, 'config', 'scripts', 'build-relay.mjs')], { + cwd: projectDir, + stdio: 'pipe', + env: { ...process.env, ORCA_RELAY_OUT_ROOT: relayOutDir } + }) +}, 120_000) + +afterAll(() => { + rmSync(relayOutDir, { recursive: true, force: true }) +}) + +describe('packaged relay artifact manifest', () => { + it.each([...RELAY_BUILD_PLATFORMS])('emits exactly the declared artifacts for %s', (platform) => { + const outDir = join(relayOutDir, platform) + const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform)) + + for (const filename of expected) { + expect(existsSync(join(outDir, filename)), `${platform}/${filename} missing`).toBe(true) + } + // Exactly, not merely at least: an undeclared artifact ships unhashed and + // unprobed, which is the same gap in the other direction. + const emitted = readdirSync(outDir) + .filter((name) => name !== RELAY_VERSION_FILENAME) + .sort() + expect(emitted).toEqual([...expected].sort()) + }) + + it.each([...RELAY_BUILD_PLATFORMS])('hashes every declared artifact for %s', (platform) => { + const outDir = join(relayOutDir, platform) + const hash = createHash('sha256') + for (const filename of relayArtifactFilenames(isWindowsRelayPlatform(platform))) { + hash.update(readFileSync(join(outDir, filename))) + } + const version = readFileSync(join(outDir, RELAY_VERSION_FILENAME), 'utf8') + + // A companion left out of the hash lets a changed relay reuse an existing + // immutable remote directory, serving a mixed-generation install forever. + expect(version.split('+')[1]).toBe(hash.digest('hex').slice(0, 12)) + }) + + it('ships the WSL transcript helper beside the service that forks it', () => { + for (const platform of RELAY_BUILD_PLATFORMS) { + const outDir = join(relayOutDir, platform) + const service = readFileSync(join(outDir, 'relay-ai-vault-service.js'), 'utf8') + + // The bundled service reaches the fork, so the entry must sit beside it: + // the spawn resolves the child relative to its own bundle directory. + expect(service, `${platform} service no longer forks the helper`).toContain( + 'wsl-transcript-fs-process-entry.js' + ) + expect( + existsSync(join(outDir, 'wsl-transcript-fs-process-entry.js')), + `${platform} forks a helper it does not ship` + ).toBe(true) + } + }) +}) diff --git a/package.json b/package.json index ceac925f0b2..3fd332f5fe1 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "dev": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs", "dev-stable-name": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs --stable-name", "dev:web": "vite --config vite.web.config.ts --host 127.0.0.1", - "build:relay": "node config/scripts/build-relay.mjs", + "build:relay": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/build-relay.mjs", "build:computer-macos": "node config/scripts/build-computer-macos.mjs", "build:keyboard-layout-macos": "node config/scripts/build-keyboard-layout-macos.mjs", "build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs", diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 18738a930c6..4e75720121b 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readdirSync, + writeFileSync, rmSync, statSync, utimesSync @@ -30,6 +31,10 @@ import { relayLivenessProbeCommand } from './ssh-remote-commands' import { getRemoteHostPlatform } from './ssh-remote-platform' +import { + RELAY_INSTALL_COMPLETE_FILENAME, + relayArtifactFilenames +} from '../../shared/relay-artifacts' const posix = getRemoteHostPlatform('linux-x64') const windows = getRemoteHostPlatform('win32-x64') @@ -120,6 +125,67 @@ describe('ssh remote command builders', () => { expect(probe).toContain('relay-ai-vault-service.js') }) + it('requires every declared relay artifact before calling an install complete', () => { + const posixProbe = probeRelayInstalledCommand(posix, '/home/me/relay') + for (const filename of relayArtifactFilenames(false)) { + expect(posixProbe, `POSIX probe ignores ${filename}`).toContain(filename) + } + expect(posixProbe).toContain(RELAY_INSTALL_COMPLETE_FILENAME) + // A POSIX relay must not be asked for the Windows-only node-pty patch. + expect(posixProbe).not.toContain('node-pty-1.1.0-console-list-agent-patch.cjs') + + const windowsProbe = decodePowerShellCommand( + probeRelayInstalledCommand(windows, 'C:/Users/me/relay') + ) + for (const filename of relayArtifactFilenames(true)) { + expect(windowsProbe, `Windows probe ignores ${filename}`).toContain(filename) + } + expect(windowsProbe).toContain(RELAY_INSTALL_COMPLETE_FILENAME) + }) + + // Stage a complete install, then remove one companion: the probe must flip. + function stageRelayInstall(isWindows: boolean): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-relay-probe-')) + for (const filename of relayArtifactFilenames(isWindows)) { + writeFileSync(join(dir, filename), '') + } + writeFileSync(join(dir, RELAY_INSTALL_COMPLETE_FILENAME), '') + return dir + } + + it.skipIf(!powerShellExecutable)( + 'rejects a Windows install missing only the WSL transcript helper', + async () => { + const dir = stageRelayInstall(true) + const probe = (): string => decodePowerShellCommand(probeRelayInstalledCommand(windows, dir)) + try { + expect((await runPowerShellCommand(powerShellExecutable!, probe())).trim()).toBe('OK') + + // The exact shape a pre-STA-4831 relay left behind: everything but this. + rmSync(join(dir, 'wsl-transcript-fs-process-entry.js')) + expect((await runPowerShellCommand(powerShellExecutable!, probe())).trim()).toBe('MISSING') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'rejects a POSIX install missing only the WSL transcript helper', + async () => { + const dir = stageRelayInstall(false) + const probe = (): string => probeRelayInstalledCommand(posix, dir) + try { + expect((await runShellCommand(probe())).trim()).toBe('OK') + + rmSync(join(dir, 'wsl-transcript-fs-process-entry.js')) + expect((await runShellCommand(probe())).trim()).toBe('MISSING') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + } + ) + it('uses encoded PowerShell for Windows deploy commands', () => { expect(readRemoteHomeCommand(windows)).toContain('powershell.exe') expect(makeRemoteDirectoryCommand(windows, 'C:/Users/me/.orca-remote')).toContain( diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 603b6356a1f..4fa1445969d 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -1,3 +1,7 @@ +import { + RELAY_INSTALL_COMPLETE_FILENAME, + relayArtifactFilenames +} from '../../shared/relay-artifacts' import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, joinRemotePath, remoteDirname } from './ssh-remote-platform' import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' @@ -85,35 +89,31 @@ export function writeRemoteEmptyFileCommand(host: RemoteHostPlatform, remotePath ) } +/** + * A partial install must read as MISSING, so every file the manifest ships is + * probed — not a hand-kept subset. A relay that advertises the AI Vault title + * service but lacks the WSL transcript helper would otherwise pass this probe + * and then answer WSL title requests with silence. + */ export function probeRelayInstalledCommand( host: RemoteHostPlatform, remoteRelayDir: string ): string { - const relayJs = joinRemotePath(host, remoteRelayDir, 'relay.js') - const relayWatcherJs = joinRemotePath(host, remoteRelayDir, 'relay-watcher.js') - const relayAiVaultServiceJs = joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js') - const managedHookRuntimeJs = joinRemotePath(host, remoteRelayDir, 'managed-hook-runtime.js') - const installComplete = joinRemotePath(host, remoteRelayDir, '.install-complete') + const required = [ + ...relayArtifactFilenames(isWindowsRemoteHost(host)), + RELAY_INSTALL_COMPLETE_FILENAME + ].map((filename) => joinRemotePath(host, remoteRelayDir, filename)) if (!isWindowsRemoteHost(host)) { - return ( - `test -d ${shellEscape(remoteRelayDir)} ` + - `&& test -f ${shellEscape(relayJs)} ` + - `&& test -f ${shellEscape(relayWatcherJs)} ` + - `&& test -f ${shellEscape(relayAiVaultServiceJs)} ` + - `&& test -f ${shellEscape(managedHookRuntimeJs)} ` + - `&& test -f ${shellEscape(installComplete)} ` + - `&& echo OK || echo MISSING` - ) + const fileTests = required.map((path) => `&& test -f ${shellEscape(path)} `).join('') + return `test -d ${shellEscape(remoteRelayDir)} ${fileTests}&& echo OK || echo MISSING` } return powerShellCommand( [ `$dir = ${powerShellLiteral(remoteRelayDir)}`, - `$relay = ${powerShellLiteral(relayJs)}`, - `$watcher = ${powerShellLiteral(relayWatcherJs)}`, - `$aiVaultService = ${powerShellLiteral(relayAiVaultServiceJs)}`, - `$managedHooks = ${powerShellLiteral(managedHookRuntimeJs)}`, - `$complete = ${powerShellLiteral(installComplete)}`, - "if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $aiVaultService -PathType Leaf) -and (Test-Path -LiteralPath $managedHooks -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }" + `$required = @(${required.map((path) => powerShellLiteral(path)).join(', ')})`, + '$ok = Test-Path -LiteralPath $dir -PathType Container', + 'foreach ($f in $required) { if (-not (Test-Path -LiteralPath $f -PathType Leaf)) { $ok = $false } }', + "if ($ok) { 'OK' } else { 'MISSING' }" ].join('; ') ) } diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index 6199dfeef52..6e45077698a 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -11,6 +11,7 @@ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { SshChannelMultiplexer } from './ssh-channel-multiplexer' import { uploadDirectoryViaSystemSsh } from './ssh-system-fallback' import type { SshTarget } from '../../shared/ssh-types' +import { relayArtifactFilenames } from '../../shared/relay-artifacts' const RELAY_VERSION = '0.1.0+systemtransport' @@ -54,9 +55,13 @@ exec /bin/sh -c "$cmd" } function writeFakeRelay(dir: string): void { - writeFileSync(join(dir, 'relay-watcher.js'), '') - writeFileSync(join(dir, 'relay-ai-vault-service.js'), '') - writeFileSync(join(dir, 'managed-hook-runtime.js'), '') + // Stage every companion the manifest declares — relay.js gets real content + // below — so adding one cannot silently fail the completeness probe here. + for (const filename of relayArtifactFilenames(false)) { + if (filename !== 'relay.js') { + writeFileSync(join(dir, filename), '') + } + } writeFileSync( join(dir, 'relay.js'), ` diff --git a/src/shared/relay-artifacts.ts b/src/shared/relay-artifacts.ts new file mode 100644 index 00000000000..1801667364c --- /dev/null +++ b/src/shared/relay-artifacts.ts @@ -0,0 +1,57 @@ +/** + * What a packaged relay directory must contain, declared once. The build, the + * content hash, and the remote install probe all read this list; they used to + * keep three of their own, which is how the WSL helper reached the desktop app + * but never a relay. + * + * Order is load-bearing: the hash concatenates these files in sequence. + * + * Keep this file erasable-only TypeScript — build-relay.mjs imports it directly + * under Node's type stripping, which rejects enums, namespaces, and parameter + * properties. + */ + +/** Every platform the relay is bundled for; each gets the full artifact set. */ +export const RELAY_BUILD_PLATFORMS = [ + 'linux-x64', + 'linux-arm64', + 'darwin-x64', + 'darwin-arm64', + 'win32-x64', + 'win32-arm64' +] as const + +export type RelayBuildPlatform = (typeof RELAY_BUILD_PLATFORMS)[number] + +export function isWindowsRelayPlatform(platform: string): boolean { + return platform.startsWith('win32-') +} + +export type RelayArtifact = { + filename: string + /** Only Windows relays ship it; other hosts must neither receive nor probe it. */ + windowsOnly?: boolean +} + +export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [ + { filename: 'relay.js' }, + { filename: 'relay-watcher.js' }, + { filename: 'relay-ai-vault-service.js' }, + { filename: 'managed-hook-runtime.js' }, + // Forked by the AI Vault title reader; without it a relay answers every WSL + // title request with no title and no error. + { filename: 'wsl-transcript-fs-process-entry.js' }, + { filename: 'node-pty-1.1.0-console-list-agent-patch.cjs', windowsOnly: true } +] + +/** Written after the artifacts, so it is never an input to its own hash. */ +export const RELAY_VERSION_FILENAME = '.version' + +/** Written last by the installer; its absence means a torn install. */ +export const RELAY_INSTALL_COMPLETE_FILENAME = '.install-complete' + +export function relayArtifactFilenames(isWindows: boolean): string[] { + return RELAY_ARTIFACTS.filter((artifact) => !artifact.windowsOnly || isWindows).map( + (artifact) => artifact.filename + ) +} From 2e895da937171dbaa8b9ff697ddff180336f1b42 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:22:59 -0700 Subject: [PATCH 011/103] fix(browser): name the requesting frame and the permission in denial notices (#15542) * fix(browser): name the requesting frame and the permission in denial notices Two defects in the same notice, both found by the review of #15481 and left out of it deliberately. The notice named the wrong site. setPermissionRequestHandler passed webContents.getURL(), which is the top-level document, so a permission request from a cross-origin sub-frame was attributed to the embedder. Every PermissionRequest variant carries requestingUrl, so all three call sites now use it and fall back to the top-level document only when it is absent. The notice also showed raw Chromium permission names. humanizePermission mapped two permissions and returned the raw token for the rest. That now matters more: #15481 granted ordinary storage-access and left top-level-storage-access denied, making it the storage denial a user can still hit - rendered as its raw token. The default still returns the raw token. Inventing prose for a permission nobody has seen is worse than showing its real name. Does not change any permission verdict, and does not fix Google sign-in (#15221). * fix(browser): keep permission denial attribution accurate Capture fallback URLs before asynchronous media permission handling and treat opaque requesters as unknown rather than blaming the top-level page. Clarify permission descriptions and cover origin normalization, navigation races, and mapped copy. --- .../browser/browser-manager-downloads.test.ts | 10 ++++ .../browser-session-partition-policies.ts | 24 +++++++- ...owser-session-registry.persistence.test.ts | 53 ++++++++++++++++++ .../navigate/browser-notices.test.ts | 55 +++++++++++++++++++ .../browser-pane/navigate/browser-notices.ts | 33 +++++++++++ 5 files changed, 172 insertions(+), 3 deletions(-) diff --git a/src/main/browser/browser-manager-downloads.test.ts b/src/main/browser/browser-manager-downloads.test.ts index 981002c727b..e79d510ff62 100644 --- a/src/main/browser/browser-manager-downloads.test.ts +++ b/src/main/browser/browser-manager-downloads.test.ts @@ -123,6 +123,16 @@ describe('browserManager', () => { permission: 'media', origin: 'https://example.com' }) + browserManager.notifyPermissionDenied({ + guestWebContentsId: guest.id, + permission: 'geolocation', + rawUrl: '' + }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:permission-denied', { + browserPageId: 'browser-1', + permission: 'geolocation', + origin: 'unknown' + }) expect(rendererSendMock).toHaveBeenCalledWith( 'browser:download-requested', expect.objectContaining({ diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index e31691b9646..2118c62ed68 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -22,6 +22,21 @@ const handleWillDownload = ( browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) } +function resolvePermissionNoticeUrl( + webContents: Electron.WebContents, + details: Electron.PermissionRequest | undefined +): string { + const requestingUrl = details?.requestingUrl + if (!requestingUrl) { + return webContents.getURL() + } + try { + return new URL(requestingUrl).origin === 'null' ? '' : requestingUrl + } catch { + return '' + } +} + export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void { const { partition } = profile const sess = session.fromPartition(partition) @@ -39,6 +54,8 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr sess.setPermissionRequestHandler((webContents, permission, callback, details) => { // Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS. if (permission === 'media') { + // Capture before async handling; opaque frames cannot be attributed to a named site. + const rawUrl = resolvePermissionNoticeUrl(webContents, details) void requestSystemMediaAccess( details as Electron.MediaAccessPermissionRequest | undefined ).then( @@ -47,7 +64,7 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr browserManager.notifyPermissionDenied({ guestWebContentsId: webContents.id, permission, - rawUrl: webContents.getURL() + rawUrl }) } callback(granted) @@ -57,7 +74,7 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr browserManager.notifyPermissionDenied({ guestWebContentsId: webContents.id, permission, - rawUrl: webContents.getURL() + rawUrl }) callback(false) } @@ -66,10 +83,11 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr } const allowed = isAutoGrantedBrowserSessionPermission(permission) if (!allowed) { + const rawUrl = resolvePermissionNoticeUrl(webContents, details) browserManager.notifyPermissionDenied({ guestWebContentsId: webContents.id, permission, - rawUrl: webContents.getURL() + rawUrl }) } callback(allowed) diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index cb9229174ed..a3caa67b19e 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -564,6 +564,58 @@ describe('BrowserSessionRegistry persistence', () => { permission: 'geolocation', rawUrl: 'https://example.com/account' }) + + // A subframe denial must name the requester, not its top-level embedder. + browserManagerNotifyPermissionDeniedMock.mockClear() + requestHandler(guestWc, 'geolocation', permissionCallback, { + requestingUrl: 'https://widget.example.net/embed', + isMainFrame: false + }) + await vi.waitFor(() => + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'geolocation', + rawUrl: 'https://widget.example.net/embed' + }) + ) + + // Missing or empty frame URLs fall back to the visible top-level page. + browserManagerNotifyPermissionDeniedMock.mockClear() + requestHandler(guestWc, 'geolocation', permissionCallback, { isMainFrame: true }) + await vi.waitFor(() => + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'geolocation', + rawUrl: 'https://example.com/account' + }) + ) + + browserManagerNotifyPermissionDeniedMock.mockClear() + requestHandler(guestWc, 'geolocation', permissionCallback, { + requestingUrl: '', + isMainFrame: false + }) + await vi.waitFor(() => + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'geolocation', + rawUrl: 'https://example.com/account' + }) + ) + + // Opaque frame URLs have no site Orca can name accurately. + browserManagerNotifyPermissionDeniedMock.mockClear() + requestHandler(guestWc, 'geolocation', permissionCallback, { + requestingUrl: 'about:blank', + isMainFrame: false + }) + await vi.waitFor(() => + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'geolocation', + rawUrl: '' + }) + ) expect( browserManagerNotifyPermissionDeniedMock.mock.calls.map(([args]) => args.permission) ).toEqual(['geolocation']) @@ -735,6 +787,7 @@ describe('BrowserSessionRegistry persistence', () => { const callback = vi.fn() requestHandler(guestWc, 'media', callback, { mediaTypes: ['video'] }) + guestWc.getURL.mockReturnValue('https://example.com/after-navigation') await vi.waitFor(() => expect(callback).toHaveBeenCalledWith(false)) expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ diff --git a/src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts b/src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts index 509dc8a08a2..f812d5fe02f 100644 --- a/src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts +++ b/src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts @@ -19,6 +19,61 @@ describe('browser notice formatting', () => { origin: 'https://example.com' }) ).toBe('https://example.com asked for camera or microphone access, and Orca denied it.') + expect( + formatPermissionNotice({ + browserPageId: 'browser-1', + permission: 'geolocation', + origin: 'unknown' + }) + ).toBe('this page asked for your location, and Orca denied it.') + }) + + it('names the storage permission in words rather than its raw token', () => { + const notice = formatPermissionNotice({ + browserPageId: 'browser-1', + permission: 'top-level-storage-access', + origin: 'https://example.com' + }) + expect(notice).not.toContain('top-level-storage-access') + expect(notice).toBe( + 'https://example.com asked for cookie access on behalf of an embedded site, and Orca denied it.' + ) + }) + + it.each([ + ['storage-access', 'access to its own cookies and storage while embedded on this page'], + ['idle-detection', 'permission to detect when you are idle'], + ['display-capture', 'permission to capture your screen'], + ['window-management', 'screen information and multi-screen window placement'], + ['keyboardLock', 'permission to capture keyboard input'], + ['openExternal', 'permission to open a link outside Orca'], + ['fileSystem', 'access to your files or folders'], + ['hid', 'access to a connected human interface device'], + ['usb', 'access to a USB device'], + ['serial', 'access to a serial device'], + ['midi', 'access to your MIDI devices'], + ['midiSysex', 'access to system-exclusive MIDI messages'], + ['mediaKeySystem', 'access to protected media playback'], + ['speaker-selection', 'permission to choose an audio output device'] + ])('formats the %s permission as readable copy', (permission, description) => { + expect( + formatPermissionNotice({ + browserPageId: 'browser-1', + permission, + origin: 'https://example.com' + }) + ).toBe(`https://example.com asked for ${description}, and Orca denied it.`) + }) + + // Pin the raw-token fallback for permissions Chromium adds later. + it('falls back to the raw permission name for anything unmapped', () => { + expect( + formatPermissionNotice({ + browserPageId: 'browser-1', + permission: 'some-future-permission', + origin: 'https://example.com' + }) + ).toBe('https://example.com asked for some-future-permission, and Orca denied it.') }) it('formats popup outcomes', () => { diff --git a/src/renderer/src/components/browser-pane/navigate/browser-notices.ts b/src/renderer/src/components/browser-pane/navigate/browser-notices.ts index 76888fc7b94..ca5e6aa209a 100644 --- a/src/renderer/src/components/browser-pane/navigate/browser-notices.ts +++ b/src/renderer/src/components/browser-pane/navigate/browser-notices.ts @@ -15,12 +15,45 @@ export type LoadFailureMeta = { type BrowserLoadErrorLike = BrowserLoadError | null +// Unknown Chromium permissions keep their raw name instead of disappearing behind invented copy. function humanizePermission(permission: string): string { switch (permission) { case 'media': return 'camera or microphone access' case 'pointerLock': return 'pointer lock' + case 'storage-access': + return 'access to its own cookies and storage while embedded on this page' + case 'top-level-storage-access': + return 'cookie access on behalf of an embedded site' + case 'geolocation': + return 'your location' + case 'idle-detection': + return 'permission to detect when you are idle' + case 'display-capture': + return 'permission to capture your screen' + case 'window-management': + return 'screen information and multi-screen window placement' + case 'keyboardLock': + return 'permission to capture keyboard input' + case 'openExternal': + return 'permission to open a link outside Orca' + case 'fileSystem': + return 'access to your files or folders' + case 'hid': + return 'access to a connected human interface device' + case 'usb': + return 'access to a USB device' + case 'serial': + return 'access to a serial device' + case 'midi': + return 'access to your MIDI devices' + case 'midiSysex': + return 'access to system-exclusive MIDI messages' + case 'mediaKeySystem': + return 'access to protected media playback' + case 'speaker-selection': + return 'permission to choose an audio output device' default: return permission } From 5ca747dad0d0583f4a1ac91c2655b345ba6c07eb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:47:22 -0700 Subject: [PATCH 012/103] docs(ssh): state the SSH execution boundary and pin the liveness vocabulary (#14971) * docs(ssh): state the SSH execution boundary and pin the liveness vocabulary Nothing under docs/ described how work splits between the client and an SSH host, so agents and humans inferred it from error strings and got it wrong: loss of contact was repeatedly reported as process death, which orphaned live remote agents and cold-started duplicates over the same worktree. Pins the vocabulary to the incumbent live/unverifiable/exited verdict from unstopped-pty-verification so no synonym is introduced, records the one real discriminator (all of a host's terminals drop together on link loss; one alone means process exit), and lists the outstanding gaps with citations. Tracked via the docs allow-list and linked from AGENTS.md, per the convention in .gitignore. * docs(ssh): cite the live restoreRequired site after it moved The throw now lives in reattachSshPtySessionForSpawn; ssh-pty-provider.ts no longer contains it. Caught by the worker fixing it, against a newer main than the audit ran on. * docs(ssh): require host evidence for liveness verdicts * docs(ssh): keep boundary references stable * docs(ssh): fence liveness evidence to its host identity * docs(ssh): state replay and environment boundaries precisely * docs(ssh): correct replay and platform boundary claims * docs(ssh): describe headless runtime continuity accurately * docs(ssh): distinguish authority from client metadata * docs(ssh): describe pending fixes accurately * docs(ssh): date the gap list and name the PR that closes each entry The Known gaps section was accurate when written and becomes actively misleading as its fixes land: it told a reader to go fix restoreRequired, the missing unverifiable verdict, and the absent terminal-list host field, three things now addressed by #14974, #14977 and #14973. Mark the section as dated, require verification against current code before acting on any entry, name the PR per entry, and move landed items out. Also correct the two body claims that the landed fixes invalidated. The rules above are durable; only this section rots. * docs(ssh): make the boundary doc a durable ruleset, not an incident record The Known gaps section was 18 of 93 lines enumerating specific defects from one investigation, several already fixed by sibling PRs in the same batch. A reference doc that needs a 'this section rots' warning is telling you the section belongs somewhere else; those entries belong in issues. Replace the six-row table of currently-lying signals with the method that outlands any particular bug: ask whether the owning host produced the signal, whether every PTY on the target went quiet together, whether the termination event matches the current incarnation and generation, and whether a returned status is actually a claim. Same for artifacts - state what ls-remote and a PR head each do and do not prove, rather than listing which command is currently wrong. Nothing here goes stale when the open fixes land. --- .gitignore | 1 + AGENTS.md | 2 +- docs/reference/ssh-execution-boundary.md | 77 ++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 docs/reference/ssh-execution-boundary.md diff --git a/.gitignore b/.gitignore index 321fe5beb21..d8e4f224baa 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,7 @@ docs/** !docs/reference/relay-grace-time-reconfiguration.md !docs/reference/remote-wire-compatibility.md !docs/reference/renderer-agent-status-performance.md +!docs/reference/ssh-execution-boundary.md !docs/reference/ssh-host-key-verification.md !docs/reference/ssh-reconnect-source-recovery.md !docs/reference/windows-setup-shell.md diff --git a/AGENTS.md b/AGENTS.md index b70868c462f..ba14ab6a967 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh ## SSH Use Case -All changes must consider the SSH use case. Don't assume local-only execution. +All changes must consider the SSH use case. Don't assume local-only execution. Before changing anything that reports on, stops, or lists remote work, follow [`docs/reference/ssh-execution-boundary.md`](./docs/reference/ssh-execution-boundary.md): the execution host owns everything that touches execution, and loss of contact is never evidence of process death — the verdict vocabulary is `live` / `unverifiable` / `exited`, with no synonyms. ## Folder Workspace Use Case diff --git a/docs/reference/ssh-execution-boundary.md b/docs/reference/ssh-execution-boundary.md new file mode 100644 index 00000000000..d24cdc38e8e --- /dev/null +++ b/docs/reference/ssh-execution-boundary.md @@ -0,0 +1,77 @@ +# SSH Execution Boundary + +How Orca splits work between your machine and an SSH host, what survives a disconnect, and how to keep `unverifiable` distinct from `exited`. Nothing under `docs/` stated this before; agents and humans were inferring it from error strings and getting it wrong. + +## The rule + +**The execution host owns everything that touches execution** — tools, credentials, identity, environment, processes, and artifacts. The client owns the UI, transport, and Orca control-plane state, but is not authoritative for execution state. + +Two consequences, both non-negotiable: + +1. **No silent substitution.** An operation on a remote `repoPath` must never fall back to running on the client. A missing SSH provider is not permission to answer locally — a local run can answer for the _wrong repository_. +2. **No asserting what you cannot observe.** Loss of contact is not evidence of `exited`. Report `unverifiable`, never `exited`. + +The vocabulary is fixed: **`live` / `unverifiable` / `exited`**, taken from the incumbent `UnstoppedPtyVerdict`. Do not introduce synonyms, and never collapse `unverifiable` into either neighbour. `exited` requires positive evidence of absence from the host that owns the process; a transport failure can only ever produce `unverifiable`. + +Rule 1 is stated at `src/main/source-control/repo-default-branch.ts:76-78`, `src/main/repo-worktrees.ts:45-48`, `OrcaRuntimeService.probeWorktreeDrift` in `src/main/runtime/orca-runtime.ts`, and `src/renderer/src/lib/connection-context.ts:22-24`. It is enforced throughout `src/main/runtime/orca-runtime-git.ts` by the guard that throws `SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE` whenever `target.connectionId` is set and no provider is registered — grep that constant for the current call sites rather than trusting a count. + +`src/main/runtime/unstopped-pty-verification.ts:12-16` is the reference implementation of rule 2: it keeps `live` / `unverifiable` / `exited` as three distinct verdicts, and treats "we could not ask" as its own answer. + +## What runs where + +| Concern | Executes on | Notes | +| -------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------- | +| PTYs, agent CLIs | **remote** | children of the detached relay daemon, not of the ssh channel | +| git (status, diff, log, fetch, push, commit, branch, worktree) | **remote** | via `src/relay/git-handler.ts` | +| filesystem, watching, search | **remote** | | +| repo setup hooks (`--setup`) | **remote** | identical policy to local | +| commit-message / PR-field AI generation | **remote** | uses the remote agent CLI and its auth | +| `gh` / GitHub API, `glab` / GitLab | **client** | inconsistent with the rule; PRs carry the client's identity | +| the `orca` CLI inside a remote terminal | **client runtime** | control plane only — your files and processes stay remote; see below | + +## Survival: what a disconnect does _not_ do + +By default, remote work survives your machine going away. The relay is a detached daemon (`nohup … When the client disconnects, every `orca …` command run on the SSH host fails with `No owning Orca client is connected to the relay`. The PTY stays `live`; its control plane does not. + +Orchestration state (Runs, Tasks, Dispatches, mailboxes) is client-resident for the same reason. An agent on an SSH host should not depend on `orca` for anything it must finish while you are away. **Commit and push early** — unpushed work on a remote box is unavailable to the client until it reconnects. + +## Distinguishing `unverifiable` from `exited` + +A verdict needs evidence from the host that owns the process. Apply these tests in order. + +**Was the signal produced by the owning host, or by the client's own bookkeeping?** Absence from a client-side set, a lookup that threw, a socket that closed, a command that timed out — none of these observe the process. They are `unverifiable` by construction, whatever the field is named. + +**Did every remote PTY on that target go quiet at once?** A transport drop takes them all together. Simultaneous silence across a host indicates a lost link, not simultaneous death. + +**Does the termination event match the current identity?** A host-delivered exit for the live PTY incarnation and provider generation, while its siblings still report, establishes `exited`. A stale event, an event for a superseded incarnation, or one quiet terminal with no host evidence does not. + +**Is a returned status actually a claim of success?** An operation that reports failure may have succeeded, and one that reports success may not have run — check the durable state it should have changed rather than trusting the return. + +Anything short of positive host evidence is `unverifiable`. Reporting it as `exited` is the error this document exists to prevent: it orphans live work and can cold-start a duplicate over the same worktree. + +## Reading artifacts instead of process state + +Artifacts are stronger evidence than liveness signals, but they answer a narrower question than they appear to. + +A matching commit from `git ls-remote --heads origin ` or a PR head lookup proves **that commit reached the remote** — not that the current run pushed it, and not that the latest work was included. An absent result proves nothing was found, not that nothing was pushed: the ref may have been deleted, the PR closed, or the query may simply have failed. + +A listing is only evidence about the hosts it actually covered. When a result does not name its scope, an empty answer is not evidence that nothing is running elsewhere. A clean **local** worktree says nothing at all about the remote one. + +## One host, one model + +An SSH host and a paired runtime (`orca environment`) imply opposite boundaries: the first is a dumb execution host driven by your client, the second is a peer that owns its own control plane. Registering the same machine both ways splits its worktrees across two identities, makes `terminal list` return different sets depending on `--environment`, and reliably confuses both humans and agents. Pick one per machine. + +For work that must continue while you are offline, use the peer/headless-runtime model on the remote host instead of the direct-SSH model. Its control plane is host-local, and its daemon-backed PTYs stay `live` across a normal runtime restart so the runtime can reattach; an explicit daemon shutdown can still make them `exited`. Do not register the same machine through both models. A detached agent process outside Orca can also survive a control-plane outage, but it has no stdin, so its instructions cannot be amended mid-run. From 4b2ed5ddd470fc69cf836d840748afae0908eeb4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:07:56 -0700 Subject: [PATCH 013/103] fix(terminal): apply pane padding on all four edges (#15544) * fix(terminal): apply pane padding on all four edges Move the configured inset onto xterm so the terminal fills its pane while the fit calculation accounts for both sides of each axis. Add a geometry golden that forces cell remainders and verifies dynamic padding without relying on renderer pixels. * fix(terminal): normalize imported padding for fitting * fix(terminal): align stored and fitted padding --- package.json | 2 +- src/main/ghostty/mapper-extended.test.ts | 4 +- src/main/ghostty/numeric-config-values.ts | 9 +- .../terminal-container-geometry.test.ts | 12 +- src/renderer/src/assets/terminal.css | 17 +- .../TerminalWindowSection.padding.test.tsx | 81 +++++++ .../settings/TerminalWindowSection.tsx | 13 +- .../terminal-pane/terminal-appearance.test.ts | 66 ++++++ .../terminal-pane/terminal-appearance.ts | 47 ++-- src/shared/terminal-padding-settings.test.ts | 14 ++ src/shared/terminal-padding-settings.ts | 6 + .../terminal-padding-geometry-golden.spec.ts | 222 ++++++++++++++++++ 12 files changed, 456 insertions(+), 37 deletions(-) create mode 100644 src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx create mode 100644 src/shared/terminal-padding-settings.test.ts create mode 100644 src/shared/terminal-padding-settings.ts create mode 100644 tests/e2e/terminal-padding-geometry-golden.spec.ts diff --git a/package.json b/package.json index 3fd332f5fe1..2dd9706e681 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "test:e2e:workspace-session-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-quit-relaunch-session.spec.ts tests/e2e/golden-terminal-file-link.spec.ts tests/e2e/golden-worktree-create-switch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", + "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts tests/e2e/terminal-padding-geometry-golden.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:source-control-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-file-open-edit-save.spec.ts tests/e2e/golden-source-control-commit.spec.ts tests/e2e/golden-source-control-open-diff.spec.ts --grep @golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1", diff --git a/src/main/ghostty/mapper-extended.test.ts b/src/main/ghostty/mapper-extended.test.ts index 402e9253f21..9584ac75017 100644 --- a/src/main/ghostty/mapper-extended.test.ts +++ b/src/main/ghostty/mapper-extended.test.ts @@ -90,9 +90,9 @@ describe('mapGhosttyToOrca — window-padding', () => { expect(result.unsupportedKeys).toEqual([]) }) - it('preserves odd-sum dual-value padding as a fractional average', () => { + it('rounds odd-sum dual-value padding to the integer used for fitting', () => { const result = mapGhosttyToOrca({ 'window-padding-x': '1,2' }) - expect(result.diff).toEqual({ terminalPaddingX: 1.5 }) + expect(result.diff).toEqual({ terminalPaddingX: 2 }) expect(result.unsupportedKeys).toEqual([]) }) diff --git a/src/main/ghostty/numeric-config-values.ts b/src/main/ghostty/numeric-config-values.ts index f8a7ce7b468..9a20f040aee 100644 --- a/src/main/ghostty/numeric-config-values.ts +++ b/src/main/ghostty/numeric-config-values.ts @@ -1,3 +1,5 @@ +import { normalizeTerminalPadding } from '../../shared/terminal-padding-settings' + // Why: `Number("1e10")` succeeds and passes `Number.isInteger`, so a Ghostty // config with `window-padding-x = 1e9` would sail through the mapper and land // an absurd value in the store. Restrict to plain decimal integers. @@ -10,9 +12,8 @@ export const parseStrictInt = (v: string): number | null => { return Number.isFinite(num) ? num : null } -// Why: Ghostty accepts "top,bottom" / "left,right" pairs for window paddings, -// but Orca stores a single value per axis — average the pair so the total -// padding along the axis stays the same. +// Why: Orca stores one integer per axis, so collapse paired paddings to the +// nearest value that xterm's integer-based fit calculation will also use. export const parsePaddingValue = (v: string): number | null => { const parts = v.split(',') if (parts.length > 2) { @@ -26,5 +27,5 @@ export const parsePaddingValue = (v: string): number | null => { } nums.push(num) } - return nums.reduce((sum, num) => sum + num, 0) / nums.length + return normalizeTerminalPadding(nums.reduce((sum, num) => sum + num, 0) / nums.length) } diff --git a/src/renderer/src/assets/terminal-container-geometry.test.ts b/src/renderer/src/assets/terminal-container-geometry.test.ts index db079c9095f..28eb858fcdd 100644 --- a/src/renderer/src/assets/terminal-container-geometry.test.ts +++ b/src/renderer/src/assets/terminal-container-geometry.test.ts @@ -5,9 +5,7 @@ const terminalCss = fs.readFileSync(new URL('./terminal.css', import.meta.url), describe('terminal container geometry', () => { it('keeps the hidden link tooltip out of the fitted terminal height', () => { - expect(terminalCss).toMatch( - /\.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--pane-padding-y, 4px\)\);/s - ) + expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*height:\s*100%;/s) expect(terminalCss).toMatch( /\.pane\[data-has-title\] \.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--orca-pane-title-height\)\);/s ) @@ -15,4 +13,12 @@ describe('terminal container geometry', () => { /\.pane-link-tooltip\s*{[^}]*height:\s*var\(--orca-terminal-link-tooltip-height\);/s ) }) + + it('insets the xterm grid on all four edges', () => { + expect(terminalCss).toMatch( + /\.xterm-container \.xterm\s*{[^}]*padding:\s*var\(--pane-padding-y, 4px\) var\(--pane-padding-x, 4px\);/s + ) + expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*width:\s*100%;/s) + expect(terminalCss).not.toMatch(/\.xterm-container\s*{[^}]*margin-left:/s) + }) }) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index ee5e0af01f5..16b3e36ec5e 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -25,9 +25,11 @@ outline: none; } -.pane-manager-root .xterm { +.xterm-container .xterm { + box-sizing: border-box; height: 100%; width: 100%; + padding: var(--pane-padding-y, 4px) var(--pane-padding-x, 4px); } .pane-manager-root .xterm-viewport { @@ -177,7 +179,7 @@ .pane[data-terminal-attention] .xterm-container::after { content: ''; position: absolute; - inset: 0 4px 4px 0; + inset: 0; z-index: 5; pointer-events: none; border-radius: 2px; @@ -498,16 +500,13 @@ border-color: var(--ring); } -/* Baseline xterm-container layout — padding is driven by CSS custom properties - so Ghostty-imported window-padding-x/y can be applied without inline styles. - The fallback 4px preserves the legacy default. */ +/* The container fills the pane; xterm owns the four-edge grid inset so its fit + calculation subtracts the configured padding before choosing cols/rows. */ .xterm-container { box-sizing: border-box; position: relative; - width: calc(100% - var(--pane-padding-x, 4px)); - height: calc(100% - var(--pane-padding-y, 4px)); - margin-top: var(--pane-padding-y, 4px); - margin-left: var(--pane-padding-x, 4px); + width: 100%; + height: 100%; } /* When a pane has a title, shift the terminal content down to make room. diff --git a/src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx b/src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx new file mode 100644 index 00000000000..5f54fb38a9e --- /dev/null +++ b/src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { getDefaultSettings } from '../../../../shared/constants' +import { TerminalWindowSection } from './TerminalWindowSection' + +type NumberFieldProps = { + label: string + value: number + onChange: (value: number) => void +} + +const numberFields = vi.hoisted(() => new Map()) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, defaultValue: string) => defaultValue +})) + +vi.mock('./SettingsFormControls', () => ({ + ColorField: function ColorField() { + return null + }, + NumberField: function NumberField(props: NumberFieldProps) { + numberFields.set(props.label, props) + return null + } +})) + +vi.mock('./SearchableSetting', () => ({ + SearchableSetting: function SearchableSetting({ children }: { children?: ReactNode }) { + return children ?? null + } +})) + +vi.mock('../ui/button', () => ({ Button: () => null })) +vi.mock('../ui/label', () => ({ Label: () => null })) +vi.mock('../ui/switch', () => ({ Switch: () => null })) + +describe('TerminalWindowSection padding', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + numberFields.clear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + document.body.replaceChildren() + }) + + it('displays and persists the same integer padding used by terminal fitting', () => { + const updateSettings = vi.fn() + const settings = { + ...getDefaultSettings('/tmp'), + terminalPaddingX: 1.5, + terminalPaddingY: 2.5 + } as GlobalSettings + + act(() => + root.render() + ) + + const horizontal = numberFields.get('Horizontal Padding') + const vertical = numberFields.get('Vertical Padding') + expect(horizontal?.value).toBe(2) + expect(vertical?.value).toBe(3) + + act(() => horizontal?.onChange(3.5)) + act(() => vertical?.onChange(4.5)) + expect(updateSettings).toHaveBeenNthCalledWith(1, { terminalPaddingX: 4 }) + expect(updateSettings).toHaveBeenNthCalledWith(2, { terminalPaddingY: 5 }) + }) +}) diff --git a/src/renderer/src/components/settings/TerminalWindowSection.tsx b/src/renderer/src/components/settings/TerminalWindowSection.tsx index 863d7fa11b0..e9efb2c4396 100644 --- a/src/renderer/src/components/settings/TerminalWindowSection.tsx +++ b/src/renderer/src/components/settings/TerminalWindowSection.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from 'react' import { RotateCw } from 'lucide-react' +import { normalizeTerminalPadding } from '../../../../shared/terminal-padding-settings' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Button } from '../ui/button' import { Label } from '../ui/label' @@ -185,13 +186,15 @@ export function TerminalWindowSection({ 'Horizontal Padding' )} description="" - value={settings.terminalPaddingX ?? 4} + value={normalizeTerminalPadding(settings.terminalPaddingX ?? 4)} defaultValue={4} min={0} max={512} step={1} suffix="px" - onChange={(value) => updateSettings({ terminalPaddingX: Math.max(0, value) })} + onChange={(value) => + updateSettings({ terminalPaddingX: normalizeTerminalPadding(value) }) + } /> @@ -212,13 +215,15 @@ export function TerminalWindowSection({ 'Vertical Padding' )} description="" - value={settings.terminalPaddingY ?? 4} + value={normalizeTerminalPadding(settings.terminalPaddingY ?? 4)} defaultValue={4} min={0} max={512} step={1} suffix="px" - onChange={(value) => updateSettings({ terminalPaddingY: Math.max(0, value) })} + onChange={(value) => + updateSettings({ terminalPaddingY: normalizeTerminalPadding(value) }) + } /> diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 6f7bec8592b..2bee7878d0b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -3,6 +3,7 @@ import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import { getDefaultSettings } from '../../../../shared/constants' import { applyTerminalAppearance, + composeRendererTerminalTheme, hexToRgba, publishTerminalViewAttributesAtAppStart } from './terminal-appearance' @@ -216,6 +217,54 @@ describe('applyTerminalAppearance theme assignment', () => { expect(pane.terminal.options.theme?.background).toBe('#102030') }) + it('lets the pane paint a fractional background exactly once', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + const manager = makeManager([pane]) + + applyTerminalAppearance( + manager, + { ...settings, terminalBackgroundOpacity: 0.5 }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + + expect(pane.terminal.options.theme?.background).toMatch(/^rgba\(.+, 0\)$/) + expect(pane.terminal.options.allowTransparency).toBe(true) + expect(manager.setPaneStyleOptions).toHaveBeenCalledWith( + expect.objectContaining({ paneBackground: expect.stringMatching(/^rgba\(.+, 0\.5\)$/) }) + ) + }) + + it('stamps four-edge padding before fitting', () => { + const pane = makePane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 79, rows: 23 })) + const manager = makeManager([pane]) + const settings = getDefaultSettings('/tmp') + + applyTerminalAppearance( + manager, + { ...settings, terminalPaddingX: 11, terminalPaddingY: 7 }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + + expect(manager.setPaneStyleOptions).toHaveBeenCalledWith( + expect.objectContaining({ paddingX: 11, paddingY: 7 }) + ) + expect(vi.mocked(manager.setPaneStyleOptions).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(pane.fitAddon.proposeDimensions).mock.invocationCallOrder[0]! + ) + }) + // #7934: contrast correction rescues invisible white text on light backgrounds but over-corrects on dark; // gate by the composed theme's background luminance (either theme slot can hold either kind of theme). it('keeps xterm contrast correction on light themes', () => { @@ -362,6 +411,23 @@ describe('applyTerminalAppearance theme assignment', () => { }) }) +describe('composeRendererTerminalTheme', () => { + it('preserves background RGB while removing fractional renderer alpha', () => { + const theme = { background: 'rgba(16, 32, 48, 0.5)', foreground: '#ffffff' } + + expect(composeRendererTerminalTheme(theme, 0.5)).toEqual({ + ...theme, + background: 'rgba(16, 32, 48, 0)' + }) + }) + + it('keeps the opaque fast path unchanged', () => { + const theme = { background: '#102030' } + + expect(composeRendererTerminalTheme(theme, 1)).toBe(theme) + }) +}) + describe('publishTerminalViewAttributesAtAppStart', () => { // Hidden-at-launch PTYs query OSC 10/11 before any pane mounts; publish with no pane manager (terminal-query-authority.md). it('publishes composed attributes without any pane mount and dedupes repeats', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index bb61a50fbbc..c7a1156500f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -29,6 +29,7 @@ import { publishTerminalViewAttributes } from './terminal-view-attributes-publis import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' import { maybePushMode2031Flip } from './terminal-mode-2031-replies' import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction' +import { normalizeTerminalPadding } from '../../../../shared/terminal-padding-settings' export function hexToRgba(hex: string, alpha: number): string { let clean = hex.replace('#', '') @@ -132,6 +133,22 @@ function composedTerminalThemesEqual(a: ITheme | undefined, b: ITheme): boolean return extA.length === extB.length && extA.every((value, i) => value === extB[i]) } +export function composeRendererTerminalTheme( + theme: ITheme | null, + backgroundOpacity: number | undefined +): ITheme | null { + if (!theme?.background || backgroundOpacity === undefined || backgroundOpacity >= 1) { + return theme + } + const transparentBackground = theme.background.replace( + /^rgba\(([^,]+),\s*([^,]+),\s*([^,]+),\s*[^)]+\)$/, + 'rgba($1, $2, $3, 0)' + ) + return transparentBackground === theme.background + ? theme + : { ...theme, background: transparentBackground } +} + export function applyTerminalAppearance( manager: PaneManager, settings: GlobalSettings, @@ -149,6 +166,7 @@ export function applyTerminalAppearance( // Publish composed appearance to main's hidden-PTY query responder — the only point it exists; deduped in the publisher. publishTerminalViewAttributes(theme, appearance.mode, settings) const paneBackground = theme?.background ?? '#000000' + const rendererTheme = composeRendererTerminalTheme(theme, settings.terminalBackgroundOpacity) const terminalFontWeights = resolveTerminalFontWeights( settings.terminalFontWeight, @@ -159,10 +177,23 @@ export function applyTerminalAppearance( settings.terminalFontFamily ) + // Why before fit: FitAddon reads live .xterm padding when proposing cols/rows. + manager.setPaneStyleOptions({ + splitBackground: paneBackground, + paneBackground, + inactivePaneOpacity: paneStyles.inactivePaneOpacity, + activePaneOpacity: paneStyles.activePaneOpacity, + opacityTransitionMs: paneStyles.opacityTransitionMs, + dividerThicknessPx: paneStyles.dividerThicknessPx, + focusFollowsMouse: paneStyles.focusFollowsMouse, + paddingX: normalizeTerminalPadding(settings.terminalPaddingX ?? 4), + paddingY: normalizeTerminalPadding(settings.terminalPaddingY ?? 4) + }) + for (const pane of manager.getPanes()) { // Why value-gated: writing options.theme rebuilds the palette, discarding TUI OSC 4/10/11/12 mutations; skip on no-op change. - if (theme && !composedTerminalThemesEqual(pane.terminal.options.theme, theme)) { - pane.terminal.options.theme = theme + if (rendererTheme && !composedTerminalThemesEqual(pane.terminal.options.theme, rendererTheme)) { + pane.terminal.options.theme = rendererTheme } // Gate off the configured theme background; the live OSC-11 background is deliberately preserved by the // theme write above, so a TUI that repaints its background at runtime won't re-gate (known limitation). @@ -227,16 +258,4 @@ export function applyTerminalAppearance( safeFit(pane) } } - - manager.setPaneStyleOptions({ - splitBackground: paneBackground, - paneBackground, - inactivePaneOpacity: paneStyles.inactivePaneOpacity, - activePaneOpacity: paneStyles.activePaneOpacity, - opacityTransitionMs: paneStyles.opacityTransitionMs, - dividerThicknessPx: paneStyles.dividerThicknessPx, - focusFollowsMouse: paneStyles.focusFollowsMouse, - paddingX: settings.terminalPaddingX, - paddingY: settings.terminalPaddingY - }) } diff --git a/src/shared/terminal-padding-settings.test.ts b/src/shared/terminal-padding-settings.test.ts new file mode 100644 index 00000000000..ee7ac432f46 --- /dev/null +++ b/src/shared/terminal-padding-settings.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { normalizeTerminalPadding } from './terminal-padding-settings' + +describe('normalizeTerminalPadding', () => { + it('rounds fractional padding to the nearest fitted pixel', () => { + expect(normalizeTerminalPadding(1.5)).toBe(2) + expect(normalizeTerminalPadding(2.49)).toBe(2) + }) + + it('keeps padding inside the settings range', () => { + expect(normalizeTerminalPadding(-1)).toBe(0) + expect(normalizeTerminalPadding(513)).toBe(512) + }) +}) diff --git a/src/shared/terminal-padding-settings.ts b/src/shared/terminal-padding-settings.ts new file mode 100644 index 00000000000..1f18a46973f --- /dev/null +++ b/src/shared/terminal-padding-settings.ts @@ -0,0 +1,6 @@ +const MIN_TERMINAL_PADDING = 0 +const MAX_TERMINAL_PADDING = 512 + +export function normalizeTerminalPadding(value: number): number { + return Math.min(MAX_TERMINAL_PADDING, Math.max(MIN_TERMINAL_PADDING, Math.round(value))) +} diff --git a/tests/e2e/terminal-padding-geometry-golden.spec.ts b/tests/e2e/terminal-padding-geometry-golden.spec.ts new file mode 100644 index 00000000000..89b744f73b9 --- /dev/null +++ b/tests/e2e/terminal-padding-geometry-golden.spec.ts @@ -0,0 +1,222 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActiveTerminalManager } from './helpers/terminal' + +type TerminalGeometry = { + cellWidth: number + cellHeight: number + cols: number + rows: number + scrollbarWidth: number + pane: { top: number; right: number; bottom: number; left: number; width: number; height: number } + terminal: { top: number; right: number; bottom: number; left: number } + screen: { + top: number + right: number + bottom: number + left: number + width: number + height: number + } +} + +const FIRST_PADDING = { x: 11, y: 7 } +const SECOND_PADDING = { x: 17, y: 13 } +const TARGET_COLS = 70 +const TARGET_ROWS = 20 +const MIN_REMAINDER = 2 + +async function readTerminalGeometry(page: Page): Promise { + return page.evaluate(() => { + const terminalElement = document.querySelector('.pane .xterm') + const paneElement = terminalElement?.closest('.pane') + const screenElement = terminalElement?.querySelector('.xterm-screen') + const manager = [...(window.__paneManagers?.values() ?? [])].find((candidate) => + candidate.getPanes().some((pane) => pane.container === paneElement) + ) + const pane = manager?.getPanes().find((candidate) => candidate.container === paneElement) + const cell = pane?.terminal.dimensions?.css.cell + if (!terminalElement || !paneElement || !screenElement || !pane || !cell) { + throw new Error('Active terminal geometry is unavailable') + } + const paneRect = paneElement.getBoundingClientRect() + const terminalRect = terminalElement.getBoundingClientRect() + const screenRect = screenElement.getBoundingClientRect() + return { + cellWidth: cell.width, + cellHeight: cell.height, + cols: pane.terminal.cols, + rows: pane.terminal.rows, + scrollbarWidth: pane.terminal.options.scrollbar?.width ?? 14, + pane: { + top: paneRect.top, + right: paneRect.right, + bottom: paneRect.bottom, + left: paneRect.left, + width: paneRect.width, + height: paneRect.height + }, + terminal: { + top: terminalRect.top, + right: terminalRect.right, + bottom: terminalRect.bottom, + left: terminalRect.left + }, + screen: { + top: screenRect.top, + right: screenRect.right, + bottom: screenRect.bottom, + left: screenRect.left, + width: screenRect.width, + height: screenRect.height + } + } + }) +} + +function findNonDivisibleSize(cellSize: number, cells: number, chrome: number): number { + const floor = Math.ceil(cellSize * cells + chrome) + for (let size = floor; size < floor + Math.ceil(cellSize); size += 1) { + const available = size - chrome + const remainder = available - Math.floor(available / cellSize) * cellSize + if (remainder > MIN_REMAINDER && remainder < cellSize - MIN_REMAINDER) { + return size + } + } + throw new Error(`Could not force a non-divisible ${cellSize}px cell geometry`) +} + +async function applyPaddingAndPaneSize( + page: Page, + padding: { x: number; y: number }, + size: { width: number; height: number } +): Promise { + await page.evaluate( + async ({ nextPadding, nextSize }) => { + const store = window.__store + const terminal = document.querySelector('.pane .xterm') + const pane = terminal?.closest('.pane') + if (!store || !pane) { + throw new Error('Terminal settings surface is unavailable') + } + await store.getState().updateSettings({ + terminalPaddingX: nextPadding.x, + terminalPaddingY: nextPadding.y + }) + pane.style.setProperty('width', `${nextSize.width}px`, 'important') + pane.style.setProperty('height', `${nextSize.height}px`, 'important') + }, + { nextPadding: padding, nextSize: size } + ) + + await page.waitForFunction( + ({ expectedPadding, expectedSize }) => { + const store = window.__store + const terminal = document.querySelector('.pane .xterm') + const pane = terminal?.closest('.pane') + if (!store || !pane) { + return false + } + const settings = store.getState().settings + const rect = pane.getBoundingClientRect() + return ( + settings?.terminalPaddingX === expectedPadding.x && + settings?.terminalPaddingY === expectedPadding.y && + Math.abs(rect.width - expectedSize.width) < 0.5 && + Math.abs(rect.height - expectedSize.height) < 0.5 + ) + }, + { expectedPadding: padding, expectedSize: size } + ) + + await page.evaluate(() => { + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of manager.getPanes()) { + pane.fitAddon.fit() + } + } + }) + await page.waitForTimeout(100) +} + +function expectClose(actual: number, expected: number): void { + expect(Math.abs(actual - expected)).toBeLessThan(0.75) +} + +function assertFourEdgeGeometry( + geometry: TerminalGeometry, + padding: { x: number; y: number } +): { horizontalRemainder: number; verticalRemainder: number } { + const verticalRemainder = geometry.pane.height - padding.y * 2 - geometry.screen.height + + // The old layout only exposed its trailing gap when cell fitting left a remainder. + expect(verticalRemainder).toBeGreaterThan(MIN_REMAINDER) + expect(verticalRemainder).toBeLessThan(geometry.cellHeight) + + expect({ + left: geometry.terminal.left - geometry.pane.left, + top: geometry.terminal.top - geometry.pane.top, + right: geometry.pane.right - geometry.terminal.right, + bottom: geometry.pane.bottom - geometry.terminal.bottom + }).toEqual({ left: 0, top: 0, right: 0, bottom: 0 }) + + const horizontalRemainder = + geometry.pane.width - padding.x * 2 - geometry.scrollbarWidth - geometry.screen.width + + expect(horizontalRemainder).toBeGreaterThan(0) + expect(horizontalRemainder).toBeLessThan(geometry.cellWidth) + + expectClose(geometry.screen.left - geometry.pane.left, padding.x) + expectClose(geometry.screen.top - geometry.pane.top, padding.y) + expectClose( + geometry.pane.right - geometry.screen.right, + padding.x + geometry.scrollbarWidth + horizontalRemainder + ) + expectClose(geometry.pane.bottom - geometry.screen.bottom, padding.y + verticalRemainder) + + return { horizontalRemainder, verticalRemainder } +} + +test.describe('terminal padding geometry', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + }) + + // #13252: the old start-edge margin left the grid flush right/bottom. The + // #14583 re-land was reverted, so this guard checks geometry, not its paint mechanism. + test('keeps configured padding on all four edges with a forced cell remainder @terminal-rendering-golden', async ({ + orcaPage + }) => { + const baseline = await readTerminalGeometry(orcaPage) + const firstSize = { + width: findNonDivisibleSize( + baseline.cellWidth, + TARGET_COLS, + FIRST_PADDING.x * 2 + baseline.scrollbarWidth + ), + height: findNonDivisibleSize(baseline.cellHeight, TARGET_ROWS, FIRST_PADDING.y * 2) + } + + await applyPaddingAndPaneSize(orcaPage, FIRST_PADDING, firstSize) + const first = await readTerminalGeometry(orcaPage) + const firstRemainders = assertFourEdgeGeometry(first, FIRST_PADDING) + + // Keep usable dimensions constant while changing both setting values. + const secondSize = { + width: firstSize.width + (SECOND_PADDING.x - FIRST_PADDING.x) * 2, + height: firstSize.height + (SECOND_PADDING.y - FIRST_PADDING.y) * 2 + } + await applyPaddingAndPaneSize(orcaPage, SECOND_PADDING, secondSize) + const second = await readTerminalGeometry(orcaPage) + const secondRemainders = assertFourEdgeGeometry(second, SECOND_PADDING) + + expect(second.cols).toBe(first.cols) + expect(second.rows).toBe(first.rows) + expectClose(secondRemainders.horizontalRemainder, firstRemainders.horizontalRemainder) + expectClose(secondRemainders.verticalRemainder, firstRemainders.verticalRemainder) + }) +}) From 3e079debec65aa958fc69bbf3ffa8979662688ef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:38:28 -0700 Subject: [PATCH 014/103] fix(sidebar): host-qualify discovery notice rows on multi-host projects (#15546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sidebar): host-qualify discovery notice rows and collapse one checkout's twins A project checked out on several hosts emits one discovery-notice row per checkout, and those rows only named the project. A sidebar with paired remote hosts therefore showed several identical "N hidden worktrees" buttons under one project header, with no way to tell which machine each belonged to — or that one of them was another machine's worktree inbox entirely. Two causes, both fixed here: - Notice rows carried no host context, unlike worktree rows, which have been host-labelled since STA-4343. Both notice rows now take a host label, applied per project (not per rendered section, since a card can land in the pinned fallback) and only when that project spans hosts. The label also lands in the review, expand, and dismiss accessible names, so the actions that write to a specific host's repo record say which host that is. - One machine registered as a direct SSH target *and* paired as a runtime environment gives a single on-disk checkout two repo records with independent hidden-worktree state, so it emitted two rows for one directory. Repos now resolve to a (hostname, path) checkout key, and twins collapse to the record this client persists itself — its visibility state is the user's own and survives the paired runtime going away. The key is deliberately conservative: an unresolved hostname, or a tunnelled environment answering on loopback, yields no key and never collapses anything. Renderer-only; no wire or persistence change. * fix(sidebar): drop the machine-identity collapse, gate notice labels on host ids Replaces this branch's second change after a plan review found it has no precedent and eight concrete failure modes. Deleted: the (hostname, path) checkout key that collapsed two repo records believed to be one machine. Orca models a direct SSH target and a paired runtime environment as different execution hosts everywhere else; that change asserted sameness by resolving strings a user typed in two places. It also dropped rows (a differing count vanished with the shadowed record), flipped with the sidebar host filter, ignored port and user so a host and a container on it could merge, tie-broke on repo-store order, was disabled in the one case Orca can prove (a tunnelled pairing answers on loopback) and fired only on coincidence, and left the visibility dialog showing state the sidebar had hidden. Its module also carried a literal NUL byte, so git classified the file as binary and the diff was unreviewable. Kept, with two corrections: notice rows still carry a host label, but the gate now counts distinct host ids rather than distinct label strings — two hosts sharing one user-facing label is exactly when the rows are hardest to tell apart — and membership is read from the unfiltered repo universe rather than the host-filtered notice candidates, so a label no longer appears and disappears with the filter. Two hosts that share a label still render the same label. Disambiguating that is a shared concern across worktree badges, host headers, and host-filter options, and needs its own design; three verification passes each found a different hole in doing it here. Follow-ups: general host-label collision, and the repo-record duplication that produces the twin rows in the first place. * fix(i18n): catalog notice host scope copy * feat(sidebar): show each notice row's host with the project-on-host glyph Notice rows on a multi-host project already carried a host label, but two hosts can share one user-facing name, and the label truncates first in a narrow sidebar. Each row now also carries its host's glyph. Deliberately the same indicator worktree cards use (worktree-card-header): a Server glyph, ServerOff when a paired runtime has no live status, and a "Project on ..." tooltip naming the host — SSH and runtime keep their distinct tooltip wording. Local hosts draw nothing, as on the cards. The glyph is shrink-0, so unlike the text label it survives the sidebar narrowing, and the row keeps an identifying mark either way. Rows now carry the host id alongside the label, since the label alone cannot select a glyph or its tooltip. Catalog entries for the new copy ship with the change rather than relying on inline fallbacks. * refactor(sidebar): draw notice-row hosts with the shared host glyph Follow-up to the notice-row host indicator: use the one glyph vocabulary the app already has instead of a second copy of it. HostRowIcon — a monitor for this computer, a server for anything remote — was private to the composer's run-target rows. Moved to a shared home and reused, so the sidebar and the composer cannot drift apart. The run-target module re-exports it, leaving its own call sites untouched. Every notice row now gets a glyph, local included, so no row is the odd one out; the tooltip still names the host and says when a paired runtime has no live status. Same size and tone tokens across kinds, so no row reads as decorated relative to its neighbours. * fix(sidebar): make notice host glyphs accessible --- src/renderer/src/components/host-row-icon.tsx | 16 ++ .../new-workspace/RunTargetComboboxRow.tsx | 11 +- .../ImportedWorktreesVisibilityLine.test.tsx | 20 ++ .../ImportedWorktreesVisibilityLine.tsx | 32 ++- .../NewExternalWorktreesInboxLine.test.tsx | 34 +++ .../sidebar/NewExternalWorktreesInboxLine.tsx | 37 ++- .../sidebar/NoticeHostGlyph.test.tsx | 157 +++++++++++++ .../components/sidebar/NoticeHostGlyph.tsx | 86 +++++++ ...ree-list-groups-notice-host-labels.test.ts | 220 ++++++++++++++++++ .../worktree-list/grouping/build-rows.ts | 18 +- .../worktree-list/grouping/group-sections.ts | 17 +- .../worktree-list/grouping/host-labels.ts | 75 +++++- .../grouping/pinned-group-rows.ts | 22 +- .../worktree-list/grouping/row-builders.ts | 17 +- .../worktree-list/grouping/row-types.ts | 6 + .../worktree-list/rows/notice-rows.tsx | 4 + src/renderer/src/i18n/locales/en.json | 9 +- src/renderer/src/i18n/locales/es.json | 6 + src/renderer/src/i18n/locales/ja.json | 6 + src/renderer/src/i18n/locales/ko.json | 6 + src/renderer/src/i18n/locales/zh.json | 6 + .../i18n/worktree-visibility-locales.test.ts | 3 + 22 files changed, 782 insertions(+), 26 deletions(-) create mode 100644 src/renderer/src/components/host-row-icon.tsx create mode 100644 src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx create mode 100644 src/renderer/src/components/sidebar/NoticeHostGlyph.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts diff --git a/src/renderer/src/components/host-row-icon.tsx b/src/renderer/src/components/host-row-icon.tsx new file mode 100644 index 00000000000..7e7d519f60a --- /dev/null +++ b/src/renderer/src/components/host-row-icon.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import { Monitor, Server } from 'lucide-react' + +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' + +/** The local machine isn't a server — a monitor glyph reads as "this computer". */ +export function HostRowIcon({ + hostId, + className +}: { + hostId: ExecutionHostId + className?: string +}): React.JSX.Element { + const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server + return +} diff --git a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx index 4d7cb3df50f..dde3ae66b28 100644 --- a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx @@ -1,17 +1,14 @@ import React from 'react' -import { AlertTriangle, ChevronRight, LoaderCircle, Monitor, Server } from 'lucide-react' +import { AlertTriangle, ChevronRight, LoaderCircle } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { ProjectOptionDetail } from './ProjectComboboxRow' import { translate } from '@/i18n/i18n' +import { HostRowIcon } from '../host-row-icon' -/** The local machine isn't a server — a monitor glyph reads as "this computer". */ -export function HostRowIcon({ hostId }: { hostId: ExecutionHostId }): React.JSX.Element { - const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server - return -} +export { HostRowIcon } /** * One run-target row. Shares the Project picker's shape — 32px, label and diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx index 527c7496a41..0dc3f7c3ce2 100644 --- a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx +++ b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx @@ -73,6 +73,26 @@ describe('ImportedWorktreesVisibilityLine', () => { expect(markup).not.toContain('/worktrees/demo-project') }) + it('names the host when the project is checked out on more than one', () => { + const markup = renderLine({ hostContextLabel: 'openclaw' }) + + expect(markup).toContain('openclaw') + expect(markup).toContain('Expand hidden worktrees for orca on openclaw') + expect(markup).toContain( + 'Keep 4 discovered worktrees hidden for orca on openclaw; recover from the project menu' + ) + }) + + it('folds the host into pinned fallback copy, which already names the repo', () => { + const markup = renderLine({ + hostContextLabel: 'openclaw', + placement: 'pinned-fallback', + onKeepHidden: undefined + }) + + expect(markup).toContain('Hiding 4 discovered worktrees in orca on openclaw') + }) + it('scopes pinned fallback copy to the repo name without a dismiss action', () => { const markup = renderLine({ placement: 'pinned-fallback', onKeepHidden: undefined }) diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx index 3c82858579e..9418b29b5ce 100644 --- a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx +++ b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx @@ -4,6 +4,8 @@ import { ChevronRight, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import NoticeHostGlyph from './NoticeHostGlyph' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { getExternalWorktreeParentPath } from '../../../../shared/external-worktree-visibility' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import { translate } from '@/i18n/i18n' @@ -19,6 +21,10 @@ export type ImportedWorktreeVisibilityPreview = { type ImportedWorktreesVisibilityLineProps = { repoDisplayName: string + /** Host this checkout lives on. Set only when the project is checked out on + * more than one host, where the line alone cannot identify the row. */ + hostContextLabel?: string + hostContextHostId?: ExecutionHostId hiddenWorktrees: readonly ImportedWorktreeVisibilityPreview[] placement: ImportedWorktreesVisibilityPlacement pending: boolean @@ -74,6 +80,8 @@ export function groupWorktreesByParentPath( export default function ImportedWorktreesVisibilityLine({ repoDisplayName, + hostContextLabel, + hostContextHostId, hiddenWorktrees, placement, pending, @@ -89,7 +97,11 @@ export default function ImportedWorktreesVisibilityLine({ const worktreeGroups = groupWorktreesByParentPath(hiddenWorktrees) const visibleWorktreeGroups = worktreeGroups.slice(0, GROUP_LIMIT) const remainingGroupCount = Math.max(0, worktreeGroups.length - visibleWorktreeGroups.length) - const keepHiddenAriaLabel = `Keep ${hiddenCount} discovered ${worktreeNoun} hidden for ${repoDisplayName}; recover from the project menu` + // Why: two hosts checking out one project render two identical lines. + const repoScopeLabel = hostContextLabel + ? `${repoDisplayName} on ${hostContextLabel}` + : repoDisplayName + const keepHiddenAriaLabel = `Keep ${hiddenCount} discovered ${worktreeNoun} hidden for ${repoScopeLabel}; recover from the project menu` if (hiddenCount === 0) { return null @@ -97,7 +109,7 @@ export default function ImportedWorktreesVisibilityLine({ const lineText = placement === 'pinned-fallback' - ? `Hiding ${hiddenCount} discovered ${worktreeNoun} in ${repoDisplayName}` + ? `Hiding ${hiddenCount} discovered ${worktreeNoun} in ${repoScopeLabel}` : `Hiding ${hiddenCount} discovered ${worktreeNoun}` const toggleGroupExpanded = (path: string): void => { @@ -133,7 +145,7 @@ export default function ImportedWorktreesVisibilityLine({ aria-label={translate( 'auto.components.sidebar.ImportedWorktreesVisibilityLine.f54f2bec5d', '{{value0}} hidden worktrees for {{value1}}', - { value0: isExpanded ? 'Collapse' : 'Expand', value1: repoDisplayName } + { value0: isExpanded ? 'Collapse' : 'Expand', value1: repoScopeLabel } )} onClick={() => setIsExpanded((value) => !value)} className="shrink-0 rounded-[4px] text-muted-foreground hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground" @@ -144,6 +156,20 @@ export default function ImportedWorktreesVisibilityLine({ /> {lineText} + {hostContextLabel && placement !== 'pinned-fallback' ? ( + + {hostContextHostId ? ( + + ) : null} + + {hostContextLabel} + + + ) : null} {onKeepHidden ? ( diff --git a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx index 38554945f12..426f9adf02d 100644 --- a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx +++ b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx @@ -17,6 +17,7 @@ vi.mock('@/components/ui/tooltip', () => ({ const roots: Root[] = [] type RenderOverrides = { + hostContextLabel?: string inboxCount?: number pending?: boolean error?: string | null @@ -34,6 +35,7 @@ async function renderLine(overrides: RenderOverrides = {}): Promise { ) }) + it('names the host so two checkouts of one project are distinguishable', async () => { + // Both rows read "N hidden worktrees"; only the host tells them apart. + const local = await renderLine({ hostContextLabel: 'Local Mac', inboxCount: 61 }) + const remote = await renderLine({ hostContextLabel: 'openclaw', inboxCount: 134 }) + + expect(local.textContent).toContain('Local Mac') + expect(getReviewButton(local)?.getAttribute('aria-label')).toBe( + 'Review 61 hidden worktrees in orca on Local Mac' + ) + expect(getReviewButton(remote)?.getAttribute('aria-label')).toBe( + 'Review 134 hidden worktrees in orca on openclaw' + ) + }) + + it('host-qualifies the suppress control, which writes to that host alone', async () => { + const container = await renderLine({ hostContextLabel: 'openclaw', onSuppress: vi.fn() }) + + expect( + container.querySelector( + 'button[aria-label="Hide external worktrees permanently for orca on openclaw"]' + ) + ).not.toBeNull() + }) + + it('stays unqualified when the project has a single checkout', async () => { + const container = await renderLine() + + expect(getReviewButton(container)?.getAttribute('aria-label')).toBe( + 'Review 24 hidden worktrees in orca' + ) + }) + it('keeps suppress as a hover-revealed control that does not trigger review', async () => { const onReview = vi.fn() const onSuppress = vi.fn() diff --git a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx index 657b3b254e0..33fe674575f 100644 --- a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx +++ b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx @@ -4,10 +4,16 @@ import { ChevronRight, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import NoticeHostGlyph from './NoticeHostGlyph' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' type NewExternalWorktreesInboxLineProps = { repoDisplayName: string + /** Host this checkout lives on. Set only when the project is checked out on + * more than one host, where the count alone cannot identify the row. */ + hostContextLabel?: string + hostContextHostId?: ExecutionHostId inboxCount: number pending: boolean error: string | null @@ -18,6 +24,8 @@ type NewExternalWorktreesInboxLineProps = { export default function NewExternalWorktreesInboxLine({ repoDisplayName, + hostContextLabel, + hostContextHostId, inboxCount, pending, error, @@ -29,10 +37,19 @@ export default function NewExternalWorktreesInboxLine({ 'auto.components.sidebar.NewExternalWorktreesInboxLine.c3e8a1f4b2', "Don't show again" ) + // Why: the same project on two hosts renders two identical rows, so every + // accessible name has to name the host as well as the project. + const repoScopeLabel = hostContextLabel + ? translate( + 'auto.components.sidebar.NewExternalWorktreesInboxLine.6c07f3a91e', + '{{value0}} on {{value1}}', + { value0: repoDisplayName, value1: hostContextLabel } + ) + : repoDisplayName const suppressAriaLabel = translate( 'auto.components.sidebar.NewExternalWorktreesInboxLine.9f2d4c8b17', 'Hide external worktrees permanently for {{value0}}', - { value0: repoDisplayName } + { value0: repoScopeLabel } ) const isSingular = inboxCount === 1 const countLabel = isSingular @@ -48,12 +65,12 @@ export default function NewExternalWorktreesInboxLine({ ? translate( 'auto.components.sidebar.NewExternalWorktreesInboxLine.7f18c5b0d3', 'Review {{value0}} hidden worktree in {{value1}}', - { value0: inboxCount, value1: repoDisplayName } + { value0: inboxCount, value1: repoScopeLabel } ) : translate( 'auto.components.sidebar.NewExternalWorktreesInboxLine.4e2b7a9c05', 'Review {{value0}} hidden worktrees in {{value1}}', - { value0: inboxCount, value1: repoDisplayName } + { value0: inboxCount, value1: repoScopeLabel } ) if (inboxCount === 0) { @@ -83,6 +100,20 @@ export default function NewExternalWorktreesInboxLine({ {inboxCount} {countLabel} + {hostContextLabel ? ( + + {hostContextHostId ? ( + + ) : null} + + {hostContextLabel} + + + ) : null}