fix(terminal): preserve panes when restored PTY owner is unverifiable (#17860)

* fix(terminal): preserve unverifiable restored pane bindings

* test(terminal): cover unverifiable restored pane identity

* fix(terminal): settle direct SSH retry on unverifiable owner

* fix(terminal): make owner warning actionable

* fix(terminal): harden owner warning recovery feedback

* test(terminal): consolidate fixture imports

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-01 19:53:11 -07:00
committed by GitHub
co-authored by Merge Sim
parent a7fda48fe3
commit 7f6cf271ce
7 changed files with 354 additions and 19 deletions
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import React from 'react'
import { cleanup, render, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const environmentMocks = vi.hoisted(() => ({
@@ -15,6 +15,7 @@ vi.mock('@/lib/client-environment-info', () => ({
import {
TerminalErrorToast,
humanizeTerminalError,
isPaneOwnerUnverifiedError,
isExplainedTerminalError,
isSshReconnectOwnedTerminalError,
shouldOfferDaemonRestart,
@@ -69,7 +70,15 @@ describe('humanizeTerminalError', () => {
it('replaces the pane-owner-unverified code with actionable copy', () => {
const humanized = humanizeTerminalError('terminal_pane_owner_unverified')
expect(humanized).not.toContain('terminal_pane_owner_unverified')
expect(humanized).toContain('Reopen this pane to retry')
expect(humanized).toContain('Click Retry to try reconnecting now')
expect(humanized).toContain('Orca left the saved session unchanged')
expect(humanized).not.toContain('was not closed or deleted')
})
it('identifies the owner-unverified safety state', () => {
expect(isPaneOwnerUnverifiedError('terminal_pane_owner_unverified')).toBe(true)
expect(isPaneOwnerUnverifiedError('Paste failed.')).toBe(false)
expect(isPaneOwnerUnverifiedError('Paste failed.\nterminal_pane_owner_unverified')).toBe(false)
})
it('humanizes an IPC-wrapped pane-owner-unverified error', () => {
@@ -78,6 +87,22 @@ describe('humanizeTerminalError', () => {
expect(humanizeTerminalError(wrapped)).not.toContain('terminal_pane_owner_unverified')
})
it('humanizes an owner marker without classifying mixed errors as safe warnings', () => {
const mixed = humanizeTerminalError('Paste failed.\nterminal_pane_owner_unverified')
expect(mixed).toContain('Paste failed.')
expect(mixed).toContain("Orca couldn't verify this terminal's owner.")
expect(mixed).not.toContain('terminal_pane_owner_unverified')
expect(isPaneOwnerUnverifiedError('Paste failed.\nterminal_pane_owner_unverified')).toBe(false)
})
it('humanizes every owner marker in an aggregated warning', () => {
const repeated = humanizeTerminalError(
"terminal_pane_owner_unverified\nError invoking remote method 'pty:spawn': Error: terminal_pane_owner_unverified"
)
expect(repeated).not.toContain('terminal_pane_owner_unverified')
})
it('leaves other errors untouched', () => {
expect(humanizeTerminalError('Paste failed.')).toBe('Paste failed.')
})
@@ -302,4 +327,59 @@ describe('TerminalErrorToast environment footer', () => {
await waitFor(() => expect(environmentMocks.resolveFooter).not.toHaveBeenCalled())
})
it('renders owner-unverified as a warning without an issue link', () => {
const onRetry = vi.fn().mockResolvedValue(true)
const view = render(
React.createElement(TerminalErrorToast, {
error: 'terminal_pane_owner_unverified',
onDismiss: vi.fn(),
onRetry
})
)
const toast = view.container.querySelector('[data-terminal-error-toast]')
expect(toast?.getAttribute('data-terminal-error-kind')).toBe('owner-unverified')
expect(toast?.querySelector('a')).toBeNull()
expect(toast?.textContent).toContain('Orca left the saved session unchanged')
expect(view.getByRole('button', { name: 'Retry' }).getAttribute('data-slot')).toBe('button')
fireEvent.click(view.getByRole('button', { name: 'Retry' }))
expect(onRetry).toHaveBeenCalledTimes(1)
})
it('keeps Retry available when the recovery attempt rejects', async () => {
const onRetry = vi.fn().mockRejectedValue(new Error('recovery unavailable'))
const view = render(
React.createElement(TerminalErrorToast, {
error: 'terminal_pane_owner_unverified',
onDismiss: vi.fn(),
onRetry
})
)
fireEvent.click(view.getByRole('button', { name: 'Retry' }))
await waitFor(() =>
expect((view.getByRole('button', { name: 'Retry' }) as HTMLButtonElement).disabled).toBe(
false
)
)
expect(onRetry).toHaveBeenCalledTimes(1)
})
it('explains when Retry is temporarily unavailable', async () => {
const onRetry = vi.fn().mockResolvedValue(false)
const view = render(
React.createElement(TerminalErrorToast, {
error: 'terminal_pane_owner_unverified',
onDismiss: vi.fn(),
onRetry
})
)
fireEvent.click(view.getByRole('button', { name: 'Retry' }))
await waitFor(() =>
expect(view.container.textContent).toContain('Retry could not reconnect yet')
)
})
})
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { translate } from '@/i18n/i18n'
import { resolveClientEnvironmentFooter } from '@/lib/client-environment-info'
import { Button } from '@/components/ui/button'
import { hasClientEnvironmentFooter } from '../../../../shared/client-environment-info'
const SSH_PREFIX = 'SSH connection is not active'
@@ -78,6 +79,11 @@ export function isExplainedTerminalError(error: string): boolean {
)
}
export function isPaneOwnerUnverifiedError(error: string): boolean {
const lines = error.split('\n').filter((line) => line.length > 0)
return lines.length > 0 && lines.every((line) => line.includes(PANE_OWNER_UNVERIFIED_MARKER))
}
function humanizeUnreattachableSession(error: string): string {
const explanation = translate(
'auto.components.terminal.pane.TerminalErrorToast.sessionUnavailable',
@@ -94,13 +100,16 @@ function humanizeUnreattachableSession(error: string): string {
export function humanizeTerminalError(error: string): string {
let humanized = error
if (humanized.includes(PANE_OWNER_UNVERIFIED_MARKER)) {
humanized = humanized.replace(
PANE_OWNER_UNVERIFIED_MARKER,
translate(
'auto.components.terminal.pane.TerminalErrorToast.7ee11bc0db',
"Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry."
)
)
const explanation = isPaneOwnerUnverifiedError(humanized)
? translate(
'auto.components.terminal.pane.TerminalErrorToast.42b283ecfc',
"Orca couldn't safely reconnect this terminal because the host couldn't verify its saved session. Orca left the saved session unchanged. Click Retry to try reconnecting now. If it still cannot reconnect, open a new terminal."
)
: translate(
'auto.components.terminal.pane.TerminalErrorToast.ownerUnknown',
"Orca couldn't verify this terminal's owner."
)
humanized = humanized.replaceAll(PANE_OWNER_UNVERIFIED_MARKER, () => explanation)
}
humanized = humanizeUnreattachableSession(humanized)
if (!isExplainedTerminalError(humanized)) {
@@ -127,17 +136,23 @@ export function humanizeTerminalError(error: string): string {
export function TerminalErrorToast({
error,
onDismiss,
onRestartDaemon
onRestartDaemon,
onRetry
}: {
error: string
onDismiss: () => void
onRestartDaemon?: () => void
onRetry?: () => Promise<boolean>
}): React.JSX.Element {
const ssh = isSshError(error)
const paneOwnerUnverified = isPaneOwnerUnverifiedError(error)
const showDaemonRestart = !ssh && onRestartDaemon && shouldOfferDaemonRestart(error)
// Restart cannot recover a session after its owning daemon exits.
const showIssueLink = !ssh && !showDaemonRestart && !isExplainedTerminalError(error)
const showIssueLink =
!ssh && !paneOwnerUnverified && !showDaemonRestart && !isExplainedTerminalError(error)
const displayError = humanizeTerminalError(error)
const [retrying, setRetrying] = useState(false)
const [retryFailed, setRetryFailed] = useState(false)
const [environmentFooter, setEnvironmentFooter] = useState<{
error: string
footer: string
@@ -160,10 +175,26 @@ export function TerminalErrorToast({
}, [displayError, ssh])
const footer = environmentFooter?.error === displayError ? environmentFooter.footer : ''
const handleRetry = async (): Promise<void> => {
if (!onRetry || retrying) {
return
}
setRetrying(true)
setRetryFailed(false)
try {
setRetryFailed(!(await onRetry()))
} catch {
// Keep the safety warning available when a best-effort remount cannot start.
setRetryFailed(true)
} finally {
setRetrying(false)
}
}
return (
<div
data-terminal-error-toast
data-terminal-error-kind={paneOwnerUnverified ? 'owner-unverified' : ssh ? 'ssh' : 'error'}
style={{
position: 'absolute',
bottom: 12,
@@ -172,9 +203,17 @@ export function TerminalErrorToast({
zIndex: 50,
padding: '10px 14px',
borderRadius: 6,
background: ssh ? 'rgba(234, 179, 8, 0.12)' : 'rgba(220, 38, 38, 0.15)',
border: ssh ? '1px solid rgba(234, 179, 8, 0.35)' : '1px solid rgba(220, 38, 38, 0.4)',
color: ssh ? '#fde68a' : '#fca5a5',
background: paneOwnerUnverified
? 'var(--popover)'
: ssh
? 'rgba(234, 179, 8, 0.12)'
: 'rgba(220, 38, 38, 0.15)',
border: paneOwnerUnverified
? '1px solid var(--color-amber-500)'
: ssh
? '1px solid rgba(234, 179, 8, 0.35)'
: '1px solid rgba(220, 38, 38, 0.4)',
color: paneOwnerUnverified ? 'var(--popover-foreground)' : ssh ? '#fde68a' : '#fca5a5',
fontSize: 12,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
@@ -212,6 +251,12 @@ export function TerminalErrorToast({
</>
) : null}
{!ssh && footer ? `\n\n${footer}` : null}
{paneOwnerUnverified && retryFailed
? `\n${translate(
'auto.components.terminal.pane.TerminalErrorToast.retryUnavailable',
'Retry could not reconnect yet. Try again shortly.'
)}`
: null}
</span>
{showDaemonRestart ? (
<button
@@ -235,12 +280,25 @@ export function TerminalErrorToast({
)}
</button>
) : null}
{paneOwnerUnverified && onRetry ? (
<Button
variant="outline"
size="xs"
onClick={() => void handleRetry()}
disabled={retrying}
className="ml-3 border-amber-500/50 bg-popover text-popover-foreground hover:bg-amber-500/20"
>
{retrying
? translate('auto.components.terminal.pane.TerminalErrorToast.retrying', 'Retrying…')
: translate('auto.components.terminal.pane.TerminalErrorToast.retry', 'Retry')}
</Button>
) : null}
<button
onClick={onDismiss}
style={{
background: 'none',
border: 'none',
color: ssh ? '#fde68a' : '#fca5a5',
color: paneOwnerUnverified ? 'var(--popover-foreground)' : ssh ? '#fde68a' : '#fca5a5',
cursor: 'pointer',
fontSize: 14,
padding: '0 0 0 8px',
@@ -6,7 +6,8 @@ import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/works
import CloseTerminalDialog from './CloseTerminalDialog'
import TerminalContextMenu from './TerminalContextMenu'
import TerminalPaneHeaderOverlay from './TerminalPaneHeaderOverlay'
import { TerminalErrorToast } from './TerminalErrorToast'
import { isPaneOwnerUnverifiedError, TerminalErrorToast } from './TerminalErrorToast'
import { requestTerminalPaneRecovery } from './terminal-pane-recovery'
import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog'
import { TerminalLinkActionPopover } from './TerminalLinkActionPopover'
import { TerminalAgentSessionForkDialog } from './TerminalAgentSessionForkDialog'
@@ -157,6 +158,25 @@ export function TerminalPaneSurface({
error={visibleTerminalError}
onDismiss={dismissTerminalError}
onRestartDaemon={() => daemonActions.setPending('restart')}
onRetry={
isPaneOwnerUnverifiedError(visibleTerminalError)
? () => {
const ptyId = activePane
? (paneTransportsRef.current.get(activePane.id)?.getPtyId() ?? null)
: null
return requestTerminalPaneRecovery({
tabId,
ptyId,
reason: 'reattach-unverifiable'
}).then((recovered) => {
if (recovered) {
dismissTerminalError()
}
return recovered
})
}
: undefined
}
/>,
activePane.container,
`terminal-error-${activePane.id}`
@@ -2,7 +2,14 @@ import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { flushAsyncTicks, createDeferred } from './pty-connection-test-async'
import { createMockTransport, createPane, createManager } from './pty-connection-test-pane-fixtures'
import {
createMockTransport,
createPane,
createManager,
LEAF_2,
type ConnectCallbacks,
type MockTransport
} from './pty-connection-test-pane-fixtures'
import { buildPaneConnectionDeps, buildDirectSshSplitRetryCommit } from './pty-connection-test-deps'
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
import type { StoreState } from './pty-connection-test-store-state'
@@ -10,7 +17,6 @@ import {
pendingSpawnByPaneKey,
pendingSpawnGenerationByPaneKey
} from './pty-connection/pty-connect-limits'
import type { MockTransport } from './pty-connection-test-pane-fixtures'
import {
installTerminalTestGlobals,
restoreTerminalTestGlobals
@@ -665,6 +671,88 @@ describe('connectPanePty', () => {
await flushAsyncTicks(12)
})
it('rejects an owner-unverified reattach after direct SSH authority rotates', async () => {
const { connectPanePty } = await import('./pty-connection')
const restoredPtyId = toAppSshPtyId('target-a', 'pty-live')
const delayedReattach = createDeferred<void>()
const capturedCallbacks: { current: ConnectCallbacks | null } = { current: null }
const transport = createMockTransport(restoredPtyId)
transport.detach = vi.fn()
transport.connect.mockImplementation(({ callbacks }) => {
capturedCallbacks.current = callbacks ?? null
return delayedReattach.promise
})
transportFactoryQueue.push(transport)
const liveRetry = {
attemptId: 'attempt-live-owner-unverified',
authority: {
targetId: 'target-a',
providerEpoch: 'epoch-old',
connectionGeneration: 3
},
tabGeneration: 7,
ptyId: restoredPtyId
}
const settleDirectSshPaneRetry = vi.fn()
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] },
ptyIdsByTabId: { 'tab-1': [restoredPtyId] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: restoredPtyId }
}
},
repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }],
sshConnectionStates: new Map([
[
'target-a',
{
targetId: 'target-a',
status: 'connected',
providerEpoch: 'epoch-old',
connectionGeneration: 3
}
]
]),
directSshPaneRetryByTabId: {},
directSshLivePtyBindingByTabId: { 'tab-1': liveRetry },
settleDirectSshPaneRetry
}
const deps = createDeps({
restoredLeafId: LEAF_2,
restoredPtyIdByLeafId: { [LEAF_2]: restoredPtyId }
})
connectPanePty(createPane(2) as never, createManager(2) as never, deps as never)
await flushAsyncTicks()
mockStoreState.sshConnectionStates = new Map([
[
'target-a',
{
targetId: 'target-a',
status: 'connected',
providerEpoch: 'epoch-new',
connectionGeneration: 4
}
]
])
capturedCallbacks.current?.onError?.('terminal_pane_owner_unverified')
delayedReattach.resolve()
await flushAsyncTicks(12)
expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled()
expect(transport.detach).toHaveBeenCalledExactlyOnceWith({ preserveExitObserver: false })
expect(settleDirectSshPaneRetry).not.toHaveBeenCalled()
expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId).toEqual({
[LEAF_2]: restoredPtyId
})
})
it('starts a new spawn and rejects a late callback after direct SSH authority rotates', async () => {
const { connectPanePty } = await import('./pty-connection')
const oldPendingSpawn = createDeferred<string>()
@@ -132,6 +132,26 @@ function createDeps(overrides: Record<string, unknown> = {}) {
return buildPaneConnectionDeps(() => mockStoreState, overrides)
}
function seedUnverifiableRestoredPane() {
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'unverifiable-pty' }] },
ptyIdsByTabId: { 'tab-1': ['unverifiable-pty'] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: 'unverifiable-pty' }
}
}
} as StoreState
return createDeps({
restoredLeafId: LEAF_2,
restoredPtyIdByLeafId: { [LEAF_2]: 'unverifiable-pty' }
})
}
// Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status.
function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void {
mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId)
@@ -548,6 +568,51 @@ describe('connectPanePty', () => {
})
})
it('preserves an owner-unverified restored pane after an empty reattach result', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transport.connect.mockImplementation(async (opts: { callbacks?: ConnectCallbacks }) => {
opts.callbacks?.onError?.('terminal_pane_owner_unverified')
return undefined
})
transportFactoryQueue.push(transport)
const deps = seedUnverifiableRestoredPane()
connectPanePty(createPane(2) as never, createManager(2) as never, deps as never)
await flushAsyncTicks()
expect(transport.connect).toHaveBeenCalledTimes(1)
expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith(2, 'terminal_pane_owner_unverified')
expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled()
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
expect(mockStoreState.tabsByWorktree['wt-1'][0]?.ptyId).toBe('unverifiable-pty')
expect(mockStoreState.ptyIdsByTabId?.['tab-1']).toEqual(['unverifiable-pty'])
expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId).toEqual({
[LEAF_2]: 'unverifiable-pty'
})
expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledWith(expect.any(String), 1)
})
it('preserves an owner-unverified restored pane after a rejected reattach', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transport.connect.mockRejectedValueOnce(new Error('terminal_pane_owner_unverified'))
transportFactoryQueue.push(transport)
const deps = seedUnverifiableRestoredPane()
connectPanePty(createPane(2) as never, createManager(2) as never, deps as never)
await flushAsyncTicks()
expect(transport.connect).toHaveBeenCalledTimes(1)
expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith(2, 'terminal_pane_owner_unverified')
expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled()
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId).toEqual({
[LEAF_2]: 'unverifiable-pty'
})
expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledWith(expect.any(String), 1)
})
describe('terminal input liveness IPC gating (perf)', () => {
// Why (perf regression guard): listSessions() is a renderer→main→daemon round-trip; terminal input must never trigger it.
async function connectActivePaneWithInput(): Promise<{
@@ -6,6 +6,8 @@ import { toProcessExitStartup } from './process-exit-startup'
import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
const PANE_OWNER_UNVERIFIED_ERROR = 'terminal_pane_owner_unverified'
export function startDeferredSessionReattach(
session: ConnectPanePtySession,
deferredReattachSessionId: string
@@ -22,6 +24,7 @@ export function startDeferredSessionReattach(
: window.api.pty.declarePendingPaneSerializer(session.cacheKey).catch(() => null)
let expiredReattachError = false
let paneOwnerUnverified = false
const coldRestoreStartup = session.buildColdRestoreAgentResumeStartup()
const outputCallbacks = session.captureTransportOutputCallbacks(
(message) => {
@@ -29,6 +32,9 @@ export function startDeferredSessionReattach(
expiredReattachError = true
return
}
if (message.includes(PANE_OWNER_UNVERIFIED_ERROR)) {
paneOwnerUnverified = true
}
if (!session.isCapturedDirectSshReattachCurrent(deferredReattachSessionId)) {
return
}
@@ -79,6 +85,15 @@ export function startDeferredSessionReattach(
}
return
}
if (!result && paneOwnerUnverified) {
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
const gen = await preSignalPromise
if (typeof gen === 'number') {
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
}
session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed')
return
}
if (!result && expiredReattachError) {
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
const gen = await preSignalPromise
@@ -137,6 +152,11 @@ export function startDeferredSessionReattach(
if (session.rejectObsoleteDirectSshReattach(deferredReattachSessionId)) {
return
}
if (message.includes(PANE_OWNER_UNVERIFIED_ERROR)) {
session.reportError(message)
session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed')
return
}
warnTerminalLifecycleAnomaly('restored PTY reattach threw', {
tabId: session.deps.tabId,
worktreeId: session.deps.worktreeId,
+5 -1
View File
@@ -3078,10 +3078,14 @@
},
"TerminalErrorToast": {
"e4aa243f8c": "Restart daemon",
"retry": "Retry",
"retrying": "Retrying…",
"retryUnavailable": "Retry could not reconnect yet. Try again shortly.",
"a7e2fd2699": "file an issue",
"5c8ce20be6": "If this persists, please",
"cc6d997c65": "Restart the terminal daemon from here to clear stale daemon state.",
"7ee11bc0db": "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry.",
"ownerUnknown": "Orca couldn't verify this terminal's owner.",
"42b283ecfc": "Orca couldn't safely reconnect this terminal because the host couldn't verify its saved session. Orca left the saved session unchanged. Click Retry to try reconnecting now. If it still cannot reconnect, open a new terminal.",
"e16012e31e": "The terminal daemon that owned this session exited, so the session and its scrollback could not be recovered. Open a new terminal to continue.",
"sessionUnavailable": "Orca couldn't reattach to this pane's terminal session on the host. Open a new terminal to continue."
},