From f7aa0f1b1b0de4464a6c149b2a4a33e5ad93220c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 06:48:45 +0000 Subject: [PATCH 1/4] fix(sentry): stop reporting remote connector tools/list refresh noise tools/list timeouts and disconnects during snapshot refresh are expected home-connector lifecycle noise (same class as websocket closes in #916). Keep the soft-fail clear + ops console.warn; reserve Sentry for auth and handler bugs. Refs KODY-CLOUDFLARE-24. --- .../src/remote-connector/session.node.test.ts | 42 +++++++++++++++++++ .../worker/src/remote-connector/session.ts | 41 +++++++++--------- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/packages/worker/src/remote-connector/session.node.test.ts b/packages/worker/src/remote-connector/session.node.test.ts index 502d48fe87..4f9adbe229 100644 --- a/packages/worker/src/remote-connector/session.node.test.ts +++ b/packages/worker/src/remote-connector/session.node.test.ts @@ -292,3 +292,45 @@ test('remote connector session lifecycle across restore, snapshot, heartbeat, cl tools: [], }) }) + +test('tools/list_changed snapshot refresh failures log a warning without Sentry', async () => { + consoleWarn.mockImplementation(() => {}) + const { session, state, persistedEntries } = + await createRemoteConnectorSession({ + storedState: { + persisted: { + connectorId: 'home', + connectedAt: '2026-04-26T05:00:00.000Z', + lastSeenAt: '2026-04-26T05:01:00.000Z', + }, + tools: [{ name: 'bond_shade_set_position' }], + }, + webSockets: [{} as WebSocket], + }) + // Drop the socket before tools/list so refresh fails immediately (same + // soft-fail path as an RPC timeout against a stalled home connector). + state.getWebSockets.mockReturnValue([]) + + await session.webSocketMessage( + {} as WebSocket, + JSON.stringify({ + type: 'connector.jsonrpc', + message: { + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + }, + }), + ) + + expect(captureMessageMock).not.toHaveBeenCalled() + expect(consoleWarn).toHaveBeenCalledWith( + 'Remote connector tools snapshot refresh failed on tools/list_changed. connectorId=home error=No remote connector is connected.', + ) + expect(persistedEntries.get('remote-connector-session-state')).toMatchObject({ + persisted: { + connectorId: 'home', + connectedAt: '2026-04-26T05:00:00.000Z', + }, + tools: [], + }) +}) diff --git a/packages/worker/src/remote-connector/session.ts b/packages/worker/src/remote-connector/session.ts index ce651e4d05..4f4cd8a6f3 100644 --- a/packages/worker/src/remote-connector/session.ts +++ b/packages/worker/src/remote-connector/session.ts @@ -503,16 +503,10 @@ class RemoteConnectorSessionBase extends DurableObject { await this.refreshToolsSnapshot() } catch (error) { this.stateSnapshot.tools = [] - this.captureSessionMessage( - 'Remote connector tools snapshot refresh failed after websocket hello.', - { - level: 'error', - extra: { - connectorId: this.stateSnapshot.persisted.connectorId, - error: getErrorMessage(error), - }, - }, - ) + this.logToolsSnapshotRefreshFailure({ + phase: 'after websocket hello', + error, + }) await this.persistState() } } @@ -539,22 +533,29 @@ class RemoteConnectorSessionBase extends DurableObject { await this.refreshToolsSnapshot() } catch (error) { this.stateSnapshot.tools = [] - this.captureSessionMessage( - 'Remote connector tools snapshot refresh failed.', - { - level: 'error', - extra: { - connectorId: this.stateSnapshot.persisted.connectorId, - error: getErrorMessage(error), - }, - }, - ) + this.logToolsSnapshotRefreshFailure({ + phase: 'on tools/list_changed', + error, + }) await this.persistState() return } } } + private logToolsSnapshotRefreshFailure(input: { + phase: 'after websocket hello' | 'on tools/list_changed' + error: unknown + }) { + // tools/list timeouts, disconnects mid-RPC, and connector-side errors are + // expected remote-connector lifecycle noise (same class as websocket + // closes). Soft-fail by clearing tools; keep an ops log line and do not + // open Sentry issues. Auth failures and message-handler bugs still capture. + console.warn( + `Remote connector tools snapshot refresh failed ${input.phase}. connectorId=${this.stateSnapshot.persisted.connectorId ?? 'null'} error=${getErrorMessage(input.error)}`, + ) + } + private async refreshToolsSnapshot() { const response = await this.sendRpcRequest('tools/list', {}) if ('error' in response) { From e0e86c40ff221b39de9543954580300d0ee954b1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 07:01:02 +0000 Subject: [PATCH 2/4] fix(sentry): keep Sentry for unexpected tools/list refresh failures Address review: only suppress expected lifecycle noise (timeout, disconnect, not connected, connector RPC error). Malformed tools/list payloads and other implementation errors still rethrow into the message-handler Sentry path and do not clear a still-valid tool cache. --- .../src/remote-connector/session.node.test.ts | 70 +++++++++++++++++++ .../worker/src/remote-connector/session.ts | 48 +++++++++---- 2 files changed, 105 insertions(+), 13 deletions(-) diff --git a/packages/worker/src/remote-connector/session.node.test.ts b/packages/worker/src/remote-connector/session.node.test.ts index 4f9adbe229..72be64f927 100644 --- a/packages/worker/src/remote-connector/session.node.test.ts +++ b/packages/worker/src/remote-connector/session.node.test.ts @@ -334,3 +334,73 @@ test('tools/list_changed snapshot refresh failures log a warning without Sentry' tools: [], }) }) + +test('malformed tools/list snapshot responses still report to Sentry', async () => { + const sent: Array = [] + const socket = { + send: (payload: string) => { + sent.push(payload) + }, + } as unknown as WebSocket + const { session, persistedEntries } = await createRemoteConnectorSession({ + storedState: { + persisted: { + connectorId: 'home', + connectedAt: '2026-04-26T05:00:00.000Z', + lastSeenAt: '2026-04-26T05:01:00.000Z', + }, + tools: [{ name: 'bond_shade_set_position' }], + }, + webSockets: [socket], + }) + + const refresh = session.webSocketMessage( + socket, + JSON.stringify({ + type: 'connector.jsonrpc', + message: { + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + }, + }), + ) + + await vi.waitFor(() => { + expect(sent.length).toBeGreaterThan(0) + }) + const request = JSON.parse(sent[0]!) as { + type: string + message: { id: string; method: string } + } + expect(request).toMatchObject({ + type: 'connector.jsonrpc', + message: { method: 'tools/list' }, + }) + + await session.webSocketMessage( + socket, + JSON.stringify({ + type: 'connector.jsonrpc', + message: { + jsonrpc: '2.0', + id: request.message.id, + result: null, + }, + }), + ) + await refresh + + expect(captureMessageMock).toHaveBeenCalledWith( + 'Remote connector session message handler threw.', + expect.objectContaining({ + level: 'error', + extra: expect.objectContaining({ + connectorId: 'home', + messageType: 'connector.jsonrpc', + }), + }), + ) + expect(persistedEntries.get('remote-connector-session-state')).toMatchObject({ + tools: [{ name: 'bond_shade_set_position' }], + }) +}) diff --git a/packages/worker/src/remote-connector/session.ts b/packages/worker/src/remote-connector/session.ts index 4f4cd8a6f3..3e80a46854 100644 --- a/packages/worker/src/remote-connector/session.ts +++ b/packages/worker/src/remote-connector/session.ts @@ -27,6 +27,23 @@ const connectorTag = 'connector' const stateStorageKey = 'remote-connector-session-state' const rpcTimeoutMs = 15_000 +const remoteConnectorToolsListRpcErrorName = 'RemoteConnectorToolsListRpcError' + +function isExpectedToolsSnapshotRefreshFailure(error: unknown) { + if ( + error instanceof Error && + error.name === remoteConnectorToolsListRpcErrorName + ) { + return true + } + const message = getErrorMessage(error) + return ( + message === 'No remote connector is connected.' || + message.startsWith('Timed out waiting for remote connector response to ') || + message.includes(' before RPC response.') + ) +} + type PendingRpcRequest = { resolve: (message: RemoteConnectorJsonRpcResponse) => void reject: (error: Error) => void @@ -502,12 +519,10 @@ class RemoteConnectorSessionBase extends DurableObject { try { await this.refreshToolsSnapshot() } catch (error) { - this.stateSnapshot.tools = [] - this.logToolsSnapshotRefreshFailure({ + await this.handleToolsSnapshotRefreshFailure({ phase: 'after websocket hello', error, }) - await this.persistState() } } @@ -532,34 +547,41 @@ class RemoteConnectorSessionBase extends DurableObject { try { await this.refreshToolsSnapshot() } catch (error) { - this.stateSnapshot.tools = [] - this.logToolsSnapshotRefreshFailure({ + await this.handleToolsSnapshotRefreshFailure({ phase: 'on tools/list_changed', error, }) - await this.persistState() - return } } } - private logToolsSnapshotRefreshFailure(input: { + private async handleToolsSnapshotRefreshFailure(input: { phase: 'after websocket hello' | 'on tools/list_changed' error: unknown }) { - // tools/list timeouts, disconnects mid-RPC, and connector-side errors are - // expected remote-connector lifecycle noise (same class as websocket - // closes). Soft-fail by clearing tools; keep an ops log line and do not - // open Sentry issues. Auth failures and message-handler bugs still capture. + if (!isExpectedToolsSnapshotRefreshFailure(input.error)) { + // Malformed tools/list payloads, storage failures, and other + // implementation bugs should still open Sentry via the outer + // message-handler catch — do not clear a still-valid tool cache. + throw input.error + } + // Timeouts, disconnects mid-RPC, and "not connected" are expected + // remote-connector lifecycle noise (same class as websocket closes). + // Soft-fail by clearing tools; keep an ops log line and do not open + // Sentry issues. + this.stateSnapshot.tools = [] console.warn( `Remote connector tools snapshot refresh failed ${input.phase}. connectorId=${this.stateSnapshot.persisted.connectorId ?? 'null'} error=${getErrorMessage(input.error)}`, ) + await this.persistState() } private async refreshToolsSnapshot() { const response = await this.sendRpcRequest('tools/list', {}) if ('error' in response) { - throw new Error(response.error.message) + const error = new Error(response.error.message) + error.name = remoteConnectorToolsListRpcErrorName + throw error } const result = response.result as { tools?: Array From 6bb0d363b36a433152a5fa1ea478b847607ace08 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 07:12:55 +0000 Subject: [PATCH 3/4] fix(sentry): validate tools/list result shape before cache replace Reject non-object results and non-array tools so malformed payloads keep the prior tool cache and surface through the message-handler Sentry path. --- .../src/remote-connector/session.node.test.ts | 1 + packages/worker/src/remote-connector/session.ts | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/remote-connector/session.node.test.ts b/packages/worker/src/remote-connector/session.node.test.ts index 72be64f927..4aff101bc1 100644 --- a/packages/worker/src/remote-connector/session.node.test.ts +++ b/packages/worker/src/remote-connector/session.node.test.ts @@ -397,6 +397,7 @@ test('malformed tools/list snapshot responses still report to Sentry', async () extra: expect.objectContaining({ connectorId: 'home', messageType: 'connector.jsonrpc', + error: 'Malformed tools/list result.', }), }), ) diff --git a/packages/worker/src/remote-connector/session.ts b/packages/worker/src/remote-connector/session.ts index 3e80a46854..0b91b371ea 100644 --- a/packages/worker/src/remote-connector/session.ts +++ b/packages/worker/src/remote-connector/session.ts @@ -583,10 +583,17 @@ class RemoteConnectorSessionBase extends DurableObject { error.name = remoteConnectorToolsListRpcErrorName throw error } - const result = response.result as { - tools?: Array + const result = response.result + if (result === null || typeof result !== 'object') { + throw new Error('Malformed tools/list result.') } - this.stateSnapshot.tools = result.tools ?? [] + const tools = (result as { tools?: unknown }).tools + if (tools !== undefined && !Array.isArray(tools)) { + throw new Error('Malformed tools/list tools.') + } + this.stateSnapshot.tools = + (tools as Array | undefined) ?? + [] this.stateSnapshot.persisted.lastSeenAt = new Date().toISOString() await this.persistState() } From fb048e613b96ff6b734772bac3bcac9b99a80e01 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 07:22:52 +0000 Subject: [PATCH 4/4] fix(sentry): harden tools/list soft-fail and result validation Reject array-valued tools/list results, and restore the in-memory tool cache if soft-fail persistence fails so storage bugs still reach Sentry without blanking later reads. --- packages/worker/src/remote-connector/session.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/remote-connector/session.ts b/packages/worker/src/remote-connector/session.ts index 0b91b371ea..0c4d7fdb62 100644 --- a/packages/worker/src/remote-connector/session.ts +++ b/packages/worker/src/remote-connector/session.ts @@ -568,12 +568,19 @@ class RemoteConnectorSessionBase extends DurableObject { // Timeouts, disconnects mid-RPC, and "not connected" are expected // remote-connector lifecycle noise (same class as websocket closes). // Soft-fail by clearing tools; keep an ops log line and do not open - // Sentry issues. + // Sentry issues. Restore the in-memory cache if persistence fails so + // later reads are not left empty while Sentry captures the storage bug. + const previousTools = this.stateSnapshot.tools this.stateSnapshot.tools = [] console.warn( `Remote connector tools snapshot refresh failed ${input.phase}. connectorId=${this.stateSnapshot.persisted.connectorId ?? 'null'} error=${getErrorMessage(input.error)}`, ) - await this.persistState() + try { + await this.persistState() + } catch (persistError) { + this.stateSnapshot.tools = previousTools + throw persistError + } } private async refreshToolsSnapshot() { @@ -584,7 +591,11 @@ class RemoteConnectorSessionBase extends DurableObject { throw error } const result = response.result - if (result === null || typeof result !== 'object') { + if ( + result === null || + typeof result !== 'object' || + Array.isArray(result) + ) { throw new Error('Malformed tools/list result.') } const tools = (result as { tools?: unknown }).tools