From 1b2a842f45cbeb6ed8849544545b4495b2e16537 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 23:11:40 +0000 Subject: [PATCH 1/2] fix(sentry): stop reporting offline remote-connector guidance Disconnected/empty/unavailable connector messages are caller-clearable user state. Keep them on mcp-event logs; skip Sentry via isCallerFailure and a beforeSend backstop (KODY issue 7659311570). Co-authored-by: Kent C. Dodds --- .../worker/src/mcp/observability.node.test.ts | 39 +++++++++++- packages/worker/src/mcp/observability.ts | 13 ++++ .../src/remote-connector/status.node.test.ts | 59 +++++++++++++++++++ .../worker/src/remote-connector/status.ts | 18 ++++++ .../worker/src/sentry-options.node.test.ts | 25 ++++++++ packages/worker/src/sentry-options.ts | 25 +++++++- 6 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 packages/worker/src/remote-connector/status.node.test.ts diff --git a/packages/worker/src/mcp/observability.node.test.ts b/packages/worker/src/mcp/observability.node.test.ts index 531e8a2d3c..81d93def9c 100644 --- a/packages/worker/src/mcp/observability.node.test.ts +++ b/packages/worker/src/mcp/observability.node.test.ts @@ -213,9 +213,23 @@ test('logMcpEvent keeps sandbox and caller failures off Sentry and still reports }), }), }) + + logMcpEvent({ + ...callerFailureBase, + capabilityName: 'remote:home:bond_shade_set_position', + domain: 'remote:home', + capabilitySource: 'remote-connector', + failurePhase: 'handler', + errorName: 'Error', + errorMessage: + 'The connector "home" is not connected. Kody cannot use this connector until it reconnects. Ask the user to start or reconnect the connector and then try again.', + cause: new Error( + 'The connector "home" is not connected. Kody cannot use this connector until it reconnects. Ask the user to start or reconnect the connector and then try again.', + ), + }) }) - expect(payloads).toHaveLength(11) + expect(payloads).toHaveLength(12) expect(JSON.parse(payloads[0]!)).toMatchObject({ tool: 'execute', outcome: 'failure', @@ -262,9 +276,23 @@ test('logMcpEvent keeps sandbox and caller failures off Sentry and still reports }, cause: new Error('no such table: notes: SQLITE_ERROR'), }) + + logMcpEvent({ + ...callerFailureBase, + capabilityName: 'remote:home:bond_shade_set_position', + domain: 'remote:home', + capabilitySource: 'remote-connector', + failurePhase: 'handler', + errorName: 'Error', + errorMessage: + 'Remote capability "home:bond_shade_set_position" failed: timeout', + cause: new Error( + 'Remote capability "home:bond_shade_set_position" failed: timeout', + ), + }) }) - expect(sentryMock.captureException).toHaveBeenCalledTimes(3) + expect(sentryMock.captureException).toHaveBeenCalledTimes(4) expect(sentryMock.captureException).toHaveBeenNthCalledWith( 1, expect.objectContaining({ message: 'platform handler blew up' }), @@ -279,6 +307,13 @@ test('logMcpEvent keeps sandbox and caller failures off Sentry and still reports message: 'no such table: notes: SQLITE_ERROR', }), ) + expect(sentryMock.captureException).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + message: + 'Remote capability "home:bond_shade_set_position" failed: timeout', + }), + ) expect(sentryMock.scope.setLevel).toHaveBeenCalledWith('error') expect(sentryMock.scope.setUser).toHaveBeenCalledWith({ id: 'user-1' }) expect(sentryMock.scope.setContext).toHaveBeenCalledWith( diff --git a/packages/worker/src/mcp/observability.ts b/packages/worker/src/mcp/observability.ts index 61666aa22b..0672a97869 100644 --- a/packages/worker/src/mcp/observability.ts +++ b/packages/worker/src/mcp/observability.ts @@ -3,6 +3,7 @@ import { type McpCallerContext } from '@kody-internal/shared/chat.ts' import { getErrorCauseChain } from '@kody-internal/shared/error-message.ts' import { CommunityActionError } from '#worker/community/errors.ts' import { isEntitlementLimitError } from '#worker/entitlements/errors.ts' +import { isRemoteConnectorUnavailableMessage } from '#worker/remote-connector/status.ts' import { isRepoLargeFileMessage } from '#worker/repo/large-file-policy.ts' import { isUserCodeError } from '#worker/user-code-error.ts' import { isMcpCallerError } from './caller-error.ts' @@ -114,6 +115,18 @@ function isCallerFailure(payload: McpObservabilityPayload, cause?: unknown) { ) { return true } + // Offline / empty / session-unavailable remote connectors throw a plain + // Error with a stable guidance message. Agents reconnect; keep volume on + // mcp-event lines. + if ( + getErrorCauseChain(cause).some( + (entry) => + entry instanceof Error && + isRemoteConnectorUnavailableMessage(entry.message), + ) + ) { + return true + } return isMcpCallerError(cause) } diff --git a/packages/worker/src/remote-connector/status.node.test.ts b/packages/worker/src/remote-connector/status.node.test.ts new file mode 100644 index 0000000000..6d376a5eb7 --- /dev/null +++ b/packages/worker/src/remote-connector/status.node.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from 'vitest' +import { + formatRemoteConnectorUnavailableMessage, + isRemoteConnectorUnavailableMessage, + type RemoteConnectorStatus, +} from './status.ts' + +function status( + partial: Pick & + Partial, +): RemoteConnectorStatus { + return { + connectorId: 'home', + connected: partial.state === 'connected', + connectedAt: null, + lastSeenAt: null, + error: null, + ...partial, + } +} + +test('isRemoteConnectorUnavailableMessage matches guidance phrases and ignores connected-tool failures', () => { + const disconnected = formatRemoteConnectorUnavailableMessage( + status({ + state: 'disconnected', + toolCount: 0, + message: 'The connector "home" is not connected.', + }), + ) + const emptyTools = formatRemoteConnectorUnavailableMessage( + status({ + state: 'connected', + toolCount: 0, + message: + 'The connector "home" is connected, but it has not exposed any tools yet.', + }), + ) + const unavailable = formatRemoteConnectorUnavailableMessage( + status({ + state: 'unavailable', + toolCount: 0, + message: 'The connector "home" is unavailable in this session.', + }), + ) + + expect(isRemoteConnectorUnavailableMessage(disconnected)).toBe(true) + expect(isRemoteConnectorUnavailableMessage(emptyTools)).toBe(true) + expect(isRemoteConnectorUnavailableMessage(unavailable)).toBe(true) + expect( + isRemoteConnectorUnavailableMessage( + 'Remote capability "home:bond_shade_set_position" failed: timeout', + ), + ).toBe(false) + expect( + isRemoteConnectorUnavailableMessage( + 'Kody could not determine the status for the connector "home".', + ), + ).toBe(false) +}) diff --git a/packages/worker/src/remote-connector/status.ts b/packages/worker/src/remote-connector/status.ts index f624fc0164..b0f681f22e 100644 --- a/packages/worker/src/remote-connector/status.ts +++ b/packages/worker/src/remote-connector/status.ts @@ -72,6 +72,24 @@ function createErrorStatus( } } +/** + * Stable phrases from `formatRemoteConnectorUnavailableMessage`. MCP + * observability and Sentry `beforeSend` depend on them so offline / empty / + * unauthenticated connector states stay on structured `mcp-event` logs and + * out of Sentry (caller-clearable user state, not platform defects). + */ +const remoteConnectorUnavailablePhrases = [ + 'Kody cannot use this connector until it reconnects', + 'Capabilities from this connector cannot be searched or used until it exposes tools', + 'Kody cannot use this connector from this session', +] as const + +export function isRemoteConnectorUnavailableMessage(message: string) { + return remoteConnectorUnavailablePhrases.some((phrase) => + message.includes(phrase), + ) +} + export function formatRemoteConnectorUnavailableMessage( status: RemoteConnectorStatus, ) { diff --git a/packages/worker/src/sentry-options.node.test.ts b/packages/worker/src/sentry-options.node.test.ts index 9745aaf0c3..2a1256c5a8 100644 --- a/packages/worker/src/sentry-options.node.test.ts +++ b/packages/worker/src/sentry-options.node.test.ts @@ -142,6 +142,31 @@ test('filterSentryEvent drops expected platform and caller noise and keeps real filterSentryEvent({ message: executorSandboxTimeoutMessage }), ).toBeNull() + expect( + filterSentryEvent({ + exception: { + values: [ + { + value: + 'The connector "home" is not connected. Kody cannot use this connector until it reconnects. Ask the user to start or reconnect the connector and then try again.', + }, + ], + }, + }), + ).toBeNull() + expect( + filterSentryEvent({ + exception: { + values: [ + { + value: + 'Remote capability "home:bond_shade_set_position" failed: timeout', + }, + ], + }, + }), + ).not.toBeNull() + const platformBuildFailure = { exception: { values: [ diff --git a/packages/worker/src/sentry-options.ts b/packages/worker/src/sentry-options.ts index 845021b1d3..a24beaf6d4 100644 --- a/packages/worker/src/sentry-options.ts +++ b/packages/worker/src/sentry-options.ts @@ -3,6 +3,7 @@ import { type ErrorEvent, type EventHint } from '@sentry/core' import { getErrorCauseChain } from '@kody-internal/shared/error-message.ts' import { isEntitlementLimitError } from './entitlements/errors.ts' import { isRetryableD1LockSentryEvent } from './d1-retry.ts' +import { isRemoteConnectorUnavailableMessage } from './remote-connector/status.ts' import { isUserCodeError } from './user-code-error.ts' function sentryEventMessages(event: ErrorEvent) { @@ -113,6 +114,25 @@ export function filterExecutorSandboxTimeoutSentryEvent(event: ErrorEvent) { return null } +/** + * Offline / empty / session-unavailable remote connectors are caller-clearable + * user state (reconnect the connector). MCP observability already skips them + * via `isCallerFailure`; this `beforeSend` gate is the backstop for any other + * capture path that still forwards the plain Error message. + */ +export function isRemoteConnectorUnavailableSentryEvent(event: ErrorEvent) { + return sentryEventMessages(event).some( + (message) => + typeof message === 'string' && + isRemoteConnectorUnavailableMessage(message), + ) +} + +export function filterRemoteConnectorUnavailableSentryEvent(event: ErrorEvent) { + if (!isRemoteConnectorUnavailableSentryEvent(event)) return event + return null +} + /** * Exact Cloudflare Durable Object platform-reset messages. When a DO hits its * memory or CPU limit, when a deploy replaces DO code under an in-flight @@ -226,6 +246,7 @@ export function filterSentryEvent(event: ErrorEvent, hint?: EventHint) { // String-match backstops for paths that cannot yet be marked. if (filterUserModuleBundlerFailureSentryEvent(event) === null) return null if (filterExecutorSandboxTimeoutSentryEvent(event) === null) return null + if (filterRemoteConnectorUnavailableSentryEvent(event) === null) return null if (filterDurableObjectIsolateResetSentryEvent(event) === null) return null return event } @@ -263,7 +284,9 @@ export function buildSentryOptions(env: Env): CloudflareOptions { // account policy, still visible on structured `mcp-event` logs. Bundler // / sandbox-timeout string matches remain as backstops for unmarked // paths; see filterUserModuleBundlerFailureSentryEvent and - // filterExecutorSandboxTimeoutSentryEvent. Bare Cloudflare Durable + // filterExecutorSandboxTimeoutSentryEvent. Offline remote-connector + // guidance messages are dropped the same way — see + // filterRemoteConnectorUnavailableSentryEvent. Bare Cloudflare Durable // Object platform reset strings (memory/CPU limits, deploy-time code // updates, blockConcurrencyWhile timeouts, DO storage operation // timeouts, and DO storage object-reset with a support reference) are From 86d156e1abb32641bec39c423ad40246e42a4fb6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 23:11:40 +0000 Subject: [PATCH 2/2] fix(admin): accept SQLite zero rowids in audit log query output admin_audit_log_query used z.number().int().positive(), so an events[].id of 0 failed parse_output. SQLite allows explicit id=0; relax to nonnegative (KODY-CLOUDFLARE-42). Co-authored-by: Kent C. Dodds --- packages/worker/src/audit-log.node.test.ts | 26 +++++++++++- .../admin/admin-audit-log-query.ts | 4 +- .../admin/admin-capabilities.node.test.ts | 41 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/worker/src/audit-log.node.test.ts b/packages/worker/src/audit-log.node.test.ts index 0a6bda6bdf..47ce63b018 100644 --- a/packages/worker/src/audit-log.node.test.ts +++ b/packages/worker/src/audit-log.node.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { DatabaseSync } from 'node:sqlite' import { expect, test, vi } from 'vitest' -import { logAuditEvent } from './audit-log.ts' +import { logAuditEvent, queryAuditLog } from './audit-log.ts' import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts' import { consoleWarn } from '#worker/test-support/console-spies.ts' @@ -111,3 +111,27 @@ test('audit writes retry transient errors and report dedicated sink failures', a errors: [expect.any(Error)], }) }) + +test('queryAuditLog returns explicit SQLite zero rowids', async () => { + const audit = createAuditDb() + audit.sqlite + .prepare( + `INSERT INTO audit_events (id, category, action, result, timestamp) + VALUES (0, 'admin', 'legacy_seed', 'success', '2026-01-01T00:00:00.000Z')`, + ) + .run() + + const result = await queryAuditLog(audit.db, { + action: 'legacy_seed', + limit: 10, + }) + expect(result.total).toBe(1) + expect(result.events).toEqual([ + expect.objectContaining({ + id: 0, + action: 'legacy_seed', + category: 'admin', + result: 'success', + }), + ]) +}) diff --git a/packages/worker/src/mcp/capabilities/admin/admin-audit-log-query.ts b/packages/worker/src/mcp/capabilities/admin/admin-audit-log-query.ts index ac5de9301c..c375d3bc51 100644 --- a/packages/worker/src/mcp/capabilities/admin/admin-audit-log-query.ts +++ b/packages/worker/src/mcp/capabilities/admin/admin-audit-log-query.ts @@ -58,7 +58,9 @@ const inputSchema = z.object({ }) const auditLogEntrySchema = z.object({ - id: z.number().int().positive(), + // SQLite INTEGER PRIMARY KEY allows explicit id=0; AUTOINCREMENT starts at + // 1 for app writes, but legacy/manual rows must still parse. + id: z.number().int().nonnegative(), category: z.enum(auditEventCategories), action: z.string(), result: z.enum(auditEventResults), diff --git a/packages/worker/src/mcp/capabilities/admin/admin-capabilities.node.test.ts b/packages/worker/src/mcp/capabilities/admin/admin-capabilities.node.test.ts index ea668f97a9..0fb1994857 100644 --- a/packages/worker/src/mcp/capabilities/admin/admin-capabilities.node.test.ts +++ b/packages/worker/src/mcp/capabilities/admin/admin-capabilities.node.test.ts @@ -655,6 +655,47 @@ test('admin capabilities list and get account metadata and query sanitized audit ]) }) +test('admin_audit_log_query accepts SQLite zero rowids through output parse', async () => { + const { db, auditEvents } = createAdminCapabilityTestDb({ + users: [ + adminTestUser({ + id: 1, + username: 'admin', + email: 'admin@example.com', + created_at: '2026-01-01 00:00:00', + updated_at: '2026-01-02 00:00:00', + }), + ], + userRoles: [{ user_id: 1, role_name: 'admin' }], + }) + auditEvents.push({ + id: 0, + category: 'admin', + action: 'legacy_seed', + result: 'success', + email_hash: null, + ip_hash: null, + client_id: null, + path: null, + reason: null, + timestamp: '2026-01-01T00:00:00.000Z', + }) + const ctx = createAdminCapabilityContext(db) + + const audit = await adminAuditLogQueryCapability.handler( + { action: 'legacy_seed', limit: 10 }, + ctx, + ) + expect(audit.events).toEqual([ + expect.objectContaining({ + id: 0, + action: 'legacy_seed', + category: 'admin', + result: 'success', + }), + ]) +}) + test('admin system email capabilities read only system-owned mail and audit reads', async () => { const { db, auditEvents } = createAdminCapabilityTestDb({ users: [