mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
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:
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user