fix(ssh): keep a native-chat session bound when the relay drops

A relay teardown calls agentHookServer.clearStatusEntriesForConnection, so an
unreachable SSH host reports a terminal with no agentStatus. Both native-chat
binding resolvers read that as "no such session" and returned not_found, which
makes loss of contact evidence the session is gone — the failure mode
docs/reference/ssh-execution-boundary.md exists to prevent.

Whether the spec saw it was pure timing: the mobile-session snapshot only loses
the provider session once a refresh lands inside the disconnect window. A 3s
wait after disconnect turns it into a deterministic failure on the previous
commit, which is what CI was hitting.

Runtime side: remember the last-known agent and provider session per terminal
handle, and fall back to it when the live tab no longer carries one. The
terminal context stays the existence gate, so a closed terminal still resolves
to nothing and drops the memory with it.

Broker side: only a vanished tab, a different terminal, or a tab rebound to
another provider session revokes the opaque grant. A tab whose host simply
stopped reporting status keeps it, so the read surfaces host_error.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-02 02:10:21 -04:00
parent 1bcd613067
commit 0772dff1a6
4 changed files with 145 additions and 23 deletions
@@ -23,7 +23,14 @@ export async function resolveFreshMobileWebNativeChatBinding(args: {
(value) => isRecord(value) && value.type === 'terminal' && value.id === binding.hostTabId
)
: undefined
if (!isCurrentBinding(tab, binding) || (args.requireTerminal && !binding.hostTerminalId)) {
// Why not revoke when the host reports no agent status: an unreachable SSH host strips it from a
// terminal that still exists, and loss of contact is never evidence the session is gone. Only a
// vanished tab, a different terminal, or a tab rebound to another session ends the grant.
const gone =
tab === undefined ||
!isSameTerminal(tab, binding) ||
(hasProviderSession(tab) && !isCurrentBinding(tab, binding))
if (gone || (args.requireTerminal && !binding.hostTerminalId)) {
args.nativeChatAuthority.revoke(args.sessionId)
throw new MobileWebBrokerError('not_found')
}
@@ -63,6 +70,22 @@ export function assertCurrentMobileWebNativeChatPageBinding(
args.nativeChatAuthority.assertBinding(binding.hostWorkspaceId, sessionId, binding)
}
function hasProviderSession(value: unknown): boolean {
return (
isRecord(value) && isRecord(value.agentStatus) && isRecord(value.agentStatus.providerSession)
)
}
function isSameTerminal(
value: unknown,
binding: Readonly<MobileWebHostNativeChatBinding>
): boolean {
return (
isRecord(value) &&
(typeof value.terminal === 'string' ? value.terminal : null) === binding.hostTerminalId
)
}
function isCurrentBinding(
value: unknown,
binding: Readonly<MobileWebHostNativeChatBinding>
@@ -144,6 +144,36 @@ describe('mobile web native chat operations', () => {
})
})
// An unreachable SSH host strips agentStatus from a terminal that still exists. Revoking the
// grant there reported the session as gone; the read must surface the host failure instead.
it('keeps the grant when the host stops reporting agent status', async () => {
const context = operationContext()
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockResolvedValueOnce(success(sessionSnapshot({ unreachable: true })))
.mockResolvedValueOnce(success({ error: 'Transcript unavailable' }))
await expect(
executeMobileWebNativeChatOperation({
operation: 'read',
payload: {
workspaceId: context.pageWorkspaceId,
sessionId: context.pageSessionId,
limit: 40
},
client: { sendRequest } as unknown as RpcClient,
workspaceAuthority: context.workspaceAuthority,
nativeChatAuthority: context.nativeChatAuthority,
nativeAuthority: {},
...OPERATION_RUNTIME
})
).rejects.toMatchObject({ code: 'host_error' })
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(context.nativeChatAuthority.resolve('workspace-1', context.pageSessionId)).toMatchObject(
{ providerSessionId: 'provider-session-secret' }
)
})
it('persists pending delivery through stable hidden chat authority', async () => {
const context = operationContext()
const sendRequest = vi
@@ -216,7 +246,7 @@ function operationContext() {
}
}
function sessionSnapshot(overrides: { providerSessionId?: string } = {}) {
function sessionSnapshot(overrides: { providerSessionId?: string; unreachable?: boolean } = {}) {
return {
worktree: 'workspace-1',
tabs: [
@@ -225,14 +255,18 @@ function sessionSnapshot(overrides: { providerSessionId?: string } = {}) {
id: 'tab-1',
terminal: 'terminal-secret',
launchAgent: 'claude',
agentStatus: {
state: 'waiting',
agentType: 'claude',
providerSession: {
id: overrides.providerSessionId ?? 'provider-session-secret',
transcriptPath: '/private/transcript.jsonl'
}
}
...(overrides.unreachable
? {}
: {
agentStatus: {
state: 'waiting',
agentType: 'claude',
providerSession: {
id: overrides.providerSessionId ?? 'provider-session-secret',
transcriptPath: '/private/transcript.jsonl'
}
}
})
}
]
}
+30 -13
View File
@@ -43,25 +43,42 @@ export type RuntimeNativeChatTranscriptBinding = {
}
export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands {
private readonly lastKnownNativeChatSessions = new Map<
string,
Pick<RuntimeNativeChatTranscriptBinding, 'agent' | 'providerSession'>
>()
resolveNativeChatTranscriptBinding(handle: string): RuntimeNativeChatTranscriptBinding | null {
const terminalContext = this.resolveTerminalContext(handle)
const snapshot = terminalContext
? this.mobileSessionTabsByWorktree.get(terminalContext.worktreeId)
: null
if (!terminalContext || !snapshot) {
if (!terminalContext) {
this.lastKnownNativeChatSessions.delete(handle)
return null
}
const terminal = this.toMobileSessionTabsResult(snapshot).tabs.find(
(tab) => tab.type === 'terminal' && tab.status === 'ready' && tab.terminal === handle
)
if (!terminal || terminal.type !== 'terminal') {
return null
const snapshot = this.mobileSessionTabsByWorktree.get(terminalContext.worktreeId)
const terminal = snapshot
? this.toMobileSessionTabsResult(snapshot).tabs.find(
(tab) => tab.type === 'terminal' && tab.status === 'ready' && tab.terminal === handle
)
: undefined
const live =
terminal?.type === 'terminal'
? {
agent: terminal.agentStatus?.agentType ?? terminal.launchAgent ?? null,
providerSession: terminal.agentStatus?.providerSession ?? null
}
: null
if (live?.providerSession) {
this.lastKnownNativeChatSessions.set(handle, live)
return { ...terminalContext, ...live }
}
return {
...terminalContext,
agent: terminal.agentStatus?.agentType ?? terminal.launchAgent ?? null,
providerSession: terminal.agentStatus?.providerSession ?? null
// Why: a relay teardown clears this connection's agent status, but loss of contact is never
// evidence the session is gone. Keeping the last-known identity lets the read fail against
// the unreachable host instead of reporting the session as missing.
const remembered = this.lastKnownNativeChatSessions.get(handle)
if (remembered) {
return { ...terminalContext, ...remembered }
}
return live ? { ...terminalContext, ...live } : null
}
constructor(
@@ -528,4 +528,52 @@ describe('OrcaRuntimeService', () => {
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1)
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined()
})
// An unreachable SSH host clears the connection's agent status, so the live tab loses its
// provider session. Treating that as a missing session reported not_found for a terminal that
// still exists; the last-known identity must survive loss of contact.
it('keeps the native-chat binding when loss of contact clears agent status', () => {
const runtime = new OrcaRuntimeService(null as never)
const readyTab = {
type: 'terminal',
status: 'ready',
terminal: 'handle-1',
launchAgent: 'claude',
agentStatus: {
agentType: 'claude',
providerSession: { id: 'session-1', transcriptPath: '/remote/transcript.jsonl' }
}
}
const strippedTab = { type: 'terminal', status: 'ready', terminal: 'handle-1' }
let tabs: unknown[] = [readyTab]
let context: unknown = { worktreeId: 'worktree-1', connectionId: 'ssh-1' }
const internals = runtime as unknown as {
resolveTerminalContext: (handle: string) => unknown
mobileSessionTabsByWorktree: Map<string, unknown>
toMobileSessionTabsResult: (snapshot: unknown) => { tabs: unknown[] }
resolveNativeChatTranscriptBinding: (handle: string) => { providerSession?: unknown } | null
}
internals.resolveTerminalContext = () => context
internals.mobileSessionTabsByWorktree = new Map([['worktree-1', {}]])
internals.toMobileSessionTabsResult = () => ({ tabs })
expect(internals.resolveNativeChatTranscriptBinding('handle-1')).toMatchObject({
agent: 'claude',
providerSession: { id: 'session-1' }
})
tabs = [strippedTab]
expect(internals.resolveNativeChatTranscriptBinding('handle-1')).toMatchObject({
agent: 'claude',
providerSession: { id: 'session-1' }
})
// A terminal that is genuinely gone still resolves to nothing.
context = null
expect(internals.resolveNativeChatTranscriptBinding('handle-1')).toBeNull()
// ...and the remembered identity goes with it, so the grant cannot outlive the terminal.
context = { worktreeId: 'worktree-1', connectionId: 'ssh-1' }
expect(internals.resolveNativeChatTranscriptBinding('handle-1')).toMatchObject({
providerSession: null
})
})
})