mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into brennanb2025/tcc-local-build-bundle-id
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Generated
+50
-83
@@ -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(
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
|
||||
Generated
+460
-855
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
@@ -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']
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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'}`
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"']]))
|
||||
|
||||
@@ -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'}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -182,7 +182,7 @@ export function registerCoreHandlers(
|
||||
onBeforeSignOut: lifecycleOptions.onBeforeOrcaProfileSignOut
|
||||
})
|
||||
registerBrowserHandlers()
|
||||
registerShellHandlers()
|
||||
registerShellHandlers(store)
|
||||
registerPetHandlers()
|
||||
registerSessionHandlers(store)
|
||||
registerUIHandlers(store)
|
||||
|
||||
+187
-13
@@ -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
@@ -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>
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user