Merge branch 'main' of https://github.com/stablyai/orca into brennanb2025/tcc-local-build-bundle-id

This commit is contained in:
Brennan Benson
2026-07-22 17:30:08 -07:00
240 changed files with 13027 additions and 2637 deletions
@@ -5,5 +5,40 @@
"text": "Terminal 1",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
"kind": "object-property:keywords",
"text": "语言",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
"kind": "object-property:keywords",
"text": "語言",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
"kind": "object-property:keywords",
"text": "언어",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
"kind": "object-property:keywords",
"text": "言語",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
"kind": "object-property:keywords",
"text": "Idioma",
"dynamic": false,
"count": 1
}
]
+3
View File
@@ -22,18 +22,21 @@
"../src/main/codex/codex-app-server-session.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-config-settings-upsert.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-hook-trust-grant.ts",
"../src/main/codex/codex-managed-trust-reconciliation.ts",
"../src/main/codex/codex-process-exit-deadline.ts",
"../src/main/codex/codex-trust-config-rollback.ts",
"../src/main/codex/codex-trust-grant-telemetry.ts",
"../src/main/codex/codex-trust-grant-host.ts",
"../src/main/codex/codex-trust-grant-ledger.ts",
"../src/main/codex/codex-user-hook-trust-rebase-client.ts",
"../src/main/codex/codex-user-hook-trust-rebase.ts",
"../src/main/codex/codex-wsl-hook-install-plan.ts",
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-toml-key-path.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
+11 -8
View File
@@ -9,13 +9,14 @@ import {
Alert
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
import { loadHosts } from '../../../src/transport/host-store'
import { useHostClient } from '../../../src/transport/client-context'
import type { RpcSuccess } from '../../../src/transport/types'
import { colors, spacing } from '../../../src/theme/mobile-theme'
import { styles } from './accounts-screen-styles'
import { useNow } from '../../../src/hooks/use-now'
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
import {
type AccountsSnapshot,
@@ -40,14 +41,16 @@ export default function AccountsScreen() {
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
const [clockEnabled, setClockEnabled] = useState(false)
// Why: the reset countdown must stay fresh while the screen sits open —
// snapshot pushes only arrive when the desktop's rate-limit poll completes.
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 60_000)
return () => clearInterval(id)
}, [])
useFocusEffect(
useCallback(() => {
setClockEnabled(true)
return () => setClockEnabled(false)
}, [])
)
// Why: snapshot pushes only arrive when the desktop's rate-limit poll completes.
const now = useNow(60_000, clockEnabled)
useEffect(() => {
if (!hostId) {
+63 -74
View File
@@ -38,6 +38,7 @@ import {
useForceReconnect
} from '../../../src/transport/client-context'
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh'
import {
useLastConnectedAt,
useReconnectAttempt
@@ -61,7 +62,7 @@ import { buildWorktreeNavigationActions } from '../../../src/agent-history/workt
import { floatingWorkspaceSessionPath } from '../../../src/session/floating-workspace'
import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen'
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
import { MobileSearchField } from '../../../src/components/MobileSearchField'
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
@@ -70,7 +71,6 @@ import { setCachedRepos } from '../../../src/cache/repo-cache'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
import { leaveHostRoute } from '../../../src/host-route-exit'
import { useHostStatusGates } from '../../../src/transport/host-status-gates'
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
import {
createInitialHostRouteActionState,
@@ -141,7 +141,8 @@ export function HostScreen({
const lastConnectedAt = useLastConnectedAt(hostId)
const clientRef = useRef<RpcClient | null>(null)
const fetchWorktreesInFlightRef = useRef(false)
const fetchRepoMetadataInFlightRef = useRef(false)
const fetchRepoMetadataInFlightRef = useRef(new WeakSet<RpcClient>())
const fetchRepoMetadataPendingRef = useRef(new WeakSet<RpcClient>())
const repoMetadataFetchedAtRef = useRef(0)
const newWorktreeModalRef = useRef<{ open: () => void }>(null)
const newWorktreeModalVisibleRef = useRef(false)
@@ -176,11 +177,7 @@ export function HostScreen({
const [showGroupPicker, setShowGroupPicker] = useState(false)
const [showFilterModal, setShowFilterModal] = useState(false)
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
const { hostCapabilities, floatingWorkspaceEnabled, compatVerdict } = useHostStatusGates({
hostId,
client,
connState
})
const { hostCapabilities, floatingWorkspaceEnabled } = useHostProtocolGates()
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
const [routeActionState, setRouteActionState] = useState(() =>
@@ -356,48 +353,54 @@ export function HostScreen({
}, [hostId])
const fetchRepoMetadata = useCallback(
async (options: { force?: boolean } = {}) => {
async (options: { force?: boolean; queueIfInFlight?: boolean } = {}) => {
if (!client || connState !== 'connected' || !hostId) {
return
}
if (fetchRepoMetadataInFlightRef.current) {
if (fetchRepoMetadataInFlightRef.current.has(client)) {
if (options.queueIfInFlight) {
fetchRepoMetadataPendingRef.current.add(client)
}
return
}
const now = Date.now()
if (!options.force && now - repoMetadataFetchedAtRef.current < REPO_METADATA_REFRESH_MS) {
return
}
fetchRepoMetadataInFlightRef.current = true
fetchRepoMetadataInFlightRef.current.add(client)
const requestClient = client,
requestHostId = hostId
try {
const repoResponse = await requestClient.sendRequest('repo.list')
if (clientRef.current !== requestClient || hostId !== requestHostId || !repoResponse.ok) {
return
}
const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] }
repoMetadataFetchedAtRef.current = Date.now()
setCachedRepos(requestHostId, repoResult.repos)
setRepoColorsByName(
new Map(
repoResult.repos.map((repo) => [
repo.displayName,
repo.badgeColor || repoColor(repo.displayName)
])
)
)
setRepoIconsByName(
new Map(
repoResult.repos.flatMap((repo) =>
repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : []
do {
fetchRepoMetadataPendingRef.current.delete(requestClient)
const repoResponse = await requestClient.sendRequest('repo.list')
if (clientRef.current !== requestClient || hostId !== requestHostId || !repoResponse.ok) {
return
}
const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] }
repoMetadataFetchedAtRef.current = Date.now()
setCachedRepos(requestHostId, repoResult.repos)
setRepoColorsByName(
new Map(
repoResult.repos.map((repo) => [
repo.displayName,
repo.badgeColor || repoColor(repo.displayName)
])
)
)
)
setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id])))
setRepoIconsByName(
new Map(
repoResult.repos.flatMap((repo) =>
repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : []
)
)
)
setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id])))
} while (fetchRepoMetadataPendingRef.current.has(requestClient))
} catch {
// Repo metadata is decorative; the next throttled refresh can retry.
// Repo metadata is decorative; the next refresh can retry.
} finally {
fetchRepoMetadataInFlightRef.current = false
fetchRepoMetadataInFlightRef.current.delete(requestClient)
}
},
[client, connState, hostId]
@@ -494,39 +497,29 @@ export function HostScreen({
}, [])
)
useFocusEffect(
useCallback(() => {
// The embedded sidebar isn't a routed screen (focus never fires); it polls via the mount effect below.
if (embedded || connState !== 'connected') {
return
}
void fetchWorktrees()
void fetchRepoMetadata()
// Pull desktop's shared view settings on focus so desktop changes show up without a manual refresh.
void syncViewSettingsFromDesktop()
// Why: React Navigation keeps prior screens mounted; only poll while this route is visible.
const interval = setInterval(() => {
void fetchWorktrees()
void fetchRepoMetadata()
}, 3000)
return () => clearInterval(interval)
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
)
// Why: the embedded sidebar is never the focused route, so useFocusEffect never polls; mirror it from a mount effect.
useEffect(() => {
if (!embedded || connState !== 'connected') {
const startWorktreeRefresh = useCallback(() => {
if (!client || connState !== 'connected') {
return
}
void fetchWorktrees()
void fetchRepoMetadata()
void syncViewSettingsFromDesktop()
const interval = setInterval(() => {
void fetchWorktrees()
void fetchRepoMetadata()
}, 3000)
return () => clearInterval(interval)
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
return startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata })
}, [client, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
useFocusEffect(
useCallback(() => {
// The embedded sidebar isn't a routed screen (focus never fires); it refreshes via the mount effect below.
if (!embedded) {
return startWorktreeRefresh()
}
}, [embedded, startWorktreeRefresh])
)
// Why: the embedded sidebar is never the focused route, so wire its refresh lifecycle from a mount effect.
useEffect(() => {
if (embedded) {
return startWorktreeRefresh()
}
}, [embedded, startWorktreeRefresh])
// Why (#8498): steady-state polls miss the transition INTO 'connected' after background/sleep, when the cache is stalest.
const { refreshing, onRefresh } = useWorktreeResync({
@@ -745,15 +738,17 @@ export function HostScreen({
const toggleCollapsed = useCallback(
(key: string) => {
const next = new Set(viewStateRef.current.collapsedGroups)
if (next.has(key)) {
next.delete(key)
} else {
if (!next.delete(key)) {
next.add(key)
}
persistViewSettings({ collapsedGroups: [...next] })
},
[persistViewSettings]
)
const toggleWorktreeLineage = useCallback(
(item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item.worktreeId)),
[toggleCollapsed]
)
const { sections, rawSections, uniqueRepos, uniqueRepoColors } = useWorkspaceSections({
displayWorktrees,
sortMode,
@@ -780,10 +775,6 @@ export function HostScreen({
)
}
if (compatVerdict.kind === 'blocked') {
return <ProtocolBlockScreen verdict={compatVerdict} />
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.topChrome}>
@@ -1200,9 +1191,7 @@ export function HostScreen({
hideRepo={groupMode === 'repo'}
onPress={openWorktreeSession}
onLongPress={item.workspaceKind === 'folder-workspace' ? undefined : setActionTarget}
onToggleLineage={(row) =>
toggleCollapsed(getMobileWorkspaceLineageGroupKey(row.worktreeId))
}
onToggleLineage={toggleWorktreeLineage}
/>
)}
/>
+34 -13
View File
@@ -2706,14 +2706,25 @@ export default function SessionScreen() {
if (connState !== 'connected') {
return
}
void fetchSessionTabs()
void fetchTerminals()
// Why: live subscription keeps stream ownership, but the fallback list poll should stop while this route is hidden.
const interval = setInterval(() => {
const refreshOnForeground = () => {
if (AppState.currentState !== 'active') {
return
}
void fetchSessionTabs()
void fetchTerminals()
}, 2000)
return () => clearInterval(interval)
}
const appStateSubscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
refreshOnForeground()
}
})
// Why: live subscription keeps stream ownership, but the fallback list poll should stop while this route is hidden or backgrounded.
const interval = setInterval(refreshOnForeground, 2000)
refreshOnForeground()
return () => {
clearInterval(interval)
appStateSubscription.remove()
}
}, [connState, fetchSessionTabs, fetchTerminals])
)
@@ -4514,14 +4525,24 @@ export default function SessionScreen() {
>
<Plus size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
{quickCommandsSupported === true ? (
<QuickCommandsTabButton
disabled={
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
{/* Why: stable placement matters, while old hosts must stay gated because they strip agentPrompt. */}
<QuickCommandsTabButton
disabled={
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
}
onPress={() => {
if (quickCommandsSupported === true) {
setShowQuickCommands(true)
return
}
onPress={() => setShowQuickCommands(true)}
/>
) : null}
showToast(
quickCommandsSupported === false
? 'Desktop update required for quick commands'
: 'Checking desktop capabilities — try again in a moment',
1600
)
}}
/>
</View>
)}
</SafeAreaView>
+18 -15
View File
@@ -10,6 +10,7 @@ import {
loadHostSidebarWidth,
saveHostSidebarWidth
} from '../../src/storage/preferences'
import { HostProtocolGate } from '../../src/components/HostProtocolGate'
import { HostScreen } from './[hostId]/index'
// Keep at least this much room for the detail pane when resizing the sidebar.
@@ -138,23 +139,25 @@ export default function HostGroupLayout() {
// changes so a fold/rotation doesn't remount the navigator and reset the
// navigation stack — only the sidebar pane toggles in and out.
return (
<View style={styles.row}>
{showSidebar && sidebarOpen ? (
<View style={[styles.sidebar, { width: sidebarWidth }]}>
<HostScreen
embedded
hostId={hostId}
action={action}
onHideSidebar={canCollapseSidebar ? hideSidebar : undefined}
/>
{/* Dedicated drag handle straddling the right border — see resizer note. */}
<View style={styles.resizeHandle} {...resizer.panHandlers} />
<HostProtocolGate hostId={hostId}>
<View style={styles.row}>
{showSidebar && sidebarOpen ? (
<View style={[styles.sidebar, { width: sidebarWidth }]}>
<HostScreen
embedded
hostId={hostId}
action={action}
onHideSidebar={canCollapseSidebar ? hideSidebar : undefined}
/>
{/* Dedicated drag handle straddling the right border — see resizer note. */}
<View style={styles.resizeHandle} {...resizer.panHandlers} />
</View>
) : null}
<View style={styles.detail}>
<HostStack animation={showSidebar ? 'none' : 'default'} />
</View>
) : null}
<View style={styles.detail}>
<HostStack animation={showSidebar ? 'none' : 'default'} />
</View>
</View>
</HostProtocolGate>
)
}
+11 -12
View File
@@ -28,6 +28,7 @@ import { loadHosts } from '../src/transport/host-store'
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
import type { RpcClient } from '../src/transport/rpc-client'
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
import {
useAllHostClients,
useCloseHost,
@@ -140,11 +141,11 @@ function clientKey(client: RpcClient): number {
function fetchStats(
client: RpcClient,
hostId: string,
setStats: (s: StatsSummary) => void,
disposed: () => boolean
) {
client
.sendRequest('stats.summary')
sendSingleFlightRequest(client, hostId, 'stats.summary')
.then((response) => {
if (disposed()) {
return
@@ -182,9 +183,8 @@ function fetchWorktreeInfo(
})
}
client
// Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate.
.sendRequest('worktree.ps', { limit: 10000 })
// Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate.
sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: 10000 })
.then((response) => {
if (disposed()) {
return
@@ -225,8 +225,7 @@ function fetchAccountsSnapshot(
) => void,
disposed: () => boolean
) {
client
.sendRequest('accounts.list')
sendSingleFlightRequest(client, hostId, 'accounts.list')
.then((response) => {
if (disposed()) {
return
@@ -248,9 +247,9 @@ function fetchTaskProviders(
disposed: () => boolean
) {
Promise.all([
client.sendRequest('settings.get'),
client.sendRequest('preflight.check'),
client.sendRequest('linear.status')
sendSingleFlightRequest(client, hostId, 'settings.get'),
sendSingleFlightRequest(client, hostId, 'preflight.check'),
sendSingleFlightRequest(client, hostId, 'linear.status')
])
.then(([settingsResponse, preflightResponse, linearResponse]) => {
if (disposed()) {
@@ -405,7 +404,7 @@ export default function HomeScreen() {
})
for (const entry of allClientsRef.current) {
if (entry.client.getState() === 'connected') {
fetchStats(entry.client, setStats, () => stale)
fetchStats(entry.client, entry.hostId, setStats, () => stale)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale)
@@ -512,7 +511,7 @@ export default function HomeScreen() {
}
if (!statsFetched) {
statsFetched = true
fetchStats(entry.client, setStats, () => false)
fetchStats(entry.client, entry.hostId, setStats, () => false)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
}
+35 -33
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ActivityIndicator,
Pressable,
@@ -9,7 +9,7 @@ import {
View
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { useFocusEffect, useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
@@ -18,6 +18,7 @@ import { useAllHostClients } from '../src/transport/client-context'
import type { RpcClient } from '../src/transport/rpc-client'
import { BottomDrawer } from '../src/components/BottomDrawer'
import { VoiceModelList } from '../src/components/VoiceModelList'
import { useDictationSetupPoller } from '../src/dictation/use-dictation-setup-poller'
import {
deleteDictationModel,
downloadDictationModel,
@@ -58,44 +59,45 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
const [error, setError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [routeFocused, setRouteFocused] = useState(false)
const refresh = useCallback(async () => {
useFocusEffect(
useCallback(() => {
setRouteFocused(true)
return () => setRouteFocused(false)
}, [])
)
const refresh = useCallback(async (): Promise<boolean | undefined> => {
if (!client) {
return
return false
}
try {
setSetup(await fetchDictationSetup(client))
const next = await fetchDictationSetup(client)
setSetup(next)
setError(null)
return next.models.some(isModelInFlight)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load voice settings')
return undefined
} finally {
setLoading(false)
}
}, [client])
// Initial load once a connected client is available.
useEffect(() => {
if (!client) {
return
}
setLoading(true)
setError(null)
void refresh().finally(() => setLoading(false))
}, [client, refresh])
const polling = setup?.models.some(isModelInFlight) ?? false
const refreshSetup = useDictationSetupPoller({
visible: routeFocused && client !== null,
polling,
refresh,
intervalMs: POLL_INTERVAL_MS
})
// Poll only while a model is downloading/extracting; stop otherwise.
useEffect(() => {
const inFlight = setup?.models.some(isModelInFlight) ?? false
if (inFlight && client) {
pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS)
return () => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
if (routeFocused && client && setup === null) {
setLoading(true)
}
return undefined
}, [setup, client, refresh])
}, [routeFocused, client, setup])
const handleToggleEnabled = useCallback(
async (enabled: boolean) => {
@@ -109,10 +111,10 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
setSetup(await setDictationConfig(client, { enabled }))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update')
void refresh()
void refreshSetup()
}
},
[client, refresh]
[client, refreshSetup]
)
const handleSelectMode = useCallback(
@@ -126,10 +128,10 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
setSetup(await setDictationConfig(client, { dictationMode }))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update')
void refresh()
void refreshSetup()
}
},
[client, refresh]
[client, refreshSetup]
)
const handleUseModel = useCallback(
@@ -160,14 +162,14 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
setError(null)
try {
await downloadDictationModel(client, model.id)
await refresh()
await refreshSetup()
} catch (err) {
setError(err instanceof Error ? err.message : 'Download failed')
} finally {
setBusyAction(null)
}
},
[client, refresh]
[client, refreshSetup]
)
const handleDelete = useCallback(
+50 -83
View File
@@ -1494,6 +1494,12 @@ packages:
cpu: [x64]
os: [win32]
'@eslint-community/eslint-utils@4.10.1':
resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -1942,56 +1948,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@@ -2064,56 +2062,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.71.0':
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.71.0':
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.71.0':
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.71.0':
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.71.0':
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.71.0':
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
@@ -2575,42 +2565,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.3':
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.3':
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.3':
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.3':
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.3':
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
@@ -3167,11 +3151,11 @@ packages:
resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==}
engines: {node: '>= 5.10.0'}
brace-expansion@1.1.15:
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
brace-expansion@1.1.16:
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
brace-expansion@5.0.7:
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
@@ -4175,8 +4159,8 @@ packages:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
flatted@3.4.3:
resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==}
flow-enums-runtime@0.0.6:
resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==}
@@ -4910,28 +4894,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -5910,8 +5890,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shell-quote@1.8.4:
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
shell-quote@1.10.0:
resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==}
engines: {node: '>= 0.4'}
side-channel-list@1.0.1:
@@ -6585,6 +6565,18 @@ packages:
utf-8-validate:
optional: true
ws@7.5.13:
resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==}
engines: {node: '>=8.3.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ^5.0.2
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
@@ -8098,6 +8090,11 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.4)':
dependencies:
eslint: 9.39.4
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)':
dependencies:
eslint: 9.39.4
@@ -9674,7 +9671,7 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
@@ -9694,7 +9691,7 @@ snapshots:
dependencies:
'@typescript-eslint/scope-manager': 8.59.2
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
eslint: 9.39.4
@@ -9711,15 +9708,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.59.2(typescript@6.0.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
'@typescript-eslint/types': 8.59.2
debug: 4.4.3
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.59.2':
dependencies:
'@typescript-eslint/types': 8.59.2
@@ -9729,10 +9717,6 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
'@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.59.2
@@ -9762,21 +9746,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)':
dependencies:
'@typescript-eslint/project-service': 8.59.2(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
minimatch: 10.2.5
semver: 7.7.4
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
@@ -10236,12 +10205,12 @@ snapshots:
dependencies:
big-integer: 1.6.52
brace-expansion@1.1.15:
brace-expansion@1.1.16:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@5.0.6:
brace-expansion@5.0.7:
dependencies:
balanced-match: 4.0.4
@@ -10839,11 +10808,11 @@ snapshots:
eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
eslint: 9.39.4
eslint-config-prettier: 9.1.2(eslint@9.39.4)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3)
eslint-plugin-node: 11.1.0(eslint@9.39.4)
eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8)
@@ -10867,7 +10836,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -10890,7 +10859,7 @@ snapshots:
eslint-utils: 2.1.0
regexpp: 3.2.0
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -10901,7 +10870,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
hasown: 2.0.3
is-core-module: 2.16.2
is-glob: 4.0.3
@@ -10998,7 +10967,7 @@ snapshots:
eslint@9.39.4:
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4)
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.2
'@eslint/config-helpers': 0.4.2
@@ -11519,10 +11488,10 @@ snapshots:
flat-cache@4.0.1:
dependencies:
flatted: 3.4.2
flatted: 3.4.3
keyv: 4.5.4
flatted@3.4.2: {}
flatted@3.4.3: {}
flow-enums-runtime@0.0.6: {}
@@ -12933,7 +12902,7 @@ snapshots:
serialize-error: 2.1.0
source-map: 0.5.7
throat: 5.0.0
ws: 7.5.11
ws: 7.5.13
yargs: 17.7.3
transitivePeerDependencies:
- bufferutil
@@ -12967,11 +12936,11 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.6
brace-expansion: 5.0.7
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.15
brace-expansion: 1.1.16
minimist@1.2.8: {}
@@ -13342,7 +13311,7 @@ snapshots:
react-devtools-core@6.1.5:
dependencies:
shell-quote: 1.8.4
shell-quote: 1.10.0
ws: 7.5.11
transitivePeerDependencies:
- bufferutil
@@ -13769,7 +13738,7 @@ snapshots:
shebang-regex@3.0.0: {}
shell-quote@1.8.4: {}
shell-quote@1.10.0: {}
side-channel-list@1.0.1:
dependencies:
@@ -14071,10 +14040,6 @@ snapshots:
dependencies:
typescript: 5.9.3
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
ts-declaration-location@1.0.7(typescript@5.9.3):
dependencies:
picomatch: 4.0.4
@@ -14405,6 +14370,8 @@ snapshots:
ws@7.5.11: {}
ws@7.5.13: {}
ws@8.21.0: {}
xcode@3.0.1:
@@ -0,0 +1,200 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { HostProtocolGate, useHostProtocolGates } from './HostProtocolGate'
const nativeTestState = vi.hoisted(() => ({
openUrl: vi.fn(),
platform: { OS: 'ios' as 'ios' | 'android' }
}))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Linking: { openURL: nativeTestState.openUrl },
Platform: nativeTestState.platform,
Pressable: 'Pressable',
StyleSheet: { create: <T>(styles: T) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('expo-router', () => ({
router: { replace: vi.fn() }
}))
// Why: mock only client acquisition; the gate must exercise the real
// useHostStatusGates → evaluateCompat → ProtocolBlockScreen wiring.
const hostClient = vi.hoisted(() => ({
current: { client: null as RpcClient | null, state: 'disconnected' as string }
}))
vi.mock('../transport/client-context', () => ({
useHostClient: () => hostClient.current
}))
function clientWithStatus(result: Record<string, unknown>): RpcClient {
return { sendRequest: vi.fn().mockResolvedValue({ ok: true, result }) } as unknown as RpcClient
}
function GateConsumer() {
const { hostCapabilities } = useHostProtocolGates()
return createElement('GateStatus', null, hostCapabilities.join(','))
}
function gateElement() {
return createElement(
HostProtocolGate,
{ hostId: 'host-1' },
createElement('HostContent', null, createElement(GateConsumer))
)
}
async function renderGate(): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
await act(async () => {
renderer = create(gateElement())
await Promise.resolve()
})
return renderer as unknown as ReactTestRenderer
}
function renderedText(renderer: ReactTestRenderer): string {
return JSON.stringify(renderer.toJSON())
}
describe('HostProtocolGate', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
nativeTestState.openUrl.mockClear()
nativeTestState.platform.OS = 'ios'
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
})
it('replaces the host UI with the block screen when mobile is too old', async () => {
// Why: blocked warns to console; keep test output clean without hiding other errors.
vi.spyOn(console, 'warn').mockImplementation(() => {})
hostClient.current = {
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }),
state: 'connected'
}
renderer = await renderGate()
const output = renderedText(renderer)
expect(output).toContain('Update Orca Mobile')
expect(output).toContain('Open App Store')
expect(output).not.toContain('HostContent')
})
it('routes Android mobile updates to GitHub Releases', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
nativeTestState.platform.OS = 'android'
hostClient.current = {
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }),
state: 'connected'
}
renderer = await renderGate()
const output = renderedText(renderer)
expect(output).toContain('Update Orca Mobile')
expect(output).toContain('Update Orca Mobile from GitHub Releases')
expect(output).toContain('Open GitHub Releases')
expect(output).not.toContain('mobile app store')
expect(output).not.toContain('HostContent')
act(() => renderer?.root.findAllByType('Pressable')[0]?.props.onPress())
expect(nativeTestState.openUrl).toHaveBeenCalledWith(
'https://github.com/stablyai/orca/releases'
)
})
it('replaces the host UI with the block screen when desktop is too old', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
hostClient.current = {
client: clientWithStatus({ protocolVersion: 0, minCompatibleMobileVersion: 0 }),
state: 'connected'
}
renderer = await renderGate()
const output = renderedText(renderer)
expect(output).toContain('Update Orca on your computer')
expect(output).toContain('Open GitHub Releases')
expect(output).not.toContain('HostContent')
})
it('renders the host UI when the verdict is ok', async () => {
const client = clientWithStatus({
protocolVersion: 5,
minCompatibleMobileVersion: 0,
capabilities: ['browser.screencast.v1']
})
hostClient.current = {
client,
state: 'connected'
}
renderer = await renderGate()
const output = renderedText(renderer)
expect(output).toContain('HostContent')
expect(output).toContain('browser.screencast.v1')
expect(output).not.toContain('Update Orca')
expect(client.sendRequest).toHaveBeenCalledOnce()
})
it('renders the host UI while the host connection is still pending', async () => {
hostClient.current = { client: null, state: 'connecting' }
renderer = await renderGate()
expect(renderedText(renderer)).toContain('HostContent')
})
it('does not mount host routes before a connected host passes the compatibility probe', async () => {
const client = {
sendRequest: vi.fn().mockReturnValue(new Promise(() => {}))
} as unknown as RpcClient
hostClient.current = { client, state: 'connected' }
renderer = await renderGate()
const output = renderedText(renderer)
expect(output).toContain('Checking host compatibility')
expect(output).not.toContain('HostContent')
expect(client.sendRequest).toHaveBeenCalledOnce()
})
it('keeps an already-validated host route mounted while reconnect status is pending', async () => {
const client = {
sendRequest: vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: { protocolVersion: 5, minCompatibleMobileVersion: 0 }
})
.mockReturnValueOnce(new Promise(() => {}))
} as unknown as RpcClient
hostClient.current = { client, state: 'connected' }
renderer = await renderGate()
await act(async () => {
hostClient.current = { client, state: 'disconnected' }
renderer?.update(gateElement())
})
await act(async () => {
hostClient.current = { client, state: 'connected' }
renderer?.update(gateElement())
await Promise.resolve()
})
expect(renderedText(renderer)).toContain('HostContent')
expect(client.sendRequest).toHaveBeenCalledTimes(2)
})
it('fails open when a connected host cannot answer the status probe', async () => {
hostClient.current = {
client: {
sendRequest: vi.fn().mockResolvedValue({ ok: false, error: { message: 'unavailable' } })
} as unknown as RpcClient,
state: 'connected'
}
renderer = await renderGate()
expect(renderedText(renderer)).toContain('HostContent')
})
})
@@ -0,0 +1,59 @@
import { createContext, useContext, useRef, type ReactNode } from 'react'
import { ActivityIndicator, StyleSheet, View } from 'react-native'
import { useHostClient } from '../transport/client-context'
import { useHostStatusGates, type HostStatusGates } from '../transport/host-status-gates'
import { colors } from '../theme/mobile-theme'
import { ProtocolBlockScreen } from './ProtocolBlockScreen'
type Props = {
hostId: string | undefined
children: ReactNode
}
const HostStatusGatesContext = createContext<HostStatusGates | null>(null)
export function useHostProtocolGates(): HostStatusGates {
const gates = useContext(HostStatusGatesContext)
if (!gates) {
throw new Error('useHostProtocolGates must be used inside <HostProtocolGate>')
}
return gates
}
// Why: single choke point above every /h/[hostId] route so a blocked verdict replaces the
// whole host UI (sidebar + detail stack) while the host list and other hosts stay usable.
export function HostProtocolGate({ hostId, children }: Props) {
const { client, state } = useHostClient(hostId)
const gates = useHostStatusGates({ hostId, client, connState: state })
const { compatVerdict, statusPending } = gates
const resolvedHostIdRef = useRef<string | null>(null)
const hostKey = hostId ?? null
if (state === 'connected' && client && !statusPending) {
resolvedHostIdRef.current = hostKey
}
if (statusPending && resolvedHostIdRef.current !== hostKey) {
// Why: child routes may call newer RPCs on mount, so wait until compatibility is known.
return (
<View style={styles.pending}>
<ActivityIndicator
color={colors.textSecondary}
accessibilityLabel="Checking host compatibility"
/>
</View>
)
}
if (compatVerdict.kind === 'blocked') {
return <ProtocolBlockScreen verdict={compatVerdict} />
}
// Why: the host sidebar needs the same status fields; sharing the result avoids a second status.get per route.
return <HostStatusGatesContext.Provider value={gates}>{children}</HostStatusGatesContext.Provider>
}
const styles = StyleSheet.create({
pending: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgBase
}
})
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, Pressable, StyleSheet, Switch, Text, View } from 'react-native'
import { Check, Download } from 'lucide-react-native'
import { BottomDrawer } from './BottomDrawer'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { RpcClient } from '../transport/rpc-client'
import { triggerError, triggerSuccess } from '../platform/haptics'
import { useDictationSetupPoller } from '../dictation/use-dictation-setup-poller'
import {
downloadDictationModel,
fetchDictationSetup,
@@ -37,40 +38,34 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
const [setup, setSetup] = useState<MobileSpeechSetup | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const refresh = useCallback(async () => {
const refresh = useCallback(async (): Promise<boolean | undefined> => {
if (!client) {
return
return false
}
try {
setSetup(await fetchDictationSetup(client))
const next = await fetchDictationSetup(client)
setSetup(next)
setError(null)
return next.models.some(isModelInFlight)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load')
return undefined
}
}, [client])
const polling = setup?.models.some(isModelInFlight) ?? false
const refreshSetup = useDictationSetupPoller({
visible: visible && client !== null,
polling,
refresh,
intervalMs: POLL_INTERVAL_MS
})
useEffect(() => {
if (visible) {
setError(null)
void refresh()
}
}, [visible, refresh])
// Poll only while something is downloading/extracting; stop otherwise.
useEffect(() => {
const inFlight = setup?.models.some(isModelInFlight) ?? false
if (visible && inFlight && client) {
pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS)
return () => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
}
return undefined
}, [visible, setup, client, refresh])
}, [visible])
const handleDownload = useCallback(
async (model: MobileSpeechModel) => {
@@ -81,7 +76,7 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
setError(null)
try {
await downloadDictationModel(client, model.id)
await refresh()
await refreshSetup()
} catch (err) {
triggerError()
setError(err instanceof Error ? err.message : 'Download failed')
@@ -89,7 +84,7 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
setBusy(null)
}
},
[client, refresh]
[client, refreshSetup]
)
const handleUseModel = useCallback(
+11 -16
View File
@@ -12,14 +12,13 @@ type Props = {
export function ProtocolBlockScreen({ verdict }: Props) {
const isMobileTooOld = verdict.reason === 'mobile-too-old'
// Why: Android APKs ship through GitHub Releases until a Play Store listing exists.
const mobileUpdateTarget =
Platform.OS === 'ios'
? { label: 'Open App Store', url: IOS_APP_STORE_URL, storeName: 'the App Store' }
: { label: null, url: null, storeName: 'your mobile app store' }
: { label: 'Open GitHub Releases', url: RELEASES_URL, storeName: 'GitHub Releases' }
const primaryAction = isMobileTooOld
? mobileUpdateTarget.url && mobileUpdateTarget.label
? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url }
: null
? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url }
: { label: 'Open GitHub Releases', url: RELEASES_URL }
const title = isMobileTooOld ? 'Update Orca Mobile' : 'Update Orca on your computer'
@@ -34,18 +33,14 @@ export function ProtocolBlockScreen({ verdict }: Props) {
<View style={styles.card}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.body}>{body}</Text>
{/* Why: desktop updates come from GitHub; mobile update links depend
on the native store available for this platform. */}
{primaryAction ? (
<Pressable
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
onPress={() => {
void Linking.openURL(primaryAction.url)
}}
>
<Text style={styles.primaryButtonText}>{primaryAction.label}</Text>
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
onPress={() => {
void Linking.openURL(primaryAction.url)
}}
>
<Text style={styles.primaryButtonText}>{primaryAction.label}</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.pressed]}
onPress={() => {
+4 -1
View File
@@ -1,3 +1,4 @@
import { memo } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { colors, spacing } from '../theme/mobile-theme'
@@ -18,7 +19,7 @@ type Props = {
// One inline agent row: state dot → identity → last message/prompt → time ago.
// Mirrors desktop DashboardAgentRow's compact in-card layout.
export function WorktreeAgentRow({ agent, depth, now, unvisited }: Props) {
function WorktreeAgentRowComponent({ agent, depth, now, unvisited }: Props) {
const dotState = agentDotState(agent, now)
const label = agentDisplayLabel(agent, now)
const ts = formatTimeAgo(agent.stateStartedAt, now)
@@ -37,6 +38,8 @@ export function WorktreeAgentRow({ agent, depth, now, unvisited }: Props) {
)
}
export const WorktreeAgentRow = memo(WorktreeAgentRowComponent)
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
@@ -0,0 +1,212 @@
import {
createElement,
Fragment,
useCallback,
useState,
type Dispatch,
type SetStateAction
} from 'react'
import { Text } from 'react-native'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { WorktreeAgentRow } from './WorktreeAgentRow'
import { WorktreeListRow, type WorktreeListRowItem } from './WorktreeListRow'
const { agentSpinnerRender, agentStateDotRender } = vi.hoisted(() => ({
agentSpinnerRender: vi.fn(),
agentStateDotRender: vi.fn()
}))
vi.mock('react-native', () => ({
Pressable: 'Pressable',
StyleSheet: { create: <T>(styles: T) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Bell: 'Bell',
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight',
GitBranch: 'GitBranch',
GitPullRequest: 'GitPullRequest'
}))
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: vi.fn() }))
vi.mock('./AgentSpinner', () => ({
AgentSpinner: (props: unknown) => {
agentSpinnerRender(props)
return null
}
}))
vi.mock('./AgentStateDot', () => ({
AgentStateDot: (props: unknown) => {
agentStateDotRender(props)
return null
}
}))
vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
vi.mock('./MobileRepoIcon', () => ({ MobileRepoIcon: () => null }))
vi.mock('./WorktreeAgentList', () => ({ WorktreeAgentList: () => null }))
vi.mock('./WorktreeMetaGlyphs', () => ({
prStateColor: () => '#000000',
WorktreeMetaGlyphs: () => null
}))
type TestItem = WorktreeListRowItem & {
status: 'working' | 'active' | 'permission' | 'done' | 'inactive'
lastOutputAt: number
}
const stableRepoIcon = { type: 'emoji', emoji: 'o' } as const
let updateSibling: Dispatch<SetStateAction<number>> = () => undefined
function ListRowHarness({ item, now }: { item: TestItem; now: number }) {
const [sibling, setSibling] = useState(0)
updateSibling = setSibling
const onPress = useCallback(() => undefined, [])
const onLongPress = useCallback(() => undefined, [])
const onToggleLineage = useCallback(() => undefined, [])
// Sibling state changes re-render the harness without changing the row's props,
// exercising the row's React.memo bailout.
return createElement(
Fragment,
null,
createElement(Text, null, sibling),
createElement(WorktreeListRow, {
item,
isReadOnly: false,
now,
repoColor: '#000000',
repoIcon: stableRepoIcon,
hideRepo: false,
status: item.status,
onPress,
onLongPress,
onToggleLineage
})
)
}
function agent(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeAgentRow {
return {
paneKey: 'agent-1',
parentPaneKey: null,
state: 'working',
agentType: null,
prompt: 'Fix the list',
taskTitle: null,
displayName: null,
lastAssistantMessage: null,
toolName: null,
toolInput: null,
interrupted: false,
stateStartedAt: 1_000,
updatedAt: 1_000,
...overrides
}
}
const baseItem: TestItem = {
worktreeId: 'worktree-1',
repo: 'orca',
branch: 'feature/mobile-list',
displayName: 'mobile-list',
liveTerminalCount: 1,
preview: 'Waiting',
unread: false,
linkedPR: null,
agents: [agent()],
status: 'active',
lastOutputAt: 1_000
}
describe('memoized worktree rows', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
agentSpinnerRender.mockClear()
agentStateDotRender.mockClear()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('skips an unrelated parent render but updates for live item fields and time', async () => {
await act(async () => {
renderer = create(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
})
expect(agentSpinnerRender).toHaveBeenCalledTimes(1)
await act(async () => updateSibling((value) => value + 1))
expect(agentSpinnerRender).toHaveBeenCalledTimes(1)
let expectedRenders = 1
const liveUpdates: TestItem[] = [
{ ...baseItem, preview: 'Running tests' },
{ ...baseItem, unread: true },
{ ...baseItem, lastOutputAt: 2_000 },
{ ...baseItem, agents: [agent({ state: 'waiting', updatedAt: 2_000 })] },
{ ...baseItem, status: 'working' }
]
for (const liveUpdate of liveUpdates) {
await act(async () =>
renderer!.update(createElement(ListRowHarness, { item: liveUpdate, now: 2_000 }))
)
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
await act(async () =>
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
)
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
}
await act(async () =>
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 32_000 }))
)
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
})
it('memoizes agent rows without hiding agent updates', async () => {
const firstAgent = agent()
await act(async () => {
renderer = create(
createElement(WorktreeAgentRow, {
agent: firstAgent,
depth: 0,
now: 2_000,
unvisited: false
})
)
})
expect(agentStateDotRender).toHaveBeenCalledTimes(1)
await act(async () => {
renderer!.update(
createElement(WorktreeAgentRow, {
agent: firstAgent,
depth: 0,
now: 2_000,
unvisited: false
})
)
})
expect(agentStateDotRender).toHaveBeenCalledTimes(1)
await act(async () => {
renderer!.update(
createElement(WorktreeAgentRow, {
agent: agent({ state: 'done', updatedAt: 2_000 }),
depth: 0,
now: 2_000,
unvisited: false
})
)
})
expect(agentStateDotRender).toHaveBeenCalledTimes(2)
})
})
+4 -1
View File
@@ -1,3 +1,4 @@
import { memo } from 'react'
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { RepoIcon } from '../../../src/shared/repo-icon'
@@ -57,7 +58,7 @@ type Props<T extends WorktreeListRowItem> = {
onToggleLineage?: (item: T) => void
}
export function WorktreeListRow<T extends WorktreeListRowItem>({
function WorktreeListRowComponent<T extends WorktreeListRowItem>({
item,
isReadOnly,
now,
@@ -195,6 +196,8 @@ export function WorktreeListRow<T extends WorktreeListRowItem>({
)
}
export const WorktreeListRow = memo(WorktreeListRowComponent) as typeof WorktreeListRowComponent
const styles = StyleSheet.create({
worktreeRow: {
flexDirection: 'row',
@@ -0,0 +1,197 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DictationSetupPollController } from './dictation-setup-poll-controller'
const POLL_INTERVAL_MS = 1500
async function flushPromises(): Promise<void> {
// Why: the refresh mock wraps its result in `.finally()` and the resume path chains
// runRefresh → requestRefresh → runRefresh, so the follow-up refresh is several microtask
// hops deep — drain generously rather than a fixed two ticks.
for (let i = 0; i < 8; i += 1) {
await Promise.resolve()
}
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
return {
promise: new Promise<T>((next) => {
resolve = next
}),
resolve
}
}
describe('DictationSetupPollController', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('does not refresh while hidden, unfocused, or backgrounded', async () => {
const refresh = vi.fn().mockResolvedValue(true)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setForeground(true)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
expect(refresh).not.toHaveBeenCalled()
poller.setVisible(true)
expect(refresh).toHaveBeenCalledOnce()
await flushPromises()
poller.setVisible(false)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
expect(refresh).toHaveBeenCalledOnce()
poller.setVisible(true)
expect(refresh).toHaveBeenCalledTimes(2)
await flushPromises()
poller.setForeground(false)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
expect(refresh).toHaveBeenCalledTimes(2)
poller.dispose()
})
it('keeps slow refreshes single-flight and waits a full delay after each response', async () => {
const requests = [deferred<boolean>(), deferred<boolean>(), deferred<boolean>()]
let active = 0
let maxActive = 0
const refresh = vi.fn(() => {
const request = requests[refresh.mock.calls.length - 1]
active += 1
maxActive = Math.max(maxActive, active)
return request.promise.finally(() => {
active -= 1
})
})
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(refresh).toHaveBeenCalledOnce()
requests[0].resolve(true)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
expect(refresh).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(refresh).toHaveBeenCalledTimes(2)
requests[1].resolve(true)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS - 1)
expect(refresh).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(refresh).toHaveBeenCalledTimes(3)
expect(maxActive).toBe(1)
requests[2].resolve(false)
await flushPromises()
poller.dispose()
})
it('coalesces an immediate resume refresh behind a slow request', async () => {
const requests = [deferred<boolean>(), deferred<boolean>()]
let active = 0
let maxActive = 0
const refresh = vi.fn(() => {
const request = requests[refresh.mock.calls.length - 1]
active += 1
maxActive = Math.max(maxActive, active)
return request.promise.finally(() => {
active -= 1
})
})
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
poller.setForeground(false)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledOnce()
requests[0].resolve(true)
await flushPromises()
expect(refresh).toHaveBeenCalledTimes(2)
expect(maxActive).toBe(1)
requests[1].resolve(false)
await flushPromises()
poller.dispose()
})
it('refreshes immediately when visibility or foreground eligibility resumes', async () => {
const refresh = vi.fn().mockResolvedValue(true)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledOnce()
await flushPromises()
poller.setForeground(false)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledTimes(2)
await flushPromises()
poller.setVisible(false)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
poller.setVisible(true)
expect(refresh).toHaveBeenCalledTimes(3)
poller.dispose()
})
it('stops after setup leaves the download or extraction lifecycle', async () => {
const refresh = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
expect(refresh).toHaveBeenCalledTimes(2)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(refresh).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
poller.dispose()
})
it('does not resurrect polling when an in-flight refresh resolves true after setPolling(false)', async () => {
const request = deferred<boolean>()
const refresh = vi.fn(() => request.promise)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledOnce()
// Explicit stop lands while the read is still on the wire.
poller.setPolling(false)
// The stale read then resolves "keep polling" — the fence must drop it, not restart the poll.
request.resolve(true)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(refresh).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
poller.dispose()
})
})
@@ -0,0 +1,153 @@
type PollState = {
visible: boolean
foreground: boolean
polling: boolean
}
type RefreshResult = boolean | undefined
export class DictationSetupPollController {
private state: PollState = { visible: false, foreground: false, polling: false }
private timer: ReturnType<typeof setTimeout> | null = null
private inFlight = false
private immediateRefreshPending = false
private refreshWaiters: Array<() => void> = []
private disposed = false
// Why: an explicit setPolling is a newer lifecycle intent than a read that was already on the wire.
// Bumped on every setPolling so an in-flight refresh resolving after an explicit stop/start can be
// fenced out instead of clobbering that intent (e.g. a late `true` resurrecting a just-stopped poll).
private pollingRevision = 0
constructor(
private readonly refresh: () => Promise<RefreshResult>,
private readonly intervalMs: number
) {}
setVisible(visible: boolean): void {
this.update({ visible })
}
setForeground(foreground: boolean): void {
this.update({ foreground })
}
setPolling(polling: boolean): void {
this.pollingRevision += 1
this.update({ polling })
}
refreshNow(): Promise<void> {
if (this.disposed || !this.isEligible()) {
return Promise.resolve()
}
return new Promise((resolve) => {
this.refreshWaiters.push(resolve)
this.requestRefresh(true)
})
}
dispose(): void {
this.disposed = true
this.immediateRefreshPending = false
this.clearTimer()
this.resolveRefreshWaiters()
}
private update(next: Partial<PollState>): void {
if (this.disposed) {
return
}
const wasEligible = this.isEligible()
const wasPolling = this.state.polling
this.state = { ...this.state, ...next }
if (!this.isEligible()) {
this.immediateRefreshPending = false
this.clearTimer()
return
}
if (!wasEligible) {
this.requestRefresh(true)
return
}
if (!this.state.polling) {
this.clearTimer()
return
}
if (!wasPolling) {
this.scheduleRefresh()
}
}
private isEligible(): boolean {
return this.state.visible && this.state.foreground
}
private requestRefresh(immediate: boolean): void {
if (this.inFlight) {
this.immediateRefreshPending ||= immediate
return
}
this.clearTimer()
this.inFlight = true
void this.runRefresh()
}
private async runRefresh(): Promise<void> {
// Snapshot the lifecycle intent this read is answering; an explicit setPolling during the read makes
// its result stale.
const revisionAtStart = this.pollingRevision
let shouldContinue: RefreshResult
try {
shouldContinue = await this.refresh()
} catch {
// A transient read failure preserves the current lifecycle for a later retry.
shouldContinue = undefined
} finally {
this.inFlight = false
}
// Fence: only let the read drive polling if no explicit setPolling superseded it mid-flight, so a
// late `true` can't resurrect a poll the caller just stopped (nor a late `false` cancel a restart).
if (shouldContinue !== undefined && this.pollingRevision === revisionAtStart) {
this.state.polling = shouldContinue
}
if (this.disposed || !this.isEligible()) {
this.resolveRefreshWaiters()
return
}
if (this.immediateRefreshPending) {
this.immediateRefreshPending = false
this.requestRefresh(true)
return
}
this.resolveRefreshWaiters()
if (this.state.polling) {
this.scheduleRefresh()
}
}
private scheduleRefresh(): void {
if (this.timer !== null || this.inFlight || !this.isEligible() || !this.state.polling) {
return
}
this.timer = setTimeout(() => {
this.timer = null
this.requestRefresh(false)
}, this.intervalMs)
}
private clearTimer(): void {
if (this.timer !== null) {
clearTimeout(this.timer)
this.timer = null
}
}
private resolveRefreshWaiters(): void {
const waiters = this.refreshWaiters.splice(0)
for (const resolve of waiters) {
resolve()
}
}
}
@@ -0,0 +1,54 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { AppState } from 'react-native'
import { DictationSetupPollController } from './dictation-setup-poll-controller'
type PollerOptions = {
visible: boolean
polling: boolean
refresh: () => Promise<boolean | undefined>
intervalMs: number
}
export function useDictationSetupPoller({
visible,
polling,
refresh,
intervalMs
}: PollerOptions): () => Promise<void> {
const refreshRef = useRef(refresh)
refreshRef.current = refresh
const poller = useMemo(
() => new DictationSetupPollController(() => refreshRef.current(), intervalMs),
[intervalMs]
)
useEffect(() => () => poller.dispose(), [poller])
useEffect(() => {
void poller.refreshNow()
}, [poller, refresh])
useEffect(() => {
poller.setPolling(polling)
}, [poller, polling])
useEffect(() => {
poller.setVisible(visible)
if (!visible) {
poller.setForeground(false)
return undefined
}
poller.setForeground(AppState.currentState === 'active')
const subscription = AppState.addEventListener('change', (state) => {
poller.setForeground(state === 'active')
})
return () => {
subscription.remove()
poller.setVisible(false)
poller.setForeground(false)
}
}, [poller, visible])
return useCallback(() => poller.refreshNow(), [poller])
}
+101
View File
@@ -0,0 +1,101 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
const appState = vi.hoisted(() => ({
current: 'active',
listener: null as ((nextState: string) => void) | null,
remove: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: {
get currentState(): string {
return appState.current
},
addEventListener: (_event: string, listener: (nextState: string) => void) => {
appState.listener = listener
return { remove: appState.remove }
}
}
}))
import { useNow } from './use-now'
describe('useNow', () => {
let renderer: ReactTestRenderer | null = null
let latest = 0
let consoleSpy: MockInstance
function Harness({ enabled = true }: { enabled?: boolean }): null {
latest = useNow(1_000, enabled)
return null
}
function changeAppState(nextState: string): void {
act(() => {
appState.current = nextState
appState.listener?.(nextState)
})
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
globalThis.IS_REACT_ACT_ENVIRONMENT = true
appState.current = 'active'
appState.listener = null
appState.remove.mockClear()
latest = 0
const original = console.error
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
act(() => {
renderer = create(createElement(Harness))
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
consoleSpy.mockRestore()
})
it('ticks while active, pauses in the background, and refreshes immediately on resume', () => {
expect(latest).toBe(1_000)
act(() => vi.advanceTimersByTime(1_000))
expect(latest).toBe(2_000)
changeAppState('background')
act(() => vi.advanceTimersByTime(5_000))
expect(latest).toBe(2_000)
changeAppState('active')
expect(latest).toBe(7_000)
act(() => vi.advanceTimersByTime(1_000))
expect(latest).toBe(8_000)
})
it('stops while disabled and refreshes immediately when re-enabled', () => {
act(() => renderer?.update(createElement(Harness, { enabled: false })))
act(() => vi.advanceTimersByTime(5_000))
expect(latest).toBe(1_000)
act(() => renderer?.update(createElement(Harness, { enabled: true })))
expect(latest).toBe(6_000)
})
it('removes the shared AppState listener after the last caller unmounts', () => {
act(() => renderer?.unmount())
renderer = null
expect(appState.remove).toHaveBeenCalledTimes(1)
})
})
+45 -7
View File
@@ -1,14 +1,52 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { AppState, type AppStateStatus } from 'react-native'
// One shared interval per caller, mirroring desktop's useNow: relative
// timestamps ("Xm") need a periodic re-render to stay honest. The worktree list
// owns a single tick that drives every visible agent row, rather than each row
// running its own interval.
export function useNow(intervalMs = 30_000): number {
const appStateListeners = new Set<() => void>()
let currentAppState: AppStateStatus | null = AppState.currentState
let appStateSubscription: ReturnType<typeof AppState.addEventListener> | null = null
function subscribeToAppState(listener: () => void): () => void {
appStateListeners.add(listener)
if (!appStateSubscription) {
currentAppState = AppState.currentState
appStateSubscription = AppState.addEventListener('change', (nextState) => {
currentAppState = nextState
appStateListeners.forEach((notify) => notify())
})
}
return () => {
appStateListeners.delete(listener)
if (appStateListeners.size === 0) {
appStateSubscription?.remove()
appStateSubscription = null
}
}
}
function isAppActive(): boolean {
return (appStateSubscription ? currentAppState : AppState.currentState) === 'active'
}
// A list-level caller's single tick drives every visible relative-time label.
export function useNow(intervalMs = 30_000, enabled = true): number {
const appActive = useSyncExternalStore(subscribeToAppState, isAppActive, isAppActive)
const running = appActive && enabled
const [now, setNow] = useState(() => Date.now())
const wasRunningRef = useRef(running)
useEffect(() => {
const resumed = running && !wasRunningRef.current
wasRunningRef.current = running
if (!running) {
return
}
if (resumed) {
setNow(Date.now())
}
const id = setInterval(() => setNow(Date.now()), intervalMs)
return () => clearInterval(id)
}, [intervalMs])
}, [intervalMs, running])
return now
}
@@ -7,7 +7,7 @@ export const MOBILE_SESSION_STATUS_LABELS: Record<ConnectionState, string> = {
connected: 'Connected',
disconnected: 'Disconnected',
reconnecting: 'Reconnecting',
'auth-failed': 'Auth failed'
'auth-failed': 'Pairing invalid'
}
export const TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY = 64
@@ -29,6 +29,26 @@ describe('mobile session startup', () => {
expect(autoCreateEffect).toContain('void handleCreateTerminal()')
})
it('stops fallback list polling in the background and reconciles on resume', () => {
const pollEffect = sliceBetween(
"const refreshOnForeground = () => {\n if (AppState.currentState !== 'active')",
'// Why: pick up Settings → Terminal text size on return'
)
expect(pollEffect).toContain(
"if (AppState.currentState !== 'active') {\n return\n }\n void fetchSessionTabs()"
)
expect(pollEffect).toContain('void fetchTerminals()')
expect(pollEffect).toContain("AppState.addEventListener('change'")
expect(pollEffect).toContain("if (state === 'active') {\n refreshOnForeground()")
expect(pollEffect).toContain('const interval = setInterval(refreshOnForeground, 2000)')
expect(pollEffect.lastIndexOf('\n refreshOnForeground()')).toBeGreaterThan(
pollEffect.indexOf("AppState.addEventListener('change'")
)
expect(pollEffect).toContain('clearInterval(interval)')
expect(pollEffect).toContain('appStateSubscription.remove()')
})
it('loads session tabs without waiting for desktop activation', () => {
const startupEffect = sliceBetween(
'void (async () => {',
@@ -0,0 +1,66 @@
import { readFileSync } from 'node:fs'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
const fileUrl = new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url)
const source = readFileSync(fileUrl, 'utf8')
const sourceFile = ts.createSourceFile(
fileUrl.href,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
)
function findQuickCommandsTabButtons(): ts.JsxSelfClosingElement[] {
const matches: ts.JsxSelfClosingElement[] = []
function visit(node: ts.Node): void {
if (
ts.isJsxSelfClosingElement(node) &&
node.tagName.getText(sourceFile) === 'QuickCommandsTabButton'
) {
matches.push(node)
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return matches
}
function getQuickCommandsTabSource(): string {
const start = source.indexOf('accessibilityLabel="New tab"')
expect(start).toBeGreaterThanOrEqual(0)
const end = source.indexOf('{/* Content-row host', start)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('quick-commands tab stability', () => {
it('keeps the button mounted while preserving the capability gate', () => {
const tabSource = getQuickCommandsTabSource()
const buttons = findQuickCommandsTabButtons()
expect(buttons).toHaveLength(1)
const tabBar = buttons[0].parent
expect(ts.isJsxElement(tabBar)).toBe(true)
if (!ts.isJsxElement(tabBar)) {
return
}
expect(tabBar.openingElement.tagName.getText(sourceFile)).toBe('View')
const style = tabBar.openingElement.attributes.properties.find(
(attribute): attribute is ts.JsxAttribute =>
ts.isJsxAttribute(attribute) && attribute.name.getText(sourceFile) === 'style'
)
expect(style?.initializer?.getText(sourceFile)).toBe('{styles.tabBar}')
expect(tabSource).toContain('if (quickCommandsSupported === true)')
expect(tabSource).toContain('setShowQuickCommands(true)')
expect(tabSource).toContain('Desktop update required for quick commands')
expect(tabSource).toContain('Checking desktop capabilities — try again in a moment')
})
it('only presents the sheet after support is confirmed', () => {
expect(source).toContain('visible={showQuickCommands && quickCommandsSupported === true}')
})
})
@@ -185,12 +185,45 @@ describe('useMobileNativeChatAnswerSend', () => {
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
})
it('submits a non-Claude answer as pasted label text with a single Enter', async () => {
it('submits a Codex answer by option-number keystroke like Claude', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex')
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
// Codex's question tool commits the pasted answer: label text + one Enter.
// Codex's request_user_input card ignores pasted labels; the digit selects AND commits.
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
})
it('does not send a trailing Enter after Codex submits a multi-question answer', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex')
const prompt: AskPrompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
]
}
let result: Promise<boolean> | undefined
await act(async () => {
result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }])
})
await act(async () => vi.runAllTimersAsync())
await expect(result).resolves.toBe(true)
expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([
expect.objectContaining({ text: '2', enter: false }),
expect.objectContaining({ text: '1', enter: false })
])
})
it('submits a non-selector answer as pasted label text with a single Enter', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'grok')
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
// Grok's question tool commits the pasted answer: label text + one Enter.
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true })
})
@@ -3,16 +3,20 @@ import type { RpcClient } from '../transport/rpc-client'
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
import {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
type AskAnswerSelection,
type AskPrompt
} from './mobile-native-chat-ask'
import { sendMobileNativeChatMessage } from './mobile-native-chat-send'
import { shouldStepNativeChatAskAnswer } from '../../../src/shared/native-chat-agent-support'
import {
resolveNativeChatTranscriptAgent,
shouldStepNativeChatAskAnswer
} from '../../../src/shared/native-chat-agent-support'
/** Sends an AskUserQuestion answer to the active chat pane. Claude's selector is
* answered by option-number keystrokes; other agents get pasted label text.
/** Sends an ask-user answer to the active chat pane. Claude and Codex selectors
* use their agent-specific keystrokes; other agents get pasted label text.
* Extracted from the session route to keep that file under its line cap and to
* own the pending-timer lifecycle in one place. */
export type MobileNativeChatAnswerSend = {
@@ -32,8 +36,8 @@ function sanitizeAskFreeText(text: string): string {
/**
* Owns the ask-answer send sequence for the mobile native chat. Reads the live
* pane/agent through refs (the route already keeps them current) so the returned
* callbacks stay stable. Claude answers are delivered as `buildAskAnswerKeys`
* keystroke groups written one selector-step apart over the EXISTING
* callbacks stay stable. Selector answers are delivered as keystroke groups
* written one step apart over the EXISTING
* `terminal.send` passthrough (raw text, no enter) — same contract the
* permission card already uses, so old runtimes replay them verbatim (no new
* RPC; keystrokes are built client-side). The scheduled wait chain is cancelled
@@ -136,14 +140,15 @@ export function useMobileNativeChatAnswerSend(args: {
}
return false
}
// Non-Claude question tools commit a pasted answer, so send the label text
// with one Enter. Claude's arrow-navigate selector ignores pasted labels
// (STA-1860): drive it by option-number keystrokes instead, one group per
// selector step so each renders before the next lands.
// Grok commits pasted labels; Claude and Codex need their selector-specific
// keystrokes paced so each step renders before the next lands.
if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
}
const groups = buildAskAnswerKeys(prompt, selections)
const groups =
resolveNativeChatTranscriptAgent(agentRef.current) === 'codex'
? buildCodexAskAnswerKeys(prompt, selections)
: buildAskAnswerKeys(prompt, selections)
for (let index = 0; index < groups.length; index += 1) {
if (generationRef.current !== generation) {
return false
@@ -1,6 +1,19 @@
import { describe, expect, it } from 'vitest'
import { classifyConnection, verdictDisplayLabel } from './connection-health'
describe('classifyConnection auth-failed verdict', () => {
it('tells the user to re-pair instead of showing a generic auth error', () => {
const verdict = classifyConnection({
state: 'auth-failed',
reconnectAttempts: 0,
lastConnectedAt: null,
nowMs: 1_000_000
})
expect(verdict.kind).toBe('auth-failed')
expect(verdictDisplayLabel(verdict)).toBe('Pairing invalid — re-pair with your desktop')
})
})
describe('classifyConnection Tailscale hint', () => {
const base = {
state: 'reconnecting' as const,
+3 -1
View File
@@ -55,8 +55,10 @@ export function classifyConnection(args: {
const now = args.nowMs ?? Date.now()
const hint = isTailscaleEndpoint(args.endpoint) ? TAILSCALE_HINT : undefined
// Why: auth-failed means the desktop no longer recognizes this pairing (e.g. it
// lost its device registry) — retrying can't fix it, only re-pairing can, so say so.
if (state === 'auth-failed') {
return { kind: 'auth-failed', label: 'Auth failed' }
return { kind: 'auth-failed', label: 'Pairing invalid — re-pair with your desktop' }
}
// Connected / connecting / handshaking are normal.
+26 -4
View File
@@ -8,6 +8,7 @@ export type HostStatusGates = {
hostCapabilities: string[]
floatingWorkspaceEnabled: boolean
compatVerdict: CompatVerdict
statusPending: boolean
}
type LoadedHostStatusGates = HostStatusGates & {
@@ -42,6 +43,14 @@ export function useHostStatusGates(args: {
return
}
if (!response.ok) {
setLoaded({
hostId,
client: requestClient,
hostCapabilities: [],
floatingWorkspaceEnabled: false,
compatVerdict: { kind: 'ok' },
statusPending: false
})
return
}
const status = (response as RpcSuccess).result as DesktopStatus & {
@@ -56,7 +65,8 @@ export function useHostStatusGates(args: {
client: requestClient,
hostCapabilities: status.capabilities ?? [],
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
compatVerdict: verdict
compatVerdict: verdict,
statusPending: false
})
if (verdict.kind === 'blocked') {
// Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
@@ -68,7 +78,17 @@ export function useHostStatusGates(args: {
})
}
} catch {
// Why: sendRequest can throw on transport tear-down; the fail-closed return below keeps gated actions hidden.
// Why: a transient status failure must not trap navigation; conservative feature gates remain disabled.
if (!cancelled) {
setLoaded({
hostId,
client: requestClient,
hostCapabilities: [],
floatingWorkspaceEnabled: false,
compatVerdict: { kind: 'ok' },
statusPending: false
})
}
}
})()
return () => {
@@ -87,12 +107,14 @@ export function useHostStatusGates(args: {
return {
hostCapabilities: EMPTY_HOST_CAPABILITIES,
floatingWorkspaceEnabled: false,
compatVerdict: { kind: 'ok' }
compatVerdict: { kind: 'ok' },
statusPending: connState === 'connected' && client !== null
}
}
return {
hostCapabilities: loaded.hostCapabilities,
floatingWorkspaceEnabled: loaded.floatingWorkspaceEnabled,
compatVerdict: loaded.compatVerdict
compatVerdict: loaded.compatVerdict,
statusPending: false
}
}
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from './rpc-client'
import type { RpcResponse } from './types'
import { sendSingleFlightRequest } from './request-single-flight'
function makeResponse(id: string): RpcResponse {
return { id, ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } }
}
const response = makeResponse('request-1')
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
function rpcClient(sendRequest: RpcClient['sendRequest']): RpcClient {
return { sendRequest } as RpcClient
}
describe('sendSingleFlightRequest', () => {
it('coalesces triggers that arrive during an in-flight read into one trailing follow-up', async () => {
const leading = deferred<RpcResponse>()
const trailing = deferred<RpcResponse>()
const leadingResponse = makeResponse('leading')
const trailingResponse = makeResponse('trailing')
const sendRequest = vi
.fn<() => Promise<RpcResponse>>()
.mockReturnValueOnce(leading.promise)
.mockReturnValueOnce(trailing.promise)
const client = rpcClient(sendRequest)
const first = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
// Two more triggers arrive while the read is on the wire: no duplicate now, and they share ONE
// trailing follow-up (not the older in-flight response).
const second = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
const third = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
expect(second).toBe(third)
expect(second).not.toBe(first)
expect(sendRequest).toHaveBeenCalledTimes(1)
leading.resolve(leadingResponse)
expect(await first).toBe(leadingResponse)
// The leading read settled → the coalesced follow-up fires exactly once.
expect(sendRequest).toHaveBeenCalledTimes(2)
trailing.resolve(trailingResponse)
expect(await second).toBe(trailingResponse)
expect(await third).toBe(trailingResponse)
})
it('starts a fresh request once nothing is in flight', async () => {
const leading = deferred<RpcResponse>()
const sendRequest = vi
.fn<() => Promise<RpcResponse>>()
.mockReturnValueOnce(leading.promise)
.mockResolvedValueOnce(response)
const client = rpcClient(sendRequest)
const first = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
leading.resolve(response)
await first
const next = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
expect(next).not.toBe(first)
expect(sendRequest).toHaveBeenCalledTimes(2)
await next
})
it('rejects the leading caller on failure but still runs a queued follow-up', async () => {
const leading = deferred<RpcResponse>()
const failure = new Error('request failed')
const sendRequest = vi
.fn<() => Promise<RpcResponse>>()
.mockReturnValueOnce(leading.promise)
.mockResolvedValueOnce(response)
const client = rpcClient(sendRequest)
const first = sendSingleFlightRequest(client, 'host-1', 'accounts.list')
const second = sendSingleFlightRequest(client, 'host-1', 'accounts.list')
leading.reject(failure)
await expect(first).rejects.toBe(failure)
// A trigger that arrived mid-flight is not poisoned by the leading failure: its follow-up runs.
await expect(second).resolves.toBe(response)
expect(sendRequest).toHaveBeenCalledTimes(2)
})
it('clears a failed leading request so the next call retries', async () => {
const failure = new Error('request failed')
const sendRequest = vi
.fn<() => Promise<RpcResponse>>()
.mockRejectedValueOnce(failure)
.mockResolvedValueOnce(response)
const client = rpcClient(sendRequest)
await expect(sendSingleFlightRequest(client, 'host-1', 'accounts.list')).rejects.toBe(failure)
await expect(sendSingleFlightRequest(client, 'host-1', 'accounts.list')).resolves.toBe(response)
expect(sendRequest).toHaveBeenCalledTimes(2)
})
it('does not share requests across clients, hosts, or request kinds', async () => {
const pending = deferred<RpcResponse>()
const firstSend = vi.fn(() => pending.promise)
const secondSend = vi.fn(() => pending.promise)
const firstClient = rpcClient(firstSend)
const secondClient = rpcClient(secondSend)
const requests = [
sendSingleFlightRequest(firstClient, 'host-1', 'settings.get'),
sendSingleFlightRequest(firstClient, 'host-2', 'settings.get'),
sendSingleFlightRequest(firstClient, 'host-1', 'preflight.check'),
sendSingleFlightRequest(secondClient, 'host-1', 'settings.get')
]
expect(firstSend).toHaveBeenCalledTimes(3)
expect(secondSend).toHaveBeenCalledTimes(1)
pending.resolve(response)
await Promise.all(requests)
})
})
@@ -0,0 +1,110 @@
import type { RpcClient } from './rpc-client'
import type { RpcResponse } from './types'
type Deferred = {
promise: Promise<RpcResponse>
resolve: (value: RpcResponse) => void
reject: (reason?: unknown) => void
}
type SingleFlightEntry = {
// The request currently on the wire for this (client, host, kind).
current: Promise<RpcResponse>
// At most one trailing follow-up: every trigger that arrives while `current` is in flight coalesces
// here so a refresh requested mid-read still re-reads the latest state (latest params win) instead of
// being silently answered by the older in-flight response.
followUp: { deferred: Deferred; params: unknown } | null
}
const inFlightRequests = new WeakMap<RpcClient, Map<string, Map<string, SingleFlightEntry>>>()
function makeDeferred(): Deferred {
let resolve!: (value: RpcResponse) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<RpcResponse>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
export function sendSingleFlightRequest(
client: RpcClient,
hostId: string,
requestKind: string,
params?: unknown
): Promise<RpcResponse> {
let requestsByHost = inFlightRequests.get(client)
if (!requestsByHost) {
requestsByHost = new Map()
inFlightRequests.set(client, requestsByHost)
}
let requestsByKind = requestsByHost.get(hostId)
if (!requestsByKind) {
requestsByKind = new Map()
requestsByHost.set(hostId, requestsByKind)
}
const send = (): Promise<RpcResponse> => {
try {
return client.sendRequest(requestKind, params)
} catch (error) {
return Promise.reject(error)
}
}
const existing = requestsByKind.get(requestKind)
if (existing) {
// A read is already on the wire: don't fire a duplicate now, but don't drop this trigger either.
// Record (or refresh) a single trailing follow-up that runs once the current read settles.
if (existing.followUp) {
existing.followUp.params = params
} else {
existing.followUp = { deferred: makeDeferred(), params }
}
return existing.followUp.deferred.promise
}
const entry: SingleFlightEntry = { current: send(), followUp: null }
requestsByKind.set(requestKind, entry)
const cleanup = (): void => {
if (requestsByKind.get(requestKind) !== entry) {
return
}
requestsByKind.delete(requestKind)
if (requestsByKind.size === 0) {
requestsByHost.delete(hostId)
}
if (requestsByHost.size === 0) {
inFlightRequests.delete(client)
}
}
// Chain each settled request into either its queued follow-up (delivering the fresh result to every
// caller that awaited it) or entry teardown. Recurses so triggers arriving during a follow-up queue
// the next one.
const onSettled = (): void => {
const followUp = entry.followUp
if (!followUp) {
cleanup()
return
}
entry.followUp = null
let next: Promise<RpcResponse>
try {
next = client.sendRequest(requestKind, followUp.params)
} catch (error) {
next = Promise.reject(error)
}
entry.current = next
next.then(
(response) => followUp.deferred.resolve(response),
(error) => followUp.deferred.reject(error)
)
void next.then(onSettled, onSettled)
}
void entry.current.then(onSettled, onSettled)
return entry.current
}
@@ -0,0 +1,169 @@
// Why: separate from rpc-client.test.ts — that file sits at its max-lines cap,
// and the silent-4001 mapping (desktop lost its device registry / regenerated
// its keypair, so the encrypted e2ee_error never decrypts) is its own scenario.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect } from './rpc-client'
vi.mock('./e2ee', () => ({
generateKeyPair: () => ({
publicKey: new Uint8Array(32),
secretKey: new Uint8Array(32)
}),
deriveSharedKey: () => new Uint8Array(32),
publicKeyFromBase64: () => new Uint8Array(32),
publicKeyToBase64: () => 'client-public-key',
encrypt: (plaintext: string) => `encrypted:${plaintext}`,
decrypt: (raw: string) => (raw === 'undecryptable' ? null : raw.replace(/^encrypted:/, '')),
decryptBytes: (bytes: Uint8Array) => bytes
}))
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
readonly CONNECTING = MockWebSocket.CONNECTING
readonly OPEN = MockWebSocket.OPEN
readonly CLOSING = MockWebSocket.CLOSING
readonly CLOSED = MockWebSocket.CLOSED
readyState = MockWebSocket.CONNECTING
onopen: (() => void) | null = null
onclose: ((event?: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
sent: string[] = []
close = vi.fn(() => {
if (this.readyState === MockWebSocket.CLOSED) {
return
}
this.readyState = MockWebSocket.CLOSED
this.onclose?.()
})
constructor(readonly endpoint: string) {
mockSockets.push(this)
}
send(payload: string): void {
this.sent.push(payload)
}
open(): void {
this.readyState = MockWebSocket.OPEN
this.onopen?.()
}
receive(payload: unknown): void {
this.onmessage?.({ data: payload })
}
serverClose(code: number, reason = ''): void {
this.readyState = MockWebSocket.CLOSED
this.onclose?.({ code, reason, wasClean: true })
}
}
const mockSockets: MockWebSocket[] = []
const originalWebSocket = globalThis.WebSocket
function lastSocket(): MockWebSocket {
return mockSockets[mockSockets.length - 1]!
}
describe('unauthorized close-code mapping (silent 4001)', () => {
beforeEach(() => {
vi.useFakeTimers()
mockSockets.length = 0
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
})
afterEach(() => {
vi.useRealTimers()
globalThis.WebSocket = originalWebSocket
})
it('counts a bare 4001 close against the auth retry budget and latches auth-failed', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
// A desktop with a regenerated keypair can't send a decryptable e2ee_error —
// the phone only ever sees the 4001 close. Three of those must latch.
for (let i = 0; i < 3; i++) {
if (i > 0) {
await vi.advanceTimersByTimeAsync(500)
}
const socket = lastSocket()
socket.open()
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
socket.serverClose(4001, 'Unauthorized')
}
expect(client.getState()).toBe('auth-failed')
expect(mockSockets).toHaveLength(3)
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(10 * 60_000)
expect(mockSockets).toHaveLength(3)
client.close()
})
it('recovers when a 4001 close was transient and the next handshake succeeds', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const first = mockSockets[0]!
first.open()
first.receive(JSON.stringify({ type: 'e2ee_ready' }))
first.serverClose(4001, 'Unauthorized')
expect(client.getState()).toBe('reconnecting')
await vi.advanceTimersByTimeAsync(500)
const next = lastSocket()
next.open()
next.receive(JSON.stringify({ type: 'e2ee_ready' }))
next.receive('encrypted:{"type":"e2ee_authenticated"}')
expect(client.getState()).toBe('connected')
client.close()
})
it('shares one budget between decrypted e2ee_error rejections and 4001 closes', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
for (let i = 0; i < 3; i++) {
if (i > 0) {
await vi.advanceTimersByTimeAsync(500)
}
const socket = lastSocket()
socket.open()
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
if (i < 2) {
socket.receive('encrypted:{"type":"e2ee_error","error":{"code":"unauthorized"}}')
} else {
socket.serverClose(4001, 'Unauthorized')
}
}
expect(client.getState()).toBe('auth-failed')
client.close()
})
it('keeps the generic reconnect loop for non-4001 closes', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
for (let i = 0; i < 5; i++) {
if (i > 0) {
await vi.advanceTimersByTimeAsync(60_000)
}
const socket = lastSocket()
socket.open()
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
socket.serverClose(1006)
}
expect(client.getState()).toBe('reconnecting')
client.close()
})
})
+17 -2
View File
@@ -98,6 +98,8 @@ const GIVE_UP_AFTER_ATTEMPTS = 12
const TRICKLE_RECONNECT_DELAY_MS = 90_000
// Why: one unauthorized isn't proof the pairing is dead (issue #5200) — retry the handshake this many times before latching auth-failed.
const AUTH_RETRY_BUDGET = 3
// Why: a desktop that regenerated its E2EE keypair sends an e2ee_error we can't decrypt — the 4001 close code is the only surviving auth-failure signal.
const UNAUTHORIZED_CLOSE_CODE = 4001
const REQUEST_TIMEOUT_MS = 30_000
const CONNECT_TIMEOUT_MS = 12_000
const HANDSHAKE_TIMEOUT_MS = 5_000
@@ -603,7 +605,7 @@ export function connect(
})
lastWsClosedAt = closeAt
currentWsOpenedAt = null
handleSocketClosed(openingWs)
handleSocketClosed(openingWs, { closeCode: e?.code })
}
ws.onerror = (event) => {
@@ -623,7 +625,10 @@ export function connect(
}
}
function handleSocketClosed(closedWs: WebSocket, opts: { timedOut?: boolean } = {}) {
function handleSocketClosed(
closedWs: WebSocket,
opts: { timedOut?: boolean; closeCode?: number } = {}
) {
if (ws !== closedWs) {
console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', {
state,
@@ -648,6 +653,16 @@ export function connect(
rejectAllPending('Connection closed', { deliveryUnknown: true })
return
}
// Why: a bare 4001 close means the desktop rejected our pairing but the encrypted
// e2ee_error never arrived (or was undecryptable) — count it against the auth
// retry budget instead of looping the generic reconnect forever.
if (opts.closeCode === UNAUTHORIZED_CLOSE_CODE) {
console.log('[net] handleSocketClosed — unauthorized close code', {
attempt: reconnectAttempt
})
handleAuthRejection('Unauthorized — pairing may be revoked')
return
}
console.log('[net] handleSocketClosed → reconnect', {
timedOut: !!opts.timedOut,
pendingCount: pending.size,
+6 -4
View File
@@ -10,7 +10,7 @@ export function useWorktreeResync(args: {
client: RpcClient | null
connState: ConnectionState
fetchWorktrees: (opts?: { allowDuringModal?: boolean }) => Promise<void>
fetchRepoMetadata: () => Promise<void>
fetchRepoMetadata: (options?: { force?: boolean; queueIfInFlight?: boolean }) => Promise<void>
}): { refreshing: boolean; onRefresh: () => Promise<void> } {
const { client, connState, fetchWorktrees, fetchRepoMetadata } = args
@@ -22,9 +22,11 @@ export function useWorktreeResync(args: {
prevConnStateRef.current = connState
if (prev !== 'connected' && connState === 'connected' && client) {
void fetchWorktrees({ allowDuringModal: true })
void fetchRepoMetadata()
}
}, [connState, client, fetchWorktrees, fetchRepoMetadata])
// Why: repo metadata refetch on reconnect is owned by startHostWorktreeRefresh (mount +
// reposChanged + stream replay); this effect only refetches worktrees, so fetchRepoMetadata
// is intentionally not a dependency here.
}, [connState, client, fetchWorktrees])
const [refreshing, setRefreshing] = useState(false)
// Why (#8498): let the user force a fresh snapshot instead of the possibly-poisoned cache.
@@ -35,7 +37,7 @@ export function useWorktreeResync(args: {
setRefreshing(true)
try {
await fetchWorktrees({ allowDuringModal: true })
await fetchRepoMetadata()
await fetchRepoMetadata({ force: true })
} finally {
setRefreshing(false)
}
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { startHostWorktreeRefresh } from './host-worktree-refresh'
const appState = vi.hoisted(() => ({
currentState: 'active',
listener: null as ((state: string) => void) | null,
remove: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: {
get currentState() {
return appState.currentState
},
addEventListener: (_event: string, listener: (state: string) => void) => {
appState.listener = listener
return { remove: appState.remove }
}
}
}))
describe('startHostWorktreeRefresh', () => {
let eventListener: ((payload: unknown) => void) | null
let fetchWorktrees: ReturnType<typeof vi.fn>
let fetchRepoMetadata: ReturnType<typeof vi.fn>
let unsubscribe: ReturnType<typeof vi.fn>
let stop: (() => void) | null
beforeEach(() => {
vi.useFakeTimers()
appState.currentState = 'active'
appState.listener = null
appState.remove.mockClear()
eventListener = null
fetchWorktrees = vi.fn().mockResolvedValue(undefined)
fetchRepoMetadata = vi.fn().mockResolvedValue(undefined)
unsubscribe = vi.fn()
stop = null
})
function start(): void {
const client = {
subscribe: vi.fn(
(_method: string, _params: unknown, listener: (payload: unknown) => void) => {
eventListener = listener
return unsubscribe
}
)
} as unknown as RpcClient
stop = startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata })
}
afterEach(() => {
stop?.()
vi.useRealTimers()
})
it('keeps the worktree poll active but skips ticks while backgrounded', async () => {
start()
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
appState.currentState = 'background'
await vi.advanceTimersByTimeAsync(6_000)
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
appState.currentState = 'active'
await vi.advanceTimersByTimeAsync(3_000)
expect(fetchWorktrees).toHaveBeenCalledTimes(2)
})
it('refreshes both snapshots immediately on foreground return', () => {
start()
fetchWorktrees.mockClear()
fetchRepoMetadata.mockClear()
appState.currentState = 'active'
appState.listener?.('active')
expect(fetchWorktrees).toHaveBeenCalledWith({ allowDuringModal: true })
expect(fetchRepoMetadata).toHaveBeenCalledWith({ queueIfInFlight: true })
})
it('polls repo metadata on the interval while foregrounded and skips it while backgrounded', async () => {
start()
// Mount force-fetch.
expect(fetchRepoMetadata).toHaveBeenCalledTimes(1)
// Foregrounded: repo.list rides the interval as a convergence safety-net for desktop
// Settings edits that never emit a runtime reposChanged (the callee self-throttles).
await vi.advanceTimersByTimeAsync(9_000)
expect(fetchWorktrees).toHaveBeenCalledTimes(4)
expect(fetchRepoMetadata).toHaveBeenCalledTimes(4)
// Backgrounded: neither snapshot is polled.
fetchWorktrees.mockClear()
fetchRepoMetadata.mockClear()
appState.currentState = 'background'
await vi.advanceTimersByTimeAsync(9_000)
expect(fetchWorktrees).not.toHaveBeenCalled()
expect(fetchRepoMetadata).not.toHaveBeenCalled()
})
it('force-refreshes repo metadata on reposChanged', () => {
start()
fetchRepoMetadata.mockClear()
eventListener?.({ type: 'reposChanged' })
expect(fetchRepoMetadata).toHaveBeenCalledOnce()
expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true })
})
it('refreshes worktrees on worktreesChanged and both snapshots after stream replay', () => {
start()
fetchWorktrees.mockClear()
fetchRepoMetadata.mockClear()
eventListener?.({ type: 'worktreesChanged', repoId: 'repo-1' })
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
eventListener?.({ type: 'ready', subscriptionId: 'events-1' })
eventListener?.({ type: 'ready', subscriptionId: 'events-2' })
expect(fetchWorktrees).toHaveBeenCalledTimes(2)
expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true })
})
})
@@ -0,0 +1,88 @@
import { AppState } from 'react-native'
import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events'
import type { RpcClient } from '../transport/rpc-client'
const WORKTREE_REFRESH_MS = 3000
type WorktreeRefreshOptions = { allowDuringModal?: boolean }
type RepoRefreshOptions = { force?: boolean; queueIfInFlight?: boolean }
type HostWorktreeRefreshArgs = {
client: RpcClient
fetchWorktrees: (options?: WorktreeRefreshOptions) => Promise<void>
fetchRepoMetadata: (options?: RepoRefreshOptions) => Promise<void>
}
export function startHostWorktreeRefresh({
client,
fetchWorktrees,
fetchRepoMetadata
}: HostWorktreeRefreshArgs): () => void {
let stale = false
let eventStreamReady = false
const refreshOnForeground = (): void => {
if (AppState.currentState !== 'active') {
return
}
void fetchWorktrees({ allowDuringModal: true })
void fetchRepoMetadata({ queueIfInFlight: true })
}
const appStateSubscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
refreshOnForeground()
}
})
const interval = setInterval(() => {
if (AppState.currentState !== 'active') {
return
}
void fetchWorktrees()
// Why: desktop Settings repo edits (icon/color/name, repo removal) notify only the
// renderer IPC, not the runtime clientEvents stream, so `reposChanged` never reaches
// mobile. Keep a periodic repo.list as the convergence safety-net; fetchRepoMetadata
// self-throttles to REPO_METADATA_REFRESH_MS (60s), so this is ~1 request/min while
// foregrounded — the AppState gate is what removes the waste (both stop while backgrounded).
void fetchRepoMetadata()
}, WORKTREE_REFRESH_MS)
const unsubscribe = client.subscribe(
'runtime.clientEvents.subscribe',
null,
(payload: unknown) => {
if (stale || !payload || typeof payload !== 'object') {
return
}
const event = payload as RuntimeClientEventStreamMessage | { type: 'error' }
if (event.type === 'ready') {
const replayedAfterReconnect = eventStreamReady
eventStreamReady = true
if (replayedAfterReconnect) {
// Why: client events are not queued while disconnected, so re-read both snapshots after replay.
void fetchWorktrees()
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
}
return
}
if (event.type === 'end' || event.type === 'error') {
eventStreamReady = false
return
}
if (event.type === 'reposChanged') {
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
} else if (event.type === 'worktreesChanged') {
void fetchWorktrees()
}
}
)
void fetchWorktrees()
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
return () => {
stale = true
clearInterval(interval)
appStateSubscription.remove()
unsubscribe()
}
}
+8 -6
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.151-rc.0",
"version": "1.4.151-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -114,7 +114,7 @@
"@xterm/addon-serialize": "0.15.0-beta.287",
"@xterm/headless": "6.1.0-beta.287",
"agent-browser": "~0.27.0",
"electron-updater": "^6.8.3",
"electron-updater": "^6.8.9",
"i18next": "^26.3.1",
"jsonc-parser": "^3.3.1",
"node-pty": "^1.1.0",
@@ -176,9 +176,10 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dompurify": "^3.4.11",
"dompurify": "^3.4.12",
"electron": "^43.1.0",
"electron-builder": "^26.8.1",
"electron-builder": "^26.15.3",
"electron-builder-squirrel-windows": "^26.15.3",
"electron-vite": "^5.0.0",
"emoji-picker-react": "^4.19.1",
"happy-dom": "^20.9.0",
@@ -213,7 +214,7 @@
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"shadcn": "^4.7.0",
"shadcn": "^4.13.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
@@ -251,7 +252,8 @@
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
"pnpm": {
"overrides": {
"monaco-editor>dompurify": "3.4.11"
"@modelcontextprotocol/sdk>@hono/node-server": "2.0.10",
"monaco-editor>dompurify": "3.4.12"
},
"supportedArchitectures": {
"os": [
+460 -855
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -117,8 +117,9 @@ Use `--json` for agent-friendly output. Coordinates are **normalized 0..1**
not. For unicode-heavy input, use the app UI directly.
- `gesture` is a straight swipe between the first and last point (adb limitation);
fine for scroll/swipe, not for true multi-touch paths.
- Capability verbs (`install/launch/permissions/ax/logcat`) are **Android-only**;
running them against an iOS device fails with `emulator_unsupported`.
- Capability verbs `install/launch/permissions/logcat` are **Android-only** and
fail against an iOS device with `emulator_unsupported`. `ax` works on both,
with backend-specific output (uiautomator tree vs serve-sim AX snapshot).
- No camera/sensor injection yet.
## Targeting devices & worktrees
+1 -1
View File
@@ -99,7 +99,7 @@ Use `--json` for agent-friendly output. Commands are workspace-scoped by default
| Rotate device | `ORCA emulator rotate landscape_left` | Remembers orientation for subsequent gestures. |
| Camera injection | `ORCA emulator camera com.acme.App --webcam` | Or --file, placeholder. Hot-swap with switch. May (re)launch app. |
| Permissions | `ORCA emulator permissions grant camera com.acme.App` | grant/revoke/reset/list. See full subcommand help. |
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Or via exec for raw endpoint. |
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Raw serve-sim AX snapshot (screen + elements). Needs an active session. |
| Raw / advanced | `ORCA emulator exec --command "tap 0.5 0.7"` | Or "ca-debug blended on", "memory-warning", full serve-sim subcommands (no "serve-sim" prefix needed in the command string). Bridge injects active device context. |
| Stop | `ORCA emulator kill [--device <id>]` | Or let pane close / Orca quit clean up. |
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -108,7 +108,7 @@ export const EMULATOR_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['emulator', 'ax'],
summary: 'Dump the Android accessibility (uiautomator) tree',
summary: 'Dump the device accessibility tree (uiautomator on Android, serve-sim AX on iOS)',
usage: 'orca emulator ax [--device <id>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'device', 'emulator', 'worktree']
},
+199
View File
@@ -1,5 +1,8 @@
import { types } from 'node:util'
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { buildGuestOverlayScript } from './grab-guest-script'
import { clampGrabPayload } from './browser-grab-payload'
describe('buildGuestOverlayScript', () => {
it('returns a non-empty string for arm action', () => {
@@ -158,3 +161,199 @@ describe('buildGuestOverlayScript', () => {
expect(script).not.toContain('ariaLabelledBy.split(/\\s+/)')
})
})
// Regression coverage for issue #9947: Zone.js swaps the global Promise for a
// non-native thenable, which executeJavaScript won't unwrap — so a page-global
// `new Promise(...)` crossed as its raw `__zone_symbol__*` wrapper, not { page, target }.
describe('awaitClick under a Zone.js-patched global Promise', () => {
type Executor<T> = (resolve: (value: T) => void, reject: (reason: unknown) => void) => void
// Native promises are kept off the instance so its own enumerable keys match
// Zone.js exactly: ['__zone_symbol__state', '__zone_symbol__value'].
const nativeOf = new WeakMap<object, Promise<unknown>>()
/** A non-native thenable that mimics Zone.js's ZoneAwarePromise wrapper. */
class ZoneAwarePromiseLike<T = unknown> {
__zone_symbol__state: unknown = null
__zone_symbol__value: unknown = undefined
constructor(executor: Executor<T>) {
nativeOf.set(
this,
new Promise<T>((res, rej) => {
executor(
(value) => {
this.__zone_symbol__state = true
this.__zone_symbol__value = value
res(value)
},
(reason) => {
this.__zone_symbol__state = false
this.__zone_symbol__value = reason
rej(reason)
}
)
})
)
}
// Why: real Zone.js defines `get [Symbol.toStringTag]() { return 'Promise' }`,
// so an Object.prototype.toString check is fooled into seeing a promise. The
// boundary below deliberately uses the brand-based V8 check instead, and the
// control test asserts this tag does NOT let the wrapper masquerade as native.
get [Symbol.toStringTag](): string {
return 'Promise'
}
// oxlint-disable-next-line unicorn/no-thenable -- intentionally a non-native thenable modeling Zone.js's ZoneAwarePromise
then(
onFulfilled?: ((value: unknown) => unknown) | null,
onRejected?: ((reason: unknown) => unknown) | null
): Promise<unknown> {
const native = nativeOf.get(this) as Promise<unknown>
return native.then(onFulfilled ?? undefined, onRejected ?? undefined)
}
}
/**
* Models Electron's boundary: it awaits only genuine V8 promises and
* serializes anything else — including non-native thenables — by value.
* `util.types.isPromise` is V8's brand-based IsPromise (what Electron uses):
* realm-independent and unforgeable, unlike an Object.prototype.toString /
* Symbol.toStringTag check that a ZoneAwarePromise would defeat.
*/
async function crossExecuteJavaScriptBoundary(completionValue: unknown): Promise<unknown> {
if (types.isPromise(completionValue)) {
return await (completionValue as Promise<unknown>)
}
return { ...(completionValue as Record<string, unknown>) }
}
const validPayload = (): Record<string, unknown> => ({
// A minimal but structurally valid payload — page + target are what
// clampGrabPayload requires and what the bug stripped away.
page: { title: 'Angular App' },
target: { tagName: 'button' },
nearbyText: [],
ancestorPath: [],
screenshot: null
})
function armGrabHarness(options?: {
extractPayload?: () => unknown
getCurrentElement?: () => unknown
}): {
window: { __orcaGrab: Record<string, unknown> }
click: () => void
contextmenu: () => void
cancel: () => void
} {
const handlers: Record<string, (event: unknown) => void> = {}
const noopEvent = {
preventDefault(): void {},
stopPropagation(): void {},
stopImmediatePropagation(): void {}
}
const grab: Record<string, unknown> = {
host: {
addEventListener(type: string, fn: (event: unknown) => void): void {
handlers[type] = fn
},
removeEventListener(): void {}
},
extractPayload: options?.extractPayload ?? validPayload,
getCurrentElement: options?.getCurrentElement ?? ((): unknown => ({})),
freezeHighlight(): void {},
cleanup(): void {}
}
const window = { __orcaGrab: grab }
return {
window,
click: () => handlers.click?.(noopEvent),
contextmenu: () => handlers.contextmenu?.(noopEvent),
// cancelAwait is installed on __orcaGrab by the script itself at runtime.
cancel: () => (window.__orcaGrab.cancelAwait as (() => void) | undefined)?.()
}
}
const runAwaitClick = (harness: ReturnType<typeof armGrabHarness>): unknown =>
runInNewContext(buildGuestOverlayScript('awaitClick'), {
window: harness.window,
Promise: ZoneAwarePromiseLike,
Error
})
it('returns the payload through a native async promise, not the page-global Promise', () => {
const script = buildGuestOverlayScript('awaitClick')
expect(script).toContain('(async function()')
expect(script).toContain('return await new Promise(')
})
it('resolves { page, target } across the boundary despite ZoneAwarePromise', async () => {
const harness = armGrabHarness()
const completion = runAwaitClick(harness)
// The async IIFE hands Electron an intrinsic promise even though the global
// Promise is a non-native thenable — so the boundary unwraps it.
expect(types.isPromise(completion)).toBe(true)
harness.click()
const received = await crossExecuteJavaScriptBoundary(completion)
expect(received).toHaveProperty('page')
expect(received).toHaveProperty('target')
expect(received).not.toHaveProperty('__zone_symbol__value')
expect(clampGrabPayload(received)).not.toBeNull()
})
it('resolves the context-menu marker across the boundary despite ZoneAwarePromise', async () => {
const harness = armGrabHarness()
const completion = runAwaitClick(harness)
harness.contextmenu()
const received = (await crossExecuteJavaScriptBoundary(completion)) as Record<string, unknown>
expect(received).toHaveProperty('__orcaContextMenu', true)
expect(received.payload).toHaveProperty('page')
expect(clampGrabPayload(received.payload)).not.toBeNull()
})
it('resolves the teardown cancel marker across the boundary despite ZoneAwarePromise', async () => {
const harness = armGrabHarness()
const completion = runAwaitClick(harness)
harness.cancel()
const received = await crossExecuteJavaScriptBoundary(completion)
expect(received).toEqual({ __orcaCancelled: true })
})
it('rejects across the boundary when selection fails despite ZoneAwarePromise', async () => {
// getCurrentElement -> null drives onClick's reject(new Error('cancelled')),
// which must surface as a rejected intrinsic promise (not a serialized value)
// so the controller classifies it as a cancellation rather than a payload.
const harness = armGrabHarness({ getCurrentElement: () => null })
const completion = runAwaitClick(harness)
harness.click()
await expect(crossExecuteJavaScriptBoundary(completion)).rejects.toThrow('cancelled')
})
it('control: a bare page-global new Promise would cross as the raw wrapper', async () => {
// Proves the harness detects the regression: without the async wrapper the
// completion value is the ZoneAwarePromise itself, which the boundary
// serializes to __zone_symbol__* fields with no page/target.
const bare = runInNewContext('new Promise(function(r){ r({ page: {}, target: {} }); })', {
Promise: ZoneAwarePromiseLike
})
// Symbol.toStringTag='Promise' fools a toString check — exactly why the
// boundary must use the brand-based IsPromise, which still rejects it.
expect(Object.prototype.toString.call(bare)).toBe('[object Promise]')
expect(types.isPromise(bare)).toBe(false)
const received = await crossExecuteJavaScriptBoundary(bare)
expect(received).not.toHaveProperty('page')
expect(received).toHaveProperty('__zone_symbol__value')
expect(clampGrabPayload(received)).toBeNull()
})
})
+81 -70
View File
@@ -823,80 +823,90 @@ const ARM_SCRIPT = `(function() {
})()`
// awaitClick: resolve when the user clicks the overlay; stopPropagation + pointer-events:all keep the click off the page.
const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) {
'use strict';
var grab = window.__orcaGrab;
if (!grab) {
reject(new Error('Grab not armed'));
return;
}
function extractSelectedPayload(el) {
try {
return grab.extractPayload(el);
} catch (error) {
grab.cleanup();
reject(error instanceof Error ? error : new Error('Failed to extract element context'));
return null;
}
}
function onClick(e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
grab.host.removeEventListener('click', onClick, true);
grab.host.removeEventListener('contextmenu', onContext, true);
var el = grab.getCurrentElement();
if (!el) {
grab.cleanup();
reject(new Error('cancelled'));
const AWAIT_CLICK_SCRIPT = `(async function() {
// Why: hand the click result to executeJavaScript through a native (intrinsic)
// Promise. On pages that replace the global Promise with a non-native thenable
// — e.g. Angular Zone.js's ZoneAwarePromise — a bare \`new Promise(...)\` is not
// recognized as a promise by Electron, so its raw wrapper object (exposing
// __zone_symbol__state/__value instead of { page, target }) crosses the boundary
// and main rejects it as an invalid payload structure. An async function's
// promise comes from the engine intrinsic that page code cannot reassign, so
// Electron always unwraps it to the resolved payload.
return await new Promise(function(resolve, reject) {
'use strict';
var grab = window.__orcaGrab;
if (!grab) {
reject(new Error('Grab not armed'));
return;
}
var payload = extractSelectedPayload(el);
if (!payload) return;
// Why: freeze the highlight instead of removing it so the user sees
// which element was selected while the copy menu is shown. Teardown
// happens later when the renderer calls setGrabMode(false) or re-arms.
grab.freezeHighlight();
resolve(payload);
}
function onContext(e) {
// Why: right-click resolves with the payload wrapped in a context-menu
// marker so the renderer can show the full action dropdown instead of
// auto-copying. This gives users a deliberate path to screenshot and
// other secondary actions while keeping left-click as the fast copy path.
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
grab.host.removeEventListener('click', onClick, true);
grab.host.removeEventListener('contextmenu', onContext, true);
var el = grab.getCurrentElement();
if (!el) {
grab.cleanup();
reject(new Error('cancelled'));
return;
function extractSelectedPayload(el) {
try {
return grab.extractPayload(el);
} catch (error) {
grab.cleanup();
reject(error instanceof Error ? error : new Error('Failed to extract element context'));
return null;
}
}
var payload = extractSelectedPayload(el);
if (!payload) return;
grab.freezeHighlight();
resolve({ __orcaContextMenu: true, payload: payload });
}
grab.host.addEventListener('click', onClick, true);
grab.host.addEventListener('contextmenu', onContext, true);
function onClick(e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
grab.host.removeEventListener('click', onClick, true);
grab.host.removeEventListener('contextmenu', onContext, true);
var el = grab.getCurrentElement();
if (!el) {
grab.cleanup();
reject(new Error('cancelled'));
return;
}
var payload = extractSelectedPayload(el);
if (!payload) return;
// Why: freeze the highlight instead of removing it so the user sees
// which element was selected while the copy menu is shown. Teardown
// happens later when the renderer calls setGrabMode(false) or re-arms.
grab.freezeHighlight();
resolve(payload);
}
// Store cancel hook so teardown can settle the Promise
grab.cancelAwait = function() {
grab.host.removeEventListener('click', onClick, true);
grab.host.removeEventListener('contextmenu', onContext, true);
grab.cleanup();
// Why: teardown cancellation is a normal user flow; resolving a marker
// avoids a noisy guest-console Error while main still treats it as cancel.
resolve({ __orcaCancelled: true });
};
})`
function onContext(e) {
// Why: right-click resolves with the payload wrapped in a context-menu
// marker so the renderer can show the full action dropdown instead of
// auto-copying. This gives users a deliberate path to screenshot and
// other secondary actions while keeping left-click as the fast copy path.
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
grab.host.removeEventListener('click', onClick, true);
grab.host.removeEventListener('contextmenu', onContext, true);
var el = grab.getCurrentElement();
if (!el) {
grab.cleanup();
reject(new Error('cancelled'));
return;
}
var payload = extractSelectedPayload(el);
if (!payload) return;
grab.freezeHighlight();
resolve({ __orcaContextMenu: true, payload: payload });
}
grab.host.addEventListener('click', onClick, true);
grab.host.addEventListener('contextmenu', onContext, true);
// Store cancel hook so teardown can settle the Promise
grab.cancelAwait = function() {
grab.host.removeEventListener('click', onClick, true);
grab.host.removeEventListener('contextmenu', onContext, true);
grab.cleanup();
// Why: teardown cancellation is a normal user flow; resolving a marker
// avoids a noisy guest-console Error while main still treats it as cancel.
resolve({ __orcaCancelled: true });
};
});
})()`
const FINALIZE_SCRIPT = `(function() {
'use strict';
@@ -933,8 +943,9 @@ const TEARDOWN_SCRIPT = `(function() {
'use strict';
var grab = window.__orcaGrab;
if (!grab) return true;
// If there's an active awaitClick Promise, cancel it so the
// executeJavaScript call in main rejects and settles the grab op.
// If there's an active awaitClick Promise, cancel it: cancelAwait resolves
// it with the __orcaCancelled marker so the executeJavaScript call in main
// settles the grab op as a cancellation.
if (grab.cancelAwait) {
grab.cancelAwait();
} else {
+3 -2
View File
@@ -6,6 +6,7 @@ import {
} from '../../shared/wsl-login-shell-command'
export const WSL_CODEX_AVAILABILITY_TIMEOUT_MS = 5_000
export const WSL_CODEX_NOT_FOUND_MESSAGE = 'Codex CLI not found in the WSL login-shell PATH.'
export function buildWslCodexAvailabilityArgs(distro: string): string[] {
const command = [buildCodexPathLookup(), '[ -n "$resolved" ]'].join('\n')
@@ -16,7 +17,7 @@ export function buildWslCodexIdentityArgs(distro: string): string[] {
const command = [
buildCodexPathLookup(),
'if [ -z "$resolved" ]; then',
" printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2",
` printf '%s\\n' '${WSL_CODEX_NOT_FOUND_MESSAGE}' >&2`,
' exit 127',
'fi',
'printf \'%s\\n\' "$resolved"',
@@ -29,7 +30,7 @@ export function buildWslCodexAppServerArgs(distro: string, linuxHomePath: string
const command = [
buildCodexPathLookup(),
'if [ -z "$resolved" ]; then',
" printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2",
` printf '%s\\n' '${WSL_CODEX_NOT_FOUND_MESSAGE}' >&2`,
' exit 127',
'fi',
`export CODEX_HOME=${quotePosixShell(linuxHomePath)}`,
@@ -321,7 +321,7 @@ describe('runCodexHookTrustGrantSession', () => {
})
const result = await runCodexHookTrustGrantSession(request)
expect(result.outcome).toBe('verify-failed')
expect(result).toMatchObject({ outcome: 'verify-failed', reasonClass: 'list-mismatch' })
})
it('rejects duplicate normalized aliases that conceal a missing expected key', async () => {
@@ -337,7 +337,8 @@ describe('runCodexHookTrustGrantSession', () => {
})
await expect(runCodexHookTrustGrantSession(request)).resolves.toMatchObject({
outcome: 'verify-failed'
outcome: 'verify-failed',
reasonClass: 'list-mismatch'
})
expect(existsSync(recordFile)).toBe(false)
})
+21 -9
View File
@@ -41,6 +41,13 @@ export type CodexGrantedHookTrust = {
trustedHash: string
}
/** Closed verify-failure taxonomy — crosses the grant-bridge JSON envelope, so
* telemetry never has to parse the free-form `reason` diagnostics string. */
export type CodexTrustGrantSessionVerifyClass =
| 'list-mismatch'
| 'post-grant-untrusted'
| 'post-grant-mismatch'
export type CodexHookTrustGrantSessionResult =
| {
outcome: 'granted'
@@ -48,7 +55,7 @@ export type CodexHookTrustGrantSessionResult =
/** False when every expected entry was already trusted (no write). */
wroteTrust: boolean
}
| { outcome: 'verify-failed'; reason: string }
| { outcome: 'verify-failed'; reason: string; reasonClass: CodexTrustGrantSessionVerifyClass }
type CodexHookListing = {
key: string
@@ -117,7 +124,8 @@ export async function runCodexHookTrustGrantSession(
) {
return {
outcome: 'verify-failed',
reason: `hooks/list reported ${managedListings.length} entries covering ${managedKeyCoverage.size} of ${expectedKeys.size} expected managed entries`
reason: `hooks/list reported ${managedListings.length} entries covering ${managedKeyCoverage.size} of ${expectedKeys.size} expected managed entries`,
reasonClass: 'list-mismatch'
}
}
@@ -144,13 +152,17 @@ export async function runCodexHookTrustGrantSession(
!setContainsEvery(verifiedKeyCoverage, expectedKeys) ||
untrusted.length > 0
) {
return {
outcome: 'verify-failed',
reason:
untrusted.length > 0
? `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}`
: `post-grant verify reported ${verifiedListings.length} entries covering ${verifiedKeyCoverage.size} of ${expectedKeys.size} expected entries`
}
return untrusted.length > 0
? {
outcome: 'verify-failed',
reason: `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}`,
reasonClass: 'post-grant-untrusted'
}
: {
outcome: 'verify-failed',
reason: `post-grant verify reported ${verifiedListings.length} entries covering ${verifiedKeyCoverage.size} of ${expectedKeys.size} expected entries`,
reasonClass: 'post-grant-mismatch'
}
}
return {
outcome: 'granted',
@@ -0,0 +1,322 @@
import {
createTomlLineScanState,
getTomlTableHeader,
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
const TUI_STRUCTURED_PREFIX = 'tui.'
// Why: promoted [tui] settings are keyed by structured path (tui.<key>) so their
// baseline/update entries can never collide with a top-level key of the same name.
export function tuiStructuredKey(key: string): string {
return `${TUI_STRUCTURED_PREFIX}${key}`
}
export function isTuiStructuredKey(structuredKey: string): boolean {
return structuredKey.startsWith(TUI_STRUCTURED_PREFIX)
}
export function tuiKeyFromStructuredKey(structuredKey: string): string {
return structuredKey.slice(TUI_STRUCTURED_PREFIX.length)
}
// Why: promoted updates arrive keyed by structured path; the preamble and [tui]
// regions are disjoint, so a mixed batch (e.g. /model + a status-line change)
// composes in one rewrite — top-level keys land in the preamble, tui.<key>
// entries wherever the [tui] placement rule puts them.
export function upsertPromotedSettingsInContent(
content: string,
updates: Map<string, string>
): string {
const topLevelUpdates = new Map<string, string>()
const tuiUpdates = new Map<string, string>()
for (const [key, raw] of updates) {
if (isTuiStructuredKey(key)) {
tuiUpdates.set(tuiKeyFromStructuredKey(key), raw)
} else {
topLevelUpdates.set(key, raw)
}
}
let result = content
if (topLevelUpdates.size > 0) {
result = upsertTopLevelSettingsInContent(result, topLevelUpdates)
}
if (tuiUpdates.size > 0) {
result = upsertTuiSettingsInContent(result, tuiUpdates)
}
return result
}
export function upsertTopLevelSettingsInContent(
content: string,
updates: Map<string, string>
): string {
const lines = content.split('\n')
let state = createTomlLineScanState()
let preambleEnd = lines.length
const keyLineIndexes = new Map<string, number>()
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (isTomlStructuralLine(state)) {
if (getTomlTableHeader(line)) {
preambleEnd = index
break
}
const parsed = parseTomlKeyPath(line)
const key = parsed?.segments.length === 1 ? parsed.segments[0] : null
if (parsed && line[parsed.end] === '=' && key && updates.has(key)) {
keyLineIndexes.set(key, index)
}
}
state = updateTomlLineScanState(state, line)
}
// Why: CRLF configs keep a trailing \r after the split; new lines must use
// the file's existing endings or a Windows-owned config becomes mixed-EOL.
const usesCrlf = content.includes('\r\n')
const insertions: string[] = []
for (const [key, raw] of updates) {
const existingIndex = keyLineIndexes.get(key)
const rendered = `${key} = ${raw}`
if (existingIndex !== undefined) {
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
} else {
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
}
}
if (insertions.length > 0) {
let insertAt = preambleEnd
while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') {
insertAt -= 1
}
if (insertAt === preambleEnd && preambleEnd < lines.length) {
insertions.push(usesCrlf ? '\r' : '')
}
lines.splice(insertAt, 0, ...insertions)
}
return joinPreservingTrailingNewline(lines, usesCrlf)
}
type TuiPlacementScan = {
bareKeyIndexes: Map<string, number>
dottedKeyIndexes: Map<string, number>
hasBareTuiTable: boolean
hasDottedTuiKey: boolean
blocksNewTuiTable: boolean
blockedAbsentKeys: Set<string>
bareBodyInsertIndex: number
lastDottedTuiIndex: number
}
/**
* Upserts promoted `[tui]` keys (keyed by bare name) into the system config,
* placing each per the design's total placement rule: replace an existing key
* in place keeping its form; else insert bare into the first `[tui]` body; else
* dotted in the preamble beside existing dotted `tui.*` keys; else create one
* `[tui]` table at EOF for every key that reaches that branch. Rendering follows
* the destination — bare inside a table, dotted in the preamble — so no `tui`
* table is ever defined twice.
*/
export function upsertTuiSettingsInContent(content: string, updates: Map<string, string>): string {
const lines = content.split('\n')
const scan = scanTuiPlacement(lines, updates)
const usesCrlf = content.includes('\r\n')
const bareBodyInserts: string[] = []
const dottedPreambleInserts: string[] = []
const newTableKeys: string[] = []
for (const [key, raw] of updates) {
const dottedIndex = scan.dottedKeyIndexes.get(key)
if (dottedIndex !== undefined) {
lines[dottedIndex] = withTrailingCr(lines[dottedIndex]!, `${tuiStructuredKey(key)} = ${raw}`)
continue
}
const bareIndex = scan.bareKeyIndexes.get(key)
if (bareIndex !== undefined) {
lines[bareIndex] = withTrailingCr(lines[bareIndex]!, `${key} = ${raw}`)
continue
}
// Why: adding a scalar beside an existing tui.<key> descendant would turn valid TOML invalid.
if (scan.blockedAbsentKeys.has(key)) {
continue
}
if (scan.hasBareTuiTable) {
bareBodyInserts.push(`${key} = ${raw}`)
} else if (scan.hasDottedTuiKey) {
dottedPreambleInserts.push(`${tuiStructuredKey(key)} = ${raw}`)
} else if (!scan.blocksNewTuiTable) {
// Why: inline/array tui definitions block this branch because adding a
// plain [tui] beside either would make the config invalid.
newTableKeys.push(`${key} = ${raw}`)
}
}
// Why: the config shape routes every absent key to the same branch, so at most
// one insert group is non-empty; still apply EOF→body→preamble so a splice
// never shifts a lower index a later splice depends on.
if (newTableKeys.length > 0) {
appendNewTuiTable(lines, newTableKeys, usesCrlf)
}
if (bareBodyInserts.length > 0) {
lines.splice(
scan.bareBodyInsertIndex,
0,
...bareBodyInserts.map((line) => withCrLine(line, usesCrlf))
)
}
if (dottedPreambleInserts.length > 0) {
lines.splice(
scan.lastDottedTuiIndex + 1,
0,
...dottedPreambleInserts.map((line) => withCrLine(line, usesCrlf))
)
}
return joinPreservingTrailingNewline(lines, usesCrlf)
}
function scanTuiPlacement(lines: string[], updates: Map<string, string>): TuiPlacementScan {
let state = createTomlLineScanState()
let inPreamble = true
let tuiTableSeen = false
let tuiBodyActive = false
let tuiBodyHeaderIndex = -1
let tuiBodyEndIndex = -1
let hasDottedTuiKey = false
let blocksNewTuiTable = false
let lastDottedTuiIndex = -1
const bareKeyIndexes = new Map<string, number>()
const dottedKeyIndexes = new Map<string, number>()
const blockedAbsentKeys = new Set<string>()
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (isTomlStructuralLine(state)) {
const header = getTomlTableHeader(line)
if (header) {
if (tuiBodyActive) {
tuiBodyEndIndex = index
tuiBodyActive = false
}
const table = parseTomlTableHeaderPath(header)
if (
table &&
!table.isArray &&
table.segments.length === 1 &&
table.segments[0] === 'tui' &&
!tuiTableSeen
) {
tuiTableSeen = true
tuiBodyActive = true
tuiBodyHeaderIndex = index
}
// Why: a root [[tui]] is already an array, so appending [tui] would
// redefine it and make an otherwise valid config unparseable.
if (table?.isArray && table.segments.length === 1 && table.segments[0] === 'tui') {
blocksNewTuiTable = true
}
const descendantKey =
table?.segments[0] === 'tui' && table.segments.length > 1 ? table.segments[1] : null
if (descendantKey && updates.has(descendantKey)) {
blockedAbsentKeys.add(descendantKey)
}
inPreamble = false
state = updateTomlLineScanState(state, line)
continue
}
if (inPreamble) {
// Why: any dotted `tui.*` key (allowlisted or not) already defines the
// implicit tui table, so a new `[tui]` table at EOF would duplicate it.
const parsed = parseTomlKeyPath(line)
const isAssignment = parsed && line[parsed.end] === '='
if (isAssignment && parsed.segments[0] === 'tui' && parsed.segments.length > 1) {
hasDottedTuiKey = true
lastDottedTuiIndex = index
const promotedKey = parsed.segments.length === 2 ? parsed.segments[1] : null
if (promotedKey && updates.has(promotedKey)) {
dottedKeyIndexes.set(promotedKey, index)
}
const descendantKey = parsed.segments.length > 2 ? parsed.segments[1] : null
if (descendantKey && updates.has(descendantKey)) {
blockedAbsentKeys.add(descendantKey)
}
} else if (isAssignment && parsed.segments.length === 1 && parsed.segments[0] === 'tui') {
blocksNewTuiTable = true
}
} else if (tuiBodyActive) {
const parsed = parseTomlKeyPath(line)
const key = parsed?.segments.length === 1 ? parsed.segments[0] : null
if (parsed && line[parsed.end] === '=' && key && updates.has(key)) {
bareKeyIndexes.set(key, index)
}
const descendantKey = parsed && parsed.segments.length > 1 ? parsed.segments[0] : null
if (descendantKey && updates.has(descendantKey)) {
blockedAbsentKeys.add(descendantKey)
}
}
}
state = updateTomlLineScanState(state, line)
}
if (tuiBodyActive) {
tuiBodyEndIndex = lines.length
}
return {
bareKeyIndexes,
dottedKeyIndexes,
hasBareTuiTable: tuiTableSeen,
hasDottedTuiKey,
blocksNewTuiTable,
blockedAbsentKeys,
bareBodyInsertIndex: computeBareBodyInsertIndex(lines, tuiBodyHeaderIndex, tuiBodyEndIndex),
lastDottedTuiIndex
}
}
// Why: TOML forbids adding bare keys to `[tui]` after a `[tui.*]` subtable opens,
// so absent keys land at the body's end — before trailing blanks and before the
// next header — which is the only valid spot.
function computeBareBodyInsertIndex(
lines: string[],
headerIndex: number,
endIndex: number
): number {
if (headerIndex === -1) {
return -1
}
let insertAt = endIndex
while (insertAt > headerIndex + 1 && (lines[insertAt - 1] ?? '').trim() === '') {
insertAt -= 1
}
return insertAt
}
function appendNewTuiTable(lines: string[], keyRenders: string[], usesCrlf: boolean): void {
let appendAt = lines.length
while (appendAt > 0 && (lines[appendAt - 1] ?? '').trim() === '') {
appendAt -= 1
}
// Why: separate the new table from prior content with a blank line, unless the
// file was empty/blank, where a leading blank would be spurious.
const block = appendAt > 0 ? ['', '[tui]', ...keyRenders] : ['[tui]', ...keyRenders]
lines.splice(appendAt, 0, ...block.map((line) => withCrLine(line, usesCrlf)))
}
function withTrailingCr(originalLine: string, rendered: string): string {
return originalLine.endsWith('\r') ? `${rendered}\r` : rendered
}
function withCrLine(rendered: string, usesCrlf: boolean): string {
return usesCrlf ? `${rendered}\r` : rendered
}
// Why: a missing trailing newline is restored in the file's own EOL so a
// preamble-only or table-appended rewrite matches the source's newline behavior.
function joinPreservingTrailingNewline(lines: string[], usesCrlf: boolean): string {
const result = lines.join('\n')
if (result.endsWith('\n') || result.length === 0) {
return result
}
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
}
+103 -4
View File
@@ -13,9 +13,9 @@ import {
CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS,
getCodexTrustGrantDiagnostics,
grantManagedCodexHookTrust,
setCodexTrustGrantTelemetry,
type CodexManagedTrustGrantPlan
} from './codex-hook-trust-grant'
import { setCodexTrustGrantTelemetry } from './codex-trust-grant-telemetry'
import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger'
import {
computeTrustKey,
@@ -76,7 +76,8 @@ function buildPlan(entries: CodexTrustEntry[]): CodexManagedTrustGrantPlan {
tomlPath: join(runtimeHomeDir, 'config.toml'),
managedCommand: MANAGED_COMMAND,
managedEntries: entries,
host: { kind: 'native' }
host: { kind: 'native' },
telemetryLane: 'real-home'
}
}
@@ -248,7 +249,11 @@ describe('grantManagedCodexHookTrust', () => {
it('falls back on verify-failed without marking unsupported', () => {
const entries = [managedEntry('session_start')]
const runner = vi.fn(() => ({ outcome: 'verify-failed' as const, reason: 'missing entries' }))
const runner = vi.fn(() => ({
outcome: 'verify-failed' as const,
reason: 'missing entries',
reasonClass: 'list-mismatch' as const
}))
_internals.setGrantSessionRunnerSync(runner)
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
@@ -307,7 +312,11 @@ describe('grantManagedCodexHookTrust', () => {
mkdirSync(runtimeHomeDir, { recursive: true })
_internals.setGrantSessionRunnerSync(() => {
writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n')
return { outcome: 'verify-failed', reason: 'post-write listing failed' }
return {
outcome: 'verify-failed',
reason: 'post-write listing failed',
reasonClass: 'post-grant-mismatch'
}
})
expect(grantManagedCodexHookTrust(plan)).toMatchObject({
@@ -347,3 +356,93 @@ describe('grantManagedCodexHookTrust', () => {
expect(request.hooksListCwd).toBe('/home/alice/.codex-runtime')
})
})
describe('trust-grant telemetry detail', () => {
type CapturedEvent = Record<string, unknown>
function captureTelemetry(): CapturedEvent[] {
const events: CapturedEvent[] = []
setCodexTrustGrantTelemetry((event) => {
events.push(event)
})
return events
}
it('attributes the plan lane on granted events', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start')]
_internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries))
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' })
expect(events).toEqual([{ outcome: 'granted', hostKind: 'native', lane: 'real-home' }])
})
it('reports the managed lane independently of host kind', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start')]
_internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries))
grantManagedCodexHookTrust({ ...buildPlan(entries), telemetryLane: 'managed' })
expect(events).toEqual([{ outcome: 'granted', hostKind: 'native', lane: 'managed' }])
})
it('classifies error fallbacks on the wire', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start')]
_internals.setGrantSessionRunnerSync(() => {
throw new Error('spawn codex ENOENT')
})
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
lane: 'fallback',
reason: 'error'
})
expect(events).toEqual([
{
outcome: 'fallback',
hostKind: 'native',
lane: 'real-home',
reason: 'error',
errorClass: 'binary-missing'
}
])
})
it('carries the session verify class through the fallback event', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start')]
_internals.setGrantSessionRunnerSync(() => ({
outcome: 'verify-failed' as const,
reason: 'post-grant verify left 1 entries untrusted',
reasonClass: 'post-grant-untrusted' as const
}))
grantManagedCodexHookTrust(buildPlan(entries))
expect(events).toEqual([
{
outcome: 'verify_failed',
hostKind: 'native',
lane: 'real-home',
reason: 'verify-failed',
verifyClass: 'post-grant-untrusted'
}
])
})
it('classifies module-detected verify failures', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start'), managedEntry('stop')]
_internals.setGrantSessionRunnerSync(() => grantedSessionResult([entries[0]!, entries[0]!]))
grantManagedCodexHookTrust(buildPlan(entries))
expect(events).toEqual([
{
outcome: 'verify_failed',
hostKind: 'native',
lane: 'real-home',
reason: 'verify-failed',
verifyClass: 'duplicate-key'
}
])
})
})
+43 -51
View File
@@ -3,6 +3,13 @@ import {
type CodexHookTrustGrantRequest,
type CodexHookTrustGrantSessionResult
} from './codex-app-server-client'
import {
classifyCodexTrustGrantError,
emitCodexTrustGrantTelemetry,
type CodexTrustGrantFallbackReason,
type CodexTrustGrantTelemetryLane,
type CodexTrustGrantVerifyClass
} from './codex-trust-grant-telemetry'
import { runCodexHookTrustGrantSessionSync } from './codex-app-server-grant-bridge'
import {
codexAppServerCapabilityCache,
@@ -46,69 +53,31 @@ export type CodexManagedTrustGrantPlan = {
/** Managed trust identities Orca just wrote (no trustedHash). */
managedEntries: readonly CodexTrustEntry[]
host: CodexTrustGrantHost
telemetryLane: CodexTrustGrantTelemetryLane
/** Match a pane where CODEX_HOME is absent instead of an explicit managed home. */
useDefaultCodexHome?: boolean
}
export type CodexTrustGrantFallbackReason =
| 'disabled'
| 'no-managed-entries'
| 'unsupported'
| 'unsupported-cached'
| 'verify-failed'
| 'retry-cached'
| 'error'
export type { CodexTrustGrantFallbackReason, CodexTrustGrantTelemetryLane }
export type CodexManagedTrustGrantOutcome =
| { lane: 'rpc'; entries: CodexTrustEntry[] }
| { lane: 'fallback'; reason: CodexTrustGrantFallbackReason }
export type CodexTrustGrantDiagnostics = {
granted: number
ledgerHits: number
fellBack: number
verifyFailed: number
lastFallbackReason: CodexTrustGrantFallbackReason | null
}
const diagnostics: CodexTrustGrantDiagnostics = {
const diagnostics = {
granted: 0,
ledgerHits: 0,
fellBack: 0,
verifyFailed: 0,
lastFallbackReason: null
lastFallbackReason: null as CodexTrustGrantFallbackReason | null
}
export type CodexTrustGrantDiagnostics = typeof diagnostics
const transientRetryAfterByHost = new Map<string, number>()
export function getCodexTrustGrantDiagnostics(): CodexTrustGrantDiagnostics {
return { ...diagnostics }
}
type CodexTrustGrantTelemetry = (event: {
outcome: 'granted' | 'fallback' | 'verify_failed'
hostKind: 'native' | 'wsl'
reason?: CodexTrustGrantFallbackReason
}) => void
// Why: hook-service is bundled into plain-node CLI entries where electron
// (and therefore the telemetry client) cannot load; the Electron main process
// injects the tracker at startup instead of a static import.
let telemetry: CodexTrustGrantTelemetry = () => {}
export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry): void {
telemetry = tracker
}
function emitTelemetry(event: Parameters<CodexTrustGrantTelemetry>[0]): void {
try {
telemetry(event)
} catch (error) {
// Why: observability must never turn a verified grant into fallback or
// violate this launch-prep API's no-throw contract.
console.warn('[codex-trust-grant] failed to emit telemetry', error)
}
}
type GrantSessionRunnerSync = (
request: CodexHookTrustGrantRequest
) => CodexHookTrustGrantSessionResult
@@ -118,7 +87,8 @@ let runSessionSync: GrantSessionRunnerSync = runCodexHookTrustGrantSessionSync
function fallback(
plan: CodexManagedTrustGrantPlan,
reason: CodexTrustGrantFallbackReason,
detail?: unknown
detail?: unknown,
verifyClass?: CodexTrustGrantVerifyClass
): CodexManagedTrustGrantOutcome {
diagnostics.fellBack += 1
diagnostics.lastFallbackReason = reason
@@ -129,10 +99,13 @@ function fallback(
`[codex-trust-grant] falling back to self-computed trust (reason=${reason}, host=${plan.host.kind})`,
detail ?? ''
)
emitTelemetry({
emitCodexTrustGrantTelemetry({
outcome: reason === 'verify-failed' ? 'verify_failed' : 'fallback',
hostKind: plan.host.kind,
reason
lane: plan.telemetryLane,
reason,
...(reason === 'error' ? { errorClass: classifyCodexTrustGrantError(detail) } : {}),
...(verifyClass !== undefined ? { verifyClass } : {})
})
return { lane: 'fallback', reason }
}
@@ -272,7 +245,7 @@ export function grantManagedCodexHookTrust(
hostKey,
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
)
return fallback(plan, 'verify-failed', result.reason)
return fallback(plan, 'verify-failed', result.reason, result.reasonClass)
}
const byNormalizedKey = new Map(expected.map((item) => [item.normalizedKey, item]))
@@ -287,7 +260,12 @@ export function grantManagedCodexHookTrust(
hostKey,
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
)
return fallback(plan, 'verify-failed', `unexpected granted key ${granted.key}`)
return fallback(
plan,
'verify-failed',
`unexpected granted key ${granted.key}`,
'unexpected-key'
)
}
if (seenNormalizedKeys.has(granted.normalizedKey)) {
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
@@ -295,7 +273,12 @@ export function grantManagedCodexHookTrust(
hostKey,
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
)
return fallback(plan, 'verify-failed', `duplicate granted key ${granted.key}`)
return fallback(
plan,
'verify-failed',
`duplicate granted key ${granted.key}`,
'duplicate-key'
)
}
seenNormalizedKeys.add(granted.normalizedKey)
grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash })
@@ -310,7 +293,12 @@ export function grantManagedCodexHookTrust(
hostKey,
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
)
return fallback(plan, 'verify-failed', 'granted entry set did not cover expected entries')
return fallback(
plan,
'verify-failed',
'granted entry set did not cover expected entries',
'coverage'
)
}
transientRetryAfterByHost.delete(hostKey)
try {
@@ -327,7 +315,11 @@ export function grantManagedCodexHookTrust(
`[codex-trust-grant] granted ${grantedEntries.length} managed hook entries via codex app-server ` +
`(host=${plan.host.kind}, wrote=${result.wroteTrust}, ${Date.now() - startedAtMs}ms)`
)
emitTelemetry({ outcome: 'granted', hostKind: plan.host.kind })
emitCodexTrustGrantTelemetry({
outcome: 'granted',
hostKind: plan.host.kind,
lane: plan.telemetryLane
})
return { lane: 'rpc', entries: grantedEntries }
} catch (error) {
return fallback(plan, 'error', error)
@@ -196,6 +196,7 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
managedCommand: material.command,
managedEntries,
host: { kind: 'native' },
telemetryLane: 'real-home',
useDefaultCodexHome: true
})
if (grant.lane === 'rpc') {
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { WSL_CODEX_NOT_FOUND_MESSAGE } from '../codex-accounts/wsl-codex-command'
import { CodexAppServerTimeoutError } from './codex-app-server-client'
import { classifyCodexTrustGrantError } from './codex-trust-grant-telemetry'
describe('classifyCodexTrustGrantError', () => {
it.each([
[new CodexAppServerTimeoutError('entry exceeded 20000ms session deadline'), 'timeout'],
[new Error('spawn codex ENOENT'), 'binary-missing'],
[new Error('spawn /Users/ada/.local/bin/codex ENOENT'), 'binary-missing'],
[
new Error(
`codex app-server exited before completing the session: ${WSL_CODEX_NOT_FOUND_MESSAGE}`
),
'binary-missing'
],
[new Error('spawn wsl.exe ENOENT'), 'unexpected'],
[
new Error("ENOENT: no such file or directory, open '/home/ada/.codex/config.toml'"),
'unexpected'
],
[new Error('codex trust-grant entry bundle not found'), 'entry-failed'],
[new Error('codex trust-grant entry produced no result (exit 1)'), 'entry-failed'],
[
new Error('codex app-server exited before completing the session: panicked at main.rs'),
'early-exit'
],
[new Error('codex app-server config/batchWrite failed: unknown key'), 'rpc-failed'],
[new Error('write EPIPE'), 'unexpected'],
['not an error object', 'unexpected']
] as const)('classifies %s as %s', (error, expected) => {
expect(classifyCodexTrustGrantError(error)).toBe(expected)
})
it('keeps an ENOENT-mentioning stderr tail classified as early-exit', () => {
expect(
classifyCodexTrustGrantError(
new Error('codex app-server exited before completing the session: ENOENT in codex output')
)
).toBe('early-exit')
})
})
@@ -0,0 +1,91 @@
import type { CodexTrustGrantSessionVerifyClass } from './codex-app-server-client'
import { WSL_CODEX_NOT_FOUND_MESSAGE } from '../codex-accounts/wsl-codex-command'
/** Which install surface asked for the grant: the system-default real ~/.codex
* or a managed (mirror/per-account) home. Telemetry attribution only — the
* grant behaves identically; host_kind alone cannot distinguish the lanes
* (native hosts grant for both surfaces). */
export type CodexTrustGrantTelemetryLane = 'real-home' | 'managed'
export type CodexTrustGrantFallbackReason =
| 'disabled'
| 'no-managed-entries'
| 'unsupported'
| 'unsupported-cached'
| 'verify-failed'
| 'retry-cached'
| 'error'
/** Closed classification of `reason: 'error'` fallbacks. Errors cross the
* grant-bridge envelope as message text (only timeout/unsupported keep their
* name), so classes are matched on the bounded message shapes each layer
* produces — never forwarded raw. */
export type CodexTrustGrantErrorClass =
| 'binary-missing'
| 'timeout'
| 'entry-failed'
| 'early-exit'
| 'rpc-failed'
| 'unexpected'
export type CodexTrustGrantVerifyClass =
| CodexTrustGrantSessionVerifyClass
| 'unexpected-key'
| 'duplicate-key'
| 'coverage'
export function classifyCodexTrustGrantError(error: unknown): CodexTrustGrantErrorClass {
if (!(error instanceof Error)) {
return 'unexpected'
}
if (error.name === 'CodexAppServerTimeoutError') {
return 'timeout'
}
const message = error.message
if (message.includes('codex trust-grant entry')) {
return 'entry-failed'
}
if (
/^spawn (?:.*[\\/])?codex(?:\.(?:cmd|exe|bat))? ENOENT$/.test(message) ||
message.includes(WSL_CODEX_NOT_FOUND_MESSAGE)
) {
return 'binary-missing'
}
if (message.includes('exited before completing the session')) {
return 'early-exit'
}
if (/codex app-server \S+ failed:/.test(message)) {
return 'rpc-failed'
}
return 'unexpected'
}
export type CodexTrustGrantTelemetryEvent = {
outcome: 'granted' | 'fallback' | 'verify_failed'
hostKind: 'native' | 'wsl'
lane: CodexTrustGrantTelemetryLane
reason?: CodexTrustGrantFallbackReason
errorClass?: CodexTrustGrantErrorClass
verifyClass?: CodexTrustGrantVerifyClass
}
type CodexTrustGrantTelemetry = (event: CodexTrustGrantTelemetryEvent) => void
// Why: hook-service is bundled into plain-node CLI entries where electron
// (and therefore the telemetry client) cannot load; the Electron main process
// injects the tracker at startup instead of a static import.
let telemetry: CodexTrustGrantTelemetry = () => {}
export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry): void {
telemetry = tracker
}
export function emitCodexTrustGrantTelemetry(event: CodexTrustGrantTelemetryEvent): void {
try {
telemetry(event)
} catch (error) {
// Why: observability must never turn a verified grant into fallback or
// violate the launch-prep no-throw contract of the grant lane.
console.warn('[codex-trust-grant] failed to emit telemetry', error)
}
}
@@ -19,7 +19,7 @@ import type * as CodexFsUtils from '../codex-accounts/fs-utils'
const { homedirMock, promotionTestState } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>(),
promotionTestState: { failAtomicWrite: false }
promotionTestState: { failAtomicWrite: false, atomicWritePaths: [] as string[] }
}))
vi.mock('node:os', async (importOriginal) => {
@@ -35,6 +35,7 @@ vi.mock('../codex-accounts/fs-utils', async (importOriginal) => {
return {
...actual,
writeFileAtomically: (...args: Parameters<typeof actual.writeFileAtomically>) => {
promotionTestState.atomicWritePaths.push(args[0])
if (promotionTestState.failAtomicWrite) {
throw new Error('injected atomic write failure')
}
@@ -44,7 +45,15 @@ vi.mock('../codex-accounts/fs-utils', async (importOriginal) => {
})
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
import { upsertTopLevelSettingsInContent } from './config-settings-promotion'
import {
upsertPromotedSettingsInContent,
upsertTopLevelSettingsInContent
} from './codex-config-settings-upsert'
// The exact [tui] block codex 0.144.6 writes via config/batchWrite (all four
// promoted keys single-line, theme a string).
const CODEX_TUI_BLOCK =
'[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\nstatus_line_use_colors = true\nterminal_title = ["model"]\ntheme = "dark-photon"\n'
let tmpHome: string
let userDataDir: string
@@ -57,6 +66,7 @@ beforeEach(() => {
process.env.ORCA_USER_DATA_PATH = userDataDir
homedirMock.mockReturnValue(tmpHome)
promotionTestState.failAtomicWrite = false
promotionTestState.atomicWritePaths.length = 0
// Why: promotion writes into homedir()/.codex — if the mock ever fails to
// intercept, these tests would rewrite the developer's real Codex config.
if (homedir() !== tmpHome) {
@@ -117,6 +127,13 @@ function simulateCodexSettingWrite(key: string, rawValue: string): void {
writeFileSync(runtimeConfigPath(), next, 'utf-8')
}
// Codex reads then rewrites the whole runtime config; simulate that by writing
// a known runtime config directly (its EOL is normalized by the mirror anyway).
function setRuntimeConfig(content: string): void {
mkdirSync(runtimeHomeDir(), { recursive: true })
writeFileSync(runtimeConfigPath(), content, 'utf-8')
}
function simulateCodexSettingRemoval(key: string): void {
const existing = readFileSync(runtimeConfigPath(), 'utf-8')
const linePattern = new RegExp(`^${key}[ \\t]*=.*\\n?`, 'm')
@@ -402,6 +419,363 @@ describe('codex settings write-back promotion', () => {
})
})
describe('codex [tui] settings write-back promotion', () => {
it('promotes a runtime [tui] block (codex 0.144.6 shape) into ~/.codex and survives the remirror', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
// The user customizes the status line/theme inside Orca-launched Codex.
writeFileSync(runtimeConfigPath(), `${readRuntimeConfig()}\n${CODEX_TUI_BLOCK}`, 'utf-8')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(`model = "gpt-5"\n\n${CODEX_TUI_BLOCK}`)
const runtime = readRuntimeConfig()
expect(runtime).toContain('status_line = ["model-with-reasoning", "task-progress"]')
expect(runtime).toContain('status_line_use_colors = true')
expect(runtime).toContain('terminal_title = ["model"]')
expect(runtime).toContain('theme = "dark-photon"')
const settledSystem = readSystemConfig()
const settledRuntime = readRuntimeConfig()
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(settledSystem)
expect(readRuntimeConfig()).toBe(settledRuntime)
})
it('replaces a promoted key in an existing [tui] table, leaving non-promoted neighbors untouched', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n'
)
})
it('promotes a changed status_line array value', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\nstatus_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig(
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\n'
)
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\n'
)
})
it('promotes a model change and a status-line change in one pass into their regions', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n'
)
})
it('detects and replaces a dotted-form system tui key without creating a [tui] table', () => {
writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
// toml_edit preserves the dotted form when codex rewrites the value.
setRuntimeConfig('model = "gpt-5"\ntui.theme = "light"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.theme = "light"\n')
expect(readSystemConfig()).not.toContain('[tui]')
})
it('promotes through a quoted tui table without creating a duplicate table', () => {
writeSystemConfig('model = "gpt-5"\n\n["tui"]\ntheme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n')
expect(readSystemConfig()).not.toContain('\n[tui]\n')
})
it('inserts a second dotted tui key beside an existing dotted-only tui config', () => {
writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\ntui.theme = "dark"\ntui.status_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\ntui.theme = "dark"\ntui.status_line = ["model"]\n'
)
expect(readSystemConfig()).not.toContain('[tui]')
})
it('inserts dotted beside a non-promoted dotted tui key instead of creating a [tui] table', () => {
// Why: any dotted tui.* key already defines the implicit tui table, so a
// fresh [tui] table at EOF would be a duplicate-definition parse error.
writeSystemConfig('model = "gpt-5"\ntui.pet = "cat"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n')
expect(readSystemConfig()).not.toContain('[tui]')
})
it('creates a [tui] table at EOF when the only tui presence is a subtable', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig(
'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n'
)
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n'
)
})
it('creates exactly one [tui] table for two keys promoted in one pass', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
const system = readSystemConfig()
expect(system.match(/^\[tui\]$/gm)?.length).toBe(1)
expect(system).toBe(
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model"]\ntheme = "dark-photon"\n'
)
})
it('lets an outside ~/.codex [tui] edit win over a conflicting in-Codex tui change', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "in-codex"\n')
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "outside-edit"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[tui]\ntheme = "outside-edit"\n')
expect(readRuntimeConfig()).toContain('theme = "outside-edit"')
})
it('does not promote a [tui] key deletion', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toContain('theme = "dark"')
})
it('inserts a promoted key into a CRLF system [tui] table preserving CRLF', () => {
writeSystemConfig('model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\nstatus_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
const system = readSystemConfig()
expect(system).toContain('status_line = ["model"]\r\n')
expect(system).toBe(
'model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n'
)
})
it('never appends a [tui] table when the system config defines tui inline', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
// In-Codex tui change racing an outside edit that adds an inline tui table:
// appending [tui] would make the system config unparseable, so the change
// is dropped instead.
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n')
writeSystemConfig('model = "gpt-5"\ntui = { animations = false }\n')
promotionTestState.atomicWritePaths.length = 0
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui = { animations = false }\n')
expect(promotionTestState.atomicWritePaths).not.toContain(systemConfigPath())
})
it('ignores an allowlisted key nested under a [tui.*] subtable', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "changed-in-subtable"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n'
)
})
})
describe('upsertPromotedSettingsInContent', () => {
it('replaces a bare key in place inside the [tui] table', () => {
expect(
upsertPromotedSettingsInContent(
'[tui]\ntheme = "dark"\n',
new Map([['tui.theme', '"light"']])
)
).toBe('[tui]\ntheme = "light"\n')
})
it('inserts a bare key at the end of the [tui] body, before a subtable', () => {
expect(
upsertPromotedSettingsInContent(
'[tui]\ntheme = "dark"\n\n[tui.notifications]\nenabled = true\n',
new Map([['tui.status_line', '["model"]']])
)
).toBe(
'[tui]\ntheme = "dark"\nstatus_line = ["model"]\n\n[tui.notifications]\nenabled = true\n'
)
})
it('replaces a dotted preamble tui key in place, keeping the dotted form', () => {
expect(
upsertPromotedSettingsInContent('tui.theme = "dark"\n', new Map([['tui.theme', '"light"']]))
).toBe('tui.theme = "light"\n')
})
it('inserts a dotted tui key beside an existing dotted tui key', () => {
expect(
upsertPromotedSettingsInContent(
'tui.theme = "dark"\n\n[features]\nx = 1\n',
new Map([['tui.status_line', '["model"]']])
)
).toBe('tui.theme = "dark"\ntui.status_line = ["model"]\n\n[features]\nx = 1\n')
})
it('creates a [tui] table from empty content', () => {
expect(upsertPromotedSettingsInContent('', new Map([['tui.theme', '"dark"']]))).toBe(
'[tui]\ntheme = "dark"\n'
)
})
it('drops an absent key instead of appending [tui] beside an inline tui table', () => {
expect(
upsertPromotedSettingsInContent(
'tui = { animations = false }\n',
new Map([['tui.theme', '"dark"']])
)
).toBe('tui = { animations = false }\n')
})
it('drops an absent key beside a quoted inline tui table', () => {
expect(
upsertPromotedSettingsInContent(
'"tui" = { animations = false }\n',
new Map([['tui.theme', '"dark"']])
)
).toBe('"tui" = { animations = false }\n')
})
it('inserts beside a quoted dotted tui key instead of appending a table', () => {
expect(
upsertPromotedSettingsInContent('"tui" . "pet" = "cat"\n', new Map([['tui.theme', '"dark"']]))
).toBe('"tui" . "pet" = "cat"\ntui.theme = "dark"\n')
})
it('creates a [tui] super-table at EOF after a [tui.*] subtable', () => {
expect(
upsertPromotedSettingsInContent(
'[tui.notifications]\nenabled = true\n',
new Map([['tui.theme', '"dark"']])
)
).toBe('[tui.notifications]\nenabled = true\n\n[tui]\ntheme = "dark"\n')
})
it('drops an absent scalar that would redefine an existing tui key table', () => {
expect(
upsertPromotedSettingsInContent(
'[tui."theme"]\nvariant = "dark"\n',
new Map([['tui.theme', '"light"']])
)
).toBe('[tui."theme"]\nvariant = "dark"\n')
})
it('drops an absent scalar that would redefine a dotted tui key table', () => {
expect(
upsertPromotedSettingsInContent(
'tui.theme.variant = "dark"\n',
new Map([['tui.theme', '"light"']])
)
).toBe('tui.theme.variant = "dark"\n')
})
it('does not mistake a dotted tui key inside an array table for a root key', () => {
expect(
upsertPromotedSettingsInContent(
'[[profiles]]\ntui.theme = "profile-theme"\n',
new Map([['tui.theme', '"root-theme"']])
)
).toBe('[[profiles]]\ntui.theme = "profile-theme"\n\n[tui]\ntheme = "root-theme"\n')
})
it('does not append a table beside a root tui array-of-tables', () => {
expect(
upsertPromotedSettingsInContent(
'[[tui]]\ntheme = "array-theme"\n',
new Map([['tui.theme', '"root-theme"']])
)
).toBe('[[tui]]\ntheme = "array-theme"\n')
})
it('does not append a table beside a quoted root tui array-of-tables', () => {
expect(
upsertPromotedSettingsInContent(
'[["tui"]]\ntheme = "array-theme"\n',
new Map([['tui.theme', '"root-theme"']])
)
).toBe('[["tui"]]\ntheme = "array-theme"\n')
})
it('creates one [tui] table for multiple keys reaching the new-table branch', () => {
expect(
upsertPromotedSettingsInContent(
'',
new Map([
['tui.status_line', '["model"]'],
['tui.theme', '"dark"']
])
)
).toBe('[tui]\nstatus_line = ["model"]\ntheme = "dark"\n')
})
it('routes a mixed top-level + tui batch to its two regions in one rewrite', () => {
expect(
upsertPromotedSettingsInContent(
'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n',
new Map([
['model', '"o4"'],
['tui.theme', '"light"']
])
)
).toBe('model = "o4"\n\n[tui]\ntheme = "light"\n')
})
it('inserts into a CRLF [tui] table with CRLF endings', () => {
expect(
upsertPromotedSettingsInContent(
'[tui]\r\ntheme = "dark"\r\n',
new Map([['tui.status_line', '["model"]']])
)
).toBe('[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n')
})
})
describe('upsertTopLevelSettingsInContent', () => {
it('writes into empty content', () => {
expect(upsertTopLevelSettingsInContent('', new Map([['model', '"x"']]))).toBe('model = "x"\n')
@@ -428,6 +802,12 @@ describe('upsertTopLevelSettingsInContent', () => {
).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n')
})
it('replaces a quoted top-level key instead of adding its bare equivalent', () => {
expect(
upsertTopLevelSettingsInContent('"model" = "old"\n', new Map([['model', '"new"']]))
).toBe('model = "new"\n')
})
it('inserts with CRLF endings into CRLF content', () => {
expect(
upsertTopLevelSettingsInContent('[features]\r\nhooks = true\r\n', new Map([['model', '"x"']]))
+109 -67
View File
@@ -18,6 +18,8 @@ import {
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
import { tuiStructuredKey, upsertPromotedSettingsInContent } from './codex-config-settings-upsert'
// Why: the mirror reverts in-Codex config changes each launch; promotion salvages them by diffing the last baseline.
@@ -29,6 +31,45 @@ export const PROMOTED_CODEX_SETTING_KEYS = [
'sandbox_mode'
] as const
// Why: the [tui] keys the Codex TUI's user-facing pickers persist (status line,
// terminal title, theme). Like the top-level list, every key here gets written
// into the user's real ~/.codex/config.toml on promotion — grow it deliberately.
export const PROMOTED_CODEX_TUI_SETTING_KEYS = [
'status_line',
'status_line_use_colors',
'terminal_title',
'theme'
] as const
// Why: promotion diffs and upserts operate on structured keys — top-level keys
// keep their bare name, [tui] keys are namespaced tui.<key> so their baseline
// entries cannot collide with a top-level key of the same name.
const PROMOTED_STRUCTURED_KEYS: readonly string[] = [
...PROMOTED_CODEX_SETTING_KEYS,
...PROMOTED_CODEX_TUI_SETTING_KEYS.map(tuiStructuredKey)
]
function isPromotedTuiKey(key: string): boolean {
return (PROMOTED_CODEX_TUI_SETTING_KEYS as readonly string[]).includes(key)
}
// Returns the structured tui key a scanned line's key represents, or null. In
// the preamble it recognizes the dotted `tui.<key>` form a user may hand-author;
// inside the first `[tui]` table body it recognizes the bare `<key>` form Codex
// writes. Both map to the same structured key so either config shape promotes.
function matchTuiStructuredKey(
keyPath: string[],
inPreamble: boolean,
tuiBodyActive: boolean
): string | null {
if (inPreamble) {
const tuiKey = keyPath.length === 2 && keyPath[0] === 'tui' ? keyPath[1] : null
return tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null
}
const tuiKey = keyPath.length === 1 ? keyPath[0] : null
return tuiBodyActive && tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null
}
type TopLevelSettingValue = {
raw: string
// Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion.
@@ -70,24 +111,67 @@ function readSettingsBaseline(runtimeHomePath: string): Map<string, string> | nu
}
}
// Why: only top-level preamble keys are scanned; rewriting nested [profiles.*] tables isn't worth the risk here.
function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
function matchPromotedStructuredKey(
line: string,
inPreamble: boolean,
tuiBodyActive: boolean
): { structuredKey: string; raw: string } | null {
const parsed = parseTomlKeyPath(line)
if (!parsed || line[parsed.end] !== '=') {
return null
}
const raw = line.slice(parsed.end + 1).trim()
const topLevelKey = parsed.segments.length === 1 ? parsed.segments[0] : null
if (
inPreamble &&
topLevelKey &&
(PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(topLevelKey)
) {
return { structuredKey: topLevelKey, raw }
}
const tuiKey = matchTuiStructuredKey(parsed.segments, inPreamble, tuiBodyActive)
return tuiKey ? { structuredKey: tuiKey, raw } : null
}
// Why: top-level preamble scalars keep the historical behavior; [tui] keys are
// collected from the first bare [tui] table body or the dotted preamble form,
// keyed by structured path. Any table header (including [tui.*] subtables) ends
// the [tui] body, and [profiles.*]/other tables are still ignored.
function readPromotedSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
const result = new Map<string, TopLevelSettingValue>()
if (!existsSync(configPath)) {
return result
}
const lines = readFileSync(configPath, 'utf-8').split('\n')
let state = createTomlLineScanState()
let inPreamble = true
let tuiTableSeen = false
let tuiBodyActive = false
for (const line of lines) {
if (isTomlStructuralLine(state)) {
if (getTomlTableHeader(line)) {
break
const header = getTomlTableHeader(line)
if (header) {
const table = parseTomlTableHeaderPath(header)
tuiBodyActive =
table !== null &&
!table.isArray &&
table.segments.length === 1 &&
table.segments[0] === 'tui' &&
!tuiTableSeen
if (tuiBodyActive) {
tuiTableSeen = true
}
inPreamble = false
state = updateTomlLineScanState(state, line)
continue
}
const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t\r]*$/.exec(line)
const key = match?.[1]
if (key && (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(key)) {
const matched = matchPromotedStructuredKey(line, inPreamble, tuiBodyActive)
if (matched) {
const nextState = updateTomlLineScanState(state, line)
result.set(key, { raw: match?.[2] ?? '', multiline: !isTomlStructuralLine(nextState) })
result.set(matched.structuredKey, {
raw: matched.raw,
multiline: !isTomlStructuralLine(nextState)
})
state = nextState
continue
}
@@ -109,7 +193,7 @@ export function snapshotCodexRuntimeSettingsBaseline(
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
// Why: record an empty baseline even for a missing runtime config, so Codex's first write still diffs and promotes.
const settings: Record<string, string> = {}
for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) {
for (const [key, value] of readPromotedSettingValues(runtimeTomlPath)) {
if (!value.multiline) {
settings[key] = value.raw
}
@@ -173,10 +257,9 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
if (!baseline) {
return
}
const runtimeValues = readTopLevelSettingValues(runtimeTomlPath)
const systemValues = readTopLevelSettingValues(systemTomlPath)
const updates = new Map<string, string>()
for (const key of PROMOTED_CODEX_SETTING_KEYS) {
const runtimeValues = readPromotedSettingValues(runtimeTomlPath)
const changedRuntimeValues = new Map<string, string>()
for (const key of PROMOTED_STRUCTURED_KEYS) {
const runtime = runtimeValues.get(key)
if (!runtime || runtime.multiline) {
continue
@@ -185,6 +268,14 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
// Orca mirrored this value and nothing touched it since — not a change.
continue
}
changedRuntimeValues.set(key, runtime.raw)
}
if (changedRuntimeValues.size === 0) {
return
}
const systemValues = readPromotedSettingValues(systemTomlPath)
const updates = new Map<string, string>()
for (const [key, runtimeRaw] of changedRuntimeValues) {
const system = systemValues.get(key)
if (system?.multiline) {
continue
@@ -193,7 +284,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
if (system?.raw !== baseline.get(key)) {
continue
}
updates.set(key, runtime.raw)
updates.set(key, runtimeRaw)
}
if (updates.size === 0) {
return
@@ -205,7 +296,10 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 })
const targetExists = existsSync(writeTarget.path)
const systemContent = targetExists ? readFileSync(writeTarget.path, 'utf-8') : ''
const nextContent = upsertTopLevelSettingsInContent(systemContent, updates)
const nextContent = upsertPromotedSettingsInContent(systemContent, updates)
if (nextContent === systemContent) {
return
}
if (targetExists && parseWslUncPath(writeTarget.path)) {
// Why: \\wsl$ 9P symlink metadata is unreliable; write through the existing file to preserve the WSL-side inode.
writeFileSync(writeTarget.path, nextContent, 'utf-8')
@@ -252,55 +346,3 @@ function resolveDanglingSymlinkTarget(linkPath: string): string {
// Why: replacing any link in a cycle would destroy dotfile-manager state; abort instead.
throw new Error(`Codex config symlink cycle at ${linkPath}`)
}
export function upsertTopLevelSettingsInContent(
content: string,
updates: Map<string, string>
): string {
const lines = content.split('\n')
let state = createTomlLineScanState()
let preambleEnd = lines.length
const keyLineIndexes = new Map<string, number>()
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (isTomlStructuralLine(state)) {
if (getTomlTableHeader(line)) {
preambleEnd = index
break
}
const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=/.exec(line)
if (match?.[1] && updates.has(match[1])) {
keyLineIndexes.set(match[1], index)
}
}
state = updateTomlLineScanState(state, line)
}
// Why: match the file's existing EOL (CRLF split leaves a trailing \r) so a Windows config doesn't go mixed-EOL.
const usesCrlf = content.includes('\r\n')
const insertions: string[] = []
for (const [key, raw] of updates) {
const existingIndex = keyLineIndexes.get(key)
const rendered = `${key} = ${raw}`
if (existingIndex !== undefined) {
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
} else {
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
}
}
if (insertions.length > 0) {
let insertAt = preambleEnd
while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') {
insertAt -= 1
}
if (insertAt === preambleEnd && preambleEnd < lines.length) {
insertions.push(usesCrlf ? '\r' : '')
}
lines.splice(insertAt, 0, ...insertions)
}
const result = lines.join('\n')
if (result.endsWith('\n') || result.length === 0) {
return result
}
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
}
+67
View File
@@ -0,0 +1,67 @@
import { parseTomlSingleLineStringValue } from './config-toml-line-scan'
export type ParsedTomlKeyPath = {
segments: string[]
end: number
}
export type ParsedTomlTableHeaderPath = ParsedTomlKeyPath & {
isArray: boolean
}
export function parseTomlTableHeaderPath(header: string): ParsedTomlTableHeaderPath | null {
const trimmed = header.trim()
let source: string
let isArray: boolean
if (trimmed.startsWith('[[')) {
if (!trimmed.endsWith(']]')) {
return null
}
source = trimmed.slice(2, -2)
isArray = true
} else {
if (!trimmed.startsWith('[') || !trimmed.endsWith(']') || trimmed.endsWith(']]')) {
return null
}
source = trimmed.slice(1, -1)
isArray = false
}
const parsed = parseTomlKeyPath(source)
if (!parsed || parsed.end !== source.length) {
return null
}
return { ...parsed, isArray }
}
export function parseTomlKeyPath(source: string, offset = 0): ParsedTomlKeyPath | null {
const segments: string[] = []
let index = skipTomlKeyWhitespace(source, offset)
while (index < source.length) {
const quoted = parseTomlSingleLineStringValue(source, index)
if (quoted) {
segments.push(quoted.value)
index = quoted.end
} else {
const bare = /^[A-Za-z0-9_-]+/.exec(source.slice(index))
if (!bare) {
return null
}
segments.push(bare[0])
index += bare[0].length
}
index = skipTomlKeyWhitespace(source, index)
if (source[index] !== '.') {
return { segments, end: index }
}
index = skipTomlKeyWhitespace(source, index + 1)
}
return null
}
function skipTomlKeyWhitespace(source: string, offset: number): number {
let index = offset
while (source[index] === ' ' || source[index] === '\t') {
index += 1
}
return index
}
+4 -2
View File
@@ -916,7 +916,8 @@ function installManagedHooksIntoWslRuntime(
tomlPath: plan.tomlPath,
managedCommand: command,
managedEntries: trustEntries,
host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome }
host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome },
telemetryLane: 'managed'
})
if (grant.lane === 'fallback') {
// Why: WSL runtime homes may carry user hook approvals we did not rebuild
@@ -1353,7 +1354,8 @@ export class CodexHookService {
tomlPath,
managedCommand: command,
managedEntries: managedTrustEntries,
host: { kind: 'native' }
host: { kind: 'native' },
telemetryLane: 'managed'
})
if (grant.lane === 'rpc') {
recentGrantEntries = grant.entries
@@ -0,0 +1,77 @@
import { readFileSync, mkdtempSync, rmSync } from 'node:fs'
import { connect, type Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
import { encodeNdjson } from './ndjson'
import type { SubprocessHandle } from './session'
import { PROTOCOL_VERSION } from './types'
function unusedSubprocess(): SubprocessHandle {
throw new Error('Test must not create a PTY')
}
describe('daemon authenticated client activity', () => {
let dir: string
let socketPath: string
let tokenPath: string
let server: DaemonServer
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-client-activity-'))
socketPath = getDaemonSocketPath(dir)
tokenPath = join(dir, 'daemon.token')
})
afterEach(async () => {
await server?.shutdown()
rmSync(dir, { recursive: true, force: true })
})
async function connectHello(role: 'control' | 'stream', clientId: string): Promise<Socket> {
const socket = connect(socketPath)
await new Promise<void>((resolve) => socket.once('connect', resolve))
socket.write(
encodeNdjson({
type: 'hello',
version: PROTOCOL_VERSION,
token: readFileSync(tokenPath, 'utf8').trim(),
clientId,
role
})
)
await new Promise<void>((resolve, reject) => {
socket.once('data', (data) => {
const response = JSON.parse(data.toString().trim()) as { ok?: boolean; error?: string }
if (response.ok) {
resolve()
} else {
reject(new Error(response.error ?? 'hello rejected'))
}
})
socket.once('error', reject)
})
return socket
}
it('excludes control-only health probes and reports one complete app pair', async () => {
const onAuthenticatedClientPair = vi.fn()
server = new DaemonServer({
socketPath,
tokenPath,
spawnSubprocess: unusedSubprocess,
onAuthenticatedClientPair
})
await server.start()
await connectHello('control', 'resolver-health-check')
expect(onAuthenticatedClientPair).not.toHaveBeenCalled()
await connectHello('control', 'app-client')
expect(onAuthenticatedClientPair).not.toHaveBeenCalled()
await connectHello('stream', 'app-client')
expect(onAuthenticatedClientPair).toHaveBeenCalledOnce()
})
})
+13
View File
@@ -97,4 +97,17 @@ describe('daemon-entry parseArgs', () => {
it('still requires --socket and --token when --log-file is given', () => {
expect(() => parseArgs(['--log-file', '/tmp/daemon.log'])).toThrow('Usage:')
})
it('parses the GUI-only --login-session-watch flag and omits it when absent', () => {
expect(
parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token', '--login-session-watch'])
).toEqual({
socketPath: '/tmp/t.sock',
tokenPath: '/tmp/t.token',
loginSessionWatch: true
})
expect(parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token'])).not.toHaveProperty(
'loginSessionWatch'
)
})
})
+81 -2
View File
@@ -6,19 +6,27 @@
* Signals readiness to parent via IPC: { type: 'ready' }
* Shuts down cleanly on SIGTERM.
*/
import { readFileSync } from 'node:fs'
import { startDaemon, type DaemonHandle } from './daemon-main'
import { createPtySubprocess } from './pty-subprocess'
import { warmWindowsConptyOnce } from './windows-conpty-warmup'
import { warmPwshAvailabilityCache } from '../pwsh'
import { createDaemonFileLog, createNoopDaemonFileLog } from './daemon-file-log'
import { PROTOCOL_VERSION } from './types'
import { prepareMacosTccLoginShell } from '../providers/macos-tcc-login-shell'
import {
prepareMacosTccLoginShell,
probeMacosLoginSessionAlive
} from '../providers/macos-tcc-login-shell'
import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch'
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
export type ParsedDaemonArgs = {
socketPath: string
tokenPath: string
pidPath?: string
launchNonce?: string
/** GUI-spawned daemons only — headless serve/SSH daemons must survive session loss. */
loginSessionWatch?: boolean
/** Optional — absent for adopted old daemons and tests, which log nothing. */
logFilePath?: string
}
@@ -29,6 +37,7 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
let logFilePath = ''
let pidPath = ''
let launchNonce = ''
let loginSessionWatch = false
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--socket' && argv[i + 1]) {
@@ -46,6 +55,8 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
} else if (argv[i] === '--launch-nonce' && argv[i + 1]) {
launchNonce = argv[i + 1]
i++
} else if (argv[i] === '--login-session-watch') {
loginSessionWatch = true
}
}
@@ -61,6 +72,7 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
socketPath,
tokenPath,
...(pidPath ? { pidPath, launchNonce } : {}),
...(loginSessionWatch ? { loginSessionWatch } : {}),
...(logFilePath ? { logFilePath } : {})
}
}
@@ -73,7 +85,7 @@ async function main(): Promise<void> {
// an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only.
process.stderr.on('error', () => {})
const { socketPath, tokenPath, pidPath, launchNonce, logFilePath } = parseArgs(
const { socketPath, tokenPath, pidPath, launchNonce, loginSessionWatch, logFilePath } = parseArgs(
process.argv.slice(2)
)
const startedAtMs = Date.now() - process.uptime() * 1000
@@ -123,6 +135,7 @@ async function main(): Promise<void> {
})
let daemon: DaemonHandle | null = null
let deathWatch: MacosLoginSessionDeathWatch | null = null
let shuttingDown = false
// Bound the wait so a wedged native shutdown can't leave the daemon running
// forever on SIGTERM/SIGINT (it would then survive a real quit, not just updates).
@@ -134,6 +147,7 @@ async function main(): Promise<void> {
return
}
shuttingDown = true
deathWatch?.stop()
daemonLog.log('shutdown', { reason })
try {
if (daemon) {
@@ -157,6 +171,63 @@ async function main(): Promise<void> {
process.on('SIGTERM', () => void shutdown('SIGTERM'))
process.on('SIGINT', () => void shutdown('SIGINT'))
// Why: a dead macOS login session cannot be fabricated without root (PAM owns
// audit-session teardown), so e2e drives the oracles from a verdict file:
// 'alive' → accepted/healthy, 'dead' → rejected/unhealthy, 'hang' →
// timeout-inconclusive/unhealthy (the fail-safe path), else inconclusive.
const e2eProbeFile = process.env.ORCA_E2E_LOGIN_SESSION_PROBE_FILE
const readE2eVerdict = (): string => {
try {
return readFileSync(e2eProbeFile as string, 'utf8').trim()
} catch {
return ''
}
}
deathWatch =
loginSessionWatch && process.platform === 'darwin'
? new MacosLoginSessionDeathWatch({
probeLoginSession: e2eProbeFile
? async () => {
const verdict = readE2eVerdict()
if (verdict === 'alive') {
return { ok: true, conclusive: true, reason: 'accepted' }
}
if (verdict === 'dead') {
return { ok: false, conclusive: true, reason: 'rejected' }
}
return { ok: false, conclusive: false, reason: 'timeout' }
}
: probeMacosLoginSessionAlive,
readResolverHealth: e2eProbeFile
? async () => {
const verdict = readE2eVerdict()
return verdict === 'dead' || verdict === 'hang' ? 'unhealthy' : 'healthy'
}
: readCurrentProcessMacSystemResolverHealth,
...(e2eProbeFile
? {
timing: {
periodicProbeMs: 2_000,
rejectionRecheckMs: 500,
ptyExitDebounceMs: 200,
clientActivityMinGapMs: 1_000,
minProbeGapMs: 100
}
}
: {}),
log: daemonLog,
onRetire: (details) => {
shuttingDown = true
daemonLog.log('login-session-dead-retire', details)
daemonLog.close()
// Why: crash-style exit (no PTY teardown) keeps session meta unclean so the
// replacement daemon cold-restores scrollback; stale socket/pid files ride
// the existing dead-endpoint recovery.
process.exit(1)
}
})
: null
daemon = await startDaemon({
socketPath,
tokenPath,
@@ -165,14 +236,22 @@ async function main(): Promise<void> {
...(pidPath ? { startedAtMs } : {}),
log: daemonLog,
preparePtySpawn: runMacosLoginPreflight,
...(deathWatch
? {
onPtySessionExit: () => deathWatch.notifyPtyExit(),
onAuthenticatedClientPair: () => deathWatch.notifyClientActivity()
}
: {}),
spawnSubprocess: (opts) => createPtySubprocess(opts),
onIdleShutdown: () => {
deathWatch?.stop()
shuttingDown = true
daemonLog.log('shutdown', { reason: 'idle' })
daemonLog.close()
process.exit(0)
}
})
deathWatch?.start()
// Signal readiness to parent via IPC (if available)
if (process.send) {
+5 -1
View File
@@ -1086,7 +1086,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
it('respawns instead of reusing a healthy daemon launched from another app path', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true })
const launcher = spawnerInstances[0].launcher as (
socketPath: string,
@@ -1137,6 +1137,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
'/fake/socket',
'--token',
'/fake/token',
'--login-session-watch',
'--log-file',
join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log')
]),
@@ -1513,6 +1514,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
await launcher('/fake/socket', '/fake/token')
const launchedArgsWithoutWatch = forkMock.mock.calls.at(-1)?.[1] as string[]
expect(launchedArgsWithoutWatch).not.toContain('--login-session-watch')
expect(offMock).toHaveBeenCalledWith('message', expect.any(Function))
expect(offMock).toHaveBeenCalledWith('error', expect.any(Function))
expect(offMock).toHaveBeenCalledWith('exit', expect.any(Function))
+10 -3
View File
@@ -321,7 +321,10 @@ async function shouldPreserveDaemonWithLiveSessions(
return true
}
function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
function createOutOfProcessLauncher(
runtimeDir: string,
macosLoginSessionWatch = false
): DaemonLauncher {
return async (socketPath, tokenPath, suppliedPidPath, suppliedLaunchNonce) => {
const entryPath = getDaemonEntryPath()
const pidPath = suppliedPidPath ?? getDaemonPidPath(runtimeDir)
@@ -448,6 +451,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
pidPath,
'--launch-nonce',
launchNonce,
...(macosLoginSessionWatch ? ['--login-session-watch'] : []),
...daemonLogArgs()
],
{
@@ -625,7 +629,10 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
}
}
export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void> {
export async function initDaemonPtyProvider(
signal?: AbortSignal,
options: { macosLoginSessionWatch?: boolean } = {}
): Promise<void> {
logDaemonMilestone('daemon-init-start')
// Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init that deterministically outlasts the first-window timeout.
const e2eInitDelayMs = Number(process.env.ORCA_E2E_DAEMON_INIT_DELAY_MS)
@@ -636,7 +643,7 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
const newSpawner = new DaemonSpawner({
runtimeDir,
launcher: createOutOfProcessLauncher(runtimeDir)
launcher: createOutOfProcessLauncher(runtimeDir, options.macosLoginSessionWatch ?? false)
})
// Why: assign the module-level spawner/adapter only after both succeed, so a failed ensureRunning() leaves no stale spawner.
+6
View File
@@ -11,6 +11,8 @@ export type DaemonStartOptions = {
protocolVersion?: number
spawnSubprocess: DaemonServerOptions['spawnSubprocess']
preparePtySpawn?: DaemonServerOptions['preparePtySpawn']
onPtySessionExit?: DaemonServerOptions['onPtySessionExit']
onAuthenticatedClientPair?: DaemonServerOptions['onAuthenticatedClientPair']
log?: DaemonFileLog
onIdleShutdown?: () => void
initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig']
@@ -30,6 +32,10 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<DaemonHandl
...(opts.protocolVersion !== undefined ? { protocolVersion: opts.protocolVersion } : {}),
spawnSubprocess: opts.spawnSubprocess,
...(opts.preparePtySpawn ? { preparePtySpawn: opts.preparePtySpawn } : {}),
...(opts.onPtySessionExit ? { onPtySessionExit: opts.onPtySessionExit } : {}),
...(opts.onAuthenticatedClientPair
? { onAuthenticatedClientPair: opts.onAuthenticatedClientPair }
: {}),
...(opts.log ? { log: opts.log } : {}),
...(opts.onIdleShutdown ? { onIdleShutdown: opts.onIdleShutdown } : {}),
...(opts.initialAdoptionTestConfig
+11 -1
View File
@@ -57,6 +57,9 @@ export type DaemonServerOptions = {
}
ptySpawnHealthCheck?: () => Promise<void>
preparePtySpawn?: () => Promise<void>
// Why: login-session death detection (#7936) probes on PTY-exit bursts and fresh app connections.
onPtySessionExit?: (sessionId: string) => void
onAuthenticatedClientPair?: () => void
log?: DaemonFileLog
spawnSubprocess: (opts: {
sessionId: string
@@ -101,6 +104,7 @@ export class DaemonServer {
private startedAtMs: number | null
private protocolVersion: number
private onIdleShutdown: () => void
private onAuthenticatedClientPair: () => void
private ptySpawnHealthCheck: () => Promise<void>
private preparePtySpawn: () => Promise<void>
private log: DaemonFileLog
@@ -180,7 +184,11 @@ export class DaemonServer {
now: () => Date.now()
}
this.token = randomUUID()
this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess })
this.onAuthenticatedClientPair = opts.onAuthenticatedClientPair ?? (() => {})
this.host = new TerminalHost({
spawnSubprocess: opts.spawnSubprocess,
...(opts.onPtySessionExit ? { onSessionReaped: opts.onPtySessionExit } : {})
})
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
this.preparePtySpawn = opts.preparePtySpawn ?? (() => Promise.resolve())
this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({
@@ -481,6 +489,8 @@ export class DaemonServer {
}
this.setupStreamSocket(socket, client)
client.authenticatedPairEstablished = true
// Why: one-shot health probes authenticate only a control socket; they are not fresh app activity.
this.onAuthenticatedClientPair()
// A complete app connection (unlike a probe) re-owns the endpoint and cancels pending retirement.
this.initialAdoptionDeadlineMs = null
this.retirementRequested = false
@@ -0,0 +1,374 @@
import { describe, expect, it, vi } from 'vitest'
import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell'
import { createNoopDaemonFileLog } from './daemon-file-log'
import {
MacosLoginSessionDeathWatch,
type MacosLoginSessionDeathWatchOptions
} from './macos-login-session-death-watch'
import type { SystemResolverHealth } from './types'
const ACCEPTED: LoginPreflightOutcome = { ok: true, conclusive: true, reason: 'accepted' }
const REJECTED: LoginPreflightOutcome = { ok: false, conclusive: true, reason: 'rejected' }
const INCONCLUSIVE: LoginPreflightOutcome = { ok: false, conclusive: false, reason: 'timeout' }
type FakeTimer = { at: number; callback: () => void; cleared: boolean }
class FakeClock {
private timers: FakeTimer[] = []
private nowMs = 0
setTimeout = (callback: () => void, delayMs: number): unknown => {
const timer: FakeTimer = { at: this.nowMs + delayMs, callback, cleared: false }
this.timers.push(timer)
return timer
}
clearTimeout = (handle: unknown): void => {
;(handle as FakeTimer).cleared = true
}
now = (): number => this.nowMs
async advance(ms: number): Promise<void> {
const target = this.nowMs + ms
for (;;) {
const due = this.timers
.filter((t) => !t.cleared && t.at <= target)
.sort((a, b) => a.at - b.at)[0]
if (!due) {
break
}
this.nowMs = due.at
due.cleared = true
due.callback()
// Why: probes are async; let their promise chains settle before firing the next timer.
await drainMicrotasks()
}
this.nowMs = target
}
pendingCount(): number {
return this.timers.filter((t) => !t.cleared).length
}
}
async function drainMicrotasks(): Promise<void> {
for (let i = 0; i < 10; i++) {
await Promise.resolve()
}
}
function createWatch(
overrides: Partial<MacosLoginSessionDeathWatchOptions> & {
outcomes?: (LoginPreflightOutcome | null)[]
} = {}
): {
watch: MacosLoginSessionDeathWatch
clock: FakeClock
onRetire: ReturnType<typeof vi.fn>
probe: ReturnType<typeof vi.fn>
setResolverHealth: (health: SystemResolverHealth) => void
} {
const clock = new FakeClock()
const onRetire = vi.fn()
const outcomes = overrides.outcomes ?? []
// Why length-check, not `??`: an explicit null outcome (wrapper not applicable) must reach the watch.
const probe = vi.fn(async () => (outcomes.length ? outcomes.shift()! : ACCEPTED))
let resolverHealth: SystemResolverHealth = 'unhealthy'
const watch = new MacosLoginSessionDeathWatch({
probeLoginSession: overrides.probeLoginSession ?? probe,
readResolverHealth: overrides.readResolverHealth ?? (async () => resolverHealth),
onRetire: overrides.onRetire ?? onRetire,
log: overrides.log ?? createNoopDaemonFileLog(),
clock,
timing: {
periodicProbeMs: 120_000,
rejectionRecheckMs: 10_000,
ptyExitDebounceMs: 2_000,
clientActivityMinGapMs: 30_000,
minProbeGapMs: 5_000,
...overrides.timing
}
})
return {
watch,
clock,
onRetire,
probe,
setResolverHealth: (health) => {
resolverHealth = health
}
}
}
describe('MacosLoginSessionDeathWatch', () => {
it('retires after consecutive conclusive rejections once armed, with a degraded resolver', async () => {
const { watch, clock, onRetire } = createWatch({
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED]
})
watch.start()
await drainMicrotasks()
expect(onRetire).not.toHaveBeenCalled()
await clock.advance(120_000) // periodic → rejection 1
await clock.advance(10_000) // recheck → rejection 2
expect(onRetire).not.toHaveBeenCalled()
await clock.advance(10_000) // recheck → rejection 3 → retire
expect(onRetire).toHaveBeenCalledWith({
cause: 'pam-rejections',
rejections: 3,
resolverHealth: 'unhealthy'
})
})
it('never retires when the session was never conclusively accepted', async () => {
const { watch, clock, onRetire } = createWatch({
outcomes: [REJECTED, REJECTED, REJECTED, REJECTED, REJECTED]
})
watch.start()
await drainMicrotasks()
for (let i = 0; i < 4; i++) {
await clock.advance(120_000)
}
expect(onRetire).not.toHaveBeenCalled()
})
it('resets the rejection streak on a conclusive acceptance', async () => {
const { watch, clock, onRetire } = createWatch({
outcomes: [ACCEPTED, REJECTED, REJECTED, ACCEPTED, REJECTED, REJECTED]
})
watch.start()
await drainMicrotasks()
await clock.advance(120_000) // rejection 1
await clock.advance(10_000) // rejection 2
await clock.advance(10_000) // acceptance → reset
await clock.advance(120_000) // rejection 1
await clock.advance(10_000) // rejection 2
expect(onRetire).not.toHaveBeenCalled()
})
it('keeps the rejection streak across interleaved inconclusive probes', async () => {
const { watch, clock, onRetire } = createWatch({
outcomes: [ACCEPTED, REJECTED, INCONCLUSIVE, REJECTED, INCONCLUSIVE, REJECTED]
})
watch.start()
await drainMicrotasks()
await clock.advance(120_000) // rejection 1
await clock.advance(10_000) // inconclusive timeout — rejection streak holds
await clock.advance(120_000) // rejection 2
await clock.advance(10_000) // inconclusive
await clock.advance(120_000) // rejection 3 → retire
expect(onRetire).toHaveBeenCalledTimes(1)
expect(onRetire.mock.calls[0][0].rejections).toBe(3)
expect(onRetire.mock.calls[0][0].cause).toBe('pam-rejections')
})
it('keeps repeated inconclusive timeouts on the bounded periodic cadence', async () => {
const readResolverHealth = vi.fn(async () => 'unhealthy' as const)
const { watch, clock, onRetire, probe } = createWatch({
outcomes: [ACCEPTED, ...Array.from({ length: 10 }, () => INCONCLUSIVE)],
readResolverHealth
})
watch.start()
await drainMicrotasks()
for (let i = 0; i < 10; i++) {
await clock.advance(120_000)
}
expect(probe).toHaveBeenCalledTimes(11)
expect(readResolverHealth).not.toHaveBeenCalled()
expect(onRetire).not.toHaveBeenCalled()
})
it.each(['healthy', 'unknown'] as const)(
'suppresses retirement while resolver health is %s, then retires on explicit degradation',
async (initialResolverHealth) => {
const { watch, clock, onRetire, probe, setResolverHealth } = createWatch({
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED]
})
setResolverHealth(initialResolverHealth)
watch.start()
await drainMicrotasks()
await clock.advance(120_000)
await clock.advance(10_000)
await clock.advance(10_000) // threshold reached but resolver did not corroborate death
expect(onRetire).not.toHaveBeenCalled()
const probesAtSuppression = probe.mock.calls.length
setResolverHealth('unhealthy')
await clock.advance(10_000)
expect(probe).toHaveBeenCalledTimes(probesAtSuppression)
await clock.advance(110_000) // suppressed states return to the bounded periodic cadence
expect(onRetire).toHaveBeenCalledTimes(1)
}
)
it('debounces a sustained PTY-exit burst into one trailing probe', async () => {
const { watch, clock, probe } = createWatch({ outcomes: [ACCEPTED, ACCEPTED] })
watch.start()
await drainMicrotasks()
await clock.advance(60_000)
const before = probe.mock.calls.length
watch.notifyPtyExit()
await clock.advance(1_500)
watch.notifyPtyExit()
await clock.advance(1_500)
watch.notifyPtyExit()
await clock.advance(1_999)
expect(probe).toHaveBeenCalledTimes(before)
await clock.advance(1)
expect(probe.mock.calls.length).toBe(before + 1)
})
it('probes on client activity only after the min gap', async () => {
const { watch, clock, probe } = createWatch({
outcomes: [ACCEPTED, ACCEPTED, ACCEPTED]
})
watch.start()
await drainMicrotasks()
const after = probe.mock.calls.length
watch.notifyClientActivity() // too soon after startup probe, so retain one deferred probe
await clock.advance(29_999)
expect(probe.mock.calls.length).toBe(after)
await clock.advance(1)
expect(probe.mock.calls.length).toBe(after + 1)
})
it('defers a PTY-exit trigger that lands inside the global probe gap', async () => {
const { watch, clock, probe } = createWatch({ outcomes: [ACCEPTED, ACCEPTED] })
watch.start()
await drainMicrotasks()
watch.notifyPtyExit()
await clock.advance(4_999)
expect(probe).toHaveBeenCalledOnce()
await clock.advance(1)
expect(probe).toHaveBeenCalledTimes(2)
})
it('retains one follow-up when a logout signal arrives during a probe', async () => {
let resolveStartup!: (outcome: LoginPreflightOutcome) => void
const startup = new Promise<LoginPreflightOutcome>((resolve) => {
resolveStartup = resolve
})
const probe = vi
.fn<MacosLoginSessionDeathWatchOptions['probeLoginSession']>()
.mockReturnValueOnce(startup)
.mockResolvedValue(ACCEPTED)
const { watch, clock } = createWatch({ probeLoginSession: probe })
watch.start()
watch.notifyPtyExit()
await clock.advance(2_000)
expect(probe).toHaveBeenCalledOnce()
resolveStartup(ACCEPTED)
await drainMicrotasks()
await clock.advance(2_999)
expect(probe).toHaveBeenCalledOnce()
await clock.advance(1)
expect(probe).toHaveBeenCalledTimes(2)
})
it('retains client activity that arrives during an armed periodic probe', async () => {
let resolvePeriodic!: (outcome: LoginPreflightOutcome) => void
const periodic = new Promise<LoginPreflightOutcome>((resolve) => {
resolvePeriodic = resolve
})
const probe = vi
.fn<MacosLoginSessionDeathWatchOptions['probeLoginSession']>()
.mockResolvedValueOnce(ACCEPTED)
.mockReturnValueOnce(periodic)
.mockResolvedValue(ACCEPTED)
const { watch, clock } = createWatch({ probeLoginSession: probe })
watch.start()
await drainMicrotasks()
await clock.advance(120_000)
watch.notifyClientActivity()
resolvePeriodic(ACCEPTED)
await drainMicrotasks()
await clock.advance(29_999)
expect(probe).toHaveBeenCalledTimes(2)
await clock.advance(1)
expect(probe).toHaveBeenCalledTimes(3)
})
it('disables itself when the wrapper machinery does not apply', async () => {
const { watch, clock, probe } = createWatch({ outcomes: [null] })
watch.start()
await drainMicrotasks()
expect(probe).toHaveBeenCalledTimes(1)
await clock.advance(600_000)
watch.notifyPtyExit()
watch.notifyClientActivity()
await clock.advance(600_000)
expect(probe).toHaveBeenCalledTimes(1)
})
it('stop() cancels all pending timers', async () => {
const { watch, clock } = createWatch({ outcomes: [ACCEPTED] })
watch.start()
await drainMicrotasks()
watch.notifyPtyExit()
watch.stop()
expect(clock.pendingCount()).toBe(0)
})
it('stop() aborts an in-flight subprocess probe', async () => {
let probeSignal: AbortSignal | undefined
const probe = vi.fn((signal?: AbortSignal) => {
probeSignal = signal
return new Promise<LoginPreflightOutcome>(() => {})
})
const { watch } = createWatch({ probeLoginSession: probe })
watch.start()
expect(probeSignal?.aborted).toBe(false)
watch.stop()
expect(probeSignal?.aborted).toBe(true)
})
it('stop() prevents an in-flight resolver check from retiring the daemon', async () => {
let resolveHealth!: (health: SystemResolverHealth) => void
let resolverSignal: AbortSignal | undefined
const resolverHealth = new Promise<SystemResolverHealth>((resolve) => {
resolveHealth = resolve
})
const { watch, clock, onRetire } = createWatch({
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED],
readResolverHealth: (signal) => {
resolverSignal = signal
return resolverHealth
}
})
watch.start()
await drainMicrotasks()
await clock.advance(120_000)
await clock.advance(10_000)
await clock.advance(10_000) // retirement is now waiting on resolver health
watch.stop()
expect(resolverSignal?.aborted).toBe(true)
resolveHealth('unhealthy')
await drainMicrotasks()
expect(onRetire).not.toHaveBeenCalled()
})
it('logs and reschedules when a probe throws instead of surfacing a rejection', async () => {
const outcomes: (() => Promise<LoginPreflightOutcome>)[] = [
async () => ACCEPTED,
async () => {
throw new Error('launchctl exploded')
},
async () => ACCEPTED
]
const probe = vi.fn(() => (outcomes.shift() ?? (async () => ACCEPTED))())
const { watch, clock, onRetire } = createWatch({ probeLoginSession: probe })
watch.start()
await drainMicrotasks()
await clock.advance(120_000) // throwing probe
await clock.advance(120_000) // rescheduled probe still runs
expect(probe).toHaveBeenCalledTimes(3)
expect(onRetire).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,317 @@
import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell'
import type { DaemonFileLog } from './daemon-file-log'
import type { SystemResolverHealth } from './types'
// Why: three conclusive PAM verdicts spread over recheck intervals keep a transient
// rejection storm (PAM db reload, OS update) from retiring a daemon whose login
// session is still alive; a real logout rejects conclusively on every probe.
const REQUIRED_CONSECUTIVE_REJECTIONS = 3
const PERIODIC_PROBE_MS = 120_000
const REJECTION_RECHECK_MS = 10_000
const PTY_EXIT_DEBOUNCE_MS = 2_000
// Why: a client hello right after login is the fastest death signal for a stale
// daemon, but steady reconnects must not turn hellos into a PAM probe storm.
const CLIENT_ACTIVITY_MIN_GAP_MS = 30_000
const MIN_PROBE_GAP_MS = 5_000
type WatchClock = {
setTimeout(callback: () => void, delayMs: number): unknown
clearTimeout(handle: unknown): void
now(): number
}
export type MacosLoginSessionDeathWatchOptions = {
/** Fresh PAM probe (cache-bypassing); null when the login wrapper doesn't apply on this host. */
probeLoginSession: (signal?: AbortSignal) => Promise<LoginPreflightOutcome | null>
readResolverHealth: (signal?: AbortSignal) => Promise<SystemResolverHealth>
onRetire: (details: {
cause: 'pam-rejections'
rejections: number
resolverHealth: SystemResolverHealth
}) => void
log: DaemonFileLog
/** Direct-construction seam for deterministic tests; production uses real timers. */
clock?: WatchClock
timing?: Partial<{
periodicProbeMs: number
rejectionRecheckMs: number
ptyExitDebounceMs: number
clientActivityMinGapMs: number
minProbeGapMs: number
}>
}
/**
* Detects that the macOS GUI login session this daemon was born into has died
* (full logout / WindowServer session teardown) and retires the daemon so the
* next app start cold-starts a replacement inside the live session (#7936).
*
* A daemon in a dead login session is unsalvageable: its PAM context can no
* longer host `login(1)` spawns ("Login incorrect" zombies) and its Mach
* bootstrap namespace has lost the system DNS resolver, so every terminal it
* hosts has no egress. Retirement is the only converging heal.
*
* The oracle is the existing TCC login-shell PAM probe: it conclusively accepts
* while the session is valid — including fast-user-switched-away sessions — and
* conclusively rejects once the session is destroyed. Retirement additionally
* requires the in-process system resolver to be explicitly degraded, so an
* inconclusive probe or PAM anomaly alone can never kill a healthy daemon.
*/
export class MacosLoginSessionDeathWatch {
private readonly probeLoginSession: MacosLoginSessionDeathWatchOptions['probeLoginSession']
private readonly readResolverHealth: MacosLoginSessionDeathWatchOptions['readResolverHealth']
private readonly onRetire: MacosLoginSessionDeathWatchOptions['onRetire']
private readonly log: DaemonFileLog
private readonly clock: WatchClock
private readonly periodicProbeMs: number
private readonly rejectionRecheckMs: number
private readonly ptyExitDebounceMs: number
private readonly clientActivityMinGapMs: number
private readonly minProbeGapMs: number
// Why: retire only a daemon that once proved its session could host login(1);
// a host where the wrapper never worked has no death signal to trust.
private armed = false
private consecutiveRejections = 0
private lastProbeStartedAtMs: number | null = null
private probeInFlight = false
private stopped = false
private retired = false
private scheduledProbeTimer: unknown | null = null
private scheduledProbeAtMs: number | null = null
private ptyExitDebounceTimer: unknown | null = null
private pendingProbeTrigger: string | null = null
private probeAbortController: AbortController | null = null
constructor(opts: MacosLoginSessionDeathWatchOptions) {
this.probeLoginSession = opts.probeLoginSession
this.readResolverHealth = opts.readResolverHealth
this.onRetire = opts.onRetire
this.log = opts.log
this.clock = opts.clock ?? {
setTimeout: (callback, delayMs) => {
const timer = setTimeout(callback, delayMs)
timer.unref()
return timer
},
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
now: () => Date.now()
}
this.periodicProbeMs = opts.timing?.periodicProbeMs ?? PERIODIC_PROBE_MS
this.rejectionRecheckMs = opts.timing?.rejectionRecheckMs ?? REJECTION_RECHECK_MS
this.ptyExitDebounceMs = opts.timing?.ptyExitDebounceMs ?? PTY_EXIT_DEBOUNCE_MS
this.clientActivityMinGapMs = opts.timing?.clientActivityMinGapMs ?? CLIENT_ACTIVITY_MIN_GAP_MS
this.minProbeGapMs = opts.timing?.minProbeGapMs ?? MIN_PROBE_GAP_MS
}
start(): void {
if (this.stopped) {
return
}
void this.runProbe('startup')
}
stop(): void {
this.stopped = true
this.probeAbortController?.abort()
this.probeAbortController = null
if (this.scheduledProbeTimer !== null) {
this.clock.clearTimeout(this.scheduledProbeTimer)
this.scheduledProbeTimer = null
this.scheduledProbeAtMs = null
}
if (this.ptyExitDebounceTimer !== null) {
this.clock.clearTimeout(this.ptyExitDebounceTimer)
this.ptyExitDebounceTimer = null
}
}
/** A mass PTY-exit burst is what a logout's SIGHUP sweep looks like from inside the daemon. */
notifyPtyExit(): void {
if (this.stopped) {
return
}
if (this.ptyExitDebounceTimer !== null) {
this.clock.clearTimeout(this.ptyExitDebounceTimer)
}
this.ptyExitDebounceTimer = this.clock.setTimeout(() => {
this.ptyExitDebounceTimer = null
void this.runProbe('pty-exit')
}, this.ptyExitDebounceMs)
}
notifyClientActivity(): void {
if (this.stopped) {
return
}
if (this.probeInFlight) {
if (this.armed) {
this.retainPendingProbe('client-hello')
}
return
}
const elapsedSinceProbe =
this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs
if (elapsedSinceProbe !== null && elapsedSinceProbe < this.clientActivityMinGapMs) {
// Why: dropping a post-login hello here can defer stale-daemon recovery to the two-minute backstop.
this.scheduleProbeNoLaterThan(this.clientActivityMinGapMs - elapsedSinceProbe, 'client-hello')
return
}
void this.runProbe('client-hello')
}
private scheduleProbe(delayMs: number, trigger: string): void {
if (this.stopped) {
return
}
if (this.scheduledProbeTimer !== null) {
this.clock.clearTimeout(this.scheduledProbeTimer)
}
this.scheduledProbeAtMs = this.clock.now() + delayMs
this.scheduledProbeTimer = this.clock.setTimeout(() => {
this.scheduledProbeTimer = null
this.scheduledProbeAtMs = null
void this.runProbe(trigger)
}, delayMs)
}
private scheduleNextProbe(delayMs: number): void {
this.scheduleProbe(delayMs, 'periodic')
}
private scheduleProbeNoLaterThan(delayMs: number, trigger: string): void {
const requestedAtMs = this.clock.now() + delayMs
if (this.scheduledProbeAtMs !== null && this.scheduledProbeAtMs <= requestedAtMs) {
return
}
this.scheduleProbe(delayMs, trigger)
}
private probeGapMs(trigger: string): number {
return trigger === 'client-hello' ? this.clientActivityMinGapMs : this.minProbeGapMs
}
private retainPendingProbe(trigger: string): void {
if (
this.pendingProbeTrigger === null ||
this.probeGapMs(trigger) < this.probeGapMs(this.pendingProbeTrigger)
) {
this.pendingProbeTrigger = trigger
}
}
private async runProbe(trigger: string): Promise<void> {
if (this.stopped || this.retired) {
return
}
if (this.probeInFlight) {
// Why: the current probe may describe the pre-logout state; retain one follow-up without polling.
this.retainPendingProbe(trigger)
return
}
const elapsedSinceProbe =
this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs
if (
trigger !== 'startup' &&
elapsedSinceProbe !== null &&
elapsedSinceProbe < this.minProbeGapMs
) {
this.scheduleProbeNoLaterThan(this.minProbeGapMs - elapsedSinceProbe, trigger)
return
}
this.probeInFlight = true
this.lastProbeStartedAtMs = this.clock.now()
const abortController = new AbortController()
this.probeAbortController = abortController
try {
const outcome = await this.probeLoginSession(abortController.signal)
if (this.stopped || this.retired) {
return
}
if (outcome === null) {
// Why: no wrapper machinery means no PAM oracle — watching would only ever misfire.
this.log.log('login-session-watch-disabled', { trigger })
this.stop()
return
}
if (!outcome.conclusive) {
// Why: repeated timeouts are still ambiguous on slow/offline PAM hosts;
// never turn an inconclusive probe into authority to orphan live PTYs.
this.scheduleNextProbe(this.periodicProbeMs)
return
}
if (outcome.ok) {
if (!this.armed) {
this.log.log('login-session-watch-armed', { trigger })
}
this.armed = true
this.consecutiveRejections = 0
this.scheduleNextProbe(this.periodicProbeMs)
return
}
if (!this.armed) {
// Session never hosted login(1) here; the preflight already degraded spawns.
this.scheduleNextProbe(this.periodicProbeMs)
return
}
this.consecutiveRejections++
this.log.log('login-session-probe-rejected', {
trigger,
rejections: this.consecutiveRejections
})
if (this.consecutiveRejections < REQUIRED_CONSECUTIVE_REJECTIONS) {
this.scheduleNextProbe(this.rejectionRecheckMs)
return
}
await this.retireIfResolverDegraded(() => {
this.consecutiveRejections = REQUIRED_CONSECUTIVE_REJECTIONS - 1
}, abortController.signal)
} catch (error) {
// Why: a probe failure is diagnostic only; an escaped rejection would trip the
// daemon's fatal unhandled-error path and kill live terminals.
this.log.log('login-session-probe-error', { message: (error as Error)?.message })
this.scheduleNextProbe(this.periodicProbeMs)
} finally {
this.probeInFlight = false
if (this.probeAbortController === abortController) {
this.probeAbortController = null
}
const pendingTrigger = this.pendingProbeTrigger
this.pendingProbeTrigger = null
if (!this.stopped && !this.retired && pendingTrigger !== null) {
const elapsed = this.clock.now() - (this.lastProbeStartedAtMs ?? this.clock.now())
this.scheduleProbeNoLaterThan(
Math.max(0, this.probeGapMs(pendingTrigger) - elapsed),
pendingTrigger
)
}
}
}
private async retireIfResolverDegraded(
holdAtThreshold: () => void,
signal: AbortSignal
): Promise<void> {
const resolverHealth = await this.readResolverHealth(signal)
if (this.stopped || this.retired) {
return
}
if (resolverHealth !== 'unhealthy') {
// Why: only explicit resolver degradation corroborates session death; unknown
// probe failures must preserve terminals and avoid a permanent fast retry loop.
this.log.log('login-session-retire-suppressed', {
cause: 'pam-rejections',
resolverHealth
})
holdAtThreshold()
this.scheduleNextProbe(this.periodicProbeMs)
return
}
this.retired = true
this.onRetire({
cause: 'pam-rejections',
rejections: this.consecutiveRejections,
resolverHealth
})
}
}
+2
View File
@@ -18,6 +18,8 @@ export type TerminalHostOptions = {
terminalWindowsWslDistro?: string | null
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
}) => SubprocessHandle
// Why: login-session death detection (#7936) needs subprocess exits even when no client is attached.
onSessionReaped?: (sessionId: string) => void
// Why: graceful shutdown checkpoints must finish in-process before teardown.
onFinalCheckpoint?: (
sessionId: string,
+3
View File
@@ -883,6 +883,8 @@ describe('TerminalHost', () => {
})
it('does not list exited sessions', async () => {
const onSessionReaped = vi.fn()
host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn, onSessionReaped })
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
@@ -892,6 +894,7 @@ describe('TerminalHost', () => {
lastSubprocess._onExitCb?.(0)
expect(host.listSessions()).toEqual([])
expect(onSessionReaped).toHaveBeenCalledWith('session-1')
})
it('never force-kills an exited session (recycled-pid SIGKILL safety)', async () => {
+3
View File
@@ -27,6 +27,7 @@ export class TerminalHost {
private sessionTeardown = new TerminalSessionTeardown(this.sessions)
private killedTombstones: TerminalHostTombstones
private spawnSubprocess: TerminalHostOptions['spawnSubprocess']
private onSessionReaped: TerminalHostOptions['onSessionReaped']
private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint']
private maxTombstones: number
private creationFenced = false
@@ -36,6 +37,7 @@ export class TerminalHost {
constructor(opts: TerminalHostOptions) {
this.spawnSubprocess = opts.spawnSubprocess
this.onSessionReaped = opts.onSessionReaped
this.onFinalCheckpoint = opts.onFinalCheckpoint
this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES
this.killedTombstones = new TerminalHostTombstones(this.maxTombstones)
@@ -119,6 +121,7 @@ export class TerminalHost {
}
session.dispose()
this.sessions.delete(sessionId)
this.onSessionReaped?.(sessionId)
}
signal(sessionId: string, sig: string): void {
@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process'
import { AndroidEmulatorBackend } from './android-emulator-backend'
import type { AndroidCommandResult, AndroidCommandRunner } from '../android/android-command-runner'
import type { AndroidSdkPaths } from '../android/android-sdk-discovery'
import type { EmulatorBackend } from './emulator-backend'
// The AVD boot spawns the emulator detached (not via the command runner).
vi.mock('node:child_process', async (importOriginal) => {
@@ -219,6 +220,26 @@ describe('AndroidEmulatorBackend', () => {
expect(tree.children[0]).toMatchObject({ text: 'Hi' })
})
it('ignores the ios ax endpoint argument and still dumps via adb', async () => {
runner.mockImplementation(async (binary: string, args: readonly string[]) => {
const a = args.join(' ')
if (binary === SDK.adb && a === 'devices -l') {
return ok(RUNNING_ADB)
}
if (binary === SDK.adb && a === '-s emulator-5554 shell cat /sdcard/window_dump.xml') {
return ok('<hierarchy><node text="Hi"/></hierarchy>')
}
return ok('')
})
// The widened EmulatorBackend.accessibilityTree(deviceId, axUrl) hands Android an
// iOS-only ax URL through the interface; Android must drop it and dump via adb.
const iface: EmulatorBackend = backend(runner)
const tree = (await iface.accessibilityTree!('emulator-5554', 'http://127.0.0.1:3100/ax')) as {
children: { text?: string }[]
}
expect(tree.children[0]).toMatchObject({ text: 'Hi' })
})
it('boots a shutdown AVD and waits for the new booted serial', async () => {
let bootStarted = false
vi.mocked(spawn).mockImplementation(() => {
@@ -80,8 +80,8 @@ export type EmulatorBackend = {
rotate(deviceId: string, orientation: string): Promise<void>
exec(deviceId: string, command: string): Promise<unknown>
// Capability-gated verbs (Android today). The router checks `capabilities`
// before calling these and rejects unsupported backends with emulator_unsupported.
// Capability-gated verbs. The router checks `capabilities` before calling
// these and rejects unsupported backends with emulator_unsupported.
installApp?(deviceId: string, apkPath: string, options?: { reinstall?: boolean }): Promise<void>
launchApp?(deviceId: string, packageName: string, activity?: string): Promise<void>
setPermission?(
@@ -90,7 +90,9 @@ export type EmulatorBackend = {
packageName: string,
permission?: string
): Promise<void>
accessibilityTree?(deviceId: string): Promise<unknown>
// axUrl is the session's serve-sim /ax endpoint from the registry; Android
// dumps via adb from the device serial and ignores it.
accessibilityTree?(deviceId: string, axUrl: string | null): Promise<unknown>
logcat?(
deviceId: string,
options?: { lines?: number; filters?: readonly string[] }
@@ -83,7 +83,7 @@ describe('IosEmulatorBackend', () => {
parseServeSimDetachedSessionMock.mockReset()
})
it('declares ios kind, mjpeg codec, and no explicit-verb capabilities', () => {
it('declares ios kind, mjpeg codec, and only the ax explicit-verb capability', () => {
const backend = new IosEmulatorBackend()
expect(backend.kind).toBe('ios')
expect(backend.streamCodec).toBe('mjpeg')
@@ -91,11 +91,23 @@ describe('IosEmulatorBackend', () => {
install: false,
launch: false,
permissions: false,
accessibilityTree: false,
accessibilityTree: true,
logcat: false
})
})
it('fetches the accessibility tree from the session ax endpoint and rejects without one', async () => {
const fetchAccessibilityTree = vi.fn(async () => ({ elements: [] }))
const backend = new IosEmulatorBackend({ fetchAccessibilityTree })
await expect(
backend.accessibilityTree('device-1', 'http://127.0.0.1:3100/ax')
).resolves.toEqual({ elements: [] })
expect(fetchAccessibilityTree).toHaveBeenCalledWith('http://127.0.0.1:3100/ax')
await expect(backend.accessibilityTree('device-1', null)).rejects.toMatchObject({
code: 'emulator_no_active'
})
})
it('taps via serve-sim with the resolved device', async () => {
const backend = new IosEmulatorBackend()
await backend.tap('iPhone 16 Pro', 0.5, 0.7)
@@ -23,6 +23,7 @@ import {
import type { EmulatorBridgeOptions } from '../emulator-bridge-types'
import { sendEmulatorGestureSequence, type EmulatorGesturePoint } from '../emulator-gesture-sender'
import { parseServeSimDetachedSession } from '../serve-sim-detached-session'
import { fetchServeSimAccessibilityTree, type FetchAccessibilityTree } from '../serve-sim-ax-tree'
import { hideNativeSimulatorApp } from '../simulator-app-visibility'
import type {
BackendAvailability,
@@ -37,20 +38,23 @@ import type {
export class IosEmulatorBackend implements EmulatorBackend {
readonly kind = 'ios' as const
readonly streamCodec = 'mjpeg' as const
// iOS exposes ax/permissions/etc. via `exec`; explicit verbs are Android-only for v1.
// iOS exposes install/launch/permissions/logcat via `exec`; ax has an explicit
// verb backed by the active session's serve-sim /ax endpoint.
readonly capabilities: EmulatorBackendCapabilities = {
install: false,
launch: false,
permissions: false,
accessibilityTree: false,
accessibilityTree: true,
logcat: false
}
private cachedServeSimExecutable: ServeSimExecutable | undefined
private readonly waitForEndpointReady: (endpoint: string) => Promise<boolean>
private readonly fetchAccessibilityTree: FetchAccessibilityTree
constructor(options: EmulatorBridgeOptions = {}) {
this.waitForEndpointReady = options.waitForEndpointReady ?? waitForServeSimEndpointReady
this.fetchAccessibilityTree = options.fetchAccessibilityTree ?? fetchServeSimAccessibilityTree
}
// Why: resolving the executable can materialize the serve-sim runtime (a one-time
@@ -170,6 +174,18 @@ export class IosEmulatorBackend implements EmulatorBackend {
await this.execServeSim(['rotate', orientation, '-d', udid])
}
// Mirrors gesture: the tree comes from the active session's helper endpoint,
// so without a session there is nothing to query.
async accessibilityTree(_deviceId: string, axUrl: string | null): Promise<unknown> {
if (!axUrl) {
throw new EmulatorError(
'emulator_no_active',
'No active emulator session for the accessibility tree. Start one first.'
)
}
return this.fetchAccessibilityTree(axUrl)
}
async exec(deviceId: string, command: string): Promise<unknown> {
const udid = await this.resolveDeviceId(deviceId)
const rawArgs = stripEmulatorTargetArgs(parseServeSimCommandArgs(command.trim()))
@@ -14,4 +14,5 @@ export type EmulatorSessionState = {
export type EmulatorBridgeOptions = {
waitForEndpointReady?: (endpoint: string) => Promise<boolean>
fetchAccessibilityTree?: (axUrl: string) => Promise<unknown>
}
+63 -1
View File
@@ -184,12 +184,51 @@ describe('EmulatorBridge helper ownership', () => {
it('rejects a capability the resolved backend does not support', async () => {
const bridge = new EmulatorBridge()
// device-1 resolves to the iOS backend, which advertises no explicit-verb caps.
// device-1 resolves to the iOS backend, which does not advertise install.
await expect(
bridge.runCapability('install', { device: 'device-1' }, async () => 'unused')
).rejects.toMatchObject({ code: 'emulator_unsupported' })
})
it('routes ax to the active session ax endpoint on iOS', async () => {
const fetchAccessibilityTree = vi.fn(async () => ({ elements: [] }))
const bridge = new EmulatorBridge({ fetchAccessibilityTree })
bridge.registerActiveEmulator('wt-1', {
...session('device-1'),
axUrl: 'http://127.0.0.1:3100/device-1/ax'
})
await expect(bridge.accessibilityTree({ worktreeId: 'wt-1' })).resolves.toEqual({
elements: []
})
expect(fetchAccessibilityTree).toHaveBeenCalledWith('http://127.0.0.1:3100/device-1/ax')
})
it('derives the ax endpoint for sessions registered without axUrl', async () => {
const fetchAccessibilityTree = vi.fn(async () => ({ elements: [] }))
const bridge = new EmulatorBridge({ fetchAccessibilityTree })
// e.g. renderer-supplied session info that predates ax derivation.
bridge.registerActiveEmulator('wt-1', {
...session('device-1'),
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg'
})
await expect(bridge.accessibilityTree({ worktreeId: 'wt-1' })).resolves.toEqual({
elements: []
})
expect(fetchAccessibilityTree).toHaveBeenCalledWith('http://127.0.0.1:3100/ax')
})
it('rejects ax when the session has no ax endpoint and none can be derived', async () => {
const bridge = new EmulatorBridge()
// session() streamUrl has no mjpeg suffix, so no /ax endpoint can be inferred.
bridge.registerActiveEmulator('wt-1', session('device-1'))
await expect(bridge.accessibilityTree({ worktreeId: 'wt-1' })).rejects.toMatchObject({
code: 'emulator_no_active'
})
})
it('kills the helper and shuts down the selected simulator', async () => {
const bridge = new EmulatorBridge()
bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true })
@@ -381,6 +420,29 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
})
})
it('routes emulatorAx through the bridge to the active session ax endpoint', async () => {
const fetchAccessibilityTree = vi.fn(async () => ({ elements: [{ label: 'Login' }] }))
const bridge = new EmulatorBridge({ fetchAccessibilityTree })
bridge.registerActiveEmulator('wt-1', {
...session('device-1'),
axUrl: 'http://127.0.0.1:3100/device-1/ax'
})
const commands = new RuntimeEmulatorCommands({
getEmulatorBridge: () => bridge,
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
getSettings: () => ({
mobileEmulatorEnabled: true,
mobileEmulatorDefaultDeviceUdid: null
})
})
await expect(commands.emulatorAx({ worktree: 'wt-1' })).resolves.toEqual({
elements: [{ label: 'Login' }]
})
expect(fetchAccessibilityTree).toHaveBeenCalledWith('http://127.0.0.1:3100/device-1/ax')
})
it('rejects attach when mobile emulator is disabled', async () => {
const bridge = new EmulatorBridge()
const commands = new RuntimeEmulatorCommands({
+12
View File
@@ -5,6 +5,7 @@ import type { SimulatorDevice } from './simctl-simulator-devices'
import type { EmulatorBridgeOptions } from './emulator-bridge-types'
import type { EmulatorGesturePoint } from './emulator-gesture-sender'
import { EmulatorSessionRegistry } from './emulator-session-registry'
import { deriveServeSimAxUrl } from './serve-sim-detached-session'
import { IosEmulatorBackend } from './backends/ios-emulator-backend'
import { AndroidEmulatorBackend } from './backends/android-emulator-backend'
import type {
@@ -199,6 +200,17 @@ export class EmulatorBridge {
return backend.exec(device, command)
}
async accessibilityTree(opts?: EmulatorTargetOpts): Promise<unknown> {
return this.runCapability('accessibilityTree', opts, async (backend, device) => {
const udid = await backend.resolveDeviceId(device)
const session = this.sessionRegistry.getSession(udid)
// Fallback heals sessions registered without axUrl (e.g. renderer-supplied
// info that predates ax derivation); Android backends ignore the argument.
const axUrl = session?.axUrl ?? deriveServeSimAxUrl(session?.streamUrl) ?? null
return backend.accessibilityTree!(udid, axUrl)
})
}
// Runs a capability-gated verb against the resolved target, rejecting backends
// that do not advertise the capability (e.g. install/logcat on iOS).
async runCapability<T>(
+175
View File
@@ -0,0 +1,175 @@
import { describe, expect, it, vi } from 'vitest'
import { fetchServeSimAccessibilityTree } from './serve-sim-ax-tree'
function sseResponse(chunks: string[], init: { status?: number } = {}): Response {
const encoder = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk))
}
controller.close()
}
})
return new Response(body, {
status: init.status ?? 200,
headers: { 'Content-Type': 'text/event-stream' }
})
}
describe('fetchServeSimAccessibilityTree', () => {
it('returns the first data event and skips the SSE comment preamble', async () => {
const tree = { screen: { width: 393, height: 852 }, elements: [{ label: 'Login' }], errors: [] }
const fetchImpl = vi.fn(async () => sseResponse([':\n\n', `data: ${JSON.stringify(tree)}\n\n`]))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).resolves.toEqual(tree)
})
it('prefers a live update over the replayed cached tree', async () => {
// The helper replays the cached tree to new clients, then polls the device
// and writes a fresh event only if the tree changed — the fresh one must win.
const stale = JSON.stringify({ elements: [{ label: 'Old' }] })
const fresh = JSON.stringify({ elements: [{ label: 'New' }] })
const fetchImpl = vi.fn(async () =>
sseResponse([':\n\n', `data: ${stale}\n\n`, `data: ${fresh}\n\n`])
)
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).resolves.toEqual({ elements: [{ label: 'New' }] })
})
it('settles on the first event when no follow-up arrives within the window', async () => {
const encoder = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(':\n\n'))
controller.enqueue(encoder.encode('data: {"elements":[]}\n\n'))
// Never closes — an unchanged tree writes nothing, so the settle window must end the read.
}
})
const fetchImpl = vi.fn(async () => new Response(body, { status: 200 }))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl, settleMs: 30 })
).resolves.toEqual({ elements: [] })
})
it('handles a data event split across stream chunks', async () => {
const payload = JSON.stringify({ elements: [] })
const mid = Math.floor(payload.length / 2)
const fetchImpl = vi.fn(async () =>
sseResponse([':\n\n', `data: ${payload.slice(0, mid)}`, `${payload.slice(mid)}\n\n`])
)
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).resolves.toEqual({ elements: [] })
})
it('maps a non-200 response to an actionable stale-helper error', async () => {
const fetchImpl = vi.fn(async () => sseResponse([], { status: 404 }))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({
code: 'emulator_error',
message: expect.stringContaining('Restart the emulator session')
})
})
it('maps a connection failure to emulator_no_active', async () => {
const fetchImpl = vi.fn(async () => {
throw new TypeError('fetch failed')
})
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({ code: 'emulator_no_active' })
})
it('fails when the stream ends without a data event', async () => {
const fetchImpl = vi.fn(async () => sseResponse([':\n\n', ':\n\n']))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({ code: 'emulator_helper_failed' })
})
it('times out when no data event arrives', async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
new ReadableStream<Uint8Array>({
start() {
// Never emits and never closes; the timeout abort must win.
}
}),
{ status: 200 }
)
)
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl, timeoutMs: 50 })
).rejects.toMatchObject({ code: 'emulator_error', message: expect.stringMatching(/Timed out/) })
})
// Pull-based: controller.error() discards still-queued chunks, so the stream
// must hand out each chunk on its own read before erroring on a later pull.
function droppingSseResponse(chunks: string[]): Response {
const encoder = new TextEncoder()
let step = 0
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (step < chunks.length) {
controller.enqueue(encoder.encode(chunks[step]))
step += 1
} else {
controller.error(new TypeError('terminated'))
}
}
})
return new Response(body, { status: 200 })
}
it('returns the last captured tree when the stream drops uncleanly mid-read', async () => {
const tree = { elements: [{ label: 'Captured' }] }
const fetchImpl = vi.fn(async () =>
droppingSseResponse([':\n\n', `data: ${JSON.stringify(tree)}\n\n`])
)
// settleMs is long so the drop, not the settle window, ends the read.
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl, settleMs: 5000 })
).resolves.toEqual(tree)
})
it('maps an unclean mid-stream drop with no captured tree to emulator_helper_failed', async () => {
const fetchImpl = vi.fn(async () => droppingSseResponse([':\n\n']))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({ code: 'emulator_helper_failed' })
})
it('returns the captured tree when the timeout aborts mid-settle', async () => {
const encoder = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(':\n\n'))
controller.enqueue(encoder.encode('data: {"elements":[{"label":"Only"}]}\n\n'))
// Never closes; the hard timeout must abort while the settle window is open.
}
})
const fetchImpl = vi.fn(async () => new Response(body, { status: 200 }))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', {
fetchImpl,
settleMs: 5000,
timeoutMs: 40
})
).resolves.toEqual({ elements: [{ label: 'Only' }] })
})
it('rejects an unparseable ax event with emulator_helper_failed', async () => {
const fetchImpl = vi.fn(async () => sseResponse([':\n\n', 'data: not-json\n\n']))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({
code: 'emulator_helper_failed',
message: expect.stringContaining('unparseable')
})
})
})
+167
View File
@@ -0,0 +1,167 @@
import { EmulatorError } from './emulator-errors'
const DEFAULT_TIMEOUT_MS = 15_000
// Why: /ax replays the last *cached* tree to every new client before polling the
// device, and polling pauses while no client is connected — so the first event
// can be stale. A fresh event is only written if the tree changed, so we linger
// briefly after the first event and return the last one seen: a follow-up means
// the cache was stale; silence means the cache still matches the device.
const DEFAULT_SETTLE_MS = 800
export type FetchAccessibilityTree = (axUrl: string) => Promise<unknown>
export async function fetchServeSimAccessibilityTree(
axUrl: string,
options: { timeoutMs?: number; settleMs?: number; fetchImpl?: typeof fetch } = {}
): Promise<unknown> {
const fetchImpl = options.fetchImpl ?? fetch
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
try {
let response: Response
try {
response = await fetchImpl(axUrl, {
headers: { Accept: 'text/event-stream' },
signal: controller.signal
})
} catch {
if (controller.signal.aborted) {
throw timeoutError(axUrl)
}
throw new EmulatorError(
'emulator_no_active',
`serve-sim accessibility endpoint is unreachable at ${axUrl}. Start the emulator session first.`
)
}
if (!response.ok || !response.body) {
// Why: a helper spawned by an older serve-sim build may predate /ax.
throw new EmulatorError(
'emulator_error',
`serve-sim /ax endpoint returned HTTP ${response.status}. Restart the emulator session to refresh the helper.`
)
}
const payload = await readSettledSseDataEvent(
response.body,
controller,
options.settleMs ?? DEFAULT_SETTLE_MS
)
if (payload === null) {
throw new EmulatorError(
'emulator_helper_failed',
'serve-sim /ax stream ended without an accessibility tree event.'
)
}
try {
return JSON.parse(payload)
} catch {
throw new EmulatorError(
'emulator_helper_failed',
'serve-sim /ax returned an unparseable accessibility tree event.'
)
}
} finally {
clearTimeout(timeout)
controller.abort()
}
}
function timeoutError(axUrl: string): EmulatorError {
return new EmulatorError(
'emulator_error',
`Timed out waiting for the accessibility tree from ${axUrl}.`
)
}
const SETTLED = Symbol('settled')
// Returns the payload of the last data event seen up to settleMs after the
// first one (see DEFAULT_SETTLE_MS), or null if the stream ends with none.
async function readSettledSseDataEvent(
body: ReadableStream<Uint8Array>,
controller: AbortController,
settleMs: number
): Promise<string | null> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let latest: string | null = null
let settled: Promise<typeof SETTLED> | null = null
let settleTimer: NodeJS.Timeout | undefined
// Why: abort must also win over a body that never emits — reads on a stalled
// stream do not observe the fetch signal on their own.
const aborted = new Promise<never>((_, reject) => {
const rejectAborted = (): void => reject(new Error('aborted'))
if (controller.signal.aborted) {
rejectAborted()
return
}
controller.signal.addEventListener('abort', rejectAborted, { once: true })
})
aborted.catch(() => {})
try {
for (;;) {
const result = await Promise.race(
settled ? [reader.read(), aborted, settled] : [reader.read(), aborted]
)
if (result === SETTLED) {
return latest
}
const { done, value } = result
if (value) {
buffer += decoder.decode(value, { stream: true })
const payload = extractLastDataPayload(buffer)
if (payload !== null) {
latest = payload
settled ??= new Promise((resolve) => {
settleTimer = setTimeout(() => resolve(SETTLED), settleMs)
})
}
// Keep only the trailing partial event; complete ones are consumed.
const lastBoundary = buffer.lastIndexOf('\n\n')
if (lastBoundary !== -1) {
buffer = buffer.slice(lastBoundary + 2)
}
}
if (done) {
return latest
}
}
} catch {
// A tree was captured before the stream aborted or dropped — return it rather
// than discarding a valid result on an unclean close (helper crash / truncated
// chunked stream), which also keeps a raw non-EmulatorError off the caller path.
if (latest !== null) {
return latest
}
if (controller.signal.aborted) {
throw new EmulatorError(
'emulator_error',
'Timed out waiting for the accessibility tree event from serve-sim.'
)
}
// Map an unclean mid-stream failure to the EmulatorError contract callers expect.
throw new EmulatorError(
'emulator_helper_failed',
'serve-sim /ax stream ended unexpectedly. Restart the emulator session and retry.'
)
} finally {
if (settleTimer) {
clearTimeout(settleTimer)
}
reader.releaseLock()
}
}
function extractLastDataPayload(buffer: string): string | null {
let payload: string | null = null
for (const event of buffer.split('\n\n').slice(0, -1)) {
const dataLines = event
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^ /, ''))
if (dataLines.length > 0) {
payload = dataLines.join('\n')
}
}
return payload
}
@@ -1,5 +1,23 @@
import { describe, expect, it } from 'vitest'
import { parseServeSimDetachedSession } from './serve-sim-detached-session'
import { deriveServeSimAxUrl, parseServeSimDetachedSession } from './serve-sim-detached-session'
describe('deriveServeSimAxUrl', () => {
it('swaps the mjpeg suffix for /ax, preserving the path prefix', () => {
expect(deriveServeSimAxUrl('http://127.0.0.1:3100/stream.mjpeg')).toBe(
'http://127.0.0.1:3100/ax'
)
expect(deriveServeSimAxUrl('http://127.0.0.1:3100/device-1/stream.mjpeg')).toBe(
'http://127.0.0.1:3100/device-1/ax'
)
})
it('does not derive from a non-mjpeg, query-tailed, or missing stream url', () => {
// A query string defeats the suffix match, so no /ax is fabricated.
expect(deriveServeSimAxUrl('http://127.0.0.1:3100/stream.mjpeg?token=x')).toBeUndefined()
expect(deriveServeSimAxUrl('http://127.0.0.1:3100/custom-stream')).toBeUndefined()
expect(deriveServeSimAxUrl(undefined)).toBeUndefined()
})
})
describe('parseServeSimDetachedSession', () => {
it('uses serve-sim streamUrl when present', () => {
@@ -31,4 +49,44 @@ describe('parseServeSimDetachedSession', () => {
expect(info.streamUrl).toBe('http://127.0.0.1:3100/stream.mjpeg')
})
it('derives the ax endpoint when serve-sim omits axUrl', () => {
const info = parseServeSimDetachedSession(
{
device: 'device-1',
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
wsUrl: 'ws://127.0.0.1:3100/ws'
},
'device-1'
)
expect(info.axUrl).toBe('http://127.0.0.1:3100/ax')
})
it('does not fabricate an ax endpoint from a non-mjpeg stream url', () => {
const info = parseServeSimDetachedSession(
{
device: 'device-1',
streamUrl: 'http://127.0.0.1:3100/custom-stream',
wsUrl: 'ws://127.0.0.1:3100/ws'
},
'device-1'
)
expect(info.axUrl).toBeUndefined()
})
it('keeps an explicit axUrl when serve-sim provides one', () => {
const info = parseServeSimDetachedSession(
{
device: 'device-1',
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
wsUrl: 'ws://127.0.0.1:3100/ws',
axUrl: 'http://127.0.0.1:3100/custom-ax'
},
'device-1'
)
expect(info.axUrl).toBe('http://127.0.0.1:3100/custom-ax')
})
})
@@ -8,6 +8,15 @@ function streamUrlFromServeSimUrl(url: string): string {
return url.endsWith('/stream.mjpeg') ? url : `${url.replace(/\/$/, '')}/stream.mjpeg`
}
// Why: serve-sim serves /ax on the helper but omits it from --detach output.
// Guarded on the mjpeg suffix so a foreign stream URL never masquerades as an
// AX endpoint. Also used by the bridge to heal sessions registered without axUrl.
export function deriveServeSimAxUrl(streamUrl: string | undefined): string | undefined {
return streamUrl?.endsWith('/stream.mjpeg')
? streamUrl.replace(/\/stream\.mjpeg$/, '/ax')
: undefined
}
export function parseServeSimDetachedSession(raw: unknown, udid: string): EmulatorSessionInfo {
if (!raw || typeof raw !== 'object') {
throw new EmulatorError('emulator_helper_failed', 'serve-sim did not return stream endpoints.')
@@ -24,7 +33,7 @@ export function parseServeSimDetachedSession(raw: unknown, udid: string): Emulat
deviceUdid: typeof json.device === 'string' ? json.device : udid,
wsUrl: wsUrl ?? '',
streamUrl: streamUrl ?? '',
axUrl: typeof json.axUrl === 'string' ? json.axUrl : undefined
axUrl: typeof json.axUrl === 'string' ? json.axUrl : deriveServeSimAxUrl(streamUrl)
}
if (!info.streamUrl || !info.wsUrl) {
throw new EmulatorError('emulator_helper_failed', 'serve-sim did not return stream endpoints.')
+71 -1
View File
@@ -9,7 +9,10 @@ vi.mock('./codex-cli/command', () => ({
}))
import { getCmdExePath } from './win32-utils'
import { resolveExternalEditorLaunchSpec } from './external-editor-launch'
import {
resolveExternalEditorLaunchSpec,
resolveVsCodeRemoteSshLaunchSpec
} from './external-editor-launch'
describe('resolveExternalEditorLaunchSpec', () => {
beforeEach(() => {
@@ -255,3 +258,70 @@ describe('resolveExternalEditorLaunchSpec', () => {
})
})
})
describe('resolveVsCodeRemoteSshLaunchSpec', () => {
beforeEach(() => {
resolveCliCommandMock.mockReset()
resolveCliCommandMock.mockImplementation((command: string) => command)
})
it.each(['code', 'code-insiders'])('builds exact Remote-SSH arguments for %s', (command) => {
expect(
resolveVsCodeRemoteSshLaunchSpec(command, '/home/Ada Lovelace/project', 'builder', {
platform: 'linux'
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: command,
spawnArgs: ['--remote', 'ssh-remote+builder', '/home/Ada Lovelace/project']
})
})
it.each([
'C:\\Program Files\\Microsoft VS Code\\Code.exe',
'C:\\Program Files\\Microsoft VS Code Insiders\\Code - Insiders.exe',
'C:\\Tools\\code.cmd',
'C:\\Tools\\code-insiders.bat'
])('supports the direct Windows launcher %s', (command) => {
expect(
resolveVsCodeRemoteSshLaunchSpec(command, 'C:\\Users\\Ada Lovelace\\project', 'builder', {
platform: 'win32'
})?.spawnArgs
).toEqual(['--remote', 'ssh-remote+builder', 'C:\\Users\\Ada Lovelace\\project'])
})
it('supports an existing direct POSIX launcher path containing spaces', () => {
const command = '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code'
expect(
resolveVsCodeRemoteSshLaunchSpec(command, '/srv/project', 'builder', {
platform: 'darwin',
fileExists: (candidate) => candidate === command
})
).toMatchObject({
kind: 'executable',
spawnCmd: command,
spawnArgs: ['--remote', 'ssh-remote+builder', '/srv/project']
})
})
it('recognizes a simple CLI name resolved to a Windows shim', () => {
resolveCliCommandMock.mockReturnValueOnce('C:\\Tools\\Code.CMD')
expect(
resolveVsCodeRemoteSshLaunchSpec('code', '/srv/project', 'builder', {
platform: 'win32'
})
).toMatchObject({ spawnCmd: 'C:\\Tools\\Code.CMD' })
})
it.each(['cursor', 'zed', 'code --reuse-window', 'open -a "Visual Studio Code"'])(
'rejects unsupported and compound SSH commands: %s',
(command) => {
expect(
resolveVsCodeRemoteSshLaunchSpec(command, '/srv/project', 'builder', {
platform: 'linux'
})
).toBeNull()
}
)
})
+33 -2
View File
@@ -1,12 +1,12 @@
import { existsSync } from 'node:fs'
import { basename, posix, win32 } from 'node:path'
import { parseWslUncPath } from '../shared/wsl-paths'
import { isVsCodeLauncherExecutable } from '../shared/vscode-remote-ssh-launcher'
import { resolveCliCommand } from './codex-cli/command'
import { getCmdExePath } from './win32-utils'
export const EXTERNAL_EDITOR_CLI_COMMAND = 'code'
const WINDOWS_CONSOLE_EDITORS = new Set(['nvim', 'vim'])
const VSCODE_REMOTE_EDITORS = new Set(['code', 'code-insiders', 'code - insiders'])
export type ExternalEditorLaunchSpec =
| {
@@ -119,7 +119,7 @@ function buildExecutableArgs(
// workbench. A new window keeps "Open in Cursor" scoped to this worktree.
return ['--new-window', pathValue]
}
if (platform === 'win32' && VSCODE_REMOTE_EDITORS.has(launcherBaseName)) {
if (platform === 'win32' && isVsCodeLauncherExecutable(editorCommand)) {
const wslPath = parseWslUncPath(pathValue)
if (wslPath) {
// Why: VS Code otherwise treats a WSL UNC path as a local Windows folder.
@@ -186,3 +186,34 @@ export function resolveExternalEditorLaunchSpec(
spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform)
}
}
export function resolveVsCodeRemoteSshLaunchSpec(
command: string | undefined,
pathValue: string,
authority: string,
options: { platform?: NodeJS.Platform; fileExists?: (path: string) => boolean } = {}
): ExternalEditorLaunchSpec | null {
const platform = options.platform ?? process.platform
const fileExists = options.fileExists ?? existsSync
const trimmed = command?.trim() || EXTERNAL_EDITOR_CLI_COMMAND
let editorCommand: string
if (isDirectExecutablePath(trimmed, platform, fileExists)) {
editorCommand = stripMatchingQuotes(trimmed)
} else {
if (isCompoundShellCommand(trimmed)) {
return null
}
editorCommand = resolveCliCommand(trimmed, { platform })
}
if (!isVsCodeLauncherExecutable(editorCommand)) {
return null
}
return {
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: editorCommand,
spawnArgs: ['--remote', `ssh-remote+${authority}`, pathValue]
}
}
+35 -5
View File
@@ -150,7 +150,7 @@ import {
ensureRealHomeCodexHookState,
isRealHomeCodexHookLaneUsable
} from './codex/codex-real-home-hook-install'
import { setCodexTrustGrantTelemetry } from './codex/codex-hook-trust-grant'
import { setCodexTrustGrantTelemetry } from './codex/codex-trust-grant-telemetry'
import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill'
import { startCodexSessionIndexHealInBackground } from './codex/codex-session-index-heal'
import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler'
@@ -253,6 +253,7 @@ let runtimeRpc: OrcaRuntimeRpcServer | null = null
const serveReadinessPublisher = new ServeReadinessPublisher()
let desktopRelayService: DesktopRelayService | null = null
let desktopRelayStatus: RelayBrokerStatus = 'offline'
let pendingUnpairedDeviceAuthFailure = false
// Why: gates whether headless serve installs the offscreen browser backend (and advertises browser pane support).
let headlessBrowserDisplayAvailable = false
@@ -673,7 +674,11 @@ function startTerminalRuntimeStartupServices(): Promise<void> {
// Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer.
startDaemonPtyProvider: async (signal) => {
logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' })
await initDaemonPtyProvider(signal)
// Why: only GUI-spawned macOS daemons watch for login-session death; a headless
// serve daemon must survive its spawning session ending (SSH disconnect).
await initDaemonPtyProvider(signal, {
macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode
})
logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' })
},
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect.
@@ -1847,11 +1852,14 @@ app.whenReady().then(async () => {
// Why: the trust-grant module is bundled into plain-node CLI entries where
// the telemetry client cannot load, so the tracker is injected here instead
// of imported there.
setCodexTrustGrantTelemetry(({ outcome, hostKind, reason }) => {
setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => {
track('codex_trust_grant', {
outcome,
host_kind: hostKind,
...(reason !== undefined ? { fallback_reason: reason } : {})
lane,
...(reason !== undefined ? { fallback_reason: reason } : {}),
...(errorClass !== undefined ? { error_class: errorClass } : {}),
...(verifyClass !== undefined ? { verify_class: verifyClass } : {})
})
})
// Why: the error-tracking lane (telemetry-error-tracking.md) is its own
@@ -2281,7 +2289,29 @@ app.whenReady().then(async () => {
: {}),
webClientRoot: getBundledWebClientRoot()
})
registerMobileHandlers(runtimeRpc, { getRelayStatus: () => desktopRelayStatus })
registerMobileHandlers(runtimeRpc, {
getRelayStatus: () => desktopRelayStatus,
consumePendingUnpairedDeviceAuthFailure: (webContentsId) => {
if (
!mainWindow ||
mainWindow.isDestroyed() ||
mainWindow.webContents.id !== webContentsId ||
!pendingUnpairedDeviceAuthFailure
) {
return false
}
pendingUnpairedDeviceAuthFailure = false
return true
}
})
// Why: repeated direct auth failures otherwise look like a client that never connects; point users to re-pairing.
runtimeRpc.setOnUnpairedDeviceAuthFailure(() => {
// Why: runtime startup races renderer mount; retain the one-shot until the listener consumes it.
pendingUnpairedDeviceAuthFailure = true
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('mobile:unpairedDeviceAuthFailure')
}
})
startTerminalRuntimeStartupServices()
app.on('activate', requestDesktopActivation)
+19
View File
@@ -273,6 +273,25 @@ describe('registerMobileHandlers', () => {
expect(handlers.get('mobile:getRelayStatus')?.()).toEqual({ status: 'registered' })
})
it('consumes a pending auth-failure notification only from a window renderer', () => {
const consumePendingUnpairedDeviceAuthFailure = vi.fn(() => true)
registerMobileHandlers({} as never, { consumePendingUnpairedDeviceAuthFailure })
expect(
handlers.get('mobile:consumePendingUnpairedDeviceAuthFailure')?.({
sender: { id: 42, isDestroyed: () => false, getType: () => 'window' }
})
).toBe(true)
expect(consumePendingUnpairedDeviceAuthFailure).toHaveBeenCalledWith(42)
expect(
handlers.get('mobile:consumePendingUnpairedDeviceAuthFailure')?.({
sender: { id: 99, isDestroyed: () => false, getType: () => 'webview' }
})
).toBe(false)
expect(consumePendingUnpairedDeviceAuthFailure).toHaveBeenCalledOnce()
})
it('inspects and repairs the current packaged Windows websocket port', async () => {
const runPowerShell = vi
.fn()
+8
View File
@@ -63,6 +63,7 @@ export type MobileHandlerDependencies = {
firewallEnvironment?: WindowsMobileFirewallEnvironment
openWindowsNetworkSettings?: () => Promise<void>
getRelayStatus?: () => RelayBrokerStatus
consumePendingUnpairedDeviceAuthFailure?: (webContentsId: number) => boolean
}
export function registerMobileHandlers(
@@ -252,6 +253,13 @@ export function registerMobileHandlers(
ipcMain.handle('mobile:getRelayStatus', () => ({
status: dependencies.getRelayStatus?.() ?? 'offline'
}))
ipcMain.handle('mobile:consumePendingUnpairedDeviceAuthFailure', (event) => {
if (!isWindowRenderer(event)) {
return false
}
return dependencies.consumePendingUnpairedDeviceAuthFailure?.(event.sender.id) ?? false
})
}
function isWindowRenderer(event: IpcMainInvokeEvent): boolean {
+1 -1
View File
@@ -536,7 +536,7 @@ describe('registerCoreHandlers', () => {
expect(registerNativeChatHandlersMock).toHaveBeenCalled()
expect(registerCliHandlersMock).toHaveBeenCalled()
expect(registerPreflightHandlersMock).toHaveBeenCalled()
expect(registerShellHandlersMock).toHaveBeenCalled()
expect(registerShellHandlersMock).toHaveBeenCalledWith(store)
expect(registerClipboardHandlersMock).toHaveBeenCalledWith(store)
expect(registerUpdaterHandlersMock).toHaveBeenCalled()
expect(setTrustedBrowserRendererWebContentsIdMock).toHaveBeenCalledWith(null)
+1 -1
View File
@@ -182,7 +182,7 @@ export function registerCoreHandlers(
onBeforeSignOut: lifecycleOptions.onBeforeOrcaProfileSignOut
})
registerBrowserHandlers()
registerShellHandlers()
registerShellHandlers(store)
registerPetHandlers()
registerSessionHandlers(store)
registerUIHandlers(store)
+187 -13
View File
@@ -57,6 +57,7 @@ vi.mock('../win32-utils', () => ({
import { EXTERNAL_EDITOR_CLI_COMMAND, registerShellHandlers } from './shell'
import { resolveExternalEditorLaunchSpec } from '../external-editor-launch'
import type { SshTarget } from '../../shared/ssh-types'
function createSpawnedProcess(result: 'spawn' | 'error' = 'spawn'): {
once: ReturnType<typeof vi.fn>
@@ -78,7 +79,27 @@ function createSpawnedProcess(result: 'spawn' | 'error' = 'spawn'): {
return child
}
function createSshTarget(overrides: Partial<SshTarget> = {}): SshTarget {
return {
id: 'ssh-1',
label: 'Builder',
host: 'builder.example.com',
port: 22,
username: 'ada',
source: 'ssh-config',
configHost: 'builder',
...overrides
}
}
describe('registerShellHandlers', () => {
const settings = { activeRuntimeEnvironmentId: null as string | null }
const sshTargets = new Map<string, SshTarget>()
const store = {
getSettings: () => settings,
getSshTarget: (id: string) => sshTargets.get(id)
}
beforeEach(() => {
handleMock.mockReset()
getSpawnArgsForWindowsMock.mockReset()
@@ -88,6 +109,8 @@ describe('registerShellHandlers', () => {
showOpenDialogMock.mockReset()
spawnMock.mockReset()
statMock.mockReset()
settings.activeRuntimeEnvironmentId = null
sshTargets.clear()
openPathMock.mockResolvedValue('')
resolveCliCommandMock.mockReturnValue('editor-cli')
getSpawnArgsForWindowsMock.mockImplementation((command: string, args: string[]) => ({
@@ -99,7 +122,7 @@ describe('registerShellHandlers', () => {
})
function getHandler(channel: string): (event: unknown, ...args: unknown[]) => Promise<unknown> {
registerShellHandlers()
registerShellHandlers(store as never)
const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === channel)
if (!call) {
throw new Error(`${channel} handler not registered`)
@@ -238,7 +261,7 @@ describe('registerShellHandlers', () => {
it('rejects relative paths', async () => {
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, 'relative/workspace')).resolves.toEqual({
await expect(handler({}, { path: 'relative/workspace' })).resolves.toEqual({
ok: false,
reason: 'not-absolute'
})
@@ -252,7 +275,7 @@ describe('registerShellHandlers', () => {
const workspacePath = resolve('missing-workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath)).resolves.toEqual({
await expect(handler({}, { path: workspacePath })).resolves.toEqual({
ok: false,
reason: 'not-found'
})
@@ -267,7 +290,7 @@ describe('registerShellHandlers', () => {
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath)).resolves.toEqual({
await expect(handler({}, { path: workspacePath })).resolves.toEqual({
ok: false,
reason: 'launch-failed'
})
@@ -293,7 +316,7 @@ describe('registerShellHandlers', () => {
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath)).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath })).resolves.toEqual({ ok: true })
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
platform: process.platform
})
@@ -314,7 +337,9 @@ describe('registerShellHandlers', () => {
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath, 'cursor')).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({
ok: true
})
expect(resolveCliCommandMock).toHaveBeenCalledWith('cursor', { platform: process.platform })
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
normalize(workspacePath)
@@ -329,7 +354,9 @@ describe('registerShellHandlers', () => {
resolveCliCommandMock.mockReturnValueOnce(codeShim)
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath, 'code')).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath, command: 'code' })).resolves.toEqual({
ok: true
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(codeShim, [
'--remote',
'wsl+Ubuntu Preview',
@@ -346,7 +373,9 @@ describe('registerShellHandlers', () => {
const nvimPath = 'C:\\Program Files\\Neovim\\bin\\nvim.exe'
try {
await expect(handler({}, workspacePath, nvimPath)).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath, command: nvimPath })).resolves.toEqual({
ok: true
})
expect(resolveCliCommandMock).not.toHaveBeenCalled()
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(nvimPath, [
normalize(workspacePath)
@@ -368,13 +397,17 @@ describe('registerShellHandlers', () => {
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath, 'cursor')).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({
ok: true
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/cursor', [
'--new-window',
normalize(workspacePath)
])
resolveCliCommandMock.mockReturnValueOnce('C:\\Cursor\\cursor.cmd')
await expect(handler({}, workspacePath, 'cursor')).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({
ok: true
})
expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith('C:\\Cursor\\cursor.cmd', [
'--new-window',
normalize(workspacePath)
@@ -385,7 +418,9 @@ describe('registerShellHandlers', () => {
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath, ' ')).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath, command: ' ' })).resolves.toEqual({
ok: true
})
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
platform: process.platform
})
@@ -399,7 +434,7 @@ describe('registerShellHandlers', () => {
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath)).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath })).resolves.toEqual({ ok: true })
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
platform: process.platform
})
@@ -419,7 +454,9 @@ describe('registerShellHandlers', () => {
const handler = getHandler('shell:openInExternalEditor')
const launchSpec = resolveExternalEditorLaunchSpec('open -a "Typora"', filePath)
await expect(handler({}, filePath, 'open -a "Typora"')).resolves.toEqual({ ok: true })
await expect(handler({}, { path: filePath, command: 'open -a "Typora"' })).resolves.toEqual({
ok: true
})
expect(resolveCliCommandMock).not.toHaveBeenCalled()
expect(getSpawnArgsForWindowsMock).not.toHaveBeenCalled()
expect(launchSpec.kind).toBe('shell')
@@ -429,6 +466,143 @@ describe('registerShellHandlers', () => {
windowsHide: true
})
})
it('rejects local and SSH launches while a remote runtime is active', async () => {
settings.activeRuntimeEnvironmentId = 'runtime-1'
sshTargets.set('ssh-1', createSshTarget())
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, { path: resolve('workspace') })).resolves.toEqual({
ok: false,
reason: 'remote-runtime-unsupported'
})
await expect(
handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: false, reason: 'remote-runtime-unsupported' })
expect(statMock).not.toHaveBeenCalled()
expect(spawnMock).not.toHaveBeenCalled()
})
it('rejects missing and runtime-owned SSH targets', async () => {
const handler = getHandler('shell:openInExternalEditor')
await expect(
handler({}, { path: '/srv/project', command: 'code', connectionId: 'missing' })
).resolves.toEqual({ ok: false, reason: 'ssh-target-not-found' })
sshTargets.set(
'ssh-1',
createSshTarget({ owner: { type: 'on-demand-runtime', runtimeId: 'runtime-1' } })
)
await expect(
handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: false, reason: 'remote-runtime-unsupported' })
expect(spawnMock).not.toHaveBeenCalled()
})
it('opens POSIX SSH paths through a persisted config alias without local validation', async () => {
sshTargets.set('ssh-1', createSshTarget())
resolveCliCommandMock.mockReturnValueOnce('/usr/local/bin/code')
const handler = getHandler('shell:openInExternalEditor')
const remotePath = '/home/Ada Lovelace/project'
await expect(
handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: true })
expect(statMock).not.toHaveBeenCalled()
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/code', [
'--remote',
'ssh-remote+builder',
remotePath
])
})
it('preserves Windows-form SSH paths and uses the manual port-22 authority', async () => {
sshTargets.set(
'ssh-1',
createSshTarget({
source: 'manual',
configHost: 'win-builder.example.com',
host: 'win-builder.example.com',
username: 'Ada'
})
)
resolveCliCommandMock.mockReturnValueOnce('C:\\Tools\\code.cmd')
const handler = getHandler('shell:openInExternalEditor')
const remotePath = 'C:\\Users\\Ada Lovelace\\project'
await expect(
handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: true })
expect(statMock).not.toHaveBeenCalled()
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('C:\\Tools\\code.cmd', [
'--remote',
'ssh-remote+Ada@win-builder.example.com',
remotePath
])
})
it('rejects relative SSH paths before resolving or spawning a launcher', async () => {
sshTargets.set('ssh-1', createSshTarget())
const handler = getHandler('shell:openInExternalEditor')
await expect(
handler({}, { path: 'relative/project', command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: false, reason: 'not-absolute' })
expect(statMock).not.toHaveBeenCalled()
expect(resolveCliCommandMock).not.toHaveBeenCalled()
expect(spawnMock).not.toHaveBeenCalled()
})
it('returns alias recovery details for manual custom-port targets', async () => {
sshTargets.set(
'ssh-1',
createSshTarget({
source: 'manual',
configHost: 'builder.example.com',
host: 'builder.example.com',
port: 2222
})
)
const handler = getHandler('shell:openInExternalEditor')
await expect(
handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({
ok: false,
reason: 'ssh-alias-required',
host: 'builder.example.com',
port: 2222
})
expect(spawnMock).not.toHaveBeenCalled()
})
it.each(['cursor', 'zed', 'code --reuse-window'])(
'rejects the unsupported SSH launcher %s',
async (command) => {
sshTargets.set('ssh-1', createSshTarget())
const handler = getHandler('shell:openInExternalEditor')
await expect(
handler({}, { path: '/srv/project', command, connectionId: 'ssh-1' })
).resolves.toEqual({ ok: false, reason: 'remote-editor-unsupported' })
expect(spawnMock).not.toHaveBeenCalled()
}
)
it('maps unsafe Windows batch arguments to a closed launch failure', async () => {
sshTargets.set('ssh-1', createSshTarget())
resolveCliCommandMock.mockReturnValueOnce('C:\\Tools\\code.cmd')
getSpawnArgsForWindowsMock.mockImplementationOnce(() => {
throw new Error('unsafe batch arguments')
})
const handler = getHandler('shell:openInExternalEditor')
await expect(
handler({}, { path: '/srv/project&whoami', command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: false, reason: 'launch-failed' })
expect(spawnMock).not.toHaveBeenCalled()
})
})
describe('legacy file open handlers', () => {
+69 -16
View File
@@ -1,15 +1,23 @@
import { ipcMain, shell, dialog } from 'electron'
import { spawn } from 'node:child_process'
import { constants, copyFile, readFile, stat } from 'node:fs/promises'
import { basename, extname, isAbsolute, normalize } from 'node:path'
import { basename, extname, isAbsolute, normalize, posix, win32 } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { ShellOpenLocalPathResult } from '../../shared/shell-open-types'
import type {
ShellOpenExternalEditorRequest,
ShellOpenExternalEditorResult,
ShellOpenLocalPathResult
} from '../../shared/shell-open-types'
import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon'
import type { Store } from '../persistence'
import { getSpawnArgsForWindows } from '../win32-utils'
import {
EXTERNAL_EDITOR_CLI_COMMAND,
resolveExternalEditorLaunchSpec
resolveExternalEditorLaunchSpec,
resolveVsCodeRemoteSshLaunchSpec,
type ExternalEditorLaunchSpec
} from '../external-editor-launch'
import { resolveVsCodeSshAuthority } from '../ssh/vscode-ssh-authority'
export { EXTERNAL_EDITOR_CLI_COMMAND }
@@ -39,7 +47,17 @@ async function validateLocalPathTarget(
return { ok: true, path: normalizedPath }
}
async function openInFileManager(pathValue: string): Promise<ShellOpenLocalPathResult> {
function hasActiveRuntime(store: Store): boolean {
return Boolean(store.getSettings().activeRuntimeEnvironmentId?.trim())
}
async function openInFileManager(
store: Store,
pathValue: string
): Promise<ShellOpenLocalPathResult> {
if (hasActiveRuntime(store)) {
return { ok: false, reason: 'remote-runtime-unsupported' }
}
const target = await validateLocalPathTarget(pathValue)
if (!target.ok) {
return target
@@ -54,8 +72,7 @@ async function openInFileManager(pathValue: string): Promise<ShellOpenLocalPathR
}
}
async function launchExternalEditor(pathValue: string, command?: string): Promise<void> {
const launchSpec = resolveExternalEditorLaunchSpec(command, pathValue)
async function launchExternalEditor(launchSpec: ExternalEditorLaunchSpec): Promise<void> {
const { spawnCmd, spawnArgs } =
launchSpec.kind === 'executable'
? getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs)
@@ -99,15 +116,51 @@ async function launchExternalEditor(pathValue: string, command?: string): Promis
}
async function openInExternalEditor(
pathValue: string,
command?: string
): Promise<ShellOpenLocalPathResult> {
const target = await validateLocalPathTarget(pathValue)
store: Store,
request: ShellOpenExternalEditorRequest
): Promise<ShellOpenExternalEditorResult> {
if (hasActiveRuntime(store)) {
return { ok: false, reason: 'remote-runtime-unsupported' }
}
const connectionId = request.connectionId?.trim()
if (connectionId) {
const sshTarget = store.getSshTarget(connectionId)
if (!sshTarget) {
return { ok: false, reason: 'ssh-target-not-found' }
}
if (sshTarget.owner?.type === 'on-demand-runtime') {
return { ok: false, reason: 'remote-runtime-unsupported' }
}
if (!posix.isAbsolute(request.path) && !win32.isAbsolute(request.path)) {
return { ok: false, reason: 'not-absolute' }
}
const authority = resolveVsCodeSshAuthority(sshTarget)
if (!authority.ok) {
return authority
}
const launchSpec = resolveVsCodeRemoteSshLaunchSpec(
request.command,
request.path,
authority.authority
)
if (!launchSpec) {
return { ok: false, reason: 'remote-editor-unsupported' }
}
try {
await launchExternalEditor(launchSpec)
return { ok: true }
} catch {
return { ok: false, reason: 'launch-failed' }
}
}
const target = await validateLocalPathTarget(request.path)
if (!target.ok) {
return target
}
try {
await launchExternalEditor(target.path, command)
await launchExternalEditor(resolveExternalEditorLaunchSpec(request.command, target.path))
return { ok: true }
} catch {
return { ok: false, reason: 'launch-failed' }
@@ -127,22 +180,22 @@ async function openWithSystemDefault(pathValue: string): Promise<boolean> {
}
}
export function registerShellHandlers(): void {
export function registerShellHandlers(store: Store): void {
ipcMain.handle('shell:openPath', async (_event, path: string): Promise<void> => {
// Why: keep the legacy fire-and-forget renderer contract while reusing the
// same absolute/existing path validation as the explicit file-manager API.
void (await openInFileManager(path))
void (await openInFileManager(store, path))
})
ipcMain.handle(
'shell:openInFileManager',
(_event, path: string): Promise<ShellOpenLocalPathResult> => openInFileManager(path)
(_event, path: string): Promise<ShellOpenLocalPathResult> => openInFileManager(store, path)
)
ipcMain.handle(
'shell:openInExternalEditor',
(_event, path: string, command?: string): Promise<ShellOpenLocalPathResult> =>
openInExternalEditor(path, command)
(_event, request: ShellOpenExternalEditorRequest): Promise<ShellOpenExternalEditorResult> =>
openInExternalEditor(store, request)
)
ipcMain.handle('shell:openUrl', (_event, rawUrl: string) => {
@@ -1,3 +1,4 @@
import type { Stats } from 'node:fs'
import { readFile, stat } from 'node:fs/promises'
import type { Repo } from '../../shared/types'
import {
@@ -7,7 +8,7 @@ import {
} from '../../shared/cross-platform-path'
import type { FileStat } from '../providers/types'
type GitDirectoryStat = Awaited<ReturnType<typeof stat>> | FileStat
type GitDirectoryStat = Stats | FileStat
type GitDirectoryAccess = {
stat?: (path: string) => Promise<GitDirectoryStat>
+121
View File
@@ -2306,6 +2306,127 @@ describe('registerWorktreeHandlers', () => {
})
})
it('hydrates detected worktrees with instance-validated legacy lineage after an update', async () => {
const parentPath = '/workspace/assigned-issues'
const childPath = '/workspace/issue-9276-nested-ssh-runtime-routing'
const parentId = `repo-1::${parentPath}`
const childId = `repo-1::${childPath}`
const metaById: Record<string, { instanceId: string }> = {
[parentId]: { instanceId: 'parent-instance' },
[childId]: { instanceId: 'child-instance' }
}
store.getWorktreeMeta.mockImplementation((id: string) => metaById[id])
store.setWorktreeMeta.mockImplementation((id: string, updates: object) => ({
...metaById[id],
...updates
}))
store.getAllWorktreeLineage.mockReturnValue({
[childId]: {
worktreeId: childId,
worktreeInstanceId: 'child-instance',
parentWorktreeId: parentId,
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
})
listWorktreesMock.mockResolvedValue([
{
path: childPath,
head: 'child-head',
branch: 'refs/heads/child',
isBare: false,
isMainWorktree: false
},
{
path: parentPath,
head: 'parent-head',
branch: 'refs/heads/parent',
isBare: false,
isMainWorktree: false
}
])
const result = (await handlers['worktrees:listDetected'](null, {
repoId: 'repo-1'
})) as { worktrees: (Worktree & { lineage?: unknown; parentWorktreeId?: string | null })[] }
expect(result.worktrees).toEqual([
expect.objectContaining({
id: childId,
parentWorktreeId: parentId,
lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' })
}),
expect.objectContaining({
id: parentId,
parentWorktreeId: null,
childWorktreeIds: [childId],
lineage: null
})
])
})
it('hydrates folder-repo detected rows with instance-validated legacy lineage', async () => {
const folderRepo = {
id: 'repo-1',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder' as const
}
const parentId = `${folderRepo.id}::${folderRepo.path}`
const childId = `${parentId}::workspace:child-instance`
const metaById: Record<string, Record<string, unknown>> = {
[parentId]: makeWorktreeMeta({
instanceId: 'parent-instance',
projectId: 'repo:repo-1',
hostId: 'local',
projectHostSetupId: 'repo-1'
}),
[childId]: makeWorktreeMeta({
instanceId: 'child-instance',
projectId: 'repo:repo-1',
hostId: 'local',
projectHostSetupId: 'repo-1'
})
}
store.getRepos.mockReturnValue([folderRepo])
store.getRepo.mockReturnValue(folderRepo)
store.getAllWorktreeMeta.mockReturnValue(metaById)
store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId])
store.getAllWorktreeLineage.mockReturnValue({
[childId]: {
worktreeId: childId,
worktreeInstanceId: 'child-instance',
parentWorktreeId: parentId,
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
})
const result = (await handlers['worktrees:listDetected'](null, {
repoId: folderRepo.id
})) as { worktrees: (Worktree & { lineage?: unknown; parentWorktreeId?: string | null })[] }
expect(result.worktrees).toEqual([
expect.objectContaining({
id: parentId,
parentWorktreeId: null,
childWorktreeIds: [childId],
lineage: null
}),
expect.objectContaining({
id: childId,
parentWorktreeId: parentId,
lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' })
})
])
})
it('hides agent scratch created inside a linked checkout from desktop listings', async () => {
const linkedCheckoutPath = '/workspace/feature-x'
const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa`
+9 -3
View File
@@ -13,6 +13,7 @@ import {
} from '../../shared/workspace-scope'
import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports'
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection'
import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage'
import { deleteWorktreeHistoryDir } from '../terminal-history'
import type {
AutomationWorkspaceProvenance,
@@ -738,7 +739,7 @@ function buildDetectedGitWorktrees(
repo.path,
...liveWorktrees.map((worktree) => worktree.path)
])
return liveWorktrees.map((gitWorktree) => {
const detected = liveWorktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
let meta = store.getWorktreeMeta(worktreeId)
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
@@ -766,6 +767,7 @@ function buildDetectedGitWorktrees(
agentScratchWorktreePathMatcher
})
})
return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
}
function stampAndMergeVisibleDetectedWorktree(
@@ -959,7 +961,7 @@ function buildDisconnectedDetectedWorktrees(
repo.path,
...worktrees.map((worktree) => worktree.path)
])
return worktrees.map((worktree) => {
const detected = worktrees.map((worktree) => {
const meta = store.getWorktreeMeta(worktree.id)
const detected = toDetectedWorktree({
repo,
@@ -972,6 +974,7 @@ function buildDisconnectedDetectedWorktrees(
})
return applyMetadataFallbackVisibility(detected)
})
return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
}
export function registerWorktreeHandlers(
@@ -1149,7 +1152,10 @@ export function registerWorktreeHandlers(
repoId: repo.id,
authoritative: true,
source: 'git',
worktrees: buildFolderDetectedWorktrees(store, repo)
worktrees: projectResolvedWorktreeLineage(
buildFolderDetectedWorktrees(store, repo),
store.getAllWorktreeLineage?.() ?? {}
)
}
} else if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
@@ -132,6 +132,23 @@ resolver #1
expect(child.listenerCount('close')).toBe(0)
})
it('kills scutil and removes listeners when its owner stops', async () => {
mockPlatform('darwin')
const child = createMockScutilProcess()
vi.mocked(spawn).mockReturnValue(child)
const abortController = new AbortController()
const healthPromise = readCurrentProcessMacSystemResolverHealth(abortController.signal)
abortController.abort()
await expect(healthPromise).resolves.toBe('unknown')
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
expect(child.stdout.listenerCount('data')).toBe(0)
expect(child.stderr.listenerCount('data')).toBe(0)
expect(child.listenerCount('error')).toBe(0)
expect(child.listenerCount('close')).toBe(0)
})
it('removes scutil listeners when the child closes normally', async () => {
mockPlatform('darwin')
const child = createMockScutilProcess()
@@ -16,8 +16,10 @@ export function classifyMacSystemResolverHealth(scutilOutput: string): SystemRes
return 'unknown'
}
export async function readCurrentProcessMacSystemResolverHealth(): Promise<SystemResolverHealth> {
if (process.platform !== 'darwin') {
export async function readCurrentProcessMacSystemResolverHealth(
signal?: AbortSignal
): Promise<SystemResolverHealth> {
if (process.platform !== 'darwin' || signal?.aborted) {
return 'unknown'
}
@@ -35,6 +37,10 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise<Syste
const onStderrData = (chunk: string): void => {
stderr += chunk
}
const onAbort = (): void => {
child.kill('SIGKILL')
finish()
}
const finish = (): void => {
if (settled) {
return
@@ -48,6 +54,7 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise<Syste
child.stderr.off('data', onStderrData)
child.off('error', finish)
child.off('close', finish)
signal?.removeEventListener('abort', onAbort)
resolve(classifyMacSystemResolverHealth(`${stdout}\n${stderr}`))
}
timer = setTimeout(() => {
@@ -62,5 +69,6 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise<Syste
child.stderr.on('data', onStderrData)
child.on('error', finish)
child.on('close', finish)
signal?.addEventListener('abort', onAbort, { once: true })
})
}
+120
View File
@@ -9703,6 +9703,110 @@ describe('Store', () => {
// ── Live Claude PTY session ids (STA-1246) ─────────────────────────
describe('mobileClientTabSelectionsByDeviceId', () => {
it('persists device tab selections across reloads and drops malformed payloads', async () => {
const store = await createStore()
store.setMobileClientTabSelections({
'device-a': {
'repo-1::/tmp/wt': { activeTabId: 'tab-1', activeGroupId: 'g1', activeTabIdByGroupId: {} }
}
})
store.flush()
const reloaded = await createStore()
expect(reloaded.getMobileClientTabSelections()['device-a']?.['repo-1::/tmp/wt']).toEqual({
activeTabId: 'tab-1',
activeGroupId: 'g1',
activeTabIdByGroupId: {}
})
writeDataFile({ mobileClientTabSelectionsByDeviceId: { 'device-a': 'corrupt' } })
const corrupted = await createStore()
expect(corrupted.getMobileClientTabSelections()).toEqual({})
})
it('prunes selections for a removed repo worktree', async () => {
const store = await createStore()
store.addRepo(makeRepo())
store.setMobileClientTabSelections({
'device-a': {
'r1::/tmp/wt': {
activeTabId: 'tab-1',
activeGroupId: null,
activeTabIdByGroupId: {}
},
'other-repo::/tmp/wt': {
activeTabId: 'tab-2',
activeGroupId: null,
activeTabIdByGroupId: {}
}
}
})
store.removeProject('r1')
store.flush()
expect(store.getMobileClientTabSelections()['device-a']).toEqual({
'other-repo::/tmp/wt': {
activeTabId: 'tab-2',
activeGroupId: null,
activeTabIdByGroupId: {}
}
})
const reloaded = await createStore()
expect(reloaded.getMobileClientTabSelections()['device-a']).toEqual({
'other-repo::/tmp/wt': {
activeTabId: 'tab-2',
activeGroupId: null,
activeTabIdByGroupId: {}
}
})
})
it('prunes selections when a folder workspace is removed directly or with its group', async () => {
const store = await createStore()
const directGroup = store.createProjectGroup({
name: 'Direct',
parentPath: '/tmp/direct',
createdFrom: 'manual'
})
const directWorkspace = store.createFolderWorkspace({
projectGroupId: directGroup.id,
name: 'Direct workspace'
})
const cascadeGroup = store.createProjectGroup({
name: 'Cascade',
parentPath: '/tmp/cascade',
createdFrom: 'manual'
})
const cascadeWorkspace = store.createFolderWorkspace({
projectGroupId: cascadeGroup.id,
name: 'Cascade workspace'
})
store.setMobileClientTabSelections({
'device-a': {
[folderWorkspaceKey(directWorkspace.id)]: {
activeTabId: 'tab-direct',
activeGroupId: null,
activeTabIdByGroupId: {}
},
[folderWorkspaceKey(cascadeWorkspace.id)]: {
activeTabId: 'tab-cascade',
activeGroupId: null,
activeTabIdByGroupId: {}
}
}
})
store.removeFolderWorkspace(directWorkspace.id)
store.deleteProjectGroup(cascadeGroup.id)
store.flush()
const reloaded = await createStore()
expect(reloaded.getMobileClientTabSelections()).toEqual({})
})
})
describe('claudeLivePtySessionIds', () => {
it('persists added ids across reloads and removes them durably', async () => {
const store = await createStore()
@@ -10360,6 +10464,22 @@ describe('Store.migrateWorktreeIdentity', () => {
expect(store.getWorktreeLineage(CHILD)?.parentWorktreeId).toBe(NEW)
})
it('moves persisted mobile selections across reloads', async () => {
const store = await createStore()
store.setMobileClientTabSelections({
'device-a': {
[OLD]: { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} }
}
})
store.migrateWorktreeIdentity(OLD, NEW)
store.flush()
expect(store.getMobileClientTabSelections()['device-a']?.[OLD]).toBeUndefined()
const reloaded = await createStore()
expect(reloaded.getMobileClientTabSelections()['device-a']?.[NEW]?.activeTabId).toBe('tab-1')
})
it('accumulates prior ids across chained renames', async () => {
const store = await createStore()
store.setWorktreeMeta(OLD, { displayName: 'Cunner' })
+41
View File
@@ -48,6 +48,7 @@ import type {
ProjectGroup,
FolderWorkspace,
SparsePreset,
PersistedMobileClientTabSelections,
WorktreeMeta,
WorktreeLineage,
WorkspaceLineage,
@@ -77,6 +78,7 @@ import {
} from '../shared/task-source-context'
import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types'
import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files'
import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence'
import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement'
import {
removeRepoFromHostWorkspaceSessions,
@@ -3034,6 +3036,9 @@ export class Store {
normalizedProjectGroups
),
worktreeLineageById: parsed.worktreeLineageById ?? {},
mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections(
parsed.mobileClientTabSelectionsByDeviceId
),
workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey(
parsed.workspaceLineageByChildKey
),
@@ -3910,8 +3915,10 @@ export class Store {
? { ...repo, projectGroupId: null }
: repo
)
const removedFolderWorkspaceKeys = new Set<string>()
for (const workspace of this.state.folderWorkspaces ?? []) {
if (deletedGroupIds.has(workspace.projectGroupId)) {
removedFolderWorkspaceKeys.add(folderWorkspaceKey(workspace.id))
this.state.workspaceSession = removeWorkspaceSessionOwner(
this.state.workspaceSession,
folderWorkspaceKey(workspace.id)
@@ -3922,6 +3929,7 @@ export class Store {
this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter(
(workspace) => !deletedGroupIds.has(workspace.projectGroupId)
)
this.pruneMobileClientTabSelections((worktreeId) => removedFolderWorkspaceKeys.has(worktreeId))
this.scheduleSave()
return true
}
@@ -4071,6 +4079,7 @@ export class Store {
folderWorkspaceKey(id)
)!
this.removeWorkspaceLineageForFolderParent(id)
this.pruneMobileClientTabSelections((worktreeId) => worktreeId === folderWorkspaceKey(id))
this.scheduleSave()
return true
}
@@ -4249,6 +4258,22 @@ export class Store {
delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey]
}
}
this.pruneMobileClientTabSelections(belongsToHost)
}
private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void {
for (const [clientNavigationId, selectionsByWorktree] of Object.entries(
this.state.mobileClientTabSelectionsByDeviceId ?? {}
)) {
for (const worktreeId of Object.keys(selectionsByWorktree)) {
if (matchesWorktreeId(worktreeId)) {
delete selectionsByWorktree[worktreeId]
}
}
if (Object.keys(selectionsByWorktree).length === 0) {
delete this.state.mobileClientTabSelectionsByDeviceId?.[clientNavigationId]
}
}
}
updateRepo(
@@ -4488,6 +4513,17 @@ export class Store {
// ── Sparse Presets ─────────────────────────────────────────────────
// ── Mobile client tab selections ──────────────────────────────────
getMobileClientTabSelections(): PersistedMobileClientTabSelections {
return this.state.mobileClientTabSelectionsByDeviceId ?? {}
}
setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void {
this.state.mobileClientTabSelectionsByDeviceId = next
this.scheduleSave()
}
getSparsePresets(repoId: string): SparsePreset[] {
return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) =>
left.name.localeCompare(right.name)
@@ -5035,6 +5071,11 @@ export class Store {
for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) {
changed = migrateSession(session) || changed
}
for (const selectionsByWorktree of Object.values(
this.state.mobileClientTabSelectionsByDeviceId ?? {}
)) {
changed = moveKey(selectionsByWorktree) || changed
}
const showDotfiles = this.state.ui?.showDotfilesByWorktree
if (showDotfiles) {
changed = moveKey(showDotfiles) || changed
@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, existsSyncMock, stdinEndMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
existsSyncMock: vi.fn(),
stdinEndMock: vi.fn()
}))
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
vi.mock('node:fs', () => ({ existsSync: existsSyncMock }))
import { runMacosLoginSessionPtyProbe } from './macos-login-session-pty-probe'
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void
describe('runMacosLoginSessionPtyProbe', () => {
beforeEach(() => {
existsSyncMock.mockReturnValue(true)
execFileMock.mockReset()
stdinEndMock.mockReset()
})
it('runs login under expect-owned PTY and requires its marker plus a clean exit', async () => {
const abortController = new AbortController()
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(null, '^D\b\bORCA_LOGIN_PREFLIGHT_OK', '')
return { stdin: { end: stdinEndMock } }
}
)
await expect(
runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024, abortController.signal)
).resolves.toEqual({ ok: true, conclusive: true, reason: 'accepted' })
expect(execFileMock).toHaveBeenCalledWith(
'/usr/bin/expect',
[
'-c',
expect.stringContaining(
'spawn -noecho /usr/bin/login -flpq $env(ORCA_LOGIN_PROBE_USERNAME)'
)
],
expect.objectContaining({
cwd: '/Users/ada',
env: expect.objectContaining({ ORCA_LOGIN_PROBE_USERNAME: 'ada' }),
killSignal: 'SIGKILL',
maxBuffer: 1_024,
signal: abortController.signal,
timeout: 4_000
}),
expect.any(Function)
)
expect(stdinEndMock).toHaveBeenCalledOnce()
expect(execFileMock.mock.calls[0]?.[1]?.[1]).toContain('send "\\004"; expect eof')
})
it('treats a natural exit without the marker as a conclusive rejection', async () => {
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(null, 'Login incorrect\r\nlogin: ', '')
return { stdin: { end: stdinEndMock } }
}
)
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
ok: false,
conclusive: true,
reason: 'rejected'
})
})
it('keeps a timeout or output overflow inconclusive', async () => {
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
ok: false,
conclusive: false,
reason: 'timeout'
})
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(
Object.assign(new Error('stdout maxBuffer length exceeded'), {
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
}),
'',
''
)
return { stdin: { end: stdinEndMock } }
}
)
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
ok: false,
conclusive: false,
reason: 'error'
})
})
it('does not mistake an expect wrapper failure for a PAM rejection', async () => {
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('expect Tcl error'), { code: 1 }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
ok: false,
conclusive: false,
reason: 'error'
})
})
it('fails safe without spawning when expect is unavailable', async () => {
existsSyncMock.mockReturnValue(false)
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
ok: false,
conclusive: false,
reason: 'error'
})
expect(execFileMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,78 @@
import { execFile, type ExecFileException } from 'node:child_process'
import { existsSync } from 'node:fs'
const MACOS_EXPECT_PATH = '/usr/bin/expect'
const LOGIN_PREFLIGHT_MARKER = 'ORCA_LOGIN_PREFLIGHT_OK'
const LOGIN_PROBE_USERNAME_ENV = 'ORCA_LOGIN_PROBE_USERNAME'
// Why: expect owns the PTY without adding a long-lived native handle to the daemon.
const EXPECT_LOGIN_PROBE_SCRIPT =
'log_user 1; ' +
'spawn -noecho /usr/bin/login -flpq $env(ORCA_LOGIN_PROBE_USERNAME) /usr/bin/printf ORCA_LOGIN_PREFLIGHT_OK; ' +
'send "\\004"; expect eof; wait; exit 0'
export type LoginPreflightOutcome = {
ok: boolean
conclusive: boolean
reason: 'accepted' | 'rejected' | 'timeout' | 'error'
}
export function classifyLoginPreflightError(error: ExecFileException): LoginPreflightOutcome {
// Why: a probe killed by our bound proves nothing about PAM and must not stick.
if (error.killed || error.code === 'ETIMEDOUT') {
return { ok: false, conclusive: false, reason: 'timeout' }
}
// Why: a natural nonzero exit is login(1)'s conclusive rejection verdict.
if (typeof error.code === 'number') {
return { ok: false, conclusive: true, reason: 'rejected' }
}
return { ok: false, conclusive: false, reason: 'error' }
}
/** Runs the login-session oracle under a real PTY when the pipe probe cannot decide. */
export function runMacosLoginSessionPtyProbe(
username: string,
accountHome: string,
timeoutMs: number,
maxOutputBytes: number,
signal?: AbortSignal
): Promise<LoginPreflightOutcome> {
if (!existsSync(MACOS_EXPECT_PATH)) {
return Promise.resolve({ ok: false, conclusive: false, reason: 'error' })
}
return new Promise((resolve) => {
try {
const child = execFile(
MACOS_EXPECT_PATH,
['-c', EXPECT_LOGIN_PROBE_SCRIPT],
{
cwd: accountHome,
encoding: 'utf8',
env: { ...process.env, [LOGIN_PROBE_USERNAME_ENV]: username },
killSignal: 'SIGKILL',
maxBuffer: maxOutputBytes,
signal,
timeout: timeoutMs
},
(error, stdout) => {
if (error !== null) {
// Why: a nonzero expect exit can be its own Tcl/PTY failure, not PAM authority.
resolve(
error.killed || error.code === 'ETIMEDOUT'
? { ok: false, conclusive: false, reason: 'timeout' }
: { ok: false, conclusive: false, reason: 'error' }
)
return
}
resolve(
stdout.includes(LOGIN_PREFLIGHT_MARKER)
? { ok: true, conclusive: true, reason: 'accepted' }
: { ok: false, conclusive: true, reason: 'rejected' }
)
}
)
child.stdin?.end()
} catch {
resolve({ ok: false, conclusive: false, reason: 'error' })
}
})
}
@@ -1,23 +1,34 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as LoginSessionPtyProbe from './macos-login-session-pty-probe'
const { existsSyncMock, userInfoMock, execFileMock, stdinEndMock } = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
userInfoMock: vi.fn(),
execFileMock: vi.fn(),
stdinEndMock: vi.fn()
}))
const { existsSyncMock, userInfoMock, execFileMock, stdinEndMock, ptyProbeMock } = vi.hoisted(
() => ({
existsSyncMock: vi.fn(),
userInfoMock: vi.fn(),
execFileMock: vi.fn(),
stdinEndMock: vi.fn(),
ptyProbeMock: vi.fn()
})
)
vi.mock('node:fs', () => ({ existsSync: existsSyncMock }))
vi.mock('node:os', () => ({ userInfo: userInfoMock }))
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
vi.mock('./macos-login-session-pty-probe', async (importOriginal) => ({
...(await importOriginal<typeof LoginSessionPtyProbe>()),
runMacosLoginSessionPtyProbe: ptyProbeMock
}))
import {
prepareMacosTccLoginShell,
probeMacosLoginSessionAlive,
resetMacosLoginShellPreflightForTests,
wrapShellSpawnForMacosTccAttribution
} from './macos-tcc-login-shell'
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void
const ACCEPTED_OUTCOME = { ok: true, conclusive: true, reason: 'accepted' } as const
const REJECTED_OUTCOME = { ok: false, conclusive: true, reason: 'rejected' } as const
describe('wrapShellSpawnForMacosTccAttribution', () => {
let origPlatform: PropertyDescriptor | undefined
@@ -39,6 +50,7 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
return { stdin: { end: stdinEndMock } }
}
)
ptyProbeMock.mockResolvedValue({ ok: true, conclusive: true, reason: 'accepted' })
resetMacosLoginShellPreflightForTests()
})
@@ -340,3 +352,200 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
expect(execFileMock).not.toHaveBeenCalled()
})
})
describe('probeMacosLoginSessionAlive', () => {
let origPlatform: PropertyDescriptor | undefined
let origDisable: string | undefined
function setPlatform(value: string): void {
Object.defineProperty(process, 'platform', { configurable: true, value })
}
beforeEach(() => {
origPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
origDisable = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
existsSyncMock.mockReturnValue(true)
userInfoMock.mockReturnValue({ username: 'ada', homedir: '/Users/ada' })
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
return { stdin: { end: stdinEndMock } }
}
)
ptyProbeMock.mockResolvedValue({ ok: true, conclusive: true, reason: 'accepted' })
resetMacosLoginShellPreflightForTests()
})
afterEach(() => {
if (origPlatform) {
Object.defineProperty(process, 'platform', origPlatform)
}
if (origDisable === undefined) {
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
} else {
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = origDisable
}
vi.restoreAllMocks()
vi.clearAllMocks()
})
it('re-probes even after a cached acceptance', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
expect(execFileMock).toHaveBeenCalledTimes(1)
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' })
expect(execFileMock).toHaveBeenCalledTimes(2)
})
it('reuses an in-flight startup warmup instead of spawning a duplicate probe', async () => {
setPlatform('darwin')
let finishPreflight!: ExecFileCallback
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
finishPreflight = callback
return { stdin: { end: stdinEndMock } }
}
)
const warmup = prepareMacosTccLoginShell()
const freshProbe = probeMacosLoginSessionAlive()
expect(execFileMock).toHaveBeenCalledOnce()
finishPreflight(null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
await expect(Promise.all([warmup, freshProbe])).resolves.toEqual([
ACCEPTED_OUTCOME,
ACCEPTED_OUTCOME
])
expect(execFileMock).toHaveBeenCalledOnce()
})
it('does not let a spawn-path probe overwrite a newer death verdict', async () => {
setPlatform('darwin')
const callbacks: ExecFileCallback[] = []
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callbacks.push(callback)
return { stdin: { end: stdinEndMock } }
}
)
const freshProbe = probeMacosLoginSessionAlive()
const spawnProbe = prepareMacosTccLoginShell()
expect(execFileMock).toHaveBeenCalledTimes(2)
callbacks[0](Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
await expect(freshProbe).resolves.toEqual(REJECTED_OUTCOME)
callbacks[1](null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
await expect(spawnProbe).resolves.toEqual(ACCEPTED_OUTCOME)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
})
it('flips the spawn wrapper off when a fresh probe conclusively rejects (dead login session)', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' })
// The dead-session daemon must stop minting login(1) prompt zombies (#7936).
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
})
it('does not overwrite the cached verdict on an inconclusive probe', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' })
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' })
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
it('returns null off macOS and when disabled', async () => {
setPlatform('linux')
expect(await probeMacosLoginSessionAlive()).toBeNull()
setPlatform('darwin')
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1'
expect(await probeMacosLoginSessionAlive()).toBeNull()
expect(execFileMock).not.toHaveBeenCalled()
})
it('escalates an inconclusive pipe probe to a PTY probe and accepts its verdict', async () => {
setPlatform('darwin')
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' })
expect(execFileMock).toHaveBeenCalledOnce()
expect(ptyProbeMock).toHaveBeenCalledWith('ada', '/Users/ada', 4_000, 1_024, undefined)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
it('treats a PTY-probe rejection as conclusive and flips the wrapper off', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: true, reason: 'rejected' })
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' })
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
})
it('stays inconclusive when both pipe and PTY probes time out', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' })
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' })
// Inconclusive must not disturb the cached acceptance.
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
it('does not start a PTY fallback after the watch cancels its pipe probe', async () => {
setPlatform('darwin')
const abortController = new AbortController()
abortController.abort()
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('aborted'), { code: 'ABORT_ERR' }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
await expect(probeMacosLoginSessionAlive(abortController.signal)).resolves.toEqual({
ok: false,
conclusive: false,
reason: 'error'
})
expect(ptyProbeMock).not.toHaveBeenCalled()
})
})
+84 -27
View File
@@ -1,11 +1,21 @@
import { execFile, type ExecFileException } from 'node:child_process'
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { userInfo } from 'node:os'
import {
classifyLoginPreflightError,
runMacosLoginSessionPtyProbe,
type LoginPreflightOutcome
} from './macos-login-session-pty-probe'
export type { LoginPreflightOutcome } from './macos-login-session-pty-probe'
const MACOS_LOGIN_PATH = '/usr/bin/login'
const MACOS_ENV_PATH = '/usr/bin/env'
const MACOS_PRINTF_PATH = '/usr/bin/printf'
const LOGIN_PREFLIGHT_TIMEOUT_MS = 500
// Why: the death-watch probe runs off the spawn path, so it can afford a bound
// that outlasts a PAM stack answering slowly rather than misreading it as a hang.
const LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS = 4_000
const LOGIN_PREFLIGHT_MARKER = 'ORCA_LOGIN_PREFLIGHT_OK'
const LOGIN_PREFLIGHT_MAX_BUFFER_BYTES = 1024
const LOGIN_PREFLIGHT_RETRY_BASE_MS = 5_000
@@ -23,15 +33,11 @@ const DISABLE_ENV_VAR = 'ORCA_DISABLE_MACOS_LOGIN_SHELL'
* reject) that may be cached; an inconclusive probe (our own timeout/SIGKILL,
* maxBuffer, or spawn error) proves nothing about PAM and must not stick.
*/
export type LoginPreflightOutcome = {
ok: boolean
conclusive: boolean
reason: 'accepted' | 'rejected' | 'timeout' | 'error'
}
let cachedLoginPreflightResult: boolean | null = null
let loginPreflightInFlight: Promise<LoginPreflightOutcome> | null = null
let transientLoginPreflightFailure: { failureCount: number; retryAtMs: number } | null = null
let loginPreflightCacheEpoch = 0
let loginSessionProbeInFlight = false
function isDisabledByEnv(): boolean {
const value = process.env[DISABLE_ENV_VAR]
@@ -45,25 +51,15 @@ function loginPreflightRetryDelayMs(failureCount: number): number {
)
}
function classifyPreflightError(error: ExecFileException): LoginPreflightOutcome {
// Why: our SIGKILL timeout cap (and maxBuffer, which also kills) is an
// environmental slow-path, not a PAM verdict — retry, don't cache (F1).
if (error.killed || error.code === 'ETIMEDOUT') {
return { ok: false, conclusive: false, reason: 'timeout' }
}
// A numeric exit code means login(1) ran to completion and rejected the user
// (it exits immediately on EOF-driven rejection); that verdict is cacheable.
if (typeof error.code === 'number') {
return { ok: false, conclusive: true, reason: 'rejected' }
}
// Spawn/EOF/other failure: inconclusive, fail open for this spawn but retry.
return { ok: false, conclusive: false, reason: 'error' }
}
// Fidelity limit: the probe runs over pipes while production shells run under a
// real PTY, so a tty-sensitive PAM stack could diverge. It fails safe — a probe
// pass with a prod failure only degrades to today's direct spawn (no wrapper).
function runLoginPreflight(username: string, accountHome: string): Promise<LoginPreflightOutcome> {
function runLoginPreflight(
username: string,
accountHome: string,
timeoutMs = LOGIN_PREFLIGHT_TIMEOUT_MS,
signal?: AbortSignal
): Promise<LoginPreflightOutcome> {
return new Promise((resolve) => {
try {
const child = execFile(
@@ -78,7 +74,8 @@ function runLoginPreflight(username: string, accountHome: string): Promise<Login
// captured diagnostics without blocking the PTY host's event loop.
killSignal: 'SIGKILL',
maxBuffer: LOGIN_PREFLIGHT_MAX_BUFFER_BYTES,
timeout: LOGIN_PREFLIGHT_TIMEOUT_MS
signal,
timeout: timeoutMs
},
(error, stdout) => {
if (error === null) {
@@ -91,7 +88,7 @@ function runLoginPreflight(username: string, accountHome: string): Promise<Login
)
return
}
resolve(classifyPreflightError(error))
resolve(classifyLoginPreflightError(error))
}
)
// Why: login(1) must see immediate EOF, not an interactive pipe, so a PAM
@@ -121,15 +118,17 @@ function loginPreflightSucceeds(
return Promise.resolve(cached)
}
if (!loginPreflightInFlight) {
const cacheEpoch = loginPreflightCacheEpoch
// Why: simultaneous pane restores share one PAM child instead of multiplying
// subprocesses at exactly the point terminal startup is already busiest.
loginPreflightInFlight = runLoginPreflight(username, accountHome).then((outcome) => {
// Why: cache only a conclusive PAM verdict; a killed/timed-out probe is
// environmental and must be retried next spawn, not stuck forever (F1).
if (outcome.conclusive) {
const mayUpdateCache = !loginSessionProbeInFlight && cacheEpoch === loginPreflightCacheEpoch
if (outcome.conclusive && mayUpdateCache) {
cachedLoginPreflightResult = outcome.ok
transientLoginPreflightFailure = null
} else {
} else if (!outcome.conclusive && mayUpdateCache) {
const failureCount = (transientLoginPreflightFailure?.failureCount ?? 0) + 1
transientLoginPreflightFailure = {
failureCount,
@@ -192,6 +191,64 @@ export function resetMacosLoginShellPreflightForTests(): void {
cachedLoginPreflightResult = null
loginPreflightInFlight = null
transientLoginPreflightFailure = null
loginPreflightCacheEpoch = 0
loginSessionProbeInFlight = false
}
/**
* Fresh PAM probe for login-session death detection (#7936): bypasses the
* cached verdict and the transient backoff, and writes any conclusive verdict
* back into the cache — so a daemon whose login session died stops wrapping
* spawns in `login(1)` (which would only mint "Login incorrect" zombies) even
* before retirement completes. Escalates to a PTY-hosted probe when the pipe
* probe is inconclusive, since a dead session's PAM stack may only misbehave
* under a real tty. Returns null when the wrapper doesn't apply.
*/
export async function probeMacosLoginSessionAlive(
signal?: AbortSignal
): Promise<LoginPreflightOutcome | null> {
if (process.platform !== 'darwin' || isDisabledByEnv() || !existsSync(MACOS_LOGIN_PATH)) {
return null
}
let username: string
let accountHome: string
try {
const account = userInfo()
username = account.username
accountHome = account.homedir
} catch {
return null
}
if (!username || !accountHome) {
return null
}
// Why: reuse the startup warmup when present, and fence older spawn-path results from restoring a stale verdict.
const existingPreflight = loginPreflightInFlight
loginSessionProbeInFlight = true
loginPreflightCacheEpoch++
let outcome: LoginPreflightOutcome
try {
outcome = await (existingPreflight ??
runLoginPreflight(username, accountHome, LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS, signal))
if (!outcome.conclusive && !signal?.aborted) {
outcome = await runMacosLoginSessionPtyProbe(
username,
accountHome,
LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS,
LOGIN_PREFLIGHT_MAX_BUFFER_BYTES,
signal
)
}
} finally {
// Why: invalidate spawn probes started during this fresh check before they can overwrite its newer verdict.
loginPreflightCacheEpoch++
loginSessionProbeInFlight = false
}
if (outcome.conclusive) {
cachedLoginPreflightResult = outcome.ok
transientLoginPreflightFailure = null
}
return outcome
}
/**

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