From 635d40e226b914a951a227e02c1891b3bf714dcd Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 11 Sep 2026 01:37:55 -0700 Subject: [PATCH] fix(runtime): carry the blocked cause through the host-status snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner collapses seven distinct RPC failure codes onto one `blocked` verification, and a client-initiated disconnect publishes the same value via dispose(). A consumer routing a blocker taxonomy on `blocked` would tell the user "version or auth skew" about a host they disconnected themselves. Record the failure code as `blockedCode` on the snapshot (client-local state, never exchanged with the host) and clear it once the host verifies again. Add a pure renderer reader that maps a map entry, a capability and the host's effective admission onto one verdict, defining absence of an entry as unknown — the catalog/pairing-revision guard, a re-pair, and an entry without a snapshot all leave a healthy host unrepresented. Retirement outranks a recorded cause, an answer older than the prober's 60s TTL reads as unknown, and a host that publishes no admission reads as supported with unknown policy, never disabled. --- .../structured-chat-host-verdict.test.ts | 258 ++++++++++++++++++ .../runtime/structured-chat-host-verdict.ts | 69 +++++ src/shared/runtime-host-status-owner.test.ts | 31 +++ src/shared/runtime-host-status-owner.ts | 11 +- src/shared/runtime-host-status.ts | 2 + 5 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/runtime/structured-chat-host-verdict.test.ts create mode 100644 src/renderer/src/runtime/structured-chat-host-verdict.ts diff --git a/src/renderer/src/runtime/structured-chat-host-verdict.test.ts b/src/renderer/src/runtime/structured-chat-host-verdict.test.ts new file mode 100644 index 00000000000..b7c94fb5e86 --- /dev/null +++ b/src/renderer/src/runtime/structured-chat-host-verdict.test.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { + createRuntimeStatusSlice, + clearRuntimeEnvironmentConnectionGenerationsForTests, + type RuntimeStatusSlice +} from '../store/slices/runtime-status' +import type { RuntimeEnvironmentStatus } from '../store/slices/runtime-status-types' +import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' +import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status' +import { RuntimeHostStatusOwner } from '../../../shared/runtime-host-status-owner' +import { + runtimeHostStatusFailure, + type RuntimeHostStatusResponse +} from '../../../shared/runtime-host-status' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { + resolveStructuredChatHostVerdict, + isUnknownStructuredChatHostVerdict, + STRUCTURED_CHAT_HOST_VERDICT_STALE_MS, + type StructuredChatHostAdmission, + type StructuredChatHostVerdict +} from './structured-chat-host-verdict' + +vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } })) +vi.mock('@/runtime/restored-client-hosted-browser-host-attach', () => ({ + ensureBrowserClientHostsForRestoredPages: vi.fn(), + ensureBrowserClientHostForRestartedRuntime: vi.fn() +})) +vi.mock('@/runtime/client-hosted-browser-close-intent-replay', () => ({ + replayClientHostedBrowserCloseIntents: vi.fn() +})) + +const CAPABILITY = STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +const NOW = 1_000_000 + +beforeEach(() => { + clearRuntimeEnvironmentConnectionGenerationsForTests() + vi.clearAllMocks() +}) + +function snapshot(patch: Partial = {}): RuntimeHostStatusSnapshot { + return { + environmentId: 'env-a', + pairingRevision: 1, + sequence: 1, + checkedAt: NOW, + transport: 'ready', + verification: 'verified', + status: { runtimeId: 'rt-1', capabilities: [CAPABILITY] } as unknown as RuntimeStatus, + ...patch + } +} + +function entryOf(patch: Partial = {}): RuntimeEnvironmentStatus { + const value = snapshot(patch) + return { snapshot: value, status: value.status, checkedAt: value.checkedAt } +} + +function verdictOf( + entry: RuntimeEnvironmentStatus | undefined, + admission: StructuredChatHostAdmission = { enabled: true } +): StructuredChatHostVerdict { + return resolveStructuredChatHostVerdict({ entry, capability: CAPABILITY, admission, now: NOW }) +} + +describe('verdict rows', () => { + const rows: [string, StructuredChatHostVerdict, () => StructuredChatHostVerdict][] = [ + [ + 'a probe in flight', + 'unknown-checking', + () => verdictOf(entryOf({ verification: 'checking' })) + ], + ['no map entry at all', 'unknown-no-entry', () => verdictOf(undefined)], + [ + 'an entry that carries no snapshot', + 'unknown-no-entry', + () => verdictOf({ status: null, checkedAt: NOW }) + ], + [ + 'a host that did not answer', + 'unknown-unavailable', + () => verdictOf(entryOf({ verification: 'unavailable', status: null })) + ], + ['a verified host that admits structured chat', 'supported', () => verdictOf(entryOf())], + [ + 'a verified host that publishes no admission', + 'supported', + () => verdictOf(entryOf(), undefined) + ], + [ + 'a verified host whose policy is off', + 'host-policy-disabled', + () => verdictOf(entryOf(), { enabled: false }) + ], + [ + 'a host that never advertised the capability', + 'host-refuses-capability', + () => + verdictOf( + entryOf({ status: { runtimeId: 'rt-1', capabilities: [] } as unknown as RuntimeStatus }) + ) + ], + [ + 'a host with no capability list at all', + 'host-refuses-capability', + () => verdictOf(entryOf({ status: { runtimeId: 'rt-1' } as unknown as RuntimeStatus })) + ], + [ + 'a retired host', + 'host-disconnected', + () => + verdictOf(entryOf({ retired: true, verification: 'blocked', transport: 'disconnected' })) + ], + [ + 'a blocked host with a failure code', + 'version-or-auth-skew', + () => verdictOf(entryOf({ verification: 'blocked', blockedCode: 'unauthorized' })) + ], + [ + 'an answer older than the prober TTL', + 'unknown-stale', + () => verdictOf(entryOf({ checkedAt: NOW - STRUCTURED_CHAT_HOST_VERDICT_STALE_MS - 1 })) + ] + ] + it.each(rows)('reads %s as %s', (_label, expected, resolve) => { + expect(resolve()).toBe(expected) + }) + + it('keeps an answer exactly at the staleness bound usable', () => { + expect(verdictOf(entryOf({ checkedAt: NOW - STRUCTURED_CHAT_HOST_VERDICT_STALE_MS }))).toBe( + 'supported' + ) + }) + + it('lets retirement outrank a blocked cause recorded before the disconnect', () => { + expect( + verdictOf(entryOf({ retired: true, verification: 'blocked', blockedCode: 'unauthorized' })) + ).toBe('host-disconnected') + }) + + it('never accuses a host of skew when blocked carries no cause', () => { + expect(verdictOf(entryOf({ verification: 'blocked' }))).toBe('unknown-unavailable') + }) + + it('groups exactly the ask-again verdicts as unknown', () => { + const verdicts: StructuredChatHostVerdict[] = [ + 'unknown-checking', + 'unknown-no-entry', + 'unknown-unavailable', + 'unknown-stale', + 'supported', + 'host-refuses-capability', + 'host-policy-disabled', + 'host-disconnected', + 'version-or-auth-skew' + ] + expect(verdicts.filter(isUnknownStructuredChatHostVerdict)).toEqual([ + 'unknown-checking', + 'unknown-no-entry', + 'unknown-unavailable', + 'unknown-stale' + ]) + }) +}) + +const environment = { + id: 'env-a', + name: 'Host', + createdAt: 1, + pairingRevision: 1, + endpoints: [], + preferredEndpointId: '' +} as unknown as PublicKnownRuntimeEnvironment + +function store() { + const value = create()((...args) => + createRuntimeStatusSlice(...(args as unknown as Parameters)) + ) + value.getState().setRuntimeEnvironments([environment]) + return value +} + +function storeVerdict(viewer: ReturnType): StructuredChatHostVerdict { + return verdictOf(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')) +} + +describe('windows that leave a healthy host without a usable entry', () => { + it('reads a snapshot dropped by the pairing-revision guard as unknown, not as a refusal', () => { + const viewer = store() + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot({ pairingRevision: 2 })) + expect(viewer.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) + expect(storeVerdict(viewer)).toBe('unknown-no-entry') + }) + + it('reads the gap after a re-pair deletes the replaced entry as unknown', () => { + const viewer = store() + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot()) + expect(storeVerdict(viewer)).toBe('supported') + viewer.getState().setRuntimeEnvironments([{ ...environment, pairingRevision: 2 }]) + expect(viewer.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) + expect(storeVerdict(viewer)).toBe('unknown-no-entry') + }) + + it('reads an entry published without a snapshot as unknown', () => { + const viewer = store() + viewer.getState().setRuntimeEnvironmentStatus('env-a', { + status: { runtimeId: 'rt-1', capabilities: [CAPABILITY] } as unknown as RuntimeStatus, + checkedAt: NOW + }) + expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.snapshot).toBeUndefined() + expect(storeVerdict(viewer)).toBe('unknown-no-entry') + }) +}) + +function ownerSnapshot(response: RuntimeHostStatusResponse): { + owner: RuntimeHostStatusOwner + read: () => RuntimeHostStatusSnapshot +} { + const published: RuntimeHostStatusSnapshot[] = [] + const owner = new RuntimeHostStatusOwner({ + environmentId: 'env-a', + pairingRevision: 1, + request: () => Promise.resolve(response), + verified: () => false, + publish: (value) => published.push(value) + }) + return { owner, read: () => published.at(-1) as RuntimeHostStatusSnapshot } +} + +describe('owner-published snapshots', () => { + it('tells a disconnect the client caused apart from host version or auth skew', async () => { + const skewed = ownerSnapshot( + runtimeHostStatusFailure('protocol_version_mismatch', 'Host is too old.') + ) + skewed.owner.activate() + await vi.waitFor(() => expect(skewed.read().verification).toBe('blocked')) + const skewEntry: RuntimeEnvironmentStatus = { + snapshot: skewed.read(), + status: null, + checkedAt: skewed.read().checkedAt + } + + const disconnected = ownerSnapshot(runtimeHostStatusFailure('unauthorized', 'Pair again.')) + disconnected.owner.dispose() + const disconnectedEntry: RuntimeEnvironmentStatus = { + snapshot: disconnected.read(), + status: null, + checkedAt: disconnected.read().checkedAt + } + expect(disconnectedEntry.snapshot?.verification).toBe('blocked') + + expect(verdictOf(skewEntry)).toBe('version-or-auth-skew') + expect(verdictOf(disconnectedEntry)).toBe('host-disconnected') + skewed.owner.dispose() + }) +}) diff --git a/src/renderer/src/runtime/structured-chat-host-verdict.ts b/src/renderer/src/runtime/structured-chat-host-verdict.ts new file mode 100644 index 00000000000..4657a36935b --- /dev/null +++ b/src/renderer/src/runtime/structured-chat-host-verdict.ts @@ -0,0 +1,69 @@ +import type { RuntimeCapability } from '../../../shared/protocol-version' +import type { RuntimeEnvironmentStatus } from '../store/slices/runtime-status-types' + +/** Matches the async capability prober's status TTL in runtime-rpc-client.ts. */ +export const STRUCTURED_CHAT_HOST_VERDICT_STALE_MS = 60_000 + +/** + * Every reason a client may or may not open structured chat against a host. + * The four `unknown-*` members mean "ask again", never "the host said no". + */ +export type StructuredChatHostVerdict = + | 'unknown-checking' + | 'unknown-no-entry' + | 'unknown-unavailable' + | 'unknown-stale' + | 'supported' + | 'host-refuses-capability' + | 'host-policy-disabled' + | 'host-disconnected' + | 'version-or-auth-skew' + +/** Effective admission published by the host; absent on hosts that predate publishing it. */ +export type StructuredChatHostAdmission = { enabled: boolean } | undefined + +export type StructuredChatHostVerdictInput = { + entry: RuntimeEnvironmentStatus | undefined + capability: RuntimeCapability + admission: StructuredChatHostAdmission + now?: number +} + +export function isUnknownStructuredChatHostVerdict(verdict: StructuredChatHostVerdict): boolean { + return verdict.startsWith('unknown-') +} + +export function resolveStructuredChatHostVerdict({ + entry, + capability, + admission, + now = Date.now() +}: StructuredChatHostVerdictInput): StructuredChatHostVerdict { + const snapshot = entry?.snapshot + // A dropped catalog/pairing-revision snapshot and a re-paired entry are both + // silent windows on a healthy host, so absence is defined as unknown. + if (!snapshot) { + return 'unknown-no-entry' + } + if (snapshot.retired) { + // The client disconnected this host; that outranks whatever blocked the last probe. + return 'host-disconnected' + } + if (snapshot.verification === 'blocked') { + return snapshot.blockedCode ? 'version-or-auth-skew' : 'unknown-unavailable' + } + if (snapshot.verification !== 'verified') { + return snapshot.verification === 'checking' ? 'unknown-checking' : 'unknown-unavailable' + } + if (!snapshot.status) { + return 'unknown-unavailable' + } + if (now - snapshot.checkedAt > STRUCTURED_CHAT_HOST_VERDICT_STALE_MS) { + return 'unknown-stale' + } + if (snapshot.status.capabilities?.includes(capability) !== true) { + return 'host-refuses-capability' + } + // An older host publishes no admission at all; that is unknown policy, not a refusal. + return admission?.enabled === false ? 'host-policy-disabled' : 'supported' +} diff --git a/src/shared/runtime-host-status-owner.test.ts b/src/shared/runtime-host-status-owner.test.ts index 32331991988..e7271ead5b0 100644 --- a/src/shared/runtime-host-status-owner.test.ts +++ b/src/shared/runtime-host-status-owner.test.ts @@ -205,3 +205,34 @@ it.each(['unknown', 'ready'] as const)( }) } ) + +it.each([ + ['protocol_version_mismatch', 'blocked', 'protocol_version_mismatch'], + ['unauthorized', 'blocked', 'unauthorized'], + ['runtime_unavailable', 'unavailable', undefined] +] as const)('records %s as the cause behind %s', async (code, verification, blockedCode) => { + const { owner, request } = createOwner() + request.mockResolvedValueOnce(runtimeHostStatusFailure(code, 'nope')) + await owner.refresh() + expect(owner.read().verification).toBe(verification) + expect(owner.read().blockedCode).toBe(blockedCode) +}) + +it('clears a recorded cause once the host verifies again', async () => { + const { owner, request } = createOwner() + request.mockResolvedValueOnce(runtimeHostStatusFailure('runtime_unavailable', 'offline')) + await owner.refresh() + expect(owner.read().blockedCode).toBeUndefined() + owner.acceptVerified(success()) + expect(owner.read()).toMatchObject({ verification: 'verified', blockedCode: undefined }) +}) + +it('keeps an authentication rejection distinguishable from a client-side disconnect', () => { + const { owner } = createOwner() + owner.authenticationRejected() + expect(owner.read()).toMatchObject({ verification: 'blocked', blockedCode: 'unauthorized' }) + const disconnected = createOwner() + disconnected.owner.dispose() + expect(disconnected.owner.read().retired).toBe(true) + expect(disconnected.owner.read().blockedCode).toBeUndefined() +}) diff --git a/src/shared/runtime-host-status-owner.ts b/src/shared/runtime-host-status-owner.ts index d9c52395c89..98ab88c94ce 100644 --- a/src/shared/runtime-host-status-owner.ts +++ b/src/shared/runtime-host-status-owner.ts @@ -218,12 +218,19 @@ export class RuntimeHostStatusOwner { this.response = response if (response.ok) { this.attempt = 0 - this.update({ status: response.result, checkedAt: Date.now(), verification: 'verified' }) + this.update({ + status: response.result, + checkedAt: Date.now(), + verification: 'verified', + blockedCode: undefined + }) this.persistent = this.options.verified(response, this.active) } else { + const blocked = isRuntimeHostStatusBlocked(response) this.update({ checkedAt: Date.now(), - verification: isRuntimeHostStatusBlocked(response) ? 'blocked' : 'unavailable' + verification: blocked ? 'blocked' : 'unavailable', + blockedCode: blocked ? response.error.code : undefined }) this.scheduleRetry() } diff --git a/src/shared/runtime-host-status.ts b/src/shared/runtime-host-status.ts index 4dd7ff7cc22..c0a5853eead 100644 --- a/src/shared/runtime-host-status.ts +++ b/src/shared/runtime-host-status.ts @@ -15,6 +15,8 @@ export type RuntimeHostStatusSnapshot = { transport: 'unknown' | 'connecting' | 'ready' | 'disconnected' remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null retired?: true + /** RPC failure code behind `verification: 'blocked'`; the seven codes are not one cause. */ + blockedCode?: string } export type RuntimeHostStatusResponse = RuntimeRpcResponse