Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion packages/worker/src/audit-log.node.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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',
}),
])
})
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
39 changes: 37 additions & 2 deletions packages/worker/src/mcp/observability.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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' }),
Expand All @@ -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(
Expand Down
13 changes: 13 additions & 0 deletions packages/worker/src/mcp/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}

Expand Down
59 changes: 59 additions & 0 deletions packages/worker/src/remote-connector/status.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test } from 'vitest'
import {
formatRemoteConnectorUnavailableMessage,
isRemoteConnectorUnavailableMessage,
type RemoteConnectorStatus,
} from './status.ts'

function status(
partial: Pick<RemoteConnectorStatus, 'state' | 'message' | 'toolCount'> &
Partial<RemoteConnectorStatus>,
): 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)
})
18 changes: 18 additions & 0 deletions packages/worker/src/remote-connector/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand Down
25 changes: 25 additions & 0 deletions packages/worker/src/sentry-options.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
25 changes: 24 additions & 1 deletion packages/worker/src/sentry-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
Loading