diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index cb3f122ad17f..3657a10e1dd7 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -3887,6 +3887,462 @@ public struct SessionsUsageParams: Codable, Sendable { } } +public struct AuditActivityAgentRunV1: Codable, Sendable { + public let eventtype: String + public let schemaversion: Int + public let eventid: String + public let sequence: Int + public let sourcesequence: Int + public let occurredat: Int + public let redaction: String + public let actor: [String: AnyCodable] + public let agentid: String + public let sessionkey: String? + public let sessionid: String? + public let runid: String + public let kind: String + public let action: AnyCodable + public let status: AnyCodable + public let errorcode: AnyCodable? + + public init( + eventtype: String, + schemaversion: Int, + eventid: String, + sequence: Int, + sourcesequence: Int, + occurredat: Int, + redaction: String, + actor: [String: AnyCodable], + agentid: String, + sessionkey: String? = nil, + sessionid: String? = nil, + runid: String, + kind: String, + action: AnyCodable, + status: AnyCodable, + errorcode: AnyCodable? = nil) + { + self.eventtype = eventtype + self.schemaversion = schemaversion + self.eventid = eventid + self.sequence = sequence + self.sourcesequence = sourcesequence + self.occurredat = occurredat + self.redaction = redaction + self.actor = actor + self.agentid = agentid + self.sessionkey = sessionkey + self.sessionid = sessionid + self.runid = runid + self.kind = kind + self.action = action + self.status = status + self.errorcode = errorcode + } + + private enum CodingKeys: String, CodingKey { + case eventtype = "eventType" + case schemaversion = "schemaVersion" + case eventid = "eventId" + case sequence + case sourcesequence = "sourceSequence" + case occurredat = "occurredAt" + case redaction + case actor + case agentid = "agentId" + case sessionkey = "sessionKey" + case sessionid = "sessionId" + case runid = "runId" + case kind + case action + case status + case errorcode = "errorCode" + } +} + +public struct AuditActivityToolActionV1: Codable, Sendable { + public let eventtype: String + public let schemaversion: Int + public let eventid: String + public let sequence: Int + public let sourcesequence: Int + public let occurredat: Int + public let redaction: String + public let actor: [String: AnyCodable] + public let agentid: String + public let sessionkey: String? + public let sessionid: String? + public let runid: String + public let kind: String + public let toolcallid: String? + public let toolname: String? + public let action: AnyCodable + public let status: AnyCodable + public let errorcode: AnyCodable? + + public init( + eventtype: String, + schemaversion: Int, + eventid: String, + sequence: Int, + sourcesequence: Int, + occurredat: Int, + redaction: String, + actor: [String: AnyCodable], + agentid: String, + sessionkey: String? = nil, + sessionid: String? = nil, + runid: String, + kind: String, + toolcallid: String? = nil, + toolname: String? = nil, + action: AnyCodable, + status: AnyCodable, + errorcode: AnyCodable? = nil) + { + self.eventtype = eventtype + self.schemaversion = schemaversion + self.eventid = eventid + self.sequence = sequence + self.sourcesequence = sourcesequence + self.occurredat = occurredat + self.redaction = redaction + self.actor = actor + self.agentid = agentid + self.sessionkey = sessionkey + self.sessionid = sessionid + self.runid = runid + self.kind = kind + self.toolcallid = toolcallid + self.toolname = toolname + self.action = action + self.status = status + self.errorcode = errorcode + } + + private enum CodingKeys: String, CodingKey { + case eventtype = "eventType" + case schemaversion = "schemaVersion" + case eventid = "eventId" + case sequence + case sourcesequence = "sourceSequence" + case occurredat = "occurredAt" + case redaction + case actor + case agentid = "agentId" + case sessionkey = "sessionKey" + case sessionid = "sessionId" + case runid = "runId" + case kind + case toolcallid = "toolCallId" + case toolname = "toolName" + case action + case status + case errorcode = "errorCode" + } +} + +public struct AuditActivityInboundMessageV1: Codable, Sendable { + public let eventtype: String + public let schemaversion: Int + public let eventid: String + public let sequence: Int + public let sourcesequence: Int + public let occurredat: Int + public let redaction: String + public let channel: String + public let conversationkind: AnyCodable + public let durationms: Int? + public let resultcount: Int? + public let agentid: String? + public let runid: String? + public let accountref: String? + public let conversationref: String? + public let messageref: String? + public let targetref: String? + public let kind: String + public let action: String + public let direction: String + public let actor: AnyCodable + public let status: AnyCodable + public let outcome: AnyCodable + public let errorcode: String? + public let reasoncode: AnyCodable? + + public init( + eventtype: String, + schemaversion: Int, + eventid: String, + sequence: Int, + sourcesequence: Int, + occurredat: Int, + redaction: String, + channel: String, + conversationkind: AnyCodable, + durationms: Int? = nil, + resultcount: Int? = nil, + agentid: String? = nil, + runid: String? = nil, + accountref: String? = nil, + conversationref: String? = nil, + messageref: String? = nil, + targetref: String? = nil, + kind: String, + action: String, + direction: String, + actor: AnyCodable, + status: AnyCodable, + outcome: AnyCodable, + errorcode: String? = nil, + reasoncode: AnyCodable? = nil) + { + self.eventtype = eventtype + self.schemaversion = schemaversion + self.eventid = eventid + self.sequence = sequence + self.sourcesequence = sourcesequence + self.occurredat = occurredat + self.redaction = redaction + self.channel = channel + self.conversationkind = conversationkind + self.durationms = durationms + self.resultcount = resultcount + self.agentid = agentid + self.runid = runid + self.accountref = accountref + self.conversationref = conversationref + self.messageref = messageref + self.targetref = targetref + self.kind = kind + self.action = action + self.direction = direction + self.actor = actor + self.status = status + self.outcome = outcome + self.errorcode = errorcode + self.reasoncode = reasoncode + } + + private enum CodingKeys: String, CodingKey { + case eventtype = "eventType" + case schemaversion = "schemaVersion" + case eventid = "eventId" + case sequence + case sourcesequence = "sourceSequence" + case occurredat = "occurredAt" + case redaction + case channel + case conversationkind = "conversationKind" + case durationms = "durationMs" + case resultcount = "resultCount" + case agentid = "agentId" + case runid = "runId" + case accountref = "accountRef" + case conversationref = "conversationRef" + case messageref = "messageRef" + case targetref = "targetRef" + case kind + case action + case direction + case actor + case status + case outcome + case errorcode = "errorCode" + case reasoncode = "reasonCode" + } +} + +public struct AuditActivityOutboundMessageV1: Codable, Sendable { + public let eventtype: String + public let schemaversion: Int + public let eventid: String + public let sequence: Int + public let sourcesequence: Int + public let occurredat: Int + public let redaction: String + public let channel: String + public let conversationkind: AnyCodable + public let durationms: Int? + public let resultcount: Int? + public let agentid: String? + public let runid: String? + public let accountref: String? + public let conversationref: String? + public let messageref: String? + public let targetref: String? + public let kind: String + public let action: String + public let direction: String + public let actor: [String: AnyCodable] + public let deliverykind: AnyCodable? + public let status: AnyCodable + public let outcome: AnyCodable + public let errorcode: AnyCodable? + public let reasoncode: AnyCodable? + public let failurestage: AnyCodable? + + public init( + eventtype: String, + schemaversion: Int, + eventid: String, + sequence: Int, + sourcesequence: Int, + occurredat: Int, + redaction: String, + channel: String, + conversationkind: AnyCodable, + durationms: Int? = nil, + resultcount: Int? = nil, + agentid: String? = nil, + runid: String? = nil, + accountref: String? = nil, + conversationref: String? = nil, + messageref: String? = nil, + targetref: String? = nil, + kind: String, + action: String, + direction: String, + actor: [String: AnyCodable], + deliverykind: AnyCodable? = nil, + status: AnyCodable, + outcome: AnyCodable, + errorcode: AnyCodable? = nil, + reasoncode: AnyCodable? = nil, + failurestage: AnyCodable? = nil) + { + self.eventtype = eventtype + self.schemaversion = schemaversion + self.eventid = eventid + self.sequence = sequence + self.sourcesequence = sourcesequence + self.occurredat = occurredat + self.redaction = redaction + self.channel = channel + self.conversationkind = conversationkind + self.durationms = durationms + self.resultcount = resultcount + self.agentid = agentid + self.runid = runid + self.accountref = accountref + self.conversationref = conversationref + self.messageref = messageref + self.targetref = targetref + self.kind = kind + self.action = action + self.direction = direction + self.actor = actor + self.deliverykind = deliverykind + self.status = status + self.outcome = outcome + self.errorcode = errorcode + self.reasoncode = reasoncode + self.failurestage = failurestage + } + + private enum CodingKeys: String, CodingKey { + case eventtype = "eventType" + case schemaversion = "schemaVersion" + case eventid = "eventId" + case sequence + case sourcesequence = "sourceSequence" + case occurredat = "occurredAt" + case redaction + case channel + case conversationkind = "conversationKind" + case durationms = "durationMs" + case resultcount = "resultCount" + case agentid = "agentId" + case runid = "runId" + case accountref = "accountRef" + case conversationref = "conversationRef" + case messageref = "messageRef" + case targetref = "targetRef" + case kind + case action + case direction + case actor + case deliverykind = "deliveryKind" + case status + case outcome + case errorcode = "errorCode" + case reasoncode = "reasonCode" + case failurestage = "failureStage" + } +} + +public struct AuditActivityListParams: Codable, Sendable { + public let agentid: String? + public let sessionkey: String? + public let runid: String? + public let kind: AnyCodable? + public let status: AnyCodable? + public let direction: AnyCodable? + public let channel: String? + public let after: Int? + public let before: Int? + public let limit: Int? + public let cursor: String? + + public init( + agentid: String? = nil, + sessionkey: String? = nil, + runid: String? = nil, + kind: AnyCodable? = nil, + status: AnyCodable? = nil, + direction: AnyCodable? = nil, + channel: String? = nil, + after: Int? = nil, + before: Int? = nil, + limit: Int? = nil, + cursor: String? = nil) + { + self.agentid = agentid + self.sessionkey = sessionkey + self.runid = runid + self.kind = kind + self.status = status + self.direction = direction + self.channel = channel + self.after = after + self.before = before + self.limit = limit + self.cursor = cursor + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case sessionkey = "sessionKey" + case runid = "runId" + case kind + case status + case direction + case channel + case after + case before + case limit + case cursor + } +} + +public struct AuditActivityListResult: Codable, Sendable { + public let events: [AuditActivityEventV1] + public let nextcursor: String? + + public init( + events: [AuditActivityEventV1], + nextcursor: String? = nil) + { + self.events = events + self.nextcursor = nextcursor + } + + private enum CodingKeys: String, CodingKey { + case events + case nextcursor = "nextCursor" + } +} + public struct AuditEvent: Codable, Sendable { public let eventid: String public let sequence: Int @@ -10742,6 +11198,43 @@ public enum GatewaySuspendStatusResult: Codable, Sendable { } } +public enum AuditActivityEventV1: Codable, Sendable { + case agentRun(AuditActivityAgentRunV1) + case toolAction(AuditActivityToolActionV1) + case inboundMessage(AuditActivityInboundMessageV1) + case outboundMessage(AuditActivityOutboundMessageV1) + + private enum CodingKeys: String, CodingKey { + case discriminator = "eventType" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(String.self, forKey: .discriminator) + switch discriminator { + case "agent_run": self = try .agentRun(AuditActivityAgentRunV1(from: decoder)) + case "tool_action": self = try .toolAction(AuditActivityToolActionV1(from: decoder)) + case "inbound_message": self = try .inboundMessage(AuditActivityInboundMessageV1(from: decoder)) + case "outbound_message": self = try .outboundMessage(AuditActivityOutboundMessageV1(from: decoder)) + default: + throw DecodingError.dataCorruptedError( + forKey: .discriminator, + in: container, + debugDescription: "Unknown AuditActivityEventV1 discriminator value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .agentRun(let value): try value.encode(to: encoder) + case .toolAction(let value): try value.encode(to: encoder) + case .inboundMessage(let value): try value.encode(to: encoder) + case .outboundMessage(let value): try value.encode(to: encoder) + } + } +} + public enum PluginCatalogInstallAction: Codable, Sendable { case clawhub(PluginCatalogClawHubInstall) case official(PluginCatalogOfficialInstall) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index d40c3793d3d4..5f16932759fb 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -a660c73743b50c4a6a546932504724647daa06bd273e19be506d40b1a2aecb53 config-baseline.json -0084dc1fce9c0e9cd2e838456a726d641c1733827cf826e6de5e8cf7206e3dd7 config-baseline.core.json +d2eded996abf00045c50ad400648f6e36565a7f158fc2000e15b805052436004 config-baseline.json +2179eacdae1e28fccf05beb4f89c24d3822e93897dd343a0ca2373f20e55ed85 config-baseline.core.json 82596aeb4c8d4cd18fefae554ff4d0c9e2cd82895b53d460db8f8ceb819f0ef6 config-baseline.channel.json 168e3c52da484c235fbc451dead71af16c3bee0c0186c460e5b4b4af70a496df config-baseline.plugin.json diff --git a/docs/cli/audit.md b/docs/cli/audit.md index 95aa10e64217..e66678e882dd 100644 --- a/docs/cli/audit.md +++ b/docs/cli/audit.md @@ -1,26 +1,29 @@ --- -summary: "CLI reference for metadata-only agent run and tool action audit records" +summary: "CLI reference for metadata-only run, tool, and message lifecycle audit records" read_when: - You need to answer who ran an agent or tool, when it ran, and how it ended + - You need content-free inbound or outbound message lifecycle metadata - You need a bounded, redaction-safe activity export title: "Audit records" --- # `openclaw audit` -Query the Gateway's metadata-only audit ledger for agent runs and tool actions. +Query the Gateway's metadata-only audit ledger for agent runs, tool actions, and +opt-in message lifecycle records. + +The ledger is on by default for run and tool events. Set +[`audit.enabled: false`](/gateway/configuration-reference#audit) and restart the +Gateway to stop all new event records. Message records are separately disabled by +default; set `audit.messages` to `direct` or `all` and restart the Gateway to +record them. Existing records stay queryable until they expire (30 days). -Recording is on by default; set [`audit.enabled: false`](/gateway/configuration-reference#audit) -to stop new writes. Existing records stay queryable until they expire (30 days). The ledger is separate from conversation transcripts: it records identity, -ordering, provenance, action, status, and normalized error codes, but never -stores prompts, messages, tool arguments, tool results, command output, or raw -error text. - -The Gateway writes records to the shared OpenClaw state database through a -bounded background writer. Queries never return records older than 30 days, -and the ledger is capped at 100,000 rows. Expired rows are deleted during -Gateway startup, hourly maintenance, and later writes. +ordering, provenance, action, status, and normalized outcome codes, but never +stores content, and message identifiers appear only as installation-local +keyed pseudonyms. [Audit history](/gateway/audit) owns the full data model, +privacy semantics, storage/retention bounds, and coverage limits; this page +covers the command surface. ```bash openclaw audit @@ -28,6 +31,7 @@ openclaw audit --agent main --status failed openclaw audit --session "agent:main:main" --after 2026-07-01T00:00:00Z openclaw audit --run 8c69f72e-8b11-4c54-98d5-1a3dd67450c3 openclaw audit --kind tool_action --limit 50 --json +openclaw audit --kind message --direction outbound --channel telegram --json ``` ## Filters @@ -35,58 +39,100 @@ openclaw audit --kind tool_action --limit 50 --json - `--agent `: exact agent id - `--session `: exact session key - `--run `: exact run id -- `--kind `: `agent_run` or `tool_action` +- `--kind `: `agent_run`, `tool_action`, or `message` - `--status `: `started`, `succeeded`, `failed`, `cancelled`, `timed_out`, `blocked`, or `unknown` +- `--direction `: message direction, `inbound` or `outbound` +- `--channel `: exact message channel - `--after ` / `--before `: inclusive ISO timestamp or Unix milliseconds - `--limit `: page size from 1 to 500; default `100` - `--cursor `: continue a previous newest-first query - `--json`: print the bounded page as JSON -Text output shows time, kind, status, agent, run, and action. Tool actions also -show the tool name. JSON output is a safe bounded export of the same metadata -and includes `nextCursor` when another page exists. Pass that value to +The CLI queries the versioned activity RPC so one command shows the complete +configured ledger. Text output shows time, kind, direction, channel, status, +agent, run, and action. Missing message provenance renders as `-`; OpenClaw +does not invent agent or run ids. Tool actions also show the tool name. JSON +output includes `nextCursor` when another page exists. Pass that value to `--cursor` to continue without reordering records that arrive during paging. +These exports remain sensitive operational metadata even though message bodies +and raw message identity fields are absent. Agent, session, and run ids, timing, +channels, outcomes, and stable HMAC references can correlate activity. Protect +them with the same access controls and retention practices as other operator +records. + ## Recorded events -The Gateway projects existing agent event streams into four actions: +The Gateway projects trusted lifecycle streams into six actions: - `agent.run.started` - `agent.run.finished` - `tool.action.started` - `tool.action.finished` +- `message.inbound.processed` +- `message.outbound.finished` -Every record has a stable event id, a monotonically increasing ledger sequence, -the original run event sequence, lifecycle timestamp when the runtime provides -one (otherwise observation time), agent/run provenance, actor, and a -`redaction: "metadata_only"` marker. Terminal records distinguish success, -failure, cancellation, timeout, and policy blocks with closed status and error -codes. `unknown` is an explicit non-success result when an upstream runtime -does not expose an authoritative terminal outcome. Tool call ids are exported -only as stable one-way fingerprints. Tool names must match the compact -model-facing name contract; other values become `unknown`. Session ids, session -keys, run ids, and retained tool names are operator metadata; protect exports -as operational records. +Every returned record has a stable event id, a monotonically increasing ledger +sequence, a lifecycle timestamp, actor, action, status, a +`schemaVersion: 1` marker, source sequence, and `redaction: "metadata_only"`. +Agent/session/run provenance and event-specific fields are present only when +the trusted source provides them. Message records intentionally omit +`sessionKey` and `sessionId`, so `--session` filters run and tool records only. + +Terminal run and tool records distinguish success, failure, cancellation, +timeout, and policy blocks with closed status and error codes. `unknown` is an +explicit non-success result when an upstream runtime does not expose an +authoritative terminal outcome. Tool call ids are exported only as stable +fingerprints. Tool names must match the compact model-facing name +contract; other values become `unknown`. + +Message records add direction, channel, conversation kind, outcome, and +optional delivery kind, failure stage, duration, result count, normalized +reason code, and keyed account/conversation/message/target pseudonyms. The +current inbound boundary covers accepted messages that reach core dispatch, +including core duplicate and terminal processing outcomes. The outbound +boundary writes one terminal row per original logical reply payload that reaches +shared durable delivery; chunking and adapter fan-out are aggregated in +`resultCount`. Queued retryable or ambiguous sends are recorded only after an +acknowledgement, dead letter, or reconciliation makes the outcome terminal. +Plugin-local and direct-send paths that bypass those shared boundaries are not +yet covered; absence of a row does not prove that no message existed. The audit ledger does not replace transcripts, task history, cron run history, or logs. It provides a small cross-run index for operator questions without copying conversation content into another store. +For inbound rows, `durationMs` measures core dispatch and `resultCount` counts +finalized queued tool, block, and reply payloads. For outbound rows, +`durationMs` includes delivery ownership through its terminal (and therefore +queued wait time), while `resultCount` counts identified physical platform +sends. `deliveryKind`, when present, describes the effective post-hook, +post-render payload; suppressed and crash-ambiguous rows omit it. + ## Gateway RPC -`audit.list` requires `operator.read` and accepts the same filters. Example: +`audit.activity.list` requires `operator.read` and accepts the same filters. It +returns the named V1 activity event union, including run, tool, inbound-message, +and outbound-message records. ```bash -openclaw gateway call audit.list --params '{"agentId":"main","status":"failed","limit":50}' +openclaw gateway call audit.activity.list --params '{"channel":"telegram","limit":50}' ``` -The result is `{ "events": AuditEvent[], "nextCursor"?: string }`. Results are -newest first and limited to 500 records per request. +The result is `{ "events": AuditActivityEventV1[], "nextCursor"?: string }`. +Results are newest first and limited to 500 records per request. + +The shipped `audit.list` RPC remains unchanged for older run/tool clients. When +`audit.activity.list` is unavailable on an older Gateway, the CLI retries +`audit.list` only if every requested filter is supported by that legacy method. `--kind message`, +`--direction`, and `--channel` fail with an upgrade message on an older Gateway +instead of being silently discarded. ## Related +- [Audit history](/gateway/audit) - [Gateway protocol](/gateway/protocol#audit-ledger-rpc) - [Sessions](/cli/sessions) - [Tasks](/cli/tasks) diff --git a/docs/docs.json b/docs/docs.json index 4f74f0041278..6dbac33169b8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1618,6 +1618,7 @@ "gateway/health", "gateway/heartbeat", "gateway/doctor", + "gateway/audit", "logging", "gateway/opentelemetry", "gateway/prometheus", diff --git a/docs/docs_map.md b/docs/docs_map.md index ed3a1a865b79..b24e944ea359 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -3029,6 +3029,20 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Notes - H2: Related +## gateway/audit.md + +- Route: /gateway/audit +- Headings: + - H1: Audit history + - H2: Record families + - H2: Message lifecycle events + - H3: Conversation-kind classification + - H2: Privacy model + - H2: Coverage and proof limits + - H2: Storage, retention, and migration + - H2: Querying + - H2: Related + ## gateway/authentication.md - Route: /gateway/authentication diff --git a/docs/gateway/audit.md b/docs/gateway/audit.md new file mode 100644 index 000000000000..6f66fc0af439 --- /dev/null +++ b/docs/gateway/audit.md @@ -0,0 +1,140 @@ +--- +summary: "Metadata-only audit history for agent runs, tool actions, and opt-in message lifecycles" +read_when: + - You need a durable record of what the Gateway did without storing content + - You are deciding whether to enable message lifecycle auditing + - You need to explain what audit records do and do not prove +title: "Audit history" +--- + +# Audit history + +The Gateway keeps a bounded, metadata-only audit ledger in the shared OpenClaw +state database. It answers operational questions such as "which agent ran, +when, and how did it end", "which tool actions did a run execute", and, when +message auditing is enabled, "did an accepted inbound message reach dispatch" +and "did an outbound message reach a terminal delivery state". + +The ledger stores identity, ordering, provenance, action, status, and +normalized outcome codes. It never stores prompts, message bodies, tool +arguments, tool results, attachments, filenames, URLs, command output, or raw +error text. + +## Record families + +Run and tool events are recorded whenever auditing is enabled (the default). +Message lifecycle events are opt-in and disabled by default. + +| Family | Actions | Default | +| ------------ | -------------------------------------------------------- | ------- | +| Agent runs | `agent.run.started`, `agent.run.finished` | on | +| Tool actions | `tool.action.started`, `tool.action.finished` | on | +| Messages | `message.inbound.processed`, `message.outbound.finished` | off | + +Every record carries a stable event id, a monotonic ledger sequence, a +lifecycle timestamp, actor, action, status, `schemaVersion: 1`, and +`redaction: "metadata_only"`. See [Audit records](/cli/audit) for the full +field reference and query filters. + +## Message lifecycle events + +Set [`audit.messages`](/gateway/configuration-reference#audit) to choose what +is recorded, then restart the Gateway: + +- `off` (default): no message records. +- `direct`: only messages in direct conversations. +- `all`: direct, group, and channel messages. + +Two authoritative boundaries produce message records: + +- **Inbound** rows are written when an accepted message reaches core dispatch, + including duplicate and terminal processing outcomes. +- **Outbound** rows are written when shared durable delivery reaches a + terminal outcome: sent, suppressed, failed, or an explicit `unknown` for + crash-ambiguous sends. Queue recovery and dead-letter outcomes are included. + Each original logical reply payload gets one terminal row; chunking and + adapter fan-out aggregate into `resultCount`. + +### Conversation-kind classification + +`direct` mode is a privacy boundary, so a message is classified as a direct +conversation only when destination facts prove it: the sending path declared +the destination conversation kind, or the delivery session route names exactly +the channel and peer being delivered to. Weaker signals, such as policy state +or the originating conversation, can classify a message as `group` (excluding +it from `direct` collection) but can never claim `direct`. Messages that +cannot be proven direct are classified `unknown` and are not recorded in +`direct` mode. Channels that do not declare chat types may therefore record +fewer rows in `direct` mode than they do in `all` mode. + +## Privacy model + +Message rows never store raw platform identifiers. Account, conversation, +message, and target identifiers, when correlation is available, are exported +only as installation-local keyed pseudonyms +(`hmac-sha256:v1::`): + +- The HMAC key is generated on first use, is domain-separated per identifier + kind, and lives in the same state database as the ledger. +- Pseudonyms are stable within one installation, so rows about the same + conversation correlate without revealing the platform identifier. +- This is **correlation, not anonymization**: anyone with read access to the + state database also has the key and can test candidate raw identifiers + against the pseudonyms. RPC and CLI exports never include the key. +- If the key material is missing or corrupt while message rows are retained, + the Gateway fails closed and drops new message records instead of silently + rotating to a new key, which would split correlation. + +Run and tool records retain `sessionKey` and `sessionId` for correlation; +canonical session keys can themselves contain platform account or peer ids. +Message records intentionally omit both. + +Audit exports remain sensitive operational metadata even without content: +timing, channels, outcomes, and stable pseudonyms can correlate activity. +Protect exports with the same access controls and retention practices as other +operator records. + +## Coverage and proof limits + +The ledger is best-effort and deliberately bounded. Treat it as evidence of +what was recorded, not as proof of what happened: + +- **Absence of a row proves nothing.** Pre-admission inbound drops, sends from + CLI processes without a running Gateway recorder, and plugin-local or + direct-send paths that bypass shared durable delivery leave no record. +- Writes go through a bounded background worker; worker failure or queue + saturation drops records and logs one operational warning. +- Crash-ambiguous outbound sends are recorded as `unknown` rather than + invented outcomes. + +This ledger supports debugging and operational review. It is not a lossless +compliance archive; if you need one, use an external system fed by +[OpenTelemetry](/gateway/opentelemetry) or channel-level tooling. + +## Storage, retention, and migration + +Records live in the shared state database (`state/openclaw.sqlite`) and are +written off the delivery hot path. Queries never return records older than 30 +days, and the ledger is capped at 100,000 rows; expired rows are pruned during +startup, hourly maintenance, and later writes. Retention maintenance keeps +running even when collection is disabled. + +Upgrading from a Gateway with the earlier run/tool-only ledger migrates the +schema automatically at startup (or via `openclaw doctor --fix`); existing +rows and their ledger sequences are preserved. + +## Querying + +- CLI: [`openclaw audit`](/cli/audit) with filters for agent, session, run, + kind, status, direction, channel, time bounds, and cursor paging. +- Gateway RPC: `audit.activity.list` (requires `operator.read`) returns the + versioned V1 activity event union; the shipped `audit.list` RPC is unchanged + for older run/tool clients. See + [Gateway protocol](/gateway/protocol#audit-ledger-rpc). + +## Related + +- [Audit records CLI](/cli/audit) +- [Configuration reference](/gateway/configuration-reference#audit) +- [Gateway protocol](/gateway/protocol#audit-ledger-rpc) +- [OpenTelemetry](/gateway/opentelemetry) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 99fe0766f922..a21e5a23be08 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1203,22 +1203,43 @@ Notes: { audit: { enabled: true, + messages: "off", // off | direct | all }, } ``` The Gateway records **metadata-only** audit events for agent runs and tool -actions into the shared state database: identity, timing, tool names, and -terminal outcomes — never prompts, messages, tool arguments, results, or raw -error text. Records expire after 30 days and the ledger is capped at 100,000 -rows. Query them with [`openclaw audit`](/cli/audit) or the -[`audit.list`](/gateway/protocol#audit-ledger-rpc) Gateway RPC. +actions into the shared state database. Message lifecycle metadata is a +separate opt-in. The ledger stores identity, timing, tool names, and normalized +outcomes, but never prompts, message bodies, tool arguments, results, or raw +error text. Message rows do not store raw platform account, conversation, +message, and target ids. Run/tool session keys remain available for correlation +and can themselves contain platform account or peer ids. Records +expire after 30 days and the ledger is capped at 100,000 rows. Query them with +[`openclaw audit`](/cli/audit) or the +[`audit.activity.list`](/gateway/protocol#audit-ledger-rpc) Gateway RPC. See +[Audit history](/gateway/audit) for the full data model, privacy semantics, +and coverage limits. - `enabled`: record new audit events (default: `true`). The ledger is on by default because an audit trail enabled only after an incident cannot explain - the incident. Setting `false` stops new writes immediately; existing records - stay readable until they expire. Turning it back on resumes recording from - that point — the gap is not backfilled. + the incident. Setting `false` stops new event inserts after the Gateway restarts; + existing records stay readable until they expire. Turning it back on resumes + recording from that point — the gap is not backfilled. +- `messages`: message metadata scope (default: `"off"`). `"direct"` records + known direct conversations only. `"all"` also records group, channel, and + unknown conversation kinds. Both modes remain content-free and replace raw + identifiers with installation-local keyed pseudonyms where correlation is + available. These are correlation aids rather than anonymization; the state + database stores the derivation key, but RPC and CLI exports do not. + +The running Gateway captures `audit.enabled` and `audit.messages` at startup; +restart it after changing either setting. Message coverage currently includes +accepted inbound messages that reach core dispatch and one terminal row per +original logical outbound reply payload that reaches shared durable delivery. +Plugin-local and direct-send paths that bypass those shared boundaries are not +yet covered. The bounded background +writer is best-effort, not a lossless compliance archive. --- diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 3fffc23bcf58..90eaad3c3d9d 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -476,7 +476,7 @@ methods. Treat this as feature discovery, not a full enumeration of - `agents.list` returns configured agent entries, including effective model and runtime metadata. - `agents.create`, `agents.update`, and `agents.delete` manage agent records and workspace wiring. - `agents.files.list`, `agents.files.get`, and `agents.files.set` manage the bootstrap workspace files exposed for an agent. - - `audit.list` returns a bounded metadata-only ledger of agent run and tool action events. + - `audit.activity.list` returns the versioned metadata-only activity ledger; `audit.list` remains the compatibility-safe run/tool RPC. - `agents.workspace.list` and `agents.workspace.get` (`operator.read`) expose read-only, paginated browsing of an agent's workspace directory for clients in the trusted operator domain described in [Operator scopes](/gateway/operator-scopes). Requests accept workspace-relative paths only; reads stay confined to the realpathed workspace root (symlink and hardlink escapes rejected), size-capped, and limited to UTF-8 text plus common image types (base64). Responses do not expose the host workspace path. There are no write operations in this namespace. - `tasks.list`, `tasks.get`, and `tasks.cancel` expose the gateway task ledger to SDK and operator clients. See [Task ledger RPCs](#task-ledger-rpcs) below. - `artifacts.list`, `artifacts.get`, and `artifacts.download` expose transcript-derived artifact summaries and downloads for an explicit `sessionKey`, `runId`, or `taskId` scope. Run and task queries resolve the owning session server-side and only return transcript media with matching provenance; unsafe or local URL sources return unsupported downloads instead of fetching server-side. @@ -585,30 +585,120 @@ for auto-allow checks. ## Audit ledger RPC -`audit.list` gives operator clients a stable newest-first view of agent run and -tool action metadata. It requires `operator.read`. Queries exclude records -older than 30 days, and the shared SQLite ledger is capped at 100,000 records. -Expired rows are deleted during Gateway startup, hourly maintenance, and later -writes. +`audit.activity.list` gives operator clients a stable newest-first view of agent +run, tool action, and opt-in message lifecycle metadata. It requires +`operator.read`. Queries exclude records older than 30 days, and the shared +SQLite ledger is capped at 100,000 records. Expired rows are deleted during +Gateway startup, hourly maintenance, and later writes. See +[Audit history](/gateway/audit) for the data model and privacy semantics. - Params: optional exact `agentId`, `sessionKey`, or `runId`; optional `kind` - (`"agent_run"` or `"tool_action"`); optional `status` (`"started"`, - `"succeeded"`, `"failed"`, `"cancelled"`, `"timed_out"`, `"blocked"`, or - `"unknown"`); optional inclusive `after` / `before` Unix-millisecond bounds; - optional `limit` from `1` to `500`; and optional string `cursor` from the - preceding page. -- Result: `{ "events": AuditEvent[], "nextCursor"?: string }`. + (`"agent_run"`, `"tool_action"`, or `"message"`); optional `status` + (`"started"`, `"succeeded"`, `"failed"`, `"cancelled"`, `"timed_out"`, + `"blocked"`, or `"unknown"`); optional message `direction` (`"inbound"` or + `"outbound"`) and exact `channel`; optional inclusive `after` / `before` + Unix-millisecond bounds; optional `limit` from `1` to `500`; and optional + string `cursor` from the preceding page. +- Result: `{ "events": AuditActivityEventV1[], "nextCursor"?: string }`. -Each event includes a stable event id, monotonic ledger sequence, source event -sequence, timestamp, actor, agent/session/run provenance, action, status, and a -normalized error code when applicable. Tool events may include tool call id and -tool name. The `redaction` field is always `"metadata_only"`: the ledger does -not store prompts, messages, tool arguments, tool results, command output, or -raw error text. +The named V1 result union has separate agent-run, tool-action, inbound-message, +and outbound-message schemas. The `eventType` discriminator is respectively +`agent_run`, `tool_action`, `inbound_message`, or `outbound_message`; `kind` and +message `direction` remain available for filtering and display. Every event has +integer `schemaVersion: 1`. Message identity references use the exact +`hmac-sha256:v1:<32 hex key id>:<64 hex digest>` format; a channel-sender actor +id uses the same format. + +All variants require `eventType`, `schemaVersion`, `eventId`, `sequence`, +`sourceSequence`, `occurredAt`, `kind`, `action`, `status`, `actor`, and +`redaction`. Variant fields are: + +| `eventType` | Required fields | Optional fields | +| ------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `agent_run` | `agentId`, `runId`; `kind: "agent_run"` | `sessionKey`, `sessionId`, `errorCode` | +| `tool_action` | `agentId`, `runId`; `kind: "tool_action"` | `sessionKey`, `sessionId`, `toolCallId`, `toolName`, `errorCode` | +| `inbound_message` | `direction: "inbound"`, `channel`, `conversationKind`, `outcome` | `agentId`, `runId`, `durationMs`, `resultCount`, identity references, `reasonCode`, `errorCode` | +| `outbound_message` | `direction: "outbound"`, `channel`, `conversationKind`, `outcome` | `agentId`, `runId`, `durationMs`, `resultCount`, identity references, `reasonCode`, `deliveryKind`, `failureStage`, `errorCode` | + +The closed message enums are: + +- `conversationKind`: `direct`, `group`, `channel`, or `unknown`. +- Inbound `outcome`: `completed`, `skipped`, or `failed`; optional + `reasonCode`: `duplicate`, `reply_operation_active`, + `reply_operation_aborted`, `fast_abort`, `plugin_bound_handled`, + `plugin_bound_unavailable`, `plugin_bound_declined`, `plugin_bound_error`, + `before_dispatch_handled`, `acp_dispatch_completed`, `acp_dispatch_failed`, + `acp_dispatch_empty`, or `acp_dispatch_aborted`. +- Outbound `outcome`: `sent`, `suppressed`, `failed`, or `unknown`; optional + `reasonCode`: `cancelled_by_message_sending_hook`, + `cancelled_by_reply_payload_sending_hook`, + `empty_after_message_sending_hook`, `empty_after_reply_payload_sending_hook`, + or `no_visible_payload`. An adapter that returns no platform identity is + `unknown`, because the external side effect cannot be disproved. +- `deliveryKind`: `text`, `media`, or `other`; `failureStage`: + `platform_send`, `queue`, or `unknown`. + +Terminal fields are correlated, not independently optional: + +| Variant | Terminal mapping | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Agent run | `started` has no `errorCode`; each non-success finished status requires its matching `run_*` code. | +| Tool action | `started` and succeeded have no `errorCode`; each other finished status requires its matching `tool_*` code. | +| Inbound message | succeeded = `completed`; blocked = `skipped`; failed = `failed` plus `message_processing_failed`. `reasonCode`, when present, must belong to that terminal family. | +| Outbound message | succeeded = `sent`; blocked = `suppressed` plus `reasonCode`; failed = `failed` plus `errorCode` and `failureStage`; unknown = `unknown` plus `failureStage`. | + +Each activity event includes a stable event id, monotonic ledger sequence, +source event sequence, timestamp, actor, action, status, integer +`schemaVersion: 1`, and `redaction: "metadata_only"`. Run and tool records +require agent and run provenance and may include session provenance. Message +records may include agent and run ids, but intentionally never include +`sessionKey` or `sessionId`; the `sessionKey` query filter therefore applies to +run and tool rows only. Tool events may include tool call id and tool name. + +Message records use `message.inbound.processed` or +`message.outbound.finished` and add direction, channel, conversation kind, +normalized outcome, and optional delivery kind, failure stage, duration, +result count, reason code, and installation-local keyed +account/conversation/message/target pseudonyms. These pseudonyms aid +correlation but are not anonymization: the state database contains their key, +while RPC and CLI exports do not. The ledger does not store prompts, message +bodies, tool arguments, tool results, command output, or raw error text. +Run/tool `sessionKey` values remain raw correlation metadata and can embed +platform account or peer ids; message records omit session keys. + +For inbound rows, `durationMs` measures core dispatch through its terminal and +`resultCount` counts finalized queued tool, block, and reply payloads. For +outbound rows, `durationMs` spans delivery ownership through acknowledgement, +dead letter, or reconciliation (including queued wait time), and `resultCount` +counts identified physical platform sends. `deliveryKind`, when present, +describes the effective payload after hooks and rendering; suppressed or +crash-ambiguous rows omit it. + +Current message coverage includes accepted inbound messages that reach core +dispatch, including core duplicate/terminal outcomes. Outbound coverage writes +one terminal row per original logical reply payload that reaches shared durable +delivery; chunking and adapter fan-out are aggregated in `resultCount`. Queued +retryable or ambiguous sends are recorded only after acknowledgement, dead +letter, or reconciliation. Plugin-local and direct-send paths that bypass those +shared boundaries are not yet covered. The bounded worker queue is best-effort +and may drop records on failure or saturation, so this surface is not a +lossless compliance archive. Recording is on by default and controlled by -[`audit.enabled`](/gateway/configuration-reference#audit); when disabled, -`audit.list` keeps serving records written earlier until they expire. +[`audit.enabled`](/gateway/configuration-reference#audit). Message recording is +separately controlled by `audit.messages` and defaults to `"off"`. When +recording is disabled, `audit.activity.list` keeps serving records written +earlier until they expire. + +The shipped `audit.list` request, result, and `AuditEvent` schemas remain +unchanged and return only agent-run and tool-action records. New operator +clients should call `audit.activity.list` when the Gateway advertises it. Older +Gateways may report either `unknown method: audit.activity.list` or, because +authorization preceded method lookup in shipped versions, `missing scope: +operator.admin` to a read-scoped request. Treat the latter as method absence +only when the method was not advertised. A client may then retry `audit.list` +only when its filters do not require message kind, direction, or channel +support. Use [`openclaw audit`](/cli/audit) for text queries and bounded JSON exports. diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index b1cd061464c9..9df0bd903ae4 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -14,6 +14,20 @@ export { formatValidationErrors, type ValidationError } from "./validation-error import { type AgentEvent, AgentEventSchema, + type AuditActivityAgentRunV1, + AuditActivityAgentRunV1Schema, + type AuditActivityEventV1, + AuditActivityEventV1Schema, + type AuditActivityInboundMessageV1, + AuditActivityInboundMessageV1Schema, + type AuditActivityListParams, + AuditActivityListParamsSchema, + type AuditActivityListResult, + AuditActivityListResultSchema, + type AuditActivityOutboundMessageV1, + AuditActivityOutboundMessageV1Schema, + type AuditActivityToolActionV1, + AuditActivityToolActionV1Schema, type AuditEvent, AuditEventSchema, type AuditListParams, @@ -815,6 +829,9 @@ export const validateMessageActionParams = export const validateSendParams = lazyCompile(SendParamsSchema); export const validatePollParams = lazyCompile(PollParamsSchema); export const validateAgentParams = lazyCompile(AgentParamsSchema); +export const validateAuditActivityListParams = lazyCompile( + AuditActivityListParamsSchema, +); export const validateAuditListParams = lazyCompile(AuditListParamsSchema); export const validateAgentIdentityParams = lazyCompile(AgentIdentityParamsSchema); @@ -1420,6 +1437,13 @@ export { ArtifactsListParamsSchema, ArtifactsGetParamsSchema, ArtifactsDownloadParamsSchema, + AuditActivityAgentRunV1Schema, + AuditActivityEventV1Schema, + AuditActivityInboundMessageV1Schema, + AuditActivityListParamsSchema, + AuditActivityListResultSchema, + AuditActivityOutboundMessageV1Schema, + AuditActivityToolActionV1Schema, AuditEventSchema, AuditListParamsSchema, AuditListResultSchema, @@ -1886,6 +1910,13 @@ export type { SessionsDeleteParams, SessionsCompactParams, SessionsUsageParams, + AuditActivityAgentRunV1, + AuditActivityEventV1, + AuditActivityInboundMessageV1, + AuditActivityListParams, + AuditActivityListResult, + AuditActivityOutboundMessageV1, + AuditActivityToolActionV1, AuditEvent, AuditListParams, AuditListResult, diff --git a/packages/gateway-protocol/src/schema.ts b/packages/gateway-protocol/src/schema.ts index 8b841aedc47c..4759b16cc476 100644 --- a/packages/gateway-protocol/src/schema.ts +++ b/packages/gateway-protocol/src/schema.ts @@ -9,6 +9,7 @@ export * from "./schema/agent.js"; export * from "./schema/agents-models-skills.js"; export * from "./schema/agents-workspace.js"; export * from "./schema/artifacts.js"; +export * from "./schema/audit-activity.js"; export * from "./schema/audit.js"; export * from "./schema/channels.js"; export * from "./schema/commands.js"; diff --git a/packages/gateway-protocol/src/schema/audit-activity.ts b/packages/gateway-protocol/src/schema/audit-activity.ts new file mode 100644 index 000000000000..e7f5e60133b8 --- /dev/null +++ b/packages/gateway-protocol/src/schema/audit-activity.ts @@ -0,0 +1,460 @@ +// Versioned metadata-only activity audit query payloads. +import { Type, type TProperties, type TSchema } from "typebox"; +import { NonEmptyString } from "./primitives.js"; + +const AuditActivitySchemaVersionV1Schema = Type.Integer({ minimum: 1, maximum: 1 }); + +export const AuditActivityStatusV1Schema: TSchema = Type.Union([ + Type.Literal("started"), + Type.Literal("succeeded"), + Type.Literal("failed"), + Type.Literal("cancelled"), + Type.Literal("timed_out"), + Type.Literal("blocked"), + Type.Literal("unknown"), +]); + +export const AuditActivityKindV1Schema: TSchema = Type.Union([ + Type.Literal("agent_run"), + Type.Literal("tool_action"), + Type.Literal("message"), +]); + +export const AuditActivityDirectionV1Schema: TSchema = Type.Union([ + Type.Literal("inbound"), + Type.Literal("outbound"), +]); + +const AuditActivityConversationKindV1Schema = Type.Union([ + Type.Literal("direct"), + Type.Literal("group"), + Type.Literal("channel"), + Type.Literal("unknown"), +]); + +const AuditActivityHmacRefV1Schema = Type.String({ + pattern: "^hmac-sha256:v1:[a-f0-9]{32}:[a-f0-9]{64}$", +}); + +const AuditActivityAgentActorV1Schema = Type.Object( + { + type: Type.Union([Type.Literal("agent"), Type.Literal("system")]), + id: NonEmptyString, + }, + { additionalProperties: false }, +); + +const AuditActivityInboundActorV1Schema = Type.Union([ + Type.Object( + { + type: Type.Literal("channel_sender"), + id: AuditActivityHmacRefV1Schema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + type: Type.Literal("system"), + id: NonEmptyString, + }, + { additionalProperties: false }, + ), +]); + +const AuditActivityOutboundActorV1Schema = Type.Object( + { + type: Type.Union([Type.Literal("agent"), Type.Literal("system")]), + id: NonEmptyString, + }, + { additionalProperties: false }, +); + +const commonProperties = { + schemaVersion: AuditActivitySchemaVersionV1Schema, + eventId: NonEmptyString, + sequence: Type.Integer({ minimum: 1 }), + sourceSequence: Type.Integer({ minimum: 1 }), + occurredAt: Type.Integer({ minimum: 0 }), + redaction: Type.Literal("metadata_only"), +}; + +const agentProperties = { + actor: AuditActivityAgentActorV1Schema, + agentId: NonEmptyString, + sessionKey: Type.Optional(NonEmptyString), + sessionId: Type.Optional(NonEmptyString), + runId: NonEmptyString, +}; + +const messageProperties = { + channel: NonEmptyString, + conversationKind: AuditActivityConversationKindV1Schema, + durationMs: Type.Optional(Type.Integer({ minimum: 0 })), + resultCount: Type.Optional(Type.Integer({ minimum: 0 })), + agentId: Type.Optional(NonEmptyString), + runId: Type.Optional(NonEmptyString), + accountRef: Type.Optional(AuditActivityHmacRefV1Schema), + conversationRef: Type.Optional(AuditActivityHmacRefV1Schema), + messageRef: Type.Optional(AuditActivityHmacRefV1Schema), + targetRef: Type.Optional(AuditActivityHmacRefV1Schema), +}; + +function correlatedObject( + properties: TProperties, + variants: ReturnType, +): TSchema { + // Keep a concrete object for generated clients while JSON Schema `allOf` + // closes cross-field terminal invariants at the wire boundary. + return Type.Object(properties, { additionalProperties: false, allOf: [variants] }); +} + +function withoutField(field: string): TSchema { + return { not: { required: [field] } } as TSchema; +} + +const withoutErrorCode = withoutField("errorCode"); +const withoutReasonCode = withoutField("reasonCode"); +const withoutFailureStage = withoutField("failureStage"); +const withoutDeliveryKind = withoutField("deliveryKind"); + +const agentRunProperties = { + eventType: Type.Literal("agent_run"), + ...commonProperties, + ...agentProperties, + kind: Type.Literal("agent_run"), +}; + +/** V1 agent-run activity record. */ +export const AuditActivityAgentRunV1Schema: TSchema = correlatedObject( + { + ...agentRunProperties, + action: Type.Union([Type.Literal("agent.run.started"), Type.Literal("agent.run.finished")]), + status: Type.Union([ + Type.Literal("started"), + Type.Literal("succeeded"), + Type.Literal("failed"), + Type.Literal("cancelled"), + Type.Literal("timed_out"), + Type.Literal("blocked"), + ]), + errorCode: Type.Optional( + Type.Union([ + Type.Literal("run_failed"), + Type.Literal("run_cancelled"), + Type.Literal("run_timed_out"), + Type.Literal("run_blocked"), + ]), + ), + }, + Type.Union([ + Type.Intersect([ + Type.Object({ + action: Type.Literal("agent.run.started"), + status: Type.Literal("started"), + }), + withoutErrorCode, + ]), + Type.Intersect([ + Type.Object({ + action: Type.Literal("agent.run.finished"), + status: Type.Literal("succeeded"), + }), + withoutErrorCode, + ]), + Type.Object({ + action: Type.Literal("agent.run.finished"), + status: Type.Literal("failed"), + errorCode: Type.Literal("run_failed"), + }), + Type.Object({ + action: Type.Literal("agent.run.finished"), + status: Type.Literal("cancelled"), + errorCode: Type.Literal("run_cancelled"), + }), + Type.Object({ + action: Type.Literal("agent.run.finished"), + status: Type.Literal("timed_out"), + errorCode: Type.Literal("run_timed_out"), + }), + Type.Object({ + action: Type.Literal("agent.run.finished"), + status: Type.Literal("blocked"), + errorCode: Type.Literal("run_blocked"), + }), + ]), +); + +const toolActionProperties = { + eventType: Type.Literal("tool_action"), + ...commonProperties, + ...agentProperties, + kind: Type.Literal("tool_action"), + toolCallId: Type.Optional(NonEmptyString), + toolName: Type.Optional(NonEmptyString), +}; + +/** V1 tool-action activity record. */ +export const AuditActivityToolActionV1Schema: TSchema = correlatedObject( + { + ...toolActionProperties, + action: Type.Union([Type.Literal("tool.action.started"), Type.Literal("tool.action.finished")]), + status: AuditActivityStatusV1Schema, + errorCode: Type.Optional( + Type.Union([ + Type.Literal("tool_failed"), + Type.Literal("tool_cancelled"), + Type.Literal("tool_timed_out"), + Type.Literal("tool_blocked"), + Type.Literal("tool_outcome_unknown"), + ]), + ), + }, + Type.Union([ + Type.Intersect([ + Type.Object({ + action: Type.Literal("tool.action.started"), + status: Type.Literal("started"), + }), + withoutErrorCode, + ]), + Type.Intersect([ + Type.Object({ + action: Type.Literal("tool.action.finished"), + status: Type.Literal("succeeded"), + }), + withoutErrorCode, + ]), + Type.Object({ + action: Type.Literal("tool.action.finished"), + status: Type.Literal("failed"), + errorCode: Type.Literal("tool_failed"), + }), + Type.Object({ + action: Type.Literal("tool.action.finished"), + status: Type.Literal("cancelled"), + errorCode: Type.Literal("tool_cancelled"), + }), + Type.Object({ + action: Type.Literal("tool.action.finished"), + status: Type.Literal("timed_out"), + errorCode: Type.Literal("tool_timed_out"), + }), + Type.Object({ + action: Type.Literal("tool.action.finished"), + status: Type.Literal("blocked"), + errorCode: Type.Literal("tool_blocked"), + }), + Type.Object({ + action: Type.Literal("tool.action.finished"), + status: Type.Literal("unknown"), + errorCode: Type.Literal("tool_outcome_unknown"), + }), + ]), +); + +const inboundMessageProperties = { + eventType: Type.Literal("inbound_message"), + ...commonProperties, + ...messageProperties, + kind: Type.Literal("message"), + action: Type.Literal("message.inbound.processed"), + direction: Type.Literal("inbound"), + actor: AuditActivityInboundActorV1Schema, +}; + +const inboundCompletedReasonSchema = Type.Union([ + Type.Literal("fast_abort"), + Type.Literal("plugin_bound_handled"), + Type.Literal("plugin_bound_unavailable"), + Type.Literal("plugin_bound_declined"), + Type.Literal("before_dispatch_handled"), + Type.Literal("acp_dispatch_completed"), + Type.Literal("acp_dispatch_empty"), +]); + +const inboundSkippedReasonSchema = Type.Union([ + Type.Literal("duplicate"), + Type.Literal("reply_operation_active"), + Type.Literal("reply_operation_aborted"), + Type.Literal("acp_dispatch_aborted"), +]); + +/** V1 inbound-message activity record. */ +const inboundFailureReasonSchema = Type.Union([ + Type.Literal("acp_dispatch_failed"), + Type.Literal("plugin_bound_error"), +]); + +export const AuditActivityInboundMessageV1Schema: TSchema = correlatedObject( + { + ...inboundMessageProperties, + status: Type.Union([ + Type.Literal("succeeded"), + Type.Literal("blocked"), + Type.Literal("failed"), + ]), + outcome: Type.Union([ + Type.Literal("completed"), + Type.Literal("skipped"), + Type.Literal("failed"), + ]), + errorCode: Type.Optional(Type.Literal("message_processing_failed")), + reasonCode: Type.Optional( + Type.Union([ + ...inboundCompletedReasonSchema.anyOf, + ...inboundSkippedReasonSchema.anyOf, + ...inboundFailureReasonSchema.anyOf, + ]), + ), + }, + Type.Union([ + Type.Intersect([ + Type.Object({ + status: Type.Literal("succeeded"), + outcome: Type.Literal("completed"), + reasonCode: Type.Optional(inboundCompletedReasonSchema), + }), + withoutErrorCode, + ]), + Type.Intersect([ + Type.Object({ + status: Type.Literal("blocked"), + outcome: Type.Literal("skipped"), + reasonCode: Type.Optional(inboundSkippedReasonSchema), + }), + withoutErrorCode, + ]), + Type.Object({ + status: Type.Literal("failed"), + outcome: Type.Literal("failed"), + errorCode: Type.Literal("message_processing_failed"), + reasonCode: Type.Optional(inboundFailureReasonSchema), + }), + ]), +); + +const outboundMessageProperties = { + eventType: Type.Literal("outbound_message"), + ...commonProperties, + ...messageProperties, + kind: Type.Literal("message"), + action: Type.Literal("message.outbound.finished"), + direction: Type.Literal("outbound"), + actor: AuditActivityOutboundActorV1Schema, + deliveryKind: Type.Optional( + Type.Union([Type.Literal("text"), Type.Literal("media"), Type.Literal("other")]), + ), +}; + +const outboundSuppressedReasonSchema = Type.Union([ + Type.Literal("cancelled_by_message_sending_hook"), + Type.Literal("cancelled_by_reply_payload_sending_hook"), + Type.Literal("empty_after_message_sending_hook"), + Type.Literal("empty_after_reply_payload_sending_hook"), + Type.Literal("no_visible_payload"), +]); + +const outboundFailureStageSchema = Type.Union([ + Type.Literal("platform_send"), + Type.Literal("queue"), + Type.Literal("unknown"), +]); + +/** V1 outbound-message activity record. */ +const outboundFailureErrorSchema = Type.Union([ + Type.Literal("message_delivery_failed"), + Type.Literal("message_delivery_partial_failure"), +]); + +export const AuditActivityOutboundMessageV1Schema: TSchema = correlatedObject( + { + ...outboundMessageProperties, + status: Type.Union([ + Type.Literal("succeeded"), + Type.Literal("blocked"), + Type.Literal("failed"), + Type.Literal("unknown"), + ]), + outcome: Type.Union([ + Type.Literal("sent"), + Type.Literal("suppressed"), + Type.Literal("failed"), + Type.Literal("unknown"), + ]), + errorCode: Type.Optional(outboundFailureErrorSchema), + reasonCode: Type.Optional(outboundSuppressedReasonSchema), + failureStage: Type.Optional(outboundFailureStageSchema), + }, + Type.Union([ + Type.Intersect([ + Type.Object({ status: Type.Literal("succeeded"), outcome: Type.Literal("sent") }), + withoutErrorCode, + withoutReasonCode, + withoutFailureStage, + ]), + Type.Intersect([ + Type.Object({ + status: Type.Literal("blocked"), + outcome: Type.Literal("suppressed"), + reasonCode: outboundSuppressedReasonSchema, + }), + withoutErrorCode, + withoutFailureStage, + withoutDeliveryKind, + ]), + Type.Intersect([ + Type.Object({ + status: Type.Literal("failed"), + outcome: Type.Literal("failed"), + errorCode: outboundFailureErrorSchema, + failureStage: outboundFailureStageSchema, + }), + withoutReasonCode, + ]), + Type.Intersect([ + Type.Object({ + status: Type.Literal("unknown"), + outcome: Type.Literal("unknown"), + failureStage: outboundFailureStageSchema, + }), + withoutErrorCode, + withoutReasonCode, + withoutDeliveryKind, + ]), + ]), +); + +/** Discriminated V1 activity record union. */ +export const AuditActivityEventV1Schema: TSchema = Type.Union([ + AuditActivityAgentRunV1Schema, + AuditActivityToolActionV1Schema, + AuditActivityInboundMessageV1Schema, + AuditActivityOutboundMessageV1Schema, +]); + +/** Bounded newest-first V1 activity query filters. */ +export const AuditActivityListParamsSchema: TSchema = Type.Object( + { + agentId: Type.Optional(NonEmptyString), + sessionKey: Type.Optional(NonEmptyString), + runId: Type.Optional(NonEmptyString), + kind: Type.Optional(AuditActivityKindV1Schema), + status: Type.Optional(AuditActivityStatusV1Schema), + direction: Type.Optional(AuditActivityDirectionV1Schema), + channel: Type.Optional(NonEmptyString), + after: Type.Optional(Type.Integer({ minimum: 0 })), + before: Type.Optional(Type.Integer({ minimum: 0 })), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 500 })), + cursor: Type.Optional(NonEmptyString), + }, + { additionalProperties: false }, +); + +/** Stable sequence-cursor V1 activity page. */ +export const AuditActivityListResultSchema: TSchema = Type.Object( + { + events: Type.Array(AuditActivityEventV1Schema), + nextCursor: Type.Optional(NonEmptyString), + }, + { additionalProperties: false }, +); diff --git a/packages/gateway-protocol/src/schema/audit.test.ts b/packages/gateway-protocol/src/schema/audit.test.ts index 8c553b83cfc3..8c019924c993 100644 --- a/packages/gateway-protocol/src/schema/audit.test.ts +++ b/packages/gateway-protocol/src/schema/audit.test.ts @@ -1,10 +1,16 @@ import { Compile } from "typebox/compile"; import { describe, expect, it } from "vitest"; -import { validateAuditListParams } from "../index.js"; +import { validateAuditActivityListParams, validateAuditListParams } from "../index.js"; +import { + AuditActivityEventV1Schema, + AuditActivityInboundMessageV1Schema, +} from "./audit-activity.js"; import { AuditEventSchema } from "./audit.js"; -describe("audit protocol schemas", () => { - it("accepts bounded query filters and rejects oversized pages", () => { +const accountRef = `hmac-sha256:v1:${"a".repeat(32)}:${"b".repeat(64)}`; + +describe("legacy audit protocol schemas", () => { + it("accepts only the shipped audit.list filters", () => { expect( validateAuditListParams({ agentId: "main", @@ -15,29 +21,217 @@ describe("audit protocol schemas", () => { }), ).toBe(true); expect(validateAuditListParams({ status: "unknown" })).toBe(true); + expect(validateAuditListParams({ kind: "message" })).toBe(false); + expect(validateAuditListParams({ direction: "outbound" })).toBe(false); + expect(validateAuditListParams({ includeMessages: true })).toBe(false); expect(validateAuditListParams({ limit: 501 })).toBe(false); }); - it("rejects content fields from metadata-only records", () => { + it("preserves the shipped run/tool event shape", () => { const validate = Compile(AuditEventSchema); const event = { eventId: "event-1", sequence: 1, - sourceSequence: 1, - occurredAt: 1, + sourceSequence: 2, + occurredAt: 3, kind: "tool_action", action: "tool.action.finished", - status: "unknown", - errorCode: "tool_outcome_unknown", + status: "failed", + errorCode: "tool_failed", actor: { type: "agent", id: "main" }, agentId: "main", sessionKey: "agent:main:main", runId: "run-1", - toolCallId: "call-1", + toolCallId: "call-fingerprint", toolName: "exec", redaction: "metadata_only", }; + expect(validate.Check(event)).toBe(true); + expect(validate.Check({ ...event, schemaVersion: 1 })).toBe(false); expect(validate.Check({ ...event, result: "secret" })).toBe(false); }); }); + +describe("audit activity protocol schemas", () => { + it("accepts bounded message filters", () => { + expect( + validateAuditActivityListParams({ + kind: "message", + direction: "outbound", + channel: "telegram", + limit: 500, + }), + ).toBe(true); + expect(validateAuditActivityListParams({ direction: "sideways" })).toBe(false); + expect(validateAuditActivityListParams({ limit: 501 })).toBe(false); + }); + + it("uses an integer V1 schema version and exact HMAC references", () => { + const validate = Compile(AuditActivityInboundMessageV1Schema); + const event = { + eventType: "inbound_message", + schemaVersion: 1, + eventId: "event-message-1", + sequence: 2, + sourceSequence: 3, + occurredAt: 4, + kind: "message", + action: "message.inbound.processed", + status: "succeeded", + actor: { type: "channel_sender", id: accountRef }, + direction: "inbound", + channel: "telegram", + conversationKind: "direct", + outcome: "completed", + accountRef, + redaction: "metadata_only", + }; + + expect(validate.Check(event)).toBe(true); + expect(validate.Check({ ...event, schemaVersion: 1.5 })).toBe(false); + expect(validate.Check({ ...event, schemaVersion: 2 })).toBe(false); + expect(validate.Check({ ...event, accountRef: "hmac256:account" })).toBe(false); + expect(validate.Check({ ...event, actor: { type: "channel_sender", id: "raw-user" } })).toBe( + false, + ); + expect(validate.Check({ ...event, text: "secret message" })).toBe(false); + }); + + it("discriminates run, tool, inbound-message, and outbound-message records", () => { + const validate = Compile(AuditActivityEventV1Schema); + const common = { + schemaVersion: 1, + eventId: "event-1", + sequence: 1, + sourceSequence: 1, + occurredAt: 1, + status: "succeeded", + actor: { type: "agent", id: "main" }, + agentId: "main", + runId: "run-1", + redaction: "metadata_only", + }; + const agentRun = { + ...common, + eventType: "agent_run", + kind: "agent_run", + action: "agent.run.finished", + }; + const toolAction = { + ...common, + eventType: "tool_action", + kind: "tool_action", + action: "tool.action.finished", + toolName: "exec", + }; + const outboundMessage = { + ...common, + eventType: "outbound_message", + kind: "message", + action: "message.outbound.finished", + direction: "outbound", + channel: "telegram", + conversationKind: "direct", + outcome: "sent", + }; + + expect(validate.Check(agentRun)).toBe(true); + expect(validate.Check(toolAction)).toBe(true); + expect(validate.Check(outboundMessage)).toBe(true); + expect( + validate.Check({ + ...agentRun, + action: "agent.run.started", + status: "failed", + errorCode: "run_failed", + }), + ).toBe(false); + expect(validate.Check({ ...toolAction, status: "failed", errorCode: "tool_cancelled" })).toBe( + false, + ); + expect( + validate.Check({ + ...outboundMessage, + status: "blocked", + outcome: "suppressed", + reasonCode: "no_visible_payload", + }), + ).toBe(true); + expect( + validate.Check({ + ...outboundMessage, + status: "blocked", + outcome: "suppressed", + reasonCode: "no_visible_payload", + deliveryKind: "text", + }), + ).toBe(false); + expect( + validate.Check({ + ...outboundMessage, + status: "unknown", + outcome: "unknown", + failureStage: "platform_send", + deliveryKind: "text", + }), + ).toBe(false); + expect( + validate.Check({ + ...outboundMessage, + outcome: "failed", + errorCode: "message_delivery_failed", + failureStage: "queue", + }), + ).toBe(false); + expect( + validate.Check({ + ...outboundMessage, + status: "blocked", + outcome: "suppressed", + reasonCode: "acp_dispatch_aborted", + }), + ).toBe(false); + expect( + validate.Check({ + ...outboundMessage, + status: "blocked", + outcome: "suppressed", + reasonCode: "adapter_returned_no_identity", + }), + ).toBe(false); + expect(validate.Check({ ...outboundMessage, direction: "inbound" })).toBe(false); + }); + + it("rejects contradictory inbound message terminals", () => { + const validate = Compile(AuditActivityEventV1Schema); + const inbound = { + eventType: "inbound_message", + schemaVersion: 1, + eventId: "event-inbound-1", + sequence: 1, + sourceSequence: 1, + occurredAt: 1, + kind: "message", + action: "message.inbound.processed", + direction: "inbound", + channel: "telegram", + conversationKind: "direct", + actor: { type: "channel_sender", id: accountRef }, + status: "succeeded", + outcome: "completed", + redaction: "metadata_only", + }; + + expect(validate.Check(inbound)).toBe(true); + expect( + validate.Check({ + ...inbound, + outcome: "failed", + errorCode: "message_processing_failed", + }), + ).toBe(false); + expect(validate.Check({ ...inbound, reasonCode: "duplicate" })).toBe(false); + expect(validate.Check({ ...inbound, status: "unknown", outcome: "unknown" })).toBe(false); + }); +}); diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 68f7e8b93400..d45a9f0b80e0 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -100,6 +100,15 @@ import { ArtifactsListParamsSchema, ArtifactsListResultSchema, } from "./artifacts.js"; +import { + AuditActivityAgentRunV1Schema, + AuditActivityEventV1Schema, + AuditActivityInboundMessageV1Schema, + AuditActivityListParamsSchema, + AuditActivityListResultSchema, + AuditActivityOutboundMessageV1Schema, + AuditActivityToolActionV1Schema, +} from "./audit-activity.js"; import { AuditEventSchema, AuditListParamsSchema, AuditListResultSchema } from "./audit.js"; import { ChannelsStartParamsSchema, @@ -597,6 +606,13 @@ export const ProtocolSchemas = { SessionsUsageParams: SessionsUsageParamsSchema, // Audit/task ledgers and config/wizard setup payloads. + AuditActivityAgentRunV1: AuditActivityAgentRunV1Schema, + AuditActivityToolActionV1: AuditActivityToolActionV1Schema, + AuditActivityInboundMessageV1: AuditActivityInboundMessageV1Schema, + AuditActivityOutboundMessageV1: AuditActivityOutboundMessageV1Schema, + AuditActivityEventV1: AuditActivityEventV1Schema, + AuditActivityListParams: AuditActivityListParamsSchema, + AuditActivityListResult: AuditActivityListResultSchema, AuditEvent: AuditEventSchema, AuditListParams: AuditListParamsSchema, AuditListResult: AuditListResultSchema, diff --git a/packages/gateway-protocol/src/schema/types.ts b/packages/gateway-protocol/src/schema/types.ts index 7b035a741589..1cfeb1c51e06 100644 --- a/packages/gateway-protocol/src/schema/types.ts +++ b/packages/gateway-protocol/src/schema/types.ts @@ -129,6 +129,181 @@ export type SessionsCompactParams = SchemaType<"SessionsCompactParams">; export type SessionsUsageParams = SchemaType<"SessionsUsageParams">; /** Metadata-only audit query payloads. */ +// These wire types stay explicit because the runtime schemas use JSON Schema +// `allOf` correlations that TypeBox cannot infer without expanding the public +// declaration graph far beyond the compact protocol contract. +type AuditActivityRecordBaseV1 = { + schemaVersion: 1; + eventId: string; + sequence: number; + sourceSequence: number; + occurredAt: number; + redaction: "metadata_only"; +}; + +type AuditActivityAgentRecordBaseV1 = AuditActivityRecordBaseV1 & { + actor: { type: "agent" | "system"; id: string }; + agentId: string; + sessionKey?: string; + sessionId?: string; + runId: string; +}; + +type AuditActivityAgentRunV1Terminal = + | { action: "agent.run.started"; status: "started"; errorCode?: never } + | { action: "agent.run.finished"; status: "succeeded"; errorCode?: never } + | { action: "agent.run.finished"; status: "failed"; errorCode: "run_failed" } + | { action: "agent.run.finished"; status: "cancelled"; errorCode: "run_cancelled" } + | { action: "agent.run.finished"; status: "timed_out"; errorCode: "run_timed_out" } + | { action: "agent.run.finished"; status: "blocked"; errorCode: "run_blocked" }; +export type AuditActivityAgentRunV1 = AuditActivityAgentRecordBaseV1 & { + eventType: "agent_run"; + kind: "agent_run"; +} & AuditActivityAgentRunV1Terminal; + +type AuditActivityToolActionV1Terminal = + | { action: "tool.action.started"; status: "started"; errorCode?: never } + | { action: "tool.action.finished"; status: "succeeded"; errorCode?: never } + | { action: "tool.action.finished"; status: "failed"; errorCode: "tool_failed" } + | { action: "tool.action.finished"; status: "cancelled"; errorCode: "tool_cancelled" } + | { action: "tool.action.finished"; status: "timed_out"; errorCode: "tool_timed_out" } + | { action: "tool.action.finished"; status: "blocked"; errorCode: "tool_blocked" } + | { + action: "tool.action.finished"; + status: "unknown"; + errorCode: "tool_outcome_unknown"; + }; +export type AuditActivityToolActionV1 = AuditActivityAgentRecordBaseV1 & { + eventType: "tool_action"; + kind: "tool_action"; + toolCallId?: string; + toolName?: string; +} & AuditActivityToolActionV1Terminal; + +type AuditActivityMessageRecordBaseV1 = AuditActivityRecordBaseV1 & { + kind: "message"; + channel: string; + conversationKind: "direct" | "group" | "channel" | "unknown"; + durationMs?: number; + resultCount?: number; + agentId?: string; + runId?: string; + accountRef?: string; + conversationRef?: string; + messageRef?: string; + targetRef?: string; + sessionKey?: never; + sessionId?: never; + toolCallId?: never; + toolName?: never; +}; + +type AuditActivityInboundMessageV1Terminal = + | { + status: "succeeded"; + outcome: "completed"; + errorCode?: never; + reasonCode?: + | "fast_abort" + | "plugin_bound_handled" + | "plugin_bound_unavailable" + | "plugin_bound_declined" + | "before_dispatch_handled" + | "acp_dispatch_completed" + | "acp_dispatch_empty"; + } + | { + status: "blocked"; + outcome: "skipped"; + errorCode?: never; + reasonCode?: + | "duplicate" + | "reply_operation_active" + | "reply_operation_aborted" + | "acp_dispatch_aborted"; + } + | { + status: "failed"; + outcome: "failed"; + errorCode: "message_processing_failed"; + reasonCode?: "acp_dispatch_failed" | "plugin_bound_error"; + }; +export type AuditActivityInboundMessageV1 = AuditActivityMessageRecordBaseV1 & { + eventType: "inbound_message"; + action: "message.inbound.processed"; + direction: "inbound"; + actor: { type: "channel_sender"; id: string } | { type: "system"; id: string }; + deliveryKind?: never; + failureStage?: never; +} & AuditActivityInboundMessageV1Terminal; + +type AuditActivityOutboundMessageV1Terminal = + | { + status: "succeeded"; + outcome: "sent"; + errorCode?: never; + reasonCode?: never; + failureStage?: never; + deliveryKind?: "text" | "media" | "other"; + } + | { + status: "blocked"; + outcome: "suppressed"; + errorCode?: never; + reasonCode: + | "cancelled_by_message_sending_hook" + | "cancelled_by_reply_payload_sending_hook" + | "empty_after_message_sending_hook" + | "empty_after_reply_payload_sending_hook" + | "no_visible_payload"; + failureStage?: never; + deliveryKind?: never; + } + | { + status: "failed"; + outcome: "failed"; + errorCode: "message_delivery_failed" | "message_delivery_partial_failure"; + reasonCode?: never; + failureStage: "platform_send" | "queue" | "unknown"; + deliveryKind?: "text" | "media" | "other"; + } + | { + status: "unknown"; + outcome: "unknown"; + errorCode?: never; + reasonCode?: never; + failureStage: "platform_send" | "queue" | "unknown"; + deliveryKind?: never; + }; +export type AuditActivityOutboundMessageV1 = AuditActivityMessageRecordBaseV1 & { + eventType: "outbound_message"; + action: "message.outbound.finished"; + direction: "outbound"; + actor: { type: "agent" | "system"; id: string }; +} & AuditActivityOutboundMessageV1Terminal; + +export type AuditActivityEventV1 = + | AuditActivityAgentRunV1 + | AuditActivityToolActionV1 + | AuditActivityInboundMessageV1 + | AuditActivityOutboundMessageV1; +export type AuditActivityListParams = { + agentId?: string; + sessionKey?: string; + runId?: string; + kind?: "agent_run" | "tool_action" | "message"; + status?: "started" | "succeeded" | "failed" | "cancelled" | "timed_out" | "blocked" | "unknown"; + direction?: "inbound" | "outbound"; + channel?: string; + after?: number; + before?: number; + limit?: number; + cursor?: string; +}; +export type AuditActivityListResult = { + events: AuditActivityEventV1[]; + nextCursor?: string; +}; export type AuditEvent = SchemaType<"AuditEvent">; export type AuditListParams = SchemaType<"AuditListParams">; export type AuditListResult = SchemaType<"AuditListResult">; diff --git a/scripts/e2e/telegram-user-crabbox-proof.ts b/scripts/e2e/telegram-user-crabbox-proof.ts index 2809dca83c1a..db9e18bb5a0a 100644 --- a/scripts/e2e/telegram-user-crabbox-proof.ts +++ b/scripts/e2e/telegram-user-crabbox-proof.ts @@ -1132,6 +1132,9 @@ function writeSutConfig(params: { }, ], }, + // Exercise the opt-in message audit surface: the DM probe should produce + // inbound/outbound rows under the privacy-sensitive "direct" mode. + audit: { enabled: true, messages: "direct" }, channels: { telegram: { allowFrom: [params.testerId], diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index dc59da690f8f..d6cda413b9f4 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -1328,6 +1328,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { channel: "discord", accountId: "acct-1", to: "dm:U123", + conversationType: "direct", content: "child completion output", idempotencyKey: "announce-dm-fallback-empty:text-direct", }), diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index b2a70c4206bf..b84d858c177e 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -1142,6 +1142,7 @@ async function deliverTextCompletionDirect(params: { threadId: params.deliveryTarget.threadId, requesterSessionKey: params.requesterSessionKey, agentId, + conversationType: "direct", content, idempotencyKey, mirror: { diff --git a/src/audit/agent-event-audit.ts b/src/audit/agent-event-audit.ts index 8a578c4f76d9..750575302263 100644 --- a/src/audit/agent-event-audit.ts +++ b/src/audit/agent-event-audit.ts @@ -15,9 +15,9 @@ import type { TrustedToolExecutionEvent } from "../infra/diagnostic-events.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import type { - AuditEventErrorCode, AuditEventInput, - AuditEventStatus, + AgentRunFinishedAuditTerminal, + ToolActionAuditEventInput, } from "./audit-event-types.js"; import { createAuditEventWriter, type AuditEventWriter } from "./audit-event-writer.js"; @@ -29,6 +29,12 @@ const MAX_TRACKED_RUN_PROVENANCE = 1_024; const log = createSubsystemLogger("audit/events"); let persistenceFailureWarned = false; +export type AgentEventAuditRecorder = { + record: (event: AgentEventPayload) => void; + recordTool: (event: TrustedToolExecutionEvent) => void; + stop: () => Promise; +}; + function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } @@ -53,6 +59,17 @@ function auditToolCallId(value: unknown): string | undefined { return `sha256:${createHash("sha256").update(toolCallId).digest("hex")}`; } +function legacyAuditSourceId(params: { + runId: string; + sourceSequence: number; + occurredAt: number; + action: string; +}): string { + // Preserve the original store-owned identity byte-for-byte so replayed + // run/tool events still deduplicate after the versioned contract refactor. + return `${params.runId}:${params.sourceSequence}:${params.occurredAt}:${params.action}`; +} + function rememberRunProvenance( runId: string, provenance: { @@ -110,9 +127,7 @@ function classifyRunTerminal( phase: "end" | "error", ): { outcome: AgentRunTerminalOutcome; - status: AuditEventStatus; - errorCode?: AuditEventErrorCode; -} { +} & AgentRunFinishedAuditTerminal { const stopReason = nonEmptyString(data.stopReason); const timeoutPhase = normalizeAgentRunTimeoutPhase(data.timeoutPhase); const terminalStatus = normalizeOptionalLowercaseString(data.status); @@ -174,12 +189,20 @@ function projectAgentEvent(event: AgentEventPayload): AgentAuditProjection | und const provenance = resolveProvenance(runId, event); if (event.stream === "lifecycle" && phase === "start") { rememberRunProvenance(runId, provenance); + const occurredAt = asDateTimestampMs(event.data.startedAt) ?? event.ts; + const action = "agent.run.started" as const; return { input: { + sourceId: legacyAuditSourceId({ + runId, + sourceSequence: event.seq, + occurredAt, + action, + }), sourceSequence: event.seq, - occurredAt: asDateTimestampMs(event.data.startedAt) ?? event.ts, + occurredAt, kind: "agent_run", - action: "agent.run.started", + action, status: "started", actorType: provenance.actorType, actorId: provenance.agentId, @@ -193,12 +216,20 @@ function projectAgentEvent(event: AgentEventPayload): AgentAuditProjection | und if (event.stream === "lifecycle" && (phase === "end" || phase === "error")) { rememberRunProvenance(runId, provenance); const { outcome, ...terminal } = classifyRunTerminal(event.data, phase); + const occurredAt = asDateTimestampMs(event.data.endedAt) ?? event.ts; + const action = "agent.run.finished" as const; return { input: { + sourceId: legacyAuditSourceId({ + runId, + sourceSequence: event.seq, + occurredAt, + action, + }), sourceSequence: event.seq, - occurredAt: asDateTimestampMs(event.data.endedAt) ?? event.ts, + occurredAt, kind: "agent_run", - action: "agent.run.finished", + action, ...terminal, actorType: provenance.actorType, actorId: provenance.agentId, @@ -221,7 +252,7 @@ export function projectAgentEventToAudit(event: AgentEventPayload): AuditEventIn /** Project the complete trusted tool-execution lifecycle without private diagnostic content. */ export function projectToolExecutionEventToAudit( event: TrustedToolExecutionEvent, -): AuditEventInput | undefined { +): ToolActionAuditEventInput | undefined { // Schema quarantine describes tool availability before invocation. Without // a call identity it must not become a durable tool-action claim. if ( @@ -238,6 +269,34 @@ export function projectToolExecutionEventToAudit( } const toolCallId = auditToolCallId(event.toolCallId); const provenance = resolveToolProvenance(runId, event); + const occurredAt = asDateTimestampMs(event.sourceTimestampMs) ?? event.ts; + const attribution = { + sourceSequence: event.seq, + occurredAt, + kind: "tool_action" as const, + actorType: provenance.actorType, + actorId: provenance.agentId, + agentId: provenance.agentId, + ...(provenance.sessionKey ? { sessionKey: provenance.sessionKey } : {}), + ...(provenance.sessionId ? { sessionId: provenance.sessionId } : {}), + runId, + ...(toolCallId ? { toolCallId } : {}), + toolName, + }; + if (event.type === "tool.execution.started") { + const action = "tool.action.started" as const; + return { + sourceId: legacyAuditSourceId({ + runId, + sourceSequence: event.seq, + occurredAt, + action, + }), + ...attribution, + action, + status: "started", + }; + } const errorCategory = event.type === "tool.execution.error" ? normalizeOptionalLowercaseString(event.errorCategory) @@ -260,34 +319,28 @@ export function projectToolExecutionEventToAudit( // Unknown is an explicit dependency boundary, not a failed-run inference. // Keep it authoritative when enclosing run provenance says cancel or timeout. const terminal = - event.type === "tool.execution.started" - ? { status: "started" as const } - : event.type === "tool.execution.completed" - ? { status: "succeeded" as const } - : event.type === "tool.execution.blocked" - ? { status: "blocked" as const, errorCode: "tool_blocked" as const } - : diagnosticErrorCode === "tool_outcome_unknown" - ? { status: "unknown" as const, errorCode: "tool_outcome_unknown" as const } - : toolCancelled - ? { status: "cancelled" as const, errorCode: "tool_cancelled" as const } - : toolTimedOut - ? { status: "timed_out" as const, errorCode: "tool_timed_out" as const } - : { status: "failed" as const, errorCode: "tool_failed" as const }; + event.type === "tool.execution.completed" + ? { status: "succeeded" as const } + : event.type === "tool.execution.blocked" + ? { status: "blocked" as const, errorCode: "tool_blocked" as const } + : diagnosticErrorCode === "tool_outcome_unknown" + ? { status: "unknown" as const, errorCode: "tool_outcome_unknown" as const } + : toolCancelled + ? { status: "cancelled" as const, errorCode: "tool_cancelled" as const } + : toolTimedOut + ? { status: "timed_out" as const, errorCode: "tool_timed_out" as const } + : { status: "failed" as const, errorCode: "tool_failed" as const }; + const action = "tool.action.finished" as const; return { - sourceSequence: event.seq, - occurredAt: asDateTimestampMs(event.sourceTimestampMs) ?? event.ts, - kind: "tool_action", - action: - event.type === "tool.execution.started" ? "tool.action.started" : "tool.action.finished", + sourceId: legacyAuditSourceId({ + runId, + sourceSequence: event.seq, + occurredAt, + action, + }), + ...attribution, + action, ...terminal, - actorType: provenance.actorType, - actorId: provenance.agentId, - agentId: provenance.agentId, - ...(provenance.sessionKey ? { sessionKey: provenance.sessionKey } : {}), - ...(provenance.sessionId ? { sessionId: provenance.sessionId } : {}), - runId, - ...(toolCallId ? { toolCallId } : {}), - toolName, }; } @@ -296,11 +349,7 @@ export function createAgentEventAuditRecorder(options?: { writer?: AuditEventWriter; stateDir?: string; terminalSettleMs?: number; -}): { - record: (event: AgentEventPayload) => void; - recordTool: (event: TrustedToolExecutionEvent) => void; - stop: () => Promise; -} { +}): AgentEventAuditRecorder { const writer = options?.writer ?? createAuditEventWriter({ diff --git a/src/audit/audit-config.test.ts b/src/audit/audit-config.test.ts index aedd97f815b2..3c84d59cfde1 100644 --- a/src/audit/audit-config.test.ts +++ b/src/audit/audit-config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isAuditLedgerEnabled } from "./audit-config.js"; +import { isAuditLedgerEnabled, resolveAuditMessageMode } from "./audit-config.js"; describe("isAuditLedgerEnabled", () => { it("defaults to enabled without config or audit section", () => { @@ -15,4 +15,11 @@ describe("isAuditLedgerEnabled", () => { it("disables only on explicit false", () => { expect(isAuditLedgerEnabled({ audit: { enabled: false } })).toBe(false); }); + + it("keeps message metadata off until explicitly enabled", () => { + expect(resolveAuditMessageMode(undefined)).toBe("off"); + expect(resolveAuditMessageMode({ audit: {} })).toBe("off"); + expect(resolveAuditMessageMode({ audit: { messages: "direct" } })).toBe("direct"); + expect(resolveAuditMessageMode({ audit: { messages: "all" } })).toBe("all"); + }); }); diff --git a/src/audit/audit-config.ts b/src/audit/audit-config.ts index 61690545dff7..b3a32f635838 100644 --- a/src/audit/audit-config.ts +++ b/src/audit/audit-config.ts @@ -1,11 +1,18 @@ /** Resolves whether the metadata-only audit ledger records new events. */ import type { OpenClawConfig } from "../config/types.openclaw.js"; +export type AuditMessageMode = "off" | "direct" | "all"; + /** * The ledger is on by default: an audit trail enabled only after an incident - * cannot explain the incident. `audit.enabled: false` stops new writes; - * existing records remain readable through `audit.list` until they expire. + * cannot explain the incident. `audit.enabled: false` stops new event inserts after + * restart; audit queries still serve retained rows until they expire. */ export function isAuditLedgerEnabled(cfg: OpenClawConfig | undefined): boolean { return cfg?.audit?.enabled !== false; } + +/** Message metadata remains an explicit opt-in inside the default-on ledger. */ +export function resolveAuditMessageMode(cfg: OpenClawConfig | undefined): AuditMessageMode { + return cfg?.audit?.messages ?? "off"; +} diff --git a/src/audit/audit-event-store.message.test.ts b/src/audit/audit-event-store.message.test.ts new file mode 100644 index 000000000000..bb171d424613 --- /dev/null +++ b/src/audit/audit-event-store.message.test.ts @@ -0,0 +1,449 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { listAuditEvents, recordAuditEvent } from "./audit-event-store.js"; +import type { + AgentRunAuditEventInput, + InboundMessageAuditEventInput, + OutboundMessageAuditEventInput, + OutboundMessageAuditTerminal, +} from "./audit-event-types.js"; + +const tempDirs: string[] = []; +const AUDIT_REF_RE = /^hmac-sha256:v1:[a-f0-9]{32}:[a-f0-9]{64}$/u; + +function createDatabaseOptions() { + return { env: { OPENCLAW_STATE_DIR: makeTempDir(tempDirs, "openclaw-message-audit-") } }; +} + +type InboundMessageOverrides = Partial< + Pick< + InboundMessageAuditEventInput, + | "sourceId" + | "sourceSequence" + | "occurredAt" + | "actorId" + | "agentId" + | "runId" + | "channel" + | "conversationKind" + | "durationMs" + | "resultCount" + | "accountId" + | "conversationId" + | "messageId" + | "targetId" + > +>; + +function messageInput(overrides: InboundMessageOverrides = {}): InboundMessageAuditEventInput { + return { + sourceId: "message-source-1", + sourceSequence: 1, + occurredAt: Date.now(), + kind: "message", + action: "message.inbound.processed", + status: "succeeded", + actorType: "channel_sender", + actorId: "sender:+15551234567", + agentId: "main", + runId: "run-1", + direction: "inbound", + channel: "telegram", + conversationKind: "direct", + outcome: "completed", + durationMs: 12, + resultCount: 1, + accountId: "operator@example.test", + conversationId: "chat-123", + messageId: "message-456", + targetId: "target-789", + ...overrides, + }; +} + +type OutboundMessageOverrides = Partial< + Pick< + OutboundMessageAuditEventInput, + | "sourceId" + | "sourceSequence" + | "occurredAt" + | "channel" + | "conversationKind" + | "durationMs" + | "resultCount" + | "accountId" + | "conversationId" + | "messageId" + | "targetId" + > +>; + +function outboundMessageInput( + terminal: OutboundMessageAuditTerminal, + overrides: OutboundMessageOverrides = {}, +): OutboundMessageAuditEventInput { + return { + sourceId: "outbound-message-source-1", + sourceSequence: 1, + occurredAt: Date.now(), + kind: "message", + action: "message.outbound.finished", + actorType: "agent", + actorId: "main", + agentId: "main", + runId: "run-1", + direction: "outbound", + channel: "telegram", + conversationKind: "direct", + durationMs: 12, + resultCount: 1, + accountId: "operator@example.test", + conversationId: "chat-123", + messageId: "message-456", + targetId: "target-789", + ...terminal, + ...overrides, + }; +} + +function runInput( + overrides: Partial< + Pick + > = {}, +): AgentRunAuditEventInput { + return { + sourceId: "run-source-1", + sourceSequence: 1, + occurredAt: Date.now(), + kind: "agent_run", + action: "agent.run.started", + status: "started", + actorType: "agent", + actorId: "main", + agentId: "main", + runId: "run-1", + ...overrides, + }; +} + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); +}); + +afterAll(() => { + cleanupTempDirs(tempDirs); +}); + +describe("message audit persistence", () => { + it("stores stable domain-separated pseudonyms without raw transport identities", () => { + const database = createDatabaseOptions(); + const rawIdentity = "same-raw-identity"; + const first = recordAuditEvent( + messageInput({ + actorId: rawIdentity, + accountId: rawIdentity, + conversationId: rawIdentity, + messageId: rawIdentity, + targetId: rawIdentity, + }), + database, + ); + + expect(first).toMatchObject({ + schemaVersion: 1, + kind: "message", + direction: "inbound", + channel: "telegram", + conversationKind: "direct", + outcome: "completed", + durationMs: 12, + resultCount: 1, + redaction: "metadata_only", + }); + expect(first).toHaveProperty("sourceSequence", 1); + const refs = [ + first?.actorId, + first?.accountRef, + first?.conversationRef, + first?.messageRef, + first?.targetRef, + ]; + expect(refs).toEqual(refs.map(() => expect.stringMatching(AUDIT_REF_RE))); + expect(new Set(refs).size).toBe(refs.length); + expect(JSON.stringify(first)).not.toContain(rawIdentity); + + const { db } = openOpenClawStateDatabase(database); + const stored = db + .prepare( + `SELECT actor_id, account_ref, conversation_ref, message_ref, target_ref, + session_key, session_id + FROM audit_events`, + ) + .get() as Record; + expect(JSON.stringify(stored)).not.toContain(rawIdentity); + expect(stored.session_key).toBeNull(); + expect(stored.session_id).toBeNull(); + + const keyRow = db.prepare("SELECT key_id, key FROM audit_identity_keys WHERE id = 1").get() as { + key_id: string; + key: Uint8Array; + }; + expect(keyRow.key_id).toMatch(/^[a-f0-9]{32}$/u); + expect(keyRow.key.byteLength).toBe(32); + + closeOpenClawStateDatabaseForTest(); + const second = recordAuditEvent( + messageInput({ + sourceId: "message-source-2", + actorId: rawIdentity, + accountId: rawIdentity, + conversationId: rawIdentity, + messageId: rawIdentity, + targetId: rawIdentity, + }), + database, + ); + expect(second).toMatchObject({ + actorId: first?.actorId, + accountRef: first?.accountRef, + conversationRef: first?.conversationRef, + messageRef: first?.messageRef, + targetRef: first?.targetRef, + }); + }); + + it("fails closed instead of replacing corrupt identity key material", () => { + const database = createDatabaseOptions(); + const { db } = openOpenClawStateDatabase(database); + db.prepare( + "INSERT INTO audit_identity_keys (id, key_id, key, created_at) VALUES (?, ?, ?, ?)", + ).run(1, "a".repeat(32), Buffer.alloc(31), Date.now()); + + expect(() => recordAuditEvent(messageInput(), database)).toThrow( + "audit identity key is corrupt", + ); + const count = db.prepare("SELECT COUNT(*) AS count FROM audit_events").get() as { + count: number; + }; + expect(count.count).toBe(0); + expect( + ( + db.prepare("SELECT length(key) AS length FROM audit_identity_keys WHERE id = 1").get() as { + length: number; + } + ).length, + ).toBe(31); + }); + + it("fails closed when retained message references lose their identity key", () => { + const database = createDatabaseOptions(); + const first = recordAuditEvent(messageInput(), database); + expect(first?.messageRef).toMatch(AUDIT_REF_RE); + + closeOpenClawStateDatabaseForTest(); + const { db } = openOpenClawStateDatabase(database); + db.prepare("DELETE FROM audit_identity_keys WHERE id = 1").run(); + closeOpenClawStateDatabaseForTest(); + + expect(() => + recordAuditEvent(messageInput({ sourceId: "message-after-key-loss" }), database), + ).toThrow("audit identity key is missing"); + const reopened = openOpenClawStateDatabase(database).db; + expect( + ( + reopened.prepare("SELECT COUNT(*) AS count FROM audit_identity_keys").get() as { + count: number; + } + ).count, + ).toBe(0); + }); + + it("forgets a transaction-local identity key after a rolled-back event insert", () => { + const database = createDatabaseOptions(); + const { db } = openOpenClawStateDatabase(database); + db.exec(` + CREATE TABLE audit_rollback_parent (id INTEGER PRIMARY KEY); + CREATE TABLE audit_rollback_child ( + parent_id INTEGER NOT NULL, + FOREIGN KEY (parent_id) REFERENCES audit_rollback_parent(id) + DEFERRABLE INITIALLY DEFERRED + ); + CREATE TRIGGER reject_audit_event + AFTER INSERT ON audit_events + BEGIN + INSERT INTO audit_rollback_child (parent_id) VALUES (1); + END; + `); + + expect(() => recordAuditEvent(messageInput(), database)).toThrow(/FOREIGN KEY/u); + expect( + (db.prepare("SELECT COUNT(*) AS count FROM audit_identity_keys").get() as { count: number }) + .count, + ).toBe(0); + db.exec(` + DROP TRIGGER reject_audit_event; + DROP TABLE audit_rollback_child; + DROP TABLE audit_rollback_parent; + `); + + const committed = recordAuditEvent( + messageInput({ sourceId: "message-after-rollback", messageId: "stable-message" }), + database, + ); + expect(committed?.messageRef).toMatch(AUDIT_REF_RE); + expect( + (db.prepare("SELECT COUNT(*) AS count FROM audit_identity_keys").get() as { count: number }) + .count, + ).toBe(1); + + closeOpenClawStateDatabaseForTest(); + const reopened = recordAuditEvent( + messageInput({ sourceId: "message-after-reopen", messageId: "stable-message" }), + database, + ); + expect(reopened?.messageRef).toBe(committed?.messageRef); + }); + + it("scopes platform message references to their conversation", () => { + const database = createDatabaseOptions(); + const first = recordAuditEvent( + messageInput({ + sourceId: "message-conversation-a-1", + conversationId: "conversation-a", + messageId: "42", + }), + database, + ); + const otherConversation = recordAuditEvent( + messageInput({ + sourceId: "message-conversation-b-1", + conversationId: "conversation-b", + messageId: "42", + }), + database, + ); + const sameConversation = recordAuditEvent( + messageInput({ + sourceId: "message-conversation-a-2", + conversationId: "conversation-a", + messageId: "42", + }), + database, + ); + + expect(first?.messageRef).toMatch(AUDIT_REF_RE); + expect(otherConversation?.messageRef).toMatch(AUDIT_REF_RE); + expect(otherConversation?.messageRef).not.toBe(first?.messageRef); + expect(sameConversation?.messageRef).toBe(first?.messageRef); + }); + + it("uses the outbound target as the conversation correlation fallback", () => { + const database = createDatabaseOptions(); + const event = recordAuditEvent( + outboundMessageInput( + { + status: "failed", + outcome: "failed", + errorCode: "message_delivery_failed", + failureStage: "platform_send", + }, + { + sourceId: "message-outbound-target-fallback", + conversationId: undefined, + targetId: "target-conversation", + }, + ), + database, + ); + + expect(event?.conversationRef).toMatch(AUDIT_REF_RE); + expect(event?.targetRef).toMatch(AUDIT_REF_RE); + expect(event?.conversationRef).not.toBe(event?.targetRef); + }); + + it("rejects persisted message terminal combinations outside the closed contract", () => { + const database = createDatabaseOptions(); + recordAuditEvent(messageInput(), database); + const { db } = openOpenClawStateDatabase(database); + db.prepare("UPDATE audit_events SET error_code = ? WHERE kind = 'message'").run( + "message_processing_failed", + ); + + expect(() => + listAuditEvents({ database, limit: 10, filters: { includeMessages: true } }), + ).toThrow("corrupt audit event row 1: unexpected error_code"); + }); + + it("rejects delivery kind on terminals where no payload was proven delivered", () => { + const database = createDatabaseOptions(); + recordAuditEvent( + outboundMessageInput({ + status: "blocked", + outcome: "suppressed", + reasonCode: "no_visible_payload", + }), + database, + ); + const { db } = openOpenClawStateDatabase(database); + db.prepare("UPDATE audit_events SET delivery_kind = 'text' WHERE kind = 'message'").run(); + + expect(() => + listAuditEvents({ database, limit: 10, filters: { includeMessages: true } }), + ).toThrow("corrupt audit event row 1: unexpected delivery_kind"); + }); + + it("rejects a persisted channel-sender actor without a keyed reference", () => { + const database = createDatabaseOptions(); + recordAuditEvent(messageInput(), database); + const { db } = openOpenClawStateDatabase(database); + db.prepare("UPDATE audit_events SET actor_id = ? WHERE kind = 'message'").run("raw-sender"); + + expect(() => + listAuditEvents({ database, limit: 10, filters: { includeMessages: true } }), + ).toThrow("corrupt audit event row 1: invalid actorId"); + }); + + it("keeps message rows opt-in while supporting message filters", () => { + const database = createDatabaseOptions(); + const now = Date.now(); + recordAuditEvent(runInput({ occurredAt: now }), database); + recordAuditEvent(messageInput({ occurredAt: now + 1 }), database); + recordAuditEvent( + outboundMessageInput( + { status: "succeeded", outcome: "sent" }, + { + sourceId: "message-source-2", + occurredAt: now + 2, + channel: "slack", + conversationKind: "channel", + }, + ), + database, + ); + + expect(listAuditEvents({ database, limit: 10 }).events.map((event) => event.kind)).toEqual([ + "agent_run", + ]); + expect( + listAuditEvents({ database, limit: 10, filters: { includeMessages: true } }).events.map( + (event) => event.kind, + ), + ).toEqual(["message", "message", "agent_run"]); + expect( + listAuditEvents({ + database, + limit: 10, + filters: { + kind: "message", + includeMessages: false, + direction: "inbound", + channel: "telegram", + }, + }).events, + ).toEqual([expect.objectContaining({ direction: "inbound", channel: "telegram" })]); + }); +}); diff --git a/src/audit/audit-event-store.ts b/src/audit/audit-event-store.ts index 6233016287bd..f952f3f81743 100644 --- a/src/audit/audit-event-store.ts +++ b/src/audit/audit-event-store.ts @@ -14,12 +14,26 @@ import { runOpenClawStateWriteTransaction, type OpenClawStateDatabaseOptions, } from "../state/openclaw-state-db.js"; -import type { - AuditEventInput, - AuditEventListFilters, - AuditEventListPage, - AuditEventRecord, +import { + AUDIT_EVENT_SCHEMA_VERSION, + AUDIT_INBOUND_MESSAGE_COMPLETED_REASONS, + AUDIT_INBOUND_MESSAGE_SKIPPED_REASONS, + AUDIT_OUTBOUND_MESSAGE_SUPPRESSED_REASONS, + type AgentRunAuditEventRecord, + type AuditEventInput, + type AuditEventListFilters, + type AuditEventListPage, + type AuditEventRecord, + type InboundMessageAuditEventRecord, + type MessageAuditEventInput, + type OutboundMessageAuditEventRecord, + type ToolActionAuditEventRecord, } from "./audit-event-types.js"; +import { + clearAuditIdentityKeyCacheForDatabase, + loadOrCreateAuditIdentityKey, + pseudonymizeAuditIdentity, +} from "./audit-identity.js"; type AuditEventsTable = OpenClawStateKyselyDatabase["audit_events"]; type AuditDatabase = Pick; @@ -27,78 +41,537 @@ type AuditEventRow = Selectable; const AUDIT_EVENT_RETENTION_MS = 30 * 24 * 60 * 60_000; const AUDIT_EVENT_MAX_ROWS = 100_000; +const AUDIT_EVENT_PRUNE_BATCH_ROWS = 1_024; +// The single audit writer owns one DB handle. Invalidate on out-of-band +// maintenance or rollback so the hot path avoids a 100k-row scan per message. +const auditEventRowCounts = new WeakMap(); function getAuditKysely(db: DatabaseSync) { return getNodeSqliteKysely(db); } -function rowToAuditEvent(row: AuditEventRow): AuditEventRecord { +const RUN_ACTIONS = ["agent.run.started", "agent.run.finished"] as const; +const TOOL_ACTIONS = ["tool.action.started", "tool.action.finished"] as const; +const CONVERSATION_KINDS = ["direct", "group", "channel", "unknown"] as const; +const DELIVERY_KINDS = ["text", "media", "other"] as const; +const FAILURE_STAGES = ["platform_send", "queue", "unknown"] as const; +const AUDIT_HMAC_REF_RE = /^hmac-sha256:v1:[a-f0-9]{32}:[a-f0-9]{64}$/u; + +const MESSAGE_COLUMNS = [ + "direction", + "channel", + "conversation_kind", + "message_outcome", + "reason_code", + "delivery_kind", + "failure_stage", + "duration_ms", + "result_count", + "account_ref", + "conversation_ref", + "message_ref", + "target_ref", +] as const satisfies readonly (keyof AuditEventRow)[]; + +function corruptAuditRow(row: AuditEventRow, problem: string): never { + const sequence = normalizeSqliteNumber(row.sequence); + const location = sequence === undefined ? "" : ` ${sequence}`; + throw new Error(`corrupt audit event row${location}: ${problem}`); +} + +function requiredInteger( + row: AuditEventRow, + value: number | bigint | null, + field: string, + minimum: number, +): number { + const normalized = normalizeSqliteNumber(value); + if (normalized === undefined || !Number.isSafeInteger(normalized) || normalized < minimum) { + corruptAuditRow(row, `invalid ${field}`); + } + return normalized; +} + +function optionalInteger( + row: AuditEventRow, + value: number | bigint | null, + field: string, + minimum: number, +): number | undefined { + if (value === null) { + return undefined; + } + return requiredInteger(row, value, field, minimum); +} + +function requiredText(row: AuditEventRow, value: unknown, field: string): string { + if (typeof value !== "string" || value.length === 0) { + corruptAuditRow(row, `invalid ${field}`); + } + return value; +} + +function optionalText(row: AuditEventRow, value: unknown, field: string): string | undefined { + if (value === null || value === undefined) { + return undefined; + } + return requiredText(row, value, field); +} + +function requiredEnum( + row: AuditEventRow, + value: unknown, + field: string, + allowed: readonly Value[], +): Value { + for (const candidate of allowed) { + if (value === candidate) { + return candidate; + } + } + return corruptAuditRow(row, `invalid ${field}`); +} + +function optionalEnum( + row: AuditEventRow, + value: unknown, + field: string, + allowed: readonly Value[], +): Value | undefined { + if (value === null || value === undefined) { + return undefined; + } + return requiredEnum(row, value, field, allowed); +} + +function requiredHmacRef(row: AuditEventRow, value: unknown, field: string): string { + const ref = requiredText(row, value, field); + if (!AUDIT_HMAC_REF_RE.test(ref)) { + corruptAuditRow(row, `invalid ${field}`); + } + return ref; +} + +function optionalHmacRef(row: AuditEventRow, value: unknown, field: string): string | undefined { + if (value === null || value === undefined) { + return undefined; + } + return requiredHmacRef(row, value, field); +} + +function requireNull(row: AuditEventRow, field: keyof AuditEventRow): void { + if (row[field] !== null) { + corruptAuditRow(row, `unexpected ${field}`); + } +} + +function requireNullColumns(row: AuditEventRow, fields: readonly (keyof AuditEventRow)[]): void { + for (const field of fields) { + requireNull(row, field); + } +} + +function parseAuditRecordBase(row: AuditEventRow) { + const schemaVersion = requiredInteger(row, row.schema_version, "schemaVersion", 1); + if (schemaVersion !== AUDIT_EVENT_SCHEMA_VERSION) { + corruptAuditRow(row, `unsupported schemaVersion ${schemaVersion}`); + } return { - sequence: normalizeSqliteNumber(row.sequence) ?? 0, - eventId: row.event_id, - sourceSequence: normalizeSqliteNumber(row.source_sequence) ?? 0, - occurredAt: normalizeSqliteNumber(row.occurred_at) ?? 0, - kind: row.kind as AuditEventRecord["kind"], - action: row.action as AuditEventRecord["action"], - status: row.status as AuditEventRecord["status"], - ...(row.error_code - ? { errorCode: row.error_code as NonNullable } - : {}), - actorType: row.actor_type as AuditEventRecord["actorType"], - actorId: row.actor_id, - agentId: row.agent_id, - ...(row.session_key ? { sessionKey: row.session_key } : {}), - ...(row.session_id ? { sessionId: row.session_id } : {}), - runId: row.run_id, - ...(row.tool_call_id ? { toolCallId: row.tool_call_id } : {}), - ...(row.tool_name ? { toolName: row.tool_name } : {}), - redaction: "metadata_only", + schemaVersion, + sequence: requiredInteger(row, row.sequence, "sequence", 1), + eventId: requiredText(row, row.event_id, "eventId"), + sourceSequence: requiredInteger(row, row.source_sequence, "sourceSequence", 1), + occurredAt: requiredInteger(row, row.occurred_at, "occurredAt", 0), + redaction: "metadata_only" as const, }; } -function bindAuditEvent(input: AuditEventInput): Insertable { +function parseAgentRecordFields(row: AuditEventRow) { + requireNullColumns(row, MESSAGE_COLUMNS); + return { + ...parseAuditRecordBase(row), + actorType: requiredEnum(row, row.actor_type, "actorType", ["agent", "system"]), + actorId: requiredText(row, row.actor_id, "actorId"), + agentId: requiredText(row, row.agent_id, "agentId"), + ...(optionalText(row, row.session_key, "sessionKey") !== undefined + ? { sessionKey: requiredText(row, row.session_key, "sessionKey") } + : {}), + ...(optionalText(row, row.session_id, "sessionId") !== undefined + ? { sessionId: requiredText(row, row.session_id, "sessionId") } + : {}), + runId: requiredText(row, row.run_id, "runId"), + }; +} + +function parseAgentRunRow(row: AuditEventRow): AgentRunAuditEventRecord { + requireNull(row, "tool_call_id"); + requireNull(row, "tool_name"); + const common = { ...parseAgentRecordFields(row), kind: "agent_run" as const }; + const action = requiredEnum(row, row.action, "action", RUN_ACTIONS); + if (action === "agent.run.started") { + requiredEnum(row, row.status, "status", ["started"]); + requireNull(row, "error_code"); + return { ...common, action, status: "started" }; + } + if (row.status === "succeeded") { + requireNull(row, "error_code"); + return { ...common, action, status: "succeeded" }; + } + const terminal = + row.status === "failed" + ? { status: "failed" as const, errorCode: "run_failed" as const } + : row.status === "cancelled" + ? { status: "cancelled" as const, errorCode: "run_cancelled" as const } + : row.status === "timed_out" + ? { status: "timed_out" as const, errorCode: "run_timed_out" as const } + : row.status === "blocked" + ? { status: "blocked" as const, errorCode: "run_blocked" as const } + : corruptAuditRow(row, "invalid run terminal status"); + requiredEnum(row, row.error_code, "errorCode", [terminal.errorCode]); + return { ...common, action, ...terminal }; +} + +function parseToolActionRow(row: AuditEventRow): ToolActionAuditEventRecord { + const toolCallId = optionalText(row, row.tool_call_id, "toolCallId"); + const toolName = optionalText(row, row.tool_name, "toolName"); + const common = { + ...parseAgentRecordFields(row), + kind: "tool_action" as const, + ...(toolCallId ? { toolCallId } : {}), + ...(toolName ? { toolName } : {}), + }; + const action = requiredEnum(row, row.action, "action", TOOL_ACTIONS); + if (action === "tool.action.started") { + requiredEnum(row, row.status, "status", ["started"]); + requireNull(row, "error_code"); + return { ...common, action, status: "started" }; + } + if (row.status === "succeeded") { + requireNull(row, "error_code"); + return { ...common, action, status: "succeeded" }; + } + const terminal = + row.status === "failed" + ? { status: "failed" as const, errorCode: "tool_failed" as const } + : row.status === "cancelled" + ? { status: "cancelled" as const, errorCode: "tool_cancelled" as const } + : row.status === "timed_out" + ? { status: "timed_out" as const, errorCode: "tool_timed_out" as const } + : row.status === "blocked" + ? { status: "blocked" as const, errorCode: "tool_blocked" as const } + : row.status === "unknown" + ? { status: "unknown" as const, errorCode: "tool_outcome_unknown" as const } + : corruptAuditRow(row, "invalid tool terminal status"); + requiredEnum(row, row.error_code, "errorCode", [terminal.errorCode]); + return { ...common, action, ...terminal }; +} + +function parseMessageRecordFields(row: AuditEventRow) { + requireNullColumns(row, ["session_key", "session_id", "tool_call_id", "tool_name"]); + const agentId = optionalText(row, row.agent_id, "agentId"); + const runId = optionalText(row, row.run_id, "runId"); + const durationMs = optionalInteger(row, row.duration_ms, "durationMs", 0); + const resultCount = optionalInteger(row, row.result_count, "resultCount", 0); + const accountRef = optionalHmacRef(row, row.account_ref, "accountRef"); + const conversationRef = optionalHmacRef(row, row.conversation_ref, "conversationRef"); + const messageRef = optionalHmacRef(row, row.message_ref, "messageRef"); + const targetRef = optionalHmacRef(row, row.target_ref, "targetRef"); + return { + ...parseAuditRecordBase(row), + kind: "message" as const, + channel: requiredText(row, row.channel, "channel"), + conversationKind: requiredEnum( + row, + row.conversation_kind, + "conversationKind", + CONVERSATION_KINDS, + ), + ...(agentId ? { agentId } : {}), + ...(runId ? { runId } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + ...(resultCount !== undefined ? { resultCount } : {}), + ...(accountRef ? { accountRef } : {}), + ...(conversationRef ? { conversationRef } : {}), + ...(messageRef ? { messageRef } : {}), + ...(targetRef ? { targetRef } : {}), + }; +} + +function parseInboundMessageRow(row: AuditEventRow): InboundMessageAuditEventRecord { + requiredEnum(row, row.action, "action", ["message.inbound.processed"]); + requiredEnum(row, row.direction, "direction", ["inbound"]); + requireNull(row, "delivery_kind"); + requireNull(row, "failure_stage"); + const actorType = requiredEnum(row, row.actor_type, "actorType", ["channel_sender", "system"]); + const actorId = + actorType === "channel_sender" + ? requiredHmacRef(row, row.actor_id, "actorId") + : requiredText(row, row.actor_id, "actorId"); + const common = { + ...parseMessageRecordFields(row), + action: "message.inbound.processed" as const, + direction: "inbound" as const, + actorType, + actorId, + }; + if (row.status === "succeeded") { + requiredEnum(row, row.message_outcome, "outcome", ["completed"]); + requireNull(row, "error_code"); + const reasonCode = optionalEnum( + row, + row.reason_code, + "reasonCode", + AUDIT_INBOUND_MESSAGE_COMPLETED_REASONS, + ); + return { + ...common, + status: "succeeded", + outcome: "completed", + ...(reasonCode ? { reasonCode } : {}), + }; + } + if (row.status === "blocked") { + requiredEnum(row, row.message_outcome, "outcome", ["skipped"]); + requireNull(row, "error_code"); + const reasonCode = optionalEnum( + row, + row.reason_code, + "reasonCode", + AUDIT_INBOUND_MESSAGE_SKIPPED_REASONS, + ); + return { + ...common, + status: "blocked", + outcome: "skipped", + ...(reasonCode ? { reasonCode } : {}), + }; + } + if (row.status === "failed") { + requiredEnum(row, row.message_outcome, "outcome", ["failed"]); + requiredEnum(row, row.error_code, "errorCode", ["message_processing_failed"]); + const reasonCode = optionalEnum(row, row.reason_code, "reasonCode", [ + "acp_dispatch_failed", + "plugin_bound_error", + ]); + return { + ...common, + status: "failed", + outcome: "failed", + errorCode: "message_processing_failed", + ...(reasonCode ? { reasonCode } : {}), + }; + } + return corruptAuditRow(row, "invalid inbound status"); +} + +function parseOutboundMessageRow(row: AuditEventRow): OutboundMessageAuditEventRecord { + requiredEnum(row, row.action, "action", ["message.outbound.finished"]); + requiredEnum(row, row.direction, "direction", ["outbound"]); + const actorType = requiredEnum(row, row.actor_type, "actorType", ["agent", "system"]); + const actorId = requiredText(row, row.actor_id, "actorId"); + const commonFields = parseMessageRecordFields(row); + const common = { + ...commonFields, + action: "message.outbound.finished" as const, + direction: "outbound" as const, + actorType, + actorId, + }; + if (row.status === "succeeded") { + const deliveryKind = optionalEnum(row, row.delivery_kind, "deliveryKind", DELIVERY_KINDS); + requiredEnum(row, row.message_outcome, "outcome", ["sent"]); + requireNullColumns(row, ["error_code", "reason_code", "failure_stage"]); + return { + ...common, + status: "succeeded", + outcome: "sent", + ...(deliveryKind ? { deliveryKind } : {}), + }; + } + if (row.status === "blocked") { + requireNull(row, "delivery_kind"); + requiredEnum(row, row.message_outcome, "outcome", ["suppressed"]); + requireNullColumns(row, ["error_code", "failure_stage"]); + const reasonCode = requiredEnum( + row, + row.reason_code, + "reasonCode", + AUDIT_OUTBOUND_MESSAGE_SUPPRESSED_REASONS, + ); + return { + ...common, + status: "blocked", + outcome: "suppressed", + reasonCode, + }; + } + if (row.status === "failed") { + const deliveryKind = optionalEnum(row, row.delivery_kind, "deliveryKind", DELIVERY_KINDS); + requiredEnum(row, row.message_outcome, "outcome", ["failed"]); + requireNull(row, "reason_code"); + const errorCode = requiredEnum(row, row.error_code, "errorCode", [ + "message_delivery_failed", + "message_delivery_partial_failure", + ]); + const failureStage = requiredEnum(row, row.failure_stage, "failureStage", FAILURE_STAGES); + return { + ...common, + status: "failed", + outcome: "failed", + errorCode, + failureStage, + ...(deliveryKind ? { deliveryKind } : {}), + }; + } + if (row.status === "unknown") { + requireNull(row, "delivery_kind"); + requiredEnum(row, row.message_outcome, "outcome", ["unknown"]); + requireNullColumns(row, ["error_code", "reason_code"]); + const failureStage = requiredEnum(row, row.failure_stage, "failureStage", FAILURE_STAGES); + return { + ...common, + status: "unknown", + outcome: "unknown", + failureStage, + }; + } + return corruptAuditRow(row, "invalid outbound status"); +} + +function rowToAuditEvent(row: AuditEventRow): AuditEventRecord { + if (row.kind === "agent_run") { + return parseAgentRunRow(row); + } + if (row.kind === "tool_action") { + return parseToolActionRow(row); + } + if (row.kind !== "message") { + corruptAuditRow(row, "invalid kind"); + } + if (row.direction === "inbound") { + return parseInboundMessageRow(row); + } + if (row.direction === "outbound") { + return parseOutboundMessageRow(row); + } + return corruptAuditRow(row, "invalid message direction"); +} + +function projectMessageIdentities(db: DatabaseSync, input: MessageAuditEventInput) { + const identity = loadOrCreateAuditIdentityKey(db); + const conversationId = + input.conversationId ?? (input.direction === "outbound" ? input.targetId : undefined); + const ref = ( + kind: Parameters[0]["kind"], + value: string | undefined, + ) => + pseudonymizeAuditIdentity({ + identity, + kind, + channel: input.channel, + ...(kind !== "account" && input.accountId !== undefined + ? { accountId: input.accountId } + : {}), + ...(kind === "message" && conversationId !== undefined ? { conversationId } : {}), + value, + }); + return { + actorId: input.actorType === "channel_sender" ? ref("actor", input.actorId) : input.actorId, + accountRef: ref("account", input.accountId), + conversationRef: ref("conversation", conversationId), + messageRef: ref("message", input.messageId), + targetRef: ref("target", input.targetId), + }; +} + +function bindAuditEvent(db: DatabaseSync, input: AuditEventInput): Insertable { + const message = input.kind === "message" ? projectMessageIdentities(db, input) : undefined; return { event_id: randomUUID(), - source_id: `${input.runId}:${input.sourceSequence}:${input.occurredAt}:${input.action}`, + source_id: input.sourceId, source_sequence: input.sourceSequence, + schema_version: AUDIT_EVENT_SCHEMA_VERSION, occurred_at: input.occurredAt, kind: input.kind, action: input.action, status: input.status, error_code: input.errorCode ?? null, actor_type: input.actorType, - actor_id: input.actorId, - agent_id: input.agentId, - session_key: input.sessionKey ?? null, - session_id: input.sessionId ?? null, - run_id: input.runId, - tool_call_id: input.toolCallId ?? null, - tool_name: input.toolName ?? null, + actor_id: message?.actorId ?? input.actorId, + agent_id: input.agentId ?? null, + session_key: input.kind === "message" ? null : (input.sessionKey ?? null), + session_id: input.kind === "message" ? null : (input.sessionId ?? null), + run_id: input.runId ?? null, + tool_call_id: input.kind === "tool_action" ? (input.toolCallId ?? null) : null, + tool_name: input.kind === "tool_action" ? input.toolName : null, + direction: input.kind === "message" ? input.direction : null, + channel: input.kind === "message" ? input.channel : null, + conversation_kind: input.kind === "message" ? input.conversationKind : null, + message_outcome: input.kind === "message" ? input.outcome : null, + reason_code: input.kind === "message" ? (input.reasonCode ?? null) : null, + delivery_kind: input.kind === "message" ? (input.deliveryKind ?? null) : null, + failure_stage: input.kind === "message" ? (input.failureStage ?? null) : null, + duration_ms: input.kind === "message" ? (input.durationMs ?? null) : null, + result_count: input.kind === "message" ? (input.resultCount ?? null) : null, + account_ref: message?.accountRef ?? null, + conversation_ref: message?.conversationRef ?? null, + message_ref: message?.messageRef ?? null, + target_ref: message?.targetRef ?? null, }; } -function pruneAuditEventsAfterInsert(db: DatabaseSync, now: number): void { +function countAuditEvents(db: DatabaseSync): number { const kysely = getAuditKysely(db); - executeSqliteQuerySync( + const row = executeSqliteQueryTakeFirstSync( + db, + kysely + .selectFrom("audit_events") + .select((expression) => expression.fn.countAll().as("count")), + ); + return normalizeSqliteNumber(row?.count ?? null) ?? 0; +} + +function pruneAuditEventsAfterInsert( + db: DatabaseSync, + now: number, + limits: { maxRows: number; pruneBatchRows: number } = { + maxRows: AUDIT_EVENT_MAX_ROWS, + pruneBatchRows: AUDIT_EVENT_PRUNE_BATCH_ROWS, + }, +): void { + const kysely = getAuditKysely(db); + const expired = executeSqliteQuerySync( db, kysely.deleteFrom("audit_events").where("occurred_at", "<", now - AUDIT_EVENT_RETENTION_MS), ); + const cachedCount = auditEventRowCounts.get(db); + let rowCount = + cachedCount === undefined + ? countAuditEvents(db) + : Math.max(0, cachedCount + 1 - Number(expired.numAffectedRows ?? 0n)); + if (rowCount <= limits.maxRows) { + auditEventRowCounts.set(db, rowCount); + return; + } + const retainedRows = Math.max(0, limits.maxRows - limits.pruneBatchRows); const overflowRow = executeSqliteQueryTakeFirstSync( db, kysely .selectFrom("audit_events") .select("sequence") .orderBy("sequence", "desc") - .offset(AUDIT_EVENT_MAX_ROWS) + .offset(retainedRows) .limit(1), ); const sequenceCutoff = overflowRow ? normalizeSqliteNumber(overflowRow.sequence) : undefined; if (sequenceCutoff !== undefined) { - executeSqliteQuerySync( + const pruned = executeSqliteQuerySync( db, kysely.deleteFrom("audit_events").where("sequence", "<=", sequenceCutoff), ); + rowCount = Math.max(0, rowCount - Number(pruned.numAffectedRows ?? 0n)); } + auditEventRowCounts.set(db, rowCount); } /** Persist one projected event idempotently and prune fixed retention bounds. */ @@ -106,28 +579,41 @@ export function recordAuditEvent( input: AuditEventInput, options: OpenClawStateDatabaseOptions = {}, ): AuditEventRecord | undefined { - return runOpenClawStateWriteTransaction(({ db }) => { - const insert = executeSqliteQuerySync( - db, - getAuditKysely(db) - .insertInto("audit_events") - .values(bindAuditEvent(input)) - .onConflict((conflict) => conflict.column("source_id").doNothing()), - ); - const insertedSequence = insert.insertId ? Number(insert.insertId) : undefined; - if (insertedSequence === undefined) { - return undefined; + let countCacheDatabase: DatabaseSync | undefined; + try { + return runOpenClawStateWriteTransaction(({ db }) => { + countCacheDatabase = db; + const insert = executeSqliteQuerySync( + db, + getAuditKysely(db) + .insertInto("audit_events") + .values(bindAuditEvent(db, input)) + .onConflict((conflict) => conflict.column("source_id").doNothing()), + ); + if (insert.insertId === undefined) { + return undefined; + } + const insertedSequence = Number(insert.insertId); + if (!Number.isSafeInteger(insertedSequence) || insertedSequence < 1) { + throw new Error("audit event sequence is outside the supported integer range"); + } + pruneAuditEventsAfterInsert(db, Date.now()); + const row = executeSqliteQueryTakeFirstSync( + db, + getAuditKysely(db) + .selectFrom("audit_events") + .selectAll() + .where("sequence", "=", insertedSequence), + ); + return row ? rowToAuditEvent(row) : undefined; + }, options); + } catch (error) { + if (countCacheDatabase) { + auditEventRowCounts.delete(countCacheDatabase); + clearAuditIdentityKeyCacheForDatabase(countCacheDatabase); } - pruneAuditEventsAfterInsert(db, Date.now()); - const row = executeSqliteQueryTakeFirstSync( - db, - getAuditKysely(db) - .selectFrom("audit_events") - .selectAll() - .where("sequence", "=", insertedSequence), - ); - return row ? rowToAuditEvent(row) : undefined; - }, options); + throw error; + } } /** List newest-first records using a stable sequence cursor. */ @@ -159,10 +645,18 @@ export function listAuditEvents(params: { } if (filters.kind) { query = query.where("kind", "=", filters.kind); + } else if (filters.includeMessages !== true) { + query = query.where("kind", "!=", "message"); } if (filters.status) { query = query.where("status", "=", filters.status); } + if (filters.direction) { + query = query.where("direction", "=", filters.direction); + } + if (filters.channel) { + query = query.where("channel", "=", filters.channel); + } if (filters.after !== undefined) { query = query.where("occurred_at", ">=", filters.after); } @@ -196,10 +690,14 @@ export function pruneExpiredAuditEvents( .deleteFrom("audit_events") .where("occurred_at", "<", (params.now ?? Date.now()) - AUDIT_EVENT_RETENTION_MS), ); + auditEventRowCounts.delete(db); }, params.database); } export const auditEventStoreLimits = { maxRows: AUDIT_EVENT_MAX_ROWS, + pruneBatchRows: AUDIT_EVENT_PRUNE_BATCH_ROWS, retentionMs: AUDIT_EVENT_RETENTION_MS, } as const; + +export const testApi = { pruneAuditEventsAfterInsert }; diff --git a/src/audit/audit-event-types.ts b/src/audit/audit-event-types.ts index ce91193ef891..a50e3925ad2f 100644 --- a/src/audit/audit-event-types.ts +++ b/src/audit/audit-event-types.ts @@ -1,12 +1,8 @@ -/** Metadata-only durable audit contract for agent runs and tool actions. */ +/** Versioned metadata-only durable audit contract. */ -export type AuditEventKind = "agent_run" | "tool_action"; +export const AUDIT_EVENT_SCHEMA_VERSION = 1 as const; -export type AuditEventAction = - | "agent.run.started" - | "agent.run.finished" - | "tool.action.started" - | "tool.action.finished"; +export type AuditEventKind = "agent_run" | "tool_action" | "message"; export type AuditEventStatus = | "started" @@ -17,28 +13,217 @@ export type AuditEventStatus = | "blocked" | "unknown"; -export type AuditEventErrorCode = - | "run_failed" - | "run_cancelled" - | "run_timed_out" - | "run_blocked" - | "tool_failed" - | "tool_cancelled" - | "tool_timed_out" - | "tool_blocked" - | "tool_outcome_unknown"; +export type AuditMessageDirection = "inbound" | "outbound"; +export type AuditMessageConversationKind = "direct" | "group" | "channel" | "unknown"; +export type AuditMessageDeliveryKind = "text" | "media" | "other"; +export type AuditMessageFailureStage = "platform_send" | "queue" | "unknown"; -export type AuditEventActorType = "agent" | "system"; +export const AUDIT_INBOUND_MESSAGE_COMPLETED_REASONS = [ + "fast_abort", + "plugin_bound_handled", + "plugin_bound_unavailable", + "plugin_bound_declined", + "before_dispatch_handled", + "acp_dispatch_completed", + "acp_dispatch_empty", +] as const; -/** Durable columns accepted from trusted lifecycle projection. */ -export type AuditEventInput = { +export type AuditInboundMessageCompletedReasonCode = + (typeof AUDIT_INBOUND_MESSAGE_COMPLETED_REASONS)[number]; + +export const AUDIT_INBOUND_MESSAGE_SKIPPED_REASONS = [ + "duplicate", + "reply_operation_active", + "reply_operation_aborted", + "acp_dispatch_aborted", +] as const; + +export type AuditInboundMessageSkippedReasonCode = + (typeof AUDIT_INBOUND_MESSAGE_SKIPPED_REASONS)[number]; + +export type AuditInboundMessageFailureReasonCode = "acp_dispatch_failed" | "plugin_bound_error"; + +export const AUDIT_OUTBOUND_MESSAGE_SUPPRESSED_REASONS = [ + "cancelled_by_message_sending_hook", + "cancelled_by_reply_payload_sending_hook", + "empty_after_message_sending_hook", + "empty_after_reply_payload_sending_hook", + "no_visible_payload", +] as const; + +export type AuditOutboundMessageSuppressedReasonCode = + (typeof AUDIT_OUTBOUND_MESSAGE_SUPPRESSED_REASONS)[number]; + +type AuditEventInputBase = { + /** Stable trusted-source identity used only for local replay deduplication. */ + sourceId: string; sourceSequence: number; occurredAt: number; - kind: AuditEventKind; - action: AuditEventAction; - status: AuditEventStatus; - errorCode?: AuditEventErrorCode; - actorType: AuditEventActorType; +}; + +type AgentAuditAttribution = { + actorType: "agent" | "system"; + actorId: string; + agentId: string; + sessionKey?: string; + sessionId?: string; + runId: string; +}; + +export type AgentRunFinishedAuditTerminal = + | { status: "succeeded"; errorCode?: never } + | { status: "failed"; errorCode: "run_failed" } + | { status: "cancelled"; errorCode: "run_cancelled" } + | { status: "timed_out"; errorCode: "run_timed_out" } + | { status: "blocked"; errorCode: "run_blocked" }; + +type AgentRunAuditLifecycle = + | { action: "agent.run.started"; status: "started"; errorCode?: never } + | ({ action: "agent.run.finished" } & AgentRunFinishedAuditTerminal); + +export type AgentRunAuditEventInput = AuditEventInputBase & + AgentAuditAttribution & + AgentRunAuditLifecycle & { kind: "agent_run" }; + +export type ToolActionFinishedAuditTerminal = + | { status: "succeeded"; errorCode?: never } + | { status: "failed"; errorCode: "tool_failed" } + | { status: "cancelled"; errorCode: "tool_cancelled" } + | { status: "timed_out"; errorCode: "tool_timed_out" } + | { status: "blocked"; errorCode: "tool_blocked" } + | { status: "unknown"; errorCode: "tool_outcome_unknown" }; + +type ToolActionAuditLifecycle = + | { action: "tool.action.started"; status: "started"; errorCode?: never } + | ({ action: "tool.action.finished" } & ToolActionFinishedAuditTerminal); + +export type ToolActionAuditEventInput = AuditEventInputBase & + AgentAuditAttribution & + ToolActionAuditLifecycle & { + kind: "tool_action"; + toolCallId?: string; + toolName: string; + }; + +type MessageAuditEventInputBase = { + sourceId: string; + sourceSequence: number; + occurredAt: number; + kind: "message"; + actorId: string; + agentId?: string; + runId?: string; + channel: string; + conversationKind: AuditMessageConversationKind; + durationMs?: number; + resultCount?: number; + accountId?: string; + conversationId?: string; + messageId?: string; + targetId?: string; +}; + +type InboundMessageAuditAttribution = { + actorType: "channel_sender" | "system"; + actorId: string; +}; + +type OutboundMessageAuditAttribution = { + actorType: "agent" | "system"; + actorId: string; +}; + +export type InboundMessageAuditTerminal = + | { + status: "succeeded"; + outcome: "completed"; + reasonCode?: AuditInboundMessageCompletedReasonCode; + errorCode?: never; + } + | { + status: "blocked"; + outcome: "skipped"; + reasonCode?: AuditInboundMessageSkippedReasonCode; + errorCode?: never; + } + | { + status: "failed"; + outcome: "failed"; + errorCode: "message_processing_failed"; + reasonCode?: AuditInboundMessageFailureReasonCode; + }; + +export type OutboundMessageAuditTerminal = + | { + status: "succeeded"; + outcome: "sent"; + errorCode?: never; + reasonCode?: never; + failureStage?: never; + deliveryKind?: AuditMessageDeliveryKind; + } + | { + status: "blocked"; + outcome: "suppressed"; + reasonCode: AuditOutboundMessageSuppressedReasonCode; + errorCode?: never; + failureStage?: never; + deliveryKind?: never; + } + | { + status: "failed"; + outcome: "failed"; + errorCode: "message_delivery_failed" | "message_delivery_partial_failure"; + failureStage: AuditMessageFailureStage; + reasonCode?: never; + deliveryKind?: AuditMessageDeliveryKind; + } + | { + status: "unknown"; + outcome: "unknown"; + failureStage: AuditMessageFailureStage; + errorCode?: never; + reasonCode?: never; + deliveryKind?: never; + }; + +/** Raw identifiers exist only on the trusted producer-to-writer boundary. */ +export type InboundMessageAuditEventInput = MessageAuditEventInputBase & + InboundMessageAuditAttribution & + InboundMessageAuditTerminal & { + action: "message.inbound.processed"; + direction: "inbound"; + deliveryKind?: never; + failureStage?: never; + }; + +/** Raw identifiers exist only on the trusted producer-to-writer boundary. */ +export type OutboundMessageAuditEventInput = MessageAuditEventInputBase & + OutboundMessageAuditAttribution & + OutboundMessageAuditTerminal & { + action: "message.outbound.finished"; + direction: "outbound"; + }; + +export type MessageAuditEventInput = InboundMessageAuditEventInput | OutboundMessageAuditEventInput; + +/** Durable columns accepted from trusted lifecycle projections. */ +export type AuditEventInput = + | AgentRunAuditEventInput + | ToolActionAuditEventInput + | MessageAuditEventInput; + +type AuditEventRecordBase = { + schemaVersion: typeof AUDIT_EVENT_SCHEMA_VERSION; + sequence: number; + eventId: string; + sourceSequence: number; + occurredAt: number; + redaction: "metadata_only"; +}; + +type AgentAuditEventRecordBase = AuditEventRecordBase & { + actorType: "agent" | "system"; actorId: string; agentId: string; sessionKey?: string; @@ -46,21 +231,78 @@ export type AuditEventInput = { runId: string; toolCallId?: string; toolName?: string; + direction?: never; + channel?: never; + conversationKind?: never; + outcome?: never; + reasonCode?: never; + deliveryKind?: never; + failureStage?: never; + durationMs?: never; + resultCount?: never; + accountRef?: never; + conversationRef?: never; + messageRef?: never; + targetRef?: never; }; -/** Public record returned by the bounded operator read surface. */ -export type AuditEventRecord = AuditEventInput & { - sequence: number; - eventId: string; - redaction: "metadata_only"; +export type AgentRunAuditEventRecord = AgentAuditEventRecordBase & { + kind: "agent_run"; +} & AgentRunAuditLifecycle; + +export type ToolActionAuditEventRecord = AgentAuditEventRecordBase & { + kind: "tool_action"; +} & ToolActionAuditLifecycle; + +type MessageAuditEventRecordBase = AuditEventRecordBase & { + kind: "message"; + actorId: string; + agentId?: string; + runId?: string; + sessionKey?: never; + sessionId?: never; + toolCallId?: never; + toolName?: never; + channel: string; + conversationKind: AuditMessageConversationKind; + durationMs?: number; + resultCount?: number; + accountRef?: string; + conversationRef?: string; + messageRef?: string; + targetRef?: string; }; +export type InboundMessageAuditEventRecord = MessageAuditEventRecordBase & + InboundMessageAuditAttribution & + InboundMessageAuditTerminal & { + action: "message.inbound.processed"; + direction: "inbound"; + }; + +export type OutboundMessageAuditEventRecord = MessageAuditEventRecordBase & + OutboundMessageAuditAttribution & + OutboundMessageAuditTerminal & { + action: "message.outbound.finished"; + direction: "outbound"; + }; + +/** Public record returned by the bounded operator read surface. */ +export type AuditEventRecord = + | AgentRunAuditEventRecord + | ToolActionAuditEventRecord + | InboundMessageAuditEventRecord + | OutboundMessageAuditEventRecord; + export type AuditEventListFilters = { agentId?: string; sessionKey?: string; runId?: string; kind?: AuditEventKind; status?: AuditEventStatus; + direction?: AuditMessageDirection; + channel?: string; + includeMessages?: boolean; after?: number; before?: number; }; diff --git a/src/audit/audit-event-writer.test.ts b/src/audit/audit-event-writer.test.ts index 60932a95e776..c968de5f5ad5 100644 --- a/src/audit/audit-event-writer.test.ts +++ b/src/audit/audit-event-writer.test.ts @@ -13,6 +13,7 @@ const tempDirs: string[] = []; function input(): AuditEventInput { return { + sourceId: "run-1:1:started", sourceSequence: 1, occurredAt: Date.now(), kind: "agent_run", diff --git a/src/audit/audit-events.test.ts b/src/audit/audit-events.test.ts index 42fdd2bc35d4..8845edf058a4 100644 --- a/src/audit/audit-events.test.ts +++ b/src/audit/audit-events.test.ts @@ -23,6 +23,7 @@ import { listAuditEvents, pruneExpiredAuditEvents, recordAuditEvent, + testApi as auditStoreTestApi, } from "./audit-event-store.js"; import type { AuditEventInput } from "./audit-event-types.js"; import type { AuditEventWriter } from "./audit-event-writer.js"; @@ -34,7 +35,7 @@ function createDatabaseOptions() { } function auditInput(overrides: Partial = {}): AuditEventInput { - return { + const input = { sourceSequence: 1, occurredAt: Date.now(), kind: "agent_run", @@ -48,6 +49,12 @@ function auditInput(overrides: Partial = {}): AuditEventInput { runId: "run-1", ...overrides, }; + return { + ...input, + sourceId: + overrides.sourceId ?? + `${input.runId}:${input.sourceSequence}:${input.occurredAt}:${input.action}`, + } as AuditEventInput; } function agentEvent(overrides: Partial): AgentEventPayload { @@ -159,6 +166,17 @@ describe("audit event persistence", () => { expect(listAuditEvents({ database, limit: 10 }).events).toHaveLength(1); }); + it("rejects persisted run lifecycle tuples outside the closed contract", () => { + const database = createDatabaseOptions(); + recordAuditEvent(auditInput(), database); + const { db } = openOpenClawStateDatabase(database); + db.prepare("UPDATE audit_events SET status = ? WHERE kind = 'agent_run'").run("failed"); + + expect(() => listAuditEvents({ database, limit: 10 })).toThrow( + "corrupt audit event row 1: invalid status", + ); + }); + it("caps actual rows without treating dedupe sequence gaps as retained records", () => { const database = createDatabaseOptions(); const occurredAt = Date.now(); @@ -173,6 +191,75 @@ describe("audit event persistence", () => { expect(listAuditEvents({ database, limit: 10 }).events).toHaveLength(2); }); + it("prunes row overflow in batches instead of scanning the full cap per insert", () => { + const database = createDatabaseOptions(); + const { db } = openOpenClawStateDatabase(database); + const occurredAt = Date.now(); + const insert = db.prepare( + `INSERT INTO audit_events ( + event_id, source_id, source_sequence, occurred_at, kind, action, status, + actor_type, actor_id, agent_id, run_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (let sequence = 1; sequence <= 4; sequence += 1) { + insert.run( + `event-${sequence}`, + `source-${sequence}`, + sequence, + occurredAt + sequence, + "agent_run", + "agent.run.started", + "started", + "agent", + "main", + "main", + `run-${sequence}`, + ); + } + + auditStoreTestApi.pruneAuditEventsAfterInsert(db, occurredAt + 4, { + maxRows: 3, + pruneBatchRows: 1, + }); + expect(listAuditEvents({ database, limit: 10 }).events.map((event) => event.sequence)).toEqual([ + 4, 3, + ]); + + insert.run( + "event-5", + "source-5", + 5, + occurredAt + 5, + "agent_run", + "agent.run.started", + "started", + "agent", + "main", + "main", + "run-5", + ); + auditStoreTestApi.pruneAuditEventsAfterInsert(db, occurredAt + 5, { + maxRows: 3, + pruneBatchRows: 1, + }); + expect(listAuditEvents({ database, limit: 10 }).events.map((event) => event.sequence)).toEqual([ + 5, 4, 3, + ]); + }); + + it("rolls back an insert whose sequence cannot be represented safely", () => { + const database = createDatabaseOptions(); + const { db } = openOpenClawStateDatabase(database); + db.prepare("INSERT INTO sqlite_sequence (name, seq) VALUES ('audit_events', ?)").run( + Number.MAX_SAFE_INTEGER, + ); + + expect(() => recordAuditEvent(auditInput(), database)).toThrow( + "audit event sequence is outside the supported integer range", + ); + expect(db.prepare("SELECT COUNT(*) AS count FROM audit_events").get()).toEqual({ count: 0 }); + }); + it("keeps reused run ids distinct across actual event timestamps", () => { const database = createDatabaseOptions(); const occurredAt = Date.now(); diff --git a/src/audit/audit-identity.ts b/src/audit/audit-identity.ts new file mode 100644 index 000000000000..7309e1352575 --- /dev/null +++ b/src/audit/audit-identity.ts @@ -0,0 +1,149 @@ +/** Stable installation-local pseudonyms for sensitive audit identifiers. */ +import { createHmac, randomBytes } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; +import type { Selectable } from "kysely"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "../infra/kysely-sync.js"; +import { registerSecretValueForRedaction } from "../logging/secret-redaction-registry.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; + +type AuditIdentityDatabase = Pick< + OpenClawStateKyselyDatabase, + "audit_events" | "audit_identity_keys" +>; +type AuditIdentityKeyRow = Pick< + Selectable, + "key_id" | "key" +>; + +const AUDIT_IDENTITY_SINGLETON_ID = 1; +const AUDIT_IDENTITY_KEY_BYTES = 32; +const AUDIT_IDENTITY_KEY_ID_BYTES = 16; +const AUDIT_IDENTITY_KEY_ID_RE = /^[a-f0-9]{32}$/u; +const AUDIT_IDENTITY_DOMAIN = "openclaw.audit.identity.v1"; +// Only a top-level (depth-0) recordAuditEvent may create the key: the caller's +// catch clears this cache on rollback, but a rolled-back outer transaction +// around a nested creation would leave a cached key that was never persisted. +const identityByDatabase = new WeakMap(); + +export type AuditIdentityKey = { + keyId: string; + key: Uint8Array; +}; + +export type AuditIdentityKind = "account" | "actor" | "conversation" | "message" | "target"; + +function registerAuditIdentityKeyForRedaction(key: Uint8Array): void { + const bytes = Buffer.from(key); + registerSecretValueForRedaction(bytes.toString("hex")); + registerSecretValueForRedaction(bytes.toString("base64url")); +} + +function parseAuditIdentityKey(row: AuditIdentityKeyRow): AuditIdentityKey { + if ( + typeof row.key_id !== "string" || + !AUDIT_IDENTITY_KEY_ID_RE.test(row.key_id) || + !(row.key instanceof Uint8Array) || + row.key.byteLength !== AUDIT_IDENTITY_KEY_BYTES + ) { + // Stable pseudonyms are an audit integrity boundary. Never silently rotate + // or fall back to an unkeyed digest when persisted key material is damaged. + throw new Error("audit identity key is corrupt"); + } + const key = Buffer.from(row.key); + registerAuditIdentityKeyForRedaction(key); + return { keyId: row.key_id, key }; +} + +/** Load the stable audit identity key or create it transactionally on first use. */ +export function loadOrCreateAuditIdentityKey(db: DatabaseSync): AuditIdentityKey { + const cached = identityByDatabase.get(db); + if (cached) { + return cached; + } + const kysely = getNodeSqliteKysely(db); + const existing = executeSqliteQueryTakeFirstSync( + db, + kysely + .selectFrom("audit_identity_keys") + .select(["key_id", "key"]) + .where("id", "=", AUDIT_IDENTITY_SINGLETON_ID), + ); + if (existing) { + const identity = parseAuditIdentityKey(existing); + identityByDatabase.set(db, identity); + return identity; + } + const retainedMessage = executeSqliteQueryTakeFirstSync( + db, + kysely.selectFrom("audit_events").select("sequence").where("kind", "=", "message").limit(1), + ); + if (retainedMessage) { + // A missing key with retained refs would split correlation on restart. + // Fail closed instead of silently rotating away from the persisted key id. + throw new Error("audit identity key is missing"); + } + + const candidate = { + id: AUDIT_IDENTITY_SINGLETON_ID, + key_id: randomBytes(AUDIT_IDENTITY_KEY_ID_BYTES).toString("hex"), + key: randomBytes(AUDIT_IDENTITY_KEY_BYTES), + created_at: Date.now(), + }; + executeSqliteQuerySync( + db, + kysely + .insertInto("audit_identity_keys") + .values(candidate) + .onConflict((conflict) => conflict.column("id").doNothing()), + ); + const stored = executeSqliteQueryTakeFirstSync( + db, + kysely + .selectFrom("audit_identity_keys") + .select(["key_id", "key"]) + .where("id", "=", AUDIT_IDENTITY_SINGLETON_ID), + ); + if (!stored) { + throw new Error("audit identity key could not be created"); + } + const identity = parseAuditIdentityKey(stored); + identityByDatabase.set(db, identity); + return identity; +} + +/** Forget transaction-local key state after a failed or rolled-back write. */ +export function clearAuditIdentityKeyCacheForDatabase(db: DatabaseSync): void { + identityByDatabase.delete(db); +} + +/** Produce a stable, domain-separated pseudonym without retaining raw identity bytes. */ +export function pseudonymizeAuditIdentity(params: { + identity: AuditIdentityKey; + kind: AuditIdentityKind; + channel: string; + accountId?: string; + conversationId?: string; + value: string | undefined; +}): string | undefined { + if (params.value === undefined || params.value.length === 0) { + return undefined; + } + const digest = createHmac("sha256", params.identity.key) + .update( + JSON.stringify([ + AUDIT_IDENTITY_DOMAIN, + params.kind, + params.channel, + params.accountId ?? null, + params.kind === "message" ? (params.conversationId ?? null) : null, + params.value, + ]), + "utf8", + ) + .digest("hex"); + return `hmac-sha256:v1:${params.identity.keyId}:${digest}`; +} diff --git a/src/audit/audit-recorder.test.ts b/src/audit/audit-recorder.test.ts new file mode 100644 index 000000000000..0a82a9490757 --- /dev/null +++ b/src/audit/audit-recorder.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { AuditEventInput } from "./audit-event-types.js"; +import type { AuditEventWriter } from "./audit-event-writer.js"; +import { createAuditEventRecorder } from "./audit-recorder.js"; +import { + emitTrustedMessageAuditEvent, + onTrustedMessageAuditEvent, + resetMessageAuditEventsForTest, +} from "./message-audit-events.js"; + +function captureWriter(inputs: AuditEventInput[]): AuditEventWriter { + return { + ready: Promise.resolve(), + record: (input) => { + inputs.push(input); + return true; + }, + stop: async () => {}, + }; +} + +function emitMessage(conversationKind: "direct" | "group") { + emitTrustedMessageAuditEvent({ + occurredAt: 1, + kind: "message", + action: "message.inbound.processed", + status: "succeeded", + actorType: "channel_sender", + actorId: "sender-raw", + direction: "inbound", + channel: "telegram", + conversationKind, + outcome: "completed", + }); +} + +afterEach(() => { + resetMessageAuditEventsForTest(); +}); + +describe("message audit recorder", () => { + it("keeps message events off by default policy", async () => { + const inputs: AuditEventInput[] = []; + const recorder = createAuditEventRecorder({ + messageMode: "off", + writer: captureWriter(inputs), + }); + const unsubscribe = onTrustedMessageAuditEvent(recorder.recordMessage); + + emitMessage("direct"); + + unsubscribe(); + await recorder.stop(); + expect(inputs).toEqual([]); + }); + + it("records only known direct conversations in direct mode", async () => { + const inputs: AuditEventInput[] = []; + const recorder = createAuditEventRecorder({ + messageMode: "direct", + writer: captureWriter(inputs), + }); + const unsubscribe = onTrustedMessageAuditEvent(recorder.recordMessage); + + emitMessage("group"); + emitMessage("direct"); + + unsubscribe(); + await recorder.stop(); + expect(inputs).toHaveLength(1); + expect(inputs[0]).toMatchObject({ + kind: "message", + conversationKind: "direct", + sourceId: expect.stringMatching(/^message:/u), + sourceSequence: 1, + }); + }); + + it("records group metadata only in all mode", async () => { + const inputs: AuditEventInput[] = []; + const recorder = createAuditEventRecorder({ + messageMode: "all", + writer: captureWriter(inputs), + }); + const unsubscribe = onTrustedMessageAuditEvent(recorder.recordMessage); + + emitMessage("group"); + + unsubscribe(); + await recorder.stop(); + expect(inputs[0]).toMatchObject({ kind: "message", conversationKind: "group" }); + }); +}); diff --git a/src/audit/audit-recorder.ts b/src/audit/audit-recorder.ts new file mode 100644 index 000000000000..abc06f451572 --- /dev/null +++ b/src/audit/audit-recorder.ts @@ -0,0 +1,65 @@ +/** Gateway-owned recorder joining trusted run, tool, and message lifecycle streams. */ +import { randomUUID } from "node:crypto"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + createAgentEventAuditRecorder, + type AgentEventAuditRecorder, +} from "./agent-event-audit.js"; +import type { AuditMessageMode } from "./audit-config.js"; +import { createAuditEventWriter, type AuditEventWriter } from "./audit-event-writer.js"; +import type { TrustedMessageAuditEvent } from "./message-audit-events.js"; + +const log = createSubsystemLogger("audit/events"); +let persistenceFailureWarned = false; + +export type AuditEventRecorder = AgentEventAuditRecorder & { + recordMessage: (event: TrustedMessageAuditEvent) => void; +}; + +export function createAuditEventRecorder(options: { + messageMode: AuditMessageMode; + writer?: AuditEventWriter; + stateDir?: string; + terminalSettleMs?: number; +}): AuditEventRecorder { + let nextAcceptedMessageSequence = 0; + const writer = + options.writer ?? + createAuditEventWriter({ + ...(options.stateDir ? { stateDir: options.stateDir } : {}), + onError: (error) => { + if (!persistenceFailureWarned) { + persistenceFailureWarned = true; + log.warn(`audit event persistence failed: ${error}`); + } + }, + }); + const agentRecorder = createAgentEventAuditRecorder({ + writer, + ...(options.terminalSettleMs !== undefined + ? { terminalSettleMs: options.terminalSettleMs } + : {}), + }); + + return { + ...agentRecorder, + recordMessage: (event) => { + if (options.messageMode === "off") { + return; + } + if (options.messageMode === "direct" && event.conversationKind !== "direct") { + return; + } + nextAcceptedMessageSequence += 1; + writer.record({ + ...event, + sourceId: event.sourceId?.trim() || `message:${randomUUID()}`, + sourceSequence: nextAcceptedMessageSequence, + }); + }, + }; +} + +export function resetAuditEventRecorderForTest(): void { + persistenceFailureWarned = false; +} diff --git a/src/audit/message-audit-events.test.ts b/src/audit/message-audit-events.test.ts new file mode 100644 index 000000000000..51df85d09a83 --- /dev/null +++ b/src/audit/message-audit-events.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + emitTrustedMessageAuditEvent, + hasTrustedMessageAuditListeners, + onTrustedMessageAuditEvent, + resetMessageAuditEventsForTest, +} from "./message-audit-events.js"; + +const event = { + occurredAt: 1, + kind: "message", + action: "message.inbound.processed", + status: "succeeded", + actorType: "system", + actorId: "gateway", + direction: "inbound", + channel: "test", + conversationKind: "direct", + outcome: "completed", +} as const; + +describe("trusted message audit events", () => { + afterEach(() => { + resetMessageAuditEventsForTest(); + }); + + it("isolates a throwing listener and continues notifying later listeners", () => { + const laterListener = vi.fn(); + onTrustedMessageAuditEvent(() => { + throw new Error("listener failed"); + }); + onTrustedMessageAuditEvent(laterListener); + + expect(() => emitTrustedMessageAuditEvent(event)).not.toThrow(); + expect(laterListener).toHaveBeenCalledOnce(); + }); + + it("tracks listeners and forwards producer metadata without durable identity work", () => { + const listener = vi.fn(); + const unsubscribe = onTrustedMessageAuditEvent(listener); + + expect(hasTrustedMessageAuditListeners()).toBe(true); + emitTrustedMessageAuditEvent({ ...event, sourceId: " message:stable " }); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + sourceId: " message:stable ", + }), + ); + expect(listener.mock.calls[0]?.[0]).not.toHaveProperty("sourceSequence"); + + unsubscribe(); + expect(hasTrustedMessageAuditListeners()).toBe(false); + }); + + it("reset clears listeners", () => { + const listener = vi.fn(); + onTrustedMessageAuditEvent(listener); + emitTrustedMessageAuditEvent(event); + resetMessageAuditEventsForTest(); + + expect(hasTrustedMessageAuditListeners()).toBe(false); + onTrustedMessageAuditEvent(listener); + emitTrustedMessageAuditEvent(event); + expect(listener).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/audit/message-audit-events.ts b/src/audit/message-audit-events.ts new file mode 100644 index 000000000000..3996e40805db --- /dev/null +++ b/src/audit/message-audit-events.ts @@ -0,0 +1,37 @@ +/** Trusted in-process message lifecycle stream for durable audit projection. */ +import { notifyListeners, registerListener } from "../shared/listeners.js"; +import type { MessageAuditEventInput } from "./audit-event-types.js"; + +type TrustedMessageAuditEventVariant = Event extends MessageAuditEventInput + ? Omit & { + /** Optional stable producer identity for replay-safe durable projections. */ + sourceId?: string; + } + : never; + +export type TrustedMessageAuditEvent = TrustedMessageAuditEventVariant; + +type MessageAuditListener = (event: TrustedMessageAuditEvent) => void; + +const listeners = new Set(); + +/** Emit only closed metadata. This stream is intentionally not part of the plugin SDK. */ +export function emitTrustedMessageAuditEvent(event: TrustedMessageAuditEvent): void { + if (listeners.size === 0) { + return; + } + notifyListeners(listeners, event); +} + +export function onTrustedMessageAuditEvent(listener: MessageAuditListener): () => void { + return registerListener(listeners, listener); +} + +/** Lets hot producers skip attribution work while message audit is disabled. */ +export function hasTrustedMessageAuditListeners(): boolean { + return listeners.size > 0; +} + +export function resetMessageAuditEventsForTest(): void { + listeners.clear(); +} diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 21f68184b4f3..cf4da2952f23 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -88,6 +88,10 @@ const diagnosticMocks = vi.hoisted(() => ({ }), ), })); +const messageAuditMocks = vi.hoisted(() => ({ + enabled: true, + emitTrustedMessageAuditEvent: vi.fn<(event: unknown) => void>(), +})); const hookMocks = vi.hoisted(() => ({ registry: { plugins: [] as Array<{ @@ -475,6 +479,10 @@ vi.mock("../../logging/diagnostic.js", () => ({ config?.diagnostics?.stuckSessionAbortMs ?? Math.max(300_000, stuckSessionWarnMs * 3), ), })); +vi.mock("../../audit/message-audit-events.js", () => ({ + emitTrustedMessageAuditEvent: messageAuditMocks.emitTrustedMessageAuditEvent, + hasTrustedMessageAuditListeners: () => messageAuditMocks.enabled, +})); vi.mock("../../config/sessions/thread-info.js", () => ({ parseSessionThreadInfo: (sessionKey: string | undefined) => threadInfoMocks.parseSessionThreadInfo(sessionKey), @@ -990,6 +998,12 @@ async function dispatchTwiceWithFreshDispatchers(params: Omit> { + return messageAuditMocks.emitTrustedMessageAuditEvent.mock.calls.map(([event]) => + event && typeof event === "object" ? (event as Record) : {}, + ); +} + describe("dispatchReplyFromConfig", () => { beforeEach(() => { clearAgentHarnesses(); @@ -1143,6 +1157,8 @@ describe("dispatchReplyFromConfig", () => { agentEventMocks.emitAgentAuditEvent.mockReset(); agentEventMocks.onAgentEvent.mockReset(); agentEventMocks.onAgentEvent.mockReturnValue(() => {}); + messageAuditMocks.enabled = true; + messageAuditMocks.emitTrustedMessageAuditEvent.mockReset(); sessionBindingMocks.listBySession.mockReset(); sessionBindingMocks.listBySession.mockReturnValue([]); pluginConversationBindingMocks.shownFallbackNoticeBindingIds.clear(); @@ -1302,6 +1318,14 @@ describe("dispatchReplyFromConfig", () => { counts: { tool: 0, block: 0, final: 0 }, }); expect(replyResolver).not.toHaveBeenCalled(); + expect(messageAuditMocks.emitTrustedMessageAuditEvent).toHaveBeenCalledOnce(); + expect(messageAuditEvents()[0]).toEqual( + expect.objectContaining({ + status: "blocked", + outcome: "skipped", + reasonCode: "reply_operation_aborted", + }), + ); activeOperation.complete(); }); @@ -1345,6 +1369,14 @@ describe("dispatchReplyFromConfig", () => { }); expect(replyResolver).not.toHaveBeenCalled(); expect(replyRunRegistry.get(sessionKey)).toBe(activeOperation); + expect(messageAuditMocks.emitTrustedMessageAuditEvent).toHaveBeenCalledOnce(); + expect(messageAuditEvents()[0]).toEqual( + expect.objectContaining({ + status: "blocked", + outcome: "skipped", + reasonCode: "reply_operation_active", + }), + ); activeOperation.complete(); }); @@ -6083,6 +6115,89 @@ describe("dispatchReplyFromConfig", () => { expect(lifecycleEvent?.stream).toBe("lifecycle"); expect(lifecycleEvent?.data?.phase).toBe("error"); expect(String(lifecycleEvent?.data?.error)).toContain("ACP exploded"); + expect(messageAuditMocks.emitTrustedMessageAuditEvent).toHaveBeenCalledOnce(); + expect(messageAuditEvents()[0]).toEqual( + expect.objectContaining({ + status: "failed", + outcome: "failed", + errorCode: "message_processing_failed", + reasonCode: "acp_dispatch_failed", + }), + ); + expect(JSON.stringify(messageAuditEvents()[0])).not.toContain("ACP exploded"); + }); + + it("audits aborted ACP turns as skipped", async () => { + setNoAbort(); + const abortController = new AbortController(); + const runtime = createAcpRuntime([]); + runtime.runTurn.mockImplementation(async function* () { + abortController.abort(); + yield { type: "done" }; + }); + acpMocks.readAcpSessionEntry.mockReturnValue({ + sessionKey: "agent:codex-acp:session-1", + storeSessionKey: "agent:codex-acp:session-1", + cfg: {}, + storePath: "/tmp/mock-sessions.json", + entry: {}, + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime:1", + mode: "persistent", + state: "idle", + lastActivityAt: Date.now(), + }, + }); + acpMocks.requireAcpRuntimeBackend.mockReturnValue({ + id: "acpx", + runtime, + }); + hookMocks.runner.runReplyDispatch.mockImplementationOnce(async (event, contextUnknown) => { + const context = contextUnknown as Record; + return ( + (await tryDispatchAcpReplyHook( + event as never, + { + ...context, + abortSignal: abortController.signal, + } as never, + )) ?? undefined + ); + }); + + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "discord", + Surface: "discord", + SessionKey: "agent:codex-acp:session-1", + BodyForAgent: "stop this turn", + }), + cfg: { + diagnostics: { enabled: true }, + acp: { + enabled: true, + dispatch: { enabled: true }, + stream: { coalesceIdleMs: 0, maxChunkChars: 128 }, + }, + } as OpenClawConfig, + dispatcher: createDispatcher(), + }); + + expect(messageAuditMocks.emitTrustedMessageAuditEvent).toHaveBeenCalledOnce(); + expect(messageAuditEvents()[0]).toEqual( + expect.objectContaining({ + status: "blocked", + outcome: "skipped", + reasonCode: "acp_dispatch_aborted", + }), + ); + expect(messageAuditEvents()[0]).not.toHaveProperty("errorCode"); + const diagnosticEvent = diagnosticMocks.logMessageProcessed.mock.calls + .map(([event]) => event as { outcome?: unknown; reason?: unknown }) + .find((event) => event.reason === "acp_aborted"); + expect(diagnosticEvent?.outcome).toBe("completed"); }); it("posts a one-time resolved-session-id notice in thread after the first ACP turn", async () => { @@ -7303,6 +7418,237 @@ describe("dispatchReplyFromConfig", () => { ); }); + it("audits completed inbound message processing", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + vi.mocked(dispatcher.getQueuedCounts).mockReturnValue({ tool: 1, block: 0, final: 1 }); + const ctx = buildTestCtx({ + Provider: "slack", + Surface: " ", + AccountId: "acc-1", + NativeChannelId: " ", + OriginatingTo: "C123", + SenderId: "U123", + SessionKey: "agent:main:slack:direct:C123", + MessageSid: "msg-audit-1", + MessageSidFull: " ", + ChatType: "dm", + }); + + await dispatchReplyFromConfig({ + ctx, + cfg: emptyConfig, + dispatcher, + replyOptions: { runId: "run-audit-1" }, + replyResolver: async () => ({ text: "hi" }) satisfies ReplyPayload, + }); + + expect(messageAuditMocks.emitTrustedMessageAuditEvent).toHaveBeenCalledTimes(1); + expect(messageAuditEvents()[0]).toEqual( + expect.objectContaining({ + occurredAt: expect.any(Number), + kind: "message", + action: "message.inbound.processed", + status: "succeeded", + actorType: "channel_sender", + actorId: "U123", + agentId: "main", + runId: "run-audit-1", + direction: "inbound", + channel: "slack", + conversationKind: "direct", + outcome: "completed", + durationMs: expect.any(Number), + resultCount: 2, + accountId: "acc-1", + conversationId: "C123", + messageId: "msg-audit-1", + }), + ); + }); + + it("uses finalized reply-dispatch counts for the inbound terminal", async () => { + setNoAbort(); + hookMocks.runner.runReplyDispatch.mockImplementationOnce(async (_event, contextUnknown) => { + const context = contextUnknown as { + recordProcessed: (outcome: "completed", options: { reason: string }) => void; + }; + context.recordProcessed("completed", { reason: "acp_dispatch" }); + return { + handled: true, + queuedFinal: true, + counts: { tool: 2, block: 3, final: 4 }, + }; + }); + const dispatcher = createDispatcher(); + + const result = await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "discord", + Surface: "discord", + MessageSid: "msg-audit-routed-counts", + }), + cfg: emptyConfig, + dispatcher, + }); + + expect(result.counts).toEqual({ tool: 2, block: 3, final: 4 }); + expect(messageAuditEvents()[0]).toEqual( + expect.objectContaining({ + reasonCode: "acp_dispatch_completed", + resultCount: 9, + }), + ); + }); + + it("correlates inbound processing with the generated agent run", async () => { + setNoAbort(); + + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ Provider: "slack", Surface: "slack" }), + cfg: emptyConfig, + dispatcher: createDispatcher(), + replyResolver: async (_ctx, options) => { + options?.onAgentRunStart?.("generated-run-audit-1"); + return { text: "hi" } satisfies ReplyPayload; + }, + }); + + expect(messageAuditEvents()[0]).toEqual( + expect.objectContaining({ runId: "generated-run-audit-1" }), + ); + }); + + it("audits setup failures without replacing the dispatch error", async () => { + setNoAbort(); + runtimePluginMocks.ensureRuntimePluginsLoaded.mockImplementationOnce(() => { + throw new Error("setup failed"); + }); + + await expect( + dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "slack", + Surface: "slack", + MessageSid: "msg-audit-setup-failure", + }), + cfg: emptyConfig, + dispatcher: createDispatcher(), + }), + ).rejects.toThrow("setup failed"); + + expect(messageAuditMocks.emitTrustedMessageAuditEvent).toHaveBeenCalledOnce(); + expect(messageAuditEvents()[0]).toEqual( + expect.objectContaining({ + status: "failed", + outcome: "failed", + errorCode: "message_processing_failed", + resultCount: 0, + }), + ); + expect(JSON.stringify(messageAuditEvents()[0])).not.toContain("setup failed"); + }); + + it("skips inbound audit attribution work when no listener is registered", async () => { + setNoAbort(); + messageAuditMocks.enabled = false; + + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ Provider: "slack", Surface: "slack" }), + cfg: emptyConfig, + dispatcher: createDispatcher(), + replyResolver: async () => ({ text: "hi" }) satisfies ReplyPayload, + }); + + expect(messageAuditMocks.emitTrustedMessageAuditEvent).not.toHaveBeenCalled(); + }); + + it("does not let an audit emission failure reject successful dispatch", async () => { + setNoAbort(); + messageAuditMocks.emitTrustedMessageAuditEvent.mockImplementationOnce(() => { + throw new Error("audit unavailable"); + }); + + await expect( + dispatchReplyFromConfig({ + ctx: buildTestCtx({ Provider: "slack", Surface: "slack" }), + cfg: emptyConfig, + dispatcher: createDispatcher(), + replyResolver: async () => ({ text: "hi" }) satisfies ReplyPayload, + }), + ).resolves.toMatchObject({ counts: expect.any(Object) }); + }); + + it("does not include message content or session identifiers in inbound audit events", async () => { + setNoAbort(); + const privateBody = "private inbound body 7ca58b"; + const privateSessionKey = "agent:private:slack:direct:C999"; + const privateSenderName = "Private Sender Name 7ca58b"; + const privateSenderUsername = "private-sender-7ca58b"; + + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "slack", + Surface: "slack", + AccountId: "acc-private", + NativeChannelId: "C999", + SessionKey: privateSessionKey, + MessageSid: "msg-audit-private", + Body: privateBody, + BodyForAgent: privateBody, + CommandBody: privateBody, + RawBody: privateBody, + SenderName: privateSenderName, + SenderUsername: privateSenderUsername, + }), + cfg: emptyConfig, + dispatcher: createDispatcher(), + replyResolver: async () => ({ text: "hi" }) satisfies ReplyPayload, + }); + + expect(messageAuditMocks.emitTrustedMessageAuditEvent).toHaveBeenCalledTimes(1); + const event = messageAuditEvents()[0]; + const serializedEvent = JSON.stringify(event); + expect(event).toEqual( + expect.objectContaining({ + action: "message.inbound.processed", + actorType: "system", + actorId: "gateway", + }), + ); + expect(event).not.toHaveProperty("body"); + expect(event).not.toHaveProperty("sessionKey"); + expect(event).not.toHaveProperty("error"); + expect(serializedEvent).not.toContain(privateBody); + expect(serializedEvent).not.toContain(privateSessionKey); + expect(serializedEvent).not.toContain(privateSenderName); + expect(serializedEvent).not.toContain(privateSenderUsername); + }); + + it("records the routing channel id ahead of surface and provider", async () => { + setNoAbort(); + // SDK plugin channels may set only OriginatingChannel, and it is the id + // outbound rows record; it must win over Surface/Provider variants. + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + OriginatingChannel: "clickclack", + AccountId: "acc-plugin", + MessageSid: "msg-audit-plugin-channel", + }), + cfg: emptyConfig, + dispatcher: createDispatcher(), + replyResolver: async () => ({ text: "hi" }) satisfies ReplyPayload, + }); + + expect(messageAuditMocks.emitTrustedMessageAuditEvent).toHaveBeenCalledTimes(1); + expect(messageAuditEvents()[0]).toEqual( + expect.objectContaining({ + action: "message.inbound.processed", + channel: "clickclack", + }), + ); + }); + it("emits diagnostics when enabled", async () => { setNoAbort(); const cfg = { diagnostics: { enabled: true } } as OpenClawConfig; @@ -8056,6 +8402,15 @@ describe("dispatchReplyFromConfig", () => { expect(replyRunRegistry.isActive(sessionKey)).toBe(false); expect(mutationRan).toBe(true); expect(replyResolver).not.toHaveBeenCalled(); + expect(messageAuditMocks.emitTrustedMessageAuditEvent).toHaveBeenCalledOnce(); + expect(messageAuditEvents()[0]).toEqual( + expect.objectContaining({ + status: "blocked", + outcome: "skipped", + reasonCode: "reply_operation_aborted", + }), + ); + expect(messageAuditEvents()[0]).not.toHaveProperty("errorCode"); externalLifecycleRequest.emitDestroy(); }); @@ -9083,7 +9438,7 @@ describe("dispatchReplyFromConfig", () => { RawBody: "hello", Body: "hello", }), - cfg: emptyConfig, + cfg: { diagnostics: { enabled: true } } as OpenClawConfig, dispatcher, replyResolver, }); @@ -9094,6 +9449,21 @@ describe("dispatchReplyFromConfig", () => { expect(finalNotice?.text).not.toContain("boom"); expect(replyResolver).not.toHaveBeenCalled(); expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + expect(messageAuditMocks.emitTrustedMessageAuditEvent).toHaveBeenCalledOnce(); + expect(messageAuditEvents()[0]).toEqual( + expect.objectContaining({ + status: "failed", + outcome: "failed", + errorCode: "message_processing_failed", + reasonCode: "plugin_bound_error", + }), + ); + expect(messageAuditEvents()[0]).not.toHaveProperty("error"); + expect(JSON.stringify(messageAuditEvents()[0])).not.toContain("boom"); + const diagnosticEvent = diagnosticMocks.logMessageProcessed.mock.calls + .map(([event]) => event as { outcome?: unknown; reason?: unknown }) + .find((event) => event.reason === "plugin-bound-error"); + expect(diagnosticEvent?.outcome).toBe("completed"); }); it("marks diagnostics skipped for duplicate inbound messages", async () => { @@ -9120,6 +9490,21 @@ describe("dispatchReplyFromConfig", () => { .find((event) => event.outcome === "skipped"); expect(skippedEvent?.channel).toBe("whatsapp"); expect(skippedEvent?.reason).toBe("duplicate"); + expect(messageAuditMocks.emitTrustedMessageAuditEvent).toHaveBeenCalledTimes(2); + const skippedAuditEvent = messageAuditEvents().find((event) => event.outcome === "skipped"); + expect(skippedAuditEvent).toEqual( + expect.objectContaining({ + action: "message.inbound.processed", + status: "blocked", + actorType: "system", + actorId: "gateway", + direction: "inbound", + channel: "whatsapp", + outcome: "skipped", + reasonCode: "duplicate", + }), + ); + expect(skippedAuditEvent).not.toHaveProperty("reason"); }); it("keeps duplicate skip diagnostics inside the active inbound trace", async () => { @@ -9214,6 +9599,20 @@ describe("dispatchReplyFromConfig", () => { .find((event) => event.outcome === "error"); expect(errorEvent?.channel).toBe("whatsapp"); expect(errorEvent?.error).toBe("Error: dispatch failed"); + expect(messageAuditMocks.emitTrustedMessageAuditEvent).toHaveBeenCalledTimes(2); + const failedAuditEvent = messageAuditEvents().find((event) => event.outcome === "failed"); + expect(failedAuditEvent).toEqual( + expect.objectContaining({ + action: "message.inbound.processed", + status: "failed", + direction: "inbound", + channel: "whatsapp", + outcome: "failed", + errorCode: "message_processing_failed", + }), + ); + expect(failedAuditEvent).not.toHaveProperty("error"); + expect(JSON.stringify(failedAuditEvent)).not.toContain("dispatch failed"); }); it("poisons inbound dedupe when dispatch fails after a block reply", async () => { diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 3f14527a240b..328fe42a21e1 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -38,6 +38,15 @@ import { } from "../../agents/subagent-capabilities.js"; import { isToolAllowedByPolicies } from "../../agents/tool-policy-match.js"; import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "../../agents/tool-policy.js"; +import type { + AuditInboundMessageCompletedReasonCode, + AuditInboundMessageSkippedReasonCode, + InboundMessageAuditTerminal, +} from "../../audit/audit-event-types.js"; +import { + emitTrustedMessageAuditEvent, + hasTrustedMessageAuditListeners, +} from "../../audit/message-audit-events.js"; import { resolveConversationBindingRecord, touchConversationBindingRecord, @@ -545,6 +554,227 @@ function createReplyDispatchEvent( }) as PluginHookReplyDispatchEvent; } +type DispatchProcessedOutcome = "completed" | "skipped" | "error"; +type DispatchProcessedOptions = { + reason?: string; + error?: string; +}; + +function resolveCompletedInboundAuditReason( + reason: string | undefined, +): AuditInboundMessageCompletedReasonCode | undefined { + switch (reason) { + case "fast_abort": + return "fast_abort"; + case "plugin-bound-handled": + return "plugin_bound_handled"; + case "plugin-bound-fallback-missing-plugin": + case "plugin-bound-fallback-no-handler": + return "plugin_bound_unavailable"; + case "plugin-bound-declined": + return "plugin_bound_declined"; + case "before_dispatch_handled": + return "before_dispatch_handled"; + case "acp_dispatch": + return "acp_dispatch_completed"; + case "acp_empty_prompt": + return "acp_dispatch_empty"; + default: + return undefined; + } +} + +function resolveSkippedInboundAuditReason( + reason: string | undefined, +): AuditInboundMessageSkippedReasonCode | undefined { + switch (reason) { + case "duplicate": + return "duplicate"; + case "reply-operation-active": + return "reply_operation_active"; + case "reply_operation_aborted": + return "reply_operation_aborted"; + default: + return undefined; + } +} + +function resolveInboundMessageAuditTerminal( + outcome: DispatchProcessedOutcome, + reason: string | undefined, +): InboundMessageAuditTerminal { + // Diagnostics keep their legacy outcomes and reason strings; audit projects + // those signals into the stricter terminal contract independently. + if (reason === "plugin-bound-error") { + return { + status: "failed", + outcome: "failed", + errorCode: "message_processing_failed", + reasonCode: "plugin_bound_error", + }; + } + if (reason?.startsWith("acp_error:")) { + return { + status: "failed", + outcome: "failed", + errorCode: "message_processing_failed", + reasonCode: "acp_dispatch_failed", + }; + } + if (reason === "reply_operation_aborted") { + return { + status: "blocked", + outcome: "skipped", + reasonCode: "reply_operation_aborted", + }; + } + if (reason === "acp_aborted") { + return { + status: "blocked", + outcome: "skipped", + reasonCode: "acp_dispatch_aborted", + }; + } + if (outcome === "completed") { + const reasonCode = resolveCompletedInboundAuditReason(reason); + return { + status: "succeeded", + outcome: "completed", + ...(reasonCode ? { reasonCode } : {}), + }; + } + if (outcome === "skipped") { + const reasonCode = resolveSkippedInboundAuditReason(reason); + return { + status: "blocked", + outcome: "skipped", + ...(reasonCode ? { reasonCode } : {}), + }; + } + return { + status: "failed", + outcome: "failed", + errorCode: "message_processing_failed", + }; +} + +type InboundMessageAuditTerminalRecorder = { + note: (outcome: DispatchProcessedOutcome, options?: DispatchProcessedOptions) => void; + observeRunId: (runId: string) => void; + finishSuccess: (result: DispatchFromConfigResult) => void; + finishError: () => void; +}; + +/** + * Captures one terminal event for the reply-processing boundary. Channel admission and + * pre-dispatch drops remain outside this boundary and need their own ingress projection. + */ +function createInboundMessageAuditTerminal( + params: DispatchFromConfigParams, +): InboundMessageAuditTerminalRecorder | undefined { + if (!hasTrustedMessageAuditListeners()) { + return undefined; + } + + const startedAt = Date.now(); + let notedTerminal: + | { outcome: DispatchProcessedOutcome; options?: DispatchProcessedOptions } + | undefined; + let observedRunId = normalizeOptionalString(params.replyOptions?.runId); + let finished = false; + + const emitTerminal = ( + terminal: { outcome: DispatchProcessedOutcome; options?: DispatchProcessedOptions }, + counts: Record, + ) => { + if (finished) { + return; + } + finished = true; + const { ctx, cfg } = params; + const occurredAt = Date.now(); + const sessionKey = + normalizeOptionalString(ctx.SessionKey) ?? + normalizeOptionalString(ctx.CommandTargetSessionKey); + const actorId = normalizeOptionalString(ctx.SenderId); + const accountId = normalizeOptionalString(ctx.AccountId); + const conversationId = + normalizeOptionalString(ctx.NativeChannelId) ?? + normalizeOptionalString(ctx.OriginatingTo) ?? + normalizeOptionalString(ctx.To) ?? + normalizeOptionalString(ctx.From); + const messageId = + normalizeOptionalString(ctx.MessageSidFull) ?? + normalizeOptionalString(ctx.MessageSid) ?? + normalizeOptionalString(ctx.MessageSidFirst) ?? + normalizeOptionalString(ctx.MessageSidLast); + const terminalFields = resolveInboundMessageAuditTerminal( + terminal.outcome, + terminal.options?.reason, + ); + let agentId = normalizeOptionalString(ctx.AgentId); + try { + agentId = resolveSessionAgentId({ + sessionKey, + config: cfg, + agentId: ctx.AgentId, + }); + } catch { + // Malformed setup must still produce a content-free terminal with available attribution. + } + try { + emitTrustedMessageAuditEvent({ + occurredAt, + kind: "message", + action: "message.inbound.processed", + ...terminalFields, + actorType: actorId ? "channel_sender" : "system", + actorId: actorId ?? "gateway", + ...(agentId ? { agentId } : {}), + ...(observedRunId ? { runId: observedRunId } : {}), + direction: "inbound", + // OriginatingChannel is the canonical routing channel id and matches + // outbound rows' channel; Surface/Provider can be UI-surface variants + // and plugin channels may set only OriginatingChannel. + channel: + normalizeLowercaseStringOrEmpty(ctx.OriginatingChannel) || + normalizeLowercaseStringOrEmpty(ctx.Surface) || + normalizeLowercaseStringOrEmpty(ctx.Provider) || + "unknown", + conversationKind: normalizeChatType(ctx.ChatType) ?? "unknown", + durationMs: Math.max(0, occurredAt - startedAt), + resultCount: counts.tool + counts.block + counts.final, + ...(accountId ? { accountId } : {}), + ...(conversationId ? { conversationId } : {}), + ...(messageId ? { messageId } : {}), + }); + } catch { + // Optional audit observers must never alter message dispatch semantics. + } + }; + + return { + note(outcome, options) { + notedTerminal = { outcome, ...(options ? { options } : {}) }; + }, + observeRunId(runId) { + observedRunId = normalizeOptionalString(runId) ?? observedRunId; + }, + finishSuccess(result) { + emitTerminal(notedTerminal ?? { outcome: "completed" }, result.counts); + }, + finishError() { + let counts: Record = { tool: 0, block: 0, final: 0 }; + try { + counts = params.dispatcher.getQueuedCounts(); + } catch { + // Preserve the original dispatch error if the dispatcher is also unhealthy. + } + emitTerminal({ outcome: "error" }, counts); + }, + }; +} + /** Test-only hooks for overriding selected dispatch dependencies. */ export const testing = { createReplyDispatchEvent, @@ -1186,9 +1416,25 @@ export type { /** Dispatches a reply from config, context, command handling, agent run, and delivery policy. */ export async function dispatchReplyFromConfig( params: DispatchFromConfigParams, +): Promise { + const messageAuditTerminal = createInboundMessageAuditTerminal(params); + try { + const result = await dispatchReplyFromConfigInner(params, messageAuditTerminal); + messageAuditTerminal?.finishSuccess(result); + return result; + } catch (error) { + messageAuditTerminal?.finishError(); + throw error; + } +} + +async function dispatchReplyFromConfigInner( + params: DispatchFromConfigParams, + messageAuditTerminal: InboundMessageAuditTerminalRecorder | undefined, ): Promise { const { ctx, cfg, dispatcher } = params; if (params.replyOptions?.abortSignal?.aborted) { + messageAuditTerminal?.note("skipped", { reason: "reply_operation_aborted" }); return { queuedFinal: false, counts: dispatcher.getQueuedCounts(), @@ -1197,7 +1443,8 @@ export async function dispatchReplyFromConfig( const diagnosticsEnabled = isDiagnosticsEnabled(cfg); const channel = normalizeLowercaseStringOrEmpty(ctx.Surface ?? ctx.Provider ?? "unknown"); const chatId = ctx.To ?? ctx.From; - const messageId = ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast; + const messageId = + ctx.MessageSidFull ?? ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast; const sessionKey = normalizeOptionalString(ctx.SessionKey) ?? normalizeOptionalString(ctx.CommandTargetSessionKey); const startTime = diagnosticsEnabled ? Date.now() : 0; @@ -1245,13 +1492,8 @@ export async function dispatchReplyFromConfig( ); let agentDispatchStartedAt = 0; - const recordProcessed = ( - outcome: "completed" | "skipped" | "error", - opts?: { - reason?: string; - error?: string; - }, - ) => { + const recordProcessed = (outcome: DispatchProcessedOutcome, opts?: DispatchProcessedOptions) => { + messageAuditTerminal?.note(outcome, opts); if (diagnosticsEnabled) { replyHotPathTiming.logIfSlow({ channel, @@ -1342,6 +1584,7 @@ export async function dispatchReplyFromConfig( dispatchOperationSessionKey && initialDispatchReplyOperation ) { + messageAuditTerminal?.note("skipped", { reason: "reply-operation-active" }); return { queuedFinal: false, counts: dispatcher.getQueuedCounts(), @@ -1741,13 +1984,24 @@ export async function dispatchReplyFromConfig( }; const getReplyOptions = () => { const abortSignal = getDispatchAbortSignal(); - if (!abortSignal) { + const onAgentRunStart = messageAuditTerminal + ? (runId: string) => { + messageAuditTerminal.observeRunId(runId); + params.replyOptions?.onAgentRunStart?.(runId); + } + : undefined; + if (!abortSignal && !onAgentRunStart) { return params.replyOptions; } return { ...params.replyOptions, - abortSignal, - queuedFollowupAbortSignal: getQueuedFollowupAbortSignal(), + ...(abortSignal + ? { + abortSignal, + queuedFollowupAbortSignal: getQueuedFollowupAbortSignal(), + } + : {}), + ...(onAgentRunStart ? { onAgentRunStart } : {}), ...(dispatchReplyOperation ? { replyOperation: dispatchReplyOperation } : {}), }; }; diff --git a/src/channels/turn/kernel.test.ts b/src/channels/turn/kernel.test.ts index 11d148829e9a..3de63b6ee114 100644 --- a/src/channels/turn/kernel.test.ts +++ b/src/channels/turn/kernel.test.ts @@ -266,6 +266,7 @@ describe("channel turn kernel", () => { requesterAccountId: "acct", requesterSenderId: "sender-1", conversationType: "group", + conversationKind: "group", }); expect(resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledTimes(1); const supportRequest = latestDurableSupportRequest(); diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts index 38e44ec1d202..e9832ff7ee66 100644 --- a/src/cli/program/core-command-descriptors.ts +++ b/src/cli/program/core-command-descriptors.ts @@ -100,7 +100,7 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([ }, { name: "audit", - description: "Inspect metadata-only agent run and tool action records", + description: "Inspect metadata-only run, tool, and message lifecycle records", hasSubcommands: false, }, { diff --git a/src/cli/program/register.audit.ts b/src/cli/program/register.audit.ts index 1f8601904fa4..9ca84cdb6326 100644 --- a/src/cli/program/register.audit.ts +++ b/src/cli/program/register.audit.ts @@ -1,4 +1,4 @@ -// Audit command registration for privacy-preserving run/tool history. +// Audit command registration for privacy-preserving activity history. import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; @@ -10,15 +10,17 @@ import { runCommandWithRuntime } from "../cli-utils.js"; export function registerAuditCommand(program: Command): void { program .command("audit") - .description("Inspect metadata-only agent run and tool action records") + .description("Inspect metadata-only run, tool, and message lifecycle records") .option("--agent ", "Filter by agent id") .option("--session ", "Filter by exact session key") .option("--run ", "Filter by run id") - .option("--kind ", "Filter by kind (agent_run or tool_action)") + .option("--kind ", "Filter by kind (agent_run, tool_action, or message)") .option( "--status ", "Filter by status (started, succeeded, failed, cancelled, timed_out, blocked, unknown)", ) + .option("--direction ", "Filter message direction (inbound or outbound)") + .option("--channel ", "Filter message channel") .option("--after ", "Include records at/after ISO time or Unix milliseconds") .option("--before ", "Include records at/before ISO time or Unix milliseconds") .option("--cursor ", "Continue from a previous result cursor") @@ -38,6 +40,8 @@ export function registerAuditCommand(program: Command): void { runId: opts.run as string | undefined, kind: opts.kind as AuditListCommandOptions["kind"], status: opts.status as AuditListCommandOptions["status"], + direction: opts.direction as AuditListCommandOptions["direction"], + channel: opts.channel as string | undefined, after: opts.after as string | undefined, before: opts.before as string | undefined, cursor: opts.cursor as string | undefined, diff --git a/src/commands/audit.test.ts b/src/commands/audit.test.ts index 18630e927f33..de0473771c08 100644 --- a/src/commands/audit.test.ts +++ b/src/commands/audit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import { auditListCommand, testApi } from "./audit.js"; @@ -10,13 +10,33 @@ vi.mock("../gateway/call.js", () => ({ callGateway: mocks.callGateway, })); +const callGateway = mocks.callGateway; + const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn(), }; +function unknownActivityMethodError() { + return Object.assign(new Error("unknown method: audit.activity.list"), { + name: "GatewayClientRequestError", + gatewayCode: "INVALID_REQUEST", + }); +} + +function oldGatewayUnknownMethodScopeError() { + return Object.assign(new Error("missing scope: operator.admin"), { + name: "GatewayClientRequestError", + gatewayCode: "INVALID_REQUEST", + }); +} + describe("audit command parsing", () => { + beforeEach(() => { + callGateway.mockReset(); + }); + it("parses ISO and millisecond timestamps", () => { expect(testApi.parseAuditTimestamp("2026-07-01T00:00:00Z", "--after")).toBe( Date.parse("2026-07-01T00:00:00Z"), @@ -76,10 +96,18 @@ describe("audit command parsing", () => { expect(() => testApi.parseAuditLimit("501")).toThrow("1 and 500"); }); + it("rejects unknown event kinds before querying the Gateway", async () => { + await expect( + auditListCommand({ kind: "bogus" as never, limit: "10" }, runtime), + ).rejects.toThrow("--kind must be agent_run, tool_action, or message"); + expect(callGateway).not.toHaveBeenCalled(); + }); + it("renders untrusted metadata as one terminal-safe row", () => { - const [header, row] = testApi.formatAuditRows([ + const events = [ { eventId: "event-1", + schemaVersion: 1, sequence: 1, sourceSequence: 1, occurredAt: 0, @@ -92,7 +120,8 @@ describe("audit command parsing", () => { toolName: "\u001b]8;;https://example.invalid\u0007unsafe", redaction: "metadata_only", }, - ]); + ]; + const [header, row] = testApi.formatAuditRows(events); expect(header).toContain("TIME"); expect(row).not.toContain("\n"); @@ -101,10 +130,35 @@ describe("audit command parsing", () => { expect(row).toContain("run\\tcolumn"); }); + it("renders message direction and channel without synthetic run provenance", () => { + const events = [ + { + schemaVersion: 1, + eventId: "event-message-1", + sequence: 2, + occurredAt: 0, + kind: "message", + action: "message.inbound.processed", + status: "succeeded", + actor: { type: "channel_sender" }, + direction: "inbound", + channel: "telegram", + conversationKind: "direct", + outcome: "completed", + redaction: "metadata_only", + }, + ]; + const [header, row] = testApi.formatAuditRows(events); + + expect(header).toContain("DIRECTION\tCHANNEL"); + expect(row).toContain("message\tinbound\ttelegram\tsucceeded\t-\t-"); + }); + it("keeps truncated audit cells UTF-16 well-formed", () => { - const [, row] = testApi.formatAuditRows([ + const events = [ { eventId: "event-utf16", + schemaVersion: 1, sequence: 1, sourceSequence: 1, occurredAt: 0, @@ -116,9 +170,140 @@ describe("audit command parsing", () => { runId: "run-utf16", redaction: "metadata_only", }, - ]); + ]; + const [, row] = testApi.formatAuditRows(events); expect(row).toContain(`${"x".repeat(16)}…`); expect(row).not.toContain("\uD83D"); }); }); + +describe("audit command gateway compatibility", () => { + beforeEach(() => { + callGateway.mockReset(); + callGateway.mockResolvedValue({ events: [] }); + }); + + it("forwards all filters to audit.activity.list", async () => { + await auditListCommand( + { + agentId: "main", + kind: "message", + status: "failed", + direction: "outbound", + channel: "telegram", + after: "100", + before: "200", + cursor: "42", + limit: "25", + }, + runtime, + ); + + expect(callGateway).toHaveBeenCalledTimes(1); + expect(callGateway).toHaveBeenCalledWith({ + method: "audit.activity.list", + params: { + limit: 25, + agentId: "main", + kind: "message", + status: "failed", + direction: "outbound", + channel: "telegram", + after: 100, + before: 200, + cursor: "42", + }, + }); + }); + + it("falls back to audit.list only with legacy-compatible filters", async () => { + callGateway.mockRejectedValueOnce(unknownActivityMethodError()).mockResolvedValueOnce({ + events: [], + nextCursor: "8", + }); + + await auditListCommand( + { + agentId: "main", + sessionKey: "agent:main:main", + runId: "run-1", + kind: "tool_action", + status: "failed", + after: "100", + before: "200", + cursor: "9", + limit: "25", + }, + runtime, + ); + + expect(callGateway.mock.calls).toEqual([ + [ + { + method: "audit.activity.list", + params: { + limit: 25, + agentId: "main", + sessionKey: "agent:main:main", + runId: "run-1", + kind: "tool_action", + status: "failed", + after: 100, + before: 200, + cursor: "9", + }, + }, + ], + [ + { + method: "audit.list", + params: { + agentId: "main", + sessionKey: "agent:main:main", + runId: "run-1", + kind: "tool_action", + status: "failed", + after: 100, + before: 200, + limit: 25, + cursor: "9", + }, + }, + ], + ]); + }); + + it("falls back when an old gateway authorizes an unknown method as admin", async () => { + callGateway.mockRejectedValueOnce(oldGatewayUnknownMethodScopeError()).mockResolvedValueOnce({ + events: [], + }); + + await auditListCommand({ limit: "10" }, runtime); + + expect(callGateway).toHaveBeenNthCalledWith(2, { + method: "audit.list", + params: { limit: 10 }, + }); + }); + + it("fails clearly instead of dropping message-specific filters on old gateways", async () => { + callGateway.mockRejectedValueOnce(unknownActivityMethodError()); + + await expect(auditListCommand({ direction: "inbound", limit: "10" }, runtime)).rejects.toThrow( + "does not support message audit filters", + ); + expect(callGateway).toHaveBeenCalledTimes(1); + }); + + it("does not fall back for other request errors", async () => { + const error = Object.assign(new Error("invalid audit activity params"), { + name: "GatewayClientRequestError", + gatewayCode: "INVALID_REQUEST", + }); + callGateway.mockRejectedValueOnce(error); + + await expect(auditListCommand({ limit: "10" }, runtime)).rejects.toBe(error); + expect(callGateway).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/commands/audit.ts b/src/commands/audit.ts index 20621b45db83..73d9dff21fd5 100644 --- a/src/commands/audit.ts +++ b/src/commands/audit.ts @@ -1,8 +1,9 @@ -/** Operator CLI for bounded metadata-only run/tool audit pages. */ +/** Operator CLI for bounded metadata-only activity audit pages. */ import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { - AuditEvent, + AuditActivityListParams, + AuditActivityListResult, AuditListParams, AuditListResult, } from "../../packages/gateway-protocol/src/index.js"; @@ -19,8 +20,10 @@ export type AuditListCommandOptions = { agentId?: string; sessionKey?: string; runId?: string; - kind?: AuditListParams["kind"]; - status?: AuditListParams["status"]; + kind?: AuditActivityListParams["kind"]; + status?: AuditActivityListParams["status"]; + direction?: AuditActivityListParams["direction"]; + channel?: string; after?: string; before?: string; cursor?: string; @@ -28,6 +31,18 @@ export type AuditListCommandOptions = { json?: boolean; }; +type AuditCliEvent = { + occurredAt: number; + kind: string; + status: string; + action: string; + direction?: string; + channel?: string; + agentId?: string; + runId?: string; + toolName?: string; +}; + function parseAuditTimestamp(value: string | undefined, flag: string): number | undefined { const trimmed = value?.trim(); if (!trimmed) { @@ -70,13 +85,15 @@ function short(value: string | undefined, maxChars: number): string { : `${truncateUtf16Safe(sanitized, maxChars - 1)}…`; } -function formatAuditRows(events: AuditEvent[]): string[] { - const rows = ["TIME\tKIND\tSTATUS\tAGENT\tRUN\tACTION"]; +function formatAuditRows(events: readonly AuditCliEvent[]): string[] { + const rows = ["TIME\tKIND\tDIRECTION\tCHANNEL\tSTATUS\tAGENT\tRUN\tACTION"]; for (const event of events) { rows.push( [ timestampMsToIsoString(event.occurredAt) ?? String(event.occurredAt), event.kind, + short(event.direction, 10), + short(event.channel, 18), event.status, short(event.agentId, 18), short(event.runId, 18), @@ -87,28 +104,98 @@ function formatAuditRows(events: AuditEvent[]): string[] { return rows; } +function isUnsupportedActivityMethodError(value: unknown): value is Error { + // Frozen shipped-gateway strings: pre-activity gateways answer unknown + // methods with "unknown method: ..." or fail closed to operator.admin. A + // current gateway registers audit.activity.list at operator.read, so it can + // never emit these for this method; matching them only triggers the legacy + // audit.list fallback. + return ( + value instanceof Error && + value.name === "GatewayClientRequestError" && + (value as Error & { gatewayCode?: unknown }).gatewayCode === "INVALID_REQUEST" && + (value.message === "unknown method: audit.activity.list" || + value.message === "missing scope: operator.admin") + ); +} + +function hasMessageSpecificFilters(options: AuditListCommandOptions): boolean { + return ( + options.kind === "message" || options.direction !== undefined || options.channel !== undefined + ); +} + +function validateAuditKind(kind: AuditListCommandOptions["kind"]): void { + if (kind !== undefined && kind !== "agent_run" && kind !== "tool_action" && kind !== "message") { + throw new Error("--kind must be agent_run, tool_action, or message."); + } +} + +function toLegacyAuditListParams(params: AuditActivityListParams): AuditListParams { + return { + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + ...(params.runId ? { runId: params.runId } : {}), + ...(params.kind === "agent_run" || params.kind === "tool_action" ? { kind: params.kind } : {}), + ...(params.status ? { status: params.status } : {}), + ...(params.after !== undefined ? { after: params.after } : {}), + ...(params.before !== undefined ? { before: params.before } : {}), + ...(params.limit !== undefined ? { limit: params.limit } : {}), + ...(params.cursor ? { cursor: params.cursor } : {}), + }; +} + +async function queryAuditActivity( + params: AuditActivityListParams, + options: AuditListCommandOptions, +): Promise { + try { + return await callGateway({ + method: "audit.activity.list", + params, + }); + } catch (error) { + if (!isUnsupportedActivityMethodError(error)) { + throw error; + } + if (hasMessageSpecificFilters(options)) { + throw new Error( + "The connected Gateway does not support message audit filters. Upgrade the Gateway to use --kind message, --direction, or --channel.", + { cause: error }, + ); + } + return await callGateway({ + method: "audit.list", + params: toLegacyAuditListParams(params), + }); + } +} + /** Query one stable page. JSON output is a bounded export with its next cursor. */ export async function auditListCommand( options: AuditListCommandOptions, runtime: RuntimeEnv, ): Promise { + validateAuditKind(options.kind); const after = parseAuditTimestamp(options.after, "--after"); const before = parseAuditTimestamp(options.before, "--before"); if (after !== undefined && before !== undefined && after > before) { throw new Error("--after must not be later than --before."); } - const params: AuditListParams = { + const params: AuditActivityListParams = { limit: parseAuditLimit(options.limit), ...(options.agentId ? { agentId: options.agentId } : {}), ...(options.sessionKey ? { sessionKey: options.sessionKey } : {}), ...(options.runId ? { runId: options.runId } : {}), ...(options.kind ? { kind: options.kind } : {}), ...(options.status ? { status: options.status } : {}), + ...(options.direction ? { direction: options.direction } : {}), + ...(options.channel ? { channel: options.channel } : {}), ...(after !== undefined ? { after } : {}), ...(before !== undefined ? { before } : {}), ...(options.cursor ? { cursor: options.cursor } : {}), }; - const result = await callGateway({ method: "audit.list", params }); + const result = await queryAuditActivity(params, options); if (options.json) { writeRuntimeJson(runtime, result); return; @@ -121,4 +208,12 @@ export async function auditListCommand( } } -export const testApi = { formatAuditRows, parseAuditLimit, parseAuditTimestamp }; +export const testApi = { + formatAuditRows, + hasMessageSpecificFilters, + isUnsupportedActivityMethodError, + parseAuditLimit, + parseAuditTimestamp, + toLegacyAuditListParams, + validateAuditKind, +}; diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index 52c7f281dd82..dce9945957e1 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -23,6 +23,7 @@ import type { InstalledPluginInstallRecordInfo } from "../plugins/installed-plug import { closeOpenClawStateDatabaseForTest, openOpenClawStateDatabase, + OPENCLAW_STATE_SCHEMA_VERSION, } from "../state/openclaw-state-db.js"; import { loadTaskFlowRegistryStateFromSqlite } from "../tasks/task-flow-registry.store.sqlite.js"; import { loadTaskRegistryStateFromSqlite } from "../tasks/task-registry.store.sqlite.js"; @@ -1019,7 +1020,7 @@ describe("doctor legacy state migrations", () => { const stateDatabasePath = createLegacyAgentDatabaseRegistry(stateDir); const { DatabaseSync } = requireNodeSqlite(); const seededDb = new DatabaseSync(stateDatabasePath); - seededDb.exec("PRAGMA user_version = 2;"); + seededDb.exec(`PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION + 1};`); seededDb.close(); const detected = await detectLegacyStateMigrations({ @@ -1030,7 +1031,9 @@ describe("doctor legacy state migrations", () => { const result = await runLegacyStateMigrations({ detected }); expect(result.changes).toStrictEqual([]); expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toContain("uses newer schema version 2"); + expect(result.warnings[0]).toContain( + `uses newer schema version ${OPENCLAW_STATE_SCHEMA_VERSION + 1}`, + ); const db = new DatabaseSync(stateDatabasePath); try { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 24b3eb930e15..f1fbe4c804d7 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -51,6 +51,12 @@ export const FIELD_HELP: Record = { 'Wizard execution mode recorded as "local" or "remote" for the most recent setup flow. Use this to understand whether setup targeted direct local runtime or remote gateway topology.', "wizard.securityAcknowledgedAt": "ISO timestamp for when the setup security acknowledgement was accepted on this config. Setup uses this to avoid repeating the acknowledgement on later wizard runs.", + audit: + "Bounded metadata-only audit history for operator review. Run and tool records are enabled by default; message lifecycle metadata is a separate privacy-sensitive opt-in. The background writer is best-effort rather than a lossless compliance archive.", + "audit.enabled": + "Records new run, tool, and enabled message audit events. Default: true. Disabling event inserts does not immediately delete existing records; retained rows remain queryable until they expire.", + "audit.messages": + 'Controls content-free message lifecycle records: "off" (default), "direct" for known direct conversations only, or "all" for direct, group, channel, and unknown conversation kinds. Both audit.enabled and audit.messages are startup-scoped; restart the Gateway after changing either setting.', diagnostics: "Diagnostics controls for targeted tracing, telemetry export, and cache inspection during debugging. Keep baseline diagnostics minimal in production and enable deeper signals only when investigating issues.", "diagnostics.memoryPressureSnapshot": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index fe4715fceda8..a0a4045dc843 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -28,6 +28,9 @@ export const FIELD_LABELS: Record = { "wizard.lastRunCommand": "Wizard Last Run Command", "wizard.lastRunMode": "Wizard Last Run Mode", "wizard.securityAcknowledgedAt": "Wizard Security Acknowledgement Timestamp", + audit: "Audit Ledger", + "audit.enabled": "Audit Ledger Enabled", + "audit.messages": "Message Audit Scope", diagnostics: "Diagnostics", "diagnostics.otel": "OpenTelemetry", "diagnostics.cacheTrace": "Cache Trace", diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 6ffbe357cf8d..0d22eb6995c5 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -348,11 +348,18 @@ export type DiagnosticsCacheTraceConfig = { export type AuditConfig = { /** - * Record metadata-only audit events (agent runs and tool actions) into the - * shared state database. Content is never stored. Default: true. Disabling - * stops new writes; existing records stay readable until they expire. + * Record metadata-only run, tool, and enabled message lifecycle events into + * the shared state database. Content is never stored. Default: true. This is + * startup-scoped; disabling stops new event inserts after restart while retained + * records stay readable until they expire. */ enabled?: boolean; + /** + * Record content-free message lifecycle metadata. `direct` records only + * known direct conversations; `all` also records group, channel, and + * unknown conversation kinds. Default: `off`. + */ + messages?: "off" | "direct" | "all"; }; export type DiagnosticsConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 4535e5d6389f..2f41901255e2 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -747,6 +747,7 @@ export const OpenClawSchema = z audit: z .object({ enabled: z.boolean().optional(), + messages: z.union([z.literal("off"), z.literal("direct"), z.literal("all")]).optional(), }) .strict() .optional(), diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index b5522a7cbeed..f153c07d2df9 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -42,6 +42,7 @@ describe("method scope resolution", () => { it.each([ ["sessions.resolve", ["operator.read"]], ["tasks.list", ["operator.read"]], + ["audit.activity.list", ["operator.read"]], ["audit.list", ["operator.read"]], ["tasks.get", ["operator.read"]], ["taskSuggestions.list", ["operator.read"]], diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index fd8c33b17da8..01a58b9daf5c 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -101,6 +101,7 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "tools.effective", scope: "operator.read", startup: true }, { name: "tools.invoke", scope: "operator.write" }, { name: "audit.list", scope: "operator.read" }, + { name: "audit.activity.list", scope: "operator.read" }, { name: "tasks.list", scope: "operator.read" }, { name: "tasks.get", scope: "operator.read" }, { name: "tasks.cancel", scope: "operator.write" }, diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index e06078eb9fbd..932fc56d24ef 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -74,6 +74,11 @@ describe("listGatewayMethods", () => { expect(listGatewayMethods()).toContain("controlUi.sessionPullRequests"); }); + it("advertises the versioned activity audit method", () => { + expect(listGatewayMethods()).toContain("audit.activity.list"); + expect(coreGatewayHandlers["audit.activity.list"]).toBeTypeOf("function"); + }); + it("does not advertise hidden core handlers", () => { const methods = listGatewayMethods(); expect(methods).not.toContain("config.openFile"); diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index e6a4929c255a..43b7e0b9fea0 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -554,7 +554,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { loadHandlers: loadTalkHandlers, }), ...createLazyCoreHandlers({ - methods: ["audit.list"], + methods: ["audit.list", "audit.activity.list"], loadHandlers: loadAuditHandlers, }), ...createLazyCoreHandlers({ diff --git a/src/gateway/server-methods/audit.test.ts b/src/gateway/server-methods/audit.test.ts index 5e75816da183..1732875a1070 100644 --- a/src/gateway/server-methods/audit.test.ts +++ b/src/gateway/server-methods/audit.test.ts @@ -1,22 +1,25 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { auditHandlers } from "./audit.js"; +import { auditHandlers, testApi } from "./audit.js"; const listAuditEvents = vi.hoisted(() => vi.fn()); vi.mock("../../audit/audit-event-store.js", () => ({ listAuditEvents })); -async function runAuditHandler(params: Record) { +const accountRef = `hmac-sha256:v1:${"a".repeat(32)}:${"b".repeat(64)}`; + +async function runAuditHandler(method: "audit.activity.list" | "audit.list", params: object) { const respond = vi.fn(); - await auditHandlers["audit.list"]({ params, respond } as never); + await auditHandlers[method]({ params, respond } as never); return respond; } -describe("audit.list", () => { +describe("audit gateway methods", () => { beforeEach(() => { listAuditEvents.mockReset(); listAuditEvents.mockReturnValue({ events: [ { + schemaVersion: 1, eventId: "event-1", sequence: 10, sourceSequence: 2, @@ -35,8 +38,8 @@ describe("audit.list", () => { }); }); - it("passes bounded filters and maps the public actor shape", async () => { - const respond = await runAuditHandler({ + it("preserves the exact shipped audit.list request and result shape", async () => { + const respond = await runAuditHandler("audit.list", { agentId: "main", kind: "agent_run", after: 50, @@ -44,31 +47,152 @@ describe("audit.list", () => { limit: 25, cursor: "11", }); + expect(listAuditEvents).toHaveBeenCalledWith({ limit: 25, cursor: 11, filters: { agentId: "main", kind: "agent_run", after: 50, before: 150 }, }); - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ - events: [expect.objectContaining({ actor: { type: "agent", id: "main" } })], - nextCursor: "10", - }), - ); + expect(respond).toHaveBeenCalledWith(true, { + events: [ + { + eventId: "event-1", + sequence: 10, + sourceSequence: 2, + occurredAt: 100, + kind: "agent_run", + action: "agent.run.finished", + status: "succeeded", + actor: { type: "agent", id: "main" }, + agentId: "main", + runId: "run-1", + redaction: "metadata_only", + }, + ], + nextCursor: "10", + }); }); - it("rejects malformed cursors and inverted ranges", async () => { - expect(await runAuditHandler({ cursor: "bad" })).toHaveBeenCalledWith( - false, - undefined, - expect.any(Object), - ); - expect(await runAuditHandler({ after: 2, before: 1 })).toHaveBeenCalledWith( - false, - undefined, - expect.any(Object), - ); + it("keeps message filters invalid on the shipped audit.list method", async () => { + const respond = await runAuditHandler("audit.list", { kind: "message" }); + + expect(respond).toHaveBeenCalledWith(false, undefined, expect.any(Object)); expect(listAuditEvents).not.toHaveBeenCalled(); }); + + it("returns versioned message activity without synthetic run provenance", async () => { + listAuditEvents.mockReturnValue({ + events: [ + { + schemaVersion: 1, + eventId: "event-message-1", + sequence: 11, + sourceSequence: 3, + occurredAt: 101, + kind: "message", + action: "message.outbound.finished", + status: "succeeded", + actorType: "system", + actorId: "gateway", + direction: "outbound", + channel: "telegram", + conversationKind: "direct", + outcome: "sent", + deliveryKind: "text", + durationMs: 12, + resultCount: 1, + accountRef, + targetRef: accountRef, + redaction: "metadata_only", + }, + ], + }); + + const respond = await runAuditHandler("audit.activity.list", { + kind: "message", + direction: "outbound", + channel: "telegram", + }); + + expect(listAuditEvents).toHaveBeenCalledWith({ + limit: 100, + filters: { + includeMessages: true, + kind: "message", + direction: "outbound", + channel: "telegram", + }, + }); + expect(respond).toHaveBeenCalledWith(true, { + events: [ + { + eventType: "outbound_message", + schemaVersion: 1, + eventId: "event-message-1", + sequence: 11, + sourceSequence: 3, + occurredAt: 101, + kind: "message", + action: "message.outbound.finished", + direction: "outbound", + status: "succeeded", + actor: { type: "system", id: "gateway" }, + channel: "telegram", + conversationKind: "direct", + outcome: "sent", + deliveryKind: "text", + durationMs: 12, + resultCount: 1, + accountRef, + targetRef: accountRef, + redaction: "metadata_only", + }, + ], + }); + const result = respond.mock.calls[0]?.[1] as { events?: Array> }; + expect(result.events?.[0]).not.toHaveProperty("agentId"); + expect(result.events?.[0]).not.toHaveProperty("runId"); + }); + + it("projects a store-validated channel-sender identity", () => { + expect( + testApi.mapAuditActivityEvent({ + schemaVersion: 1, + eventId: "event-message-2", + sequence: 12, + sourceSequence: 4, + occurredAt: 102, + kind: "message", + action: "message.inbound.processed", + status: "succeeded", + actorType: "channel_sender", + actorId: accountRef, + direction: "inbound", + channel: "telegram", + conversationKind: "direct", + outcome: "completed", + redaction: "metadata_only", + }), + ).toMatchObject({ + eventType: "inbound_message", + actor: { type: "channel_sender", id: accountRef }, + }); + }); + + it.each(["audit.list", "audit.activity.list"] as const)( + "rejects malformed cursors and inverted ranges for %s", + async (method) => { + expect(await runAuditHandler(method, { cursor: "bad" })).toHaveBeenCalledWith( + false, + undefined, + expect.any(Object), + ); + expect(await runAuditHandler(method, { after: 2, before: 1 })).toHaveBeenCalledWith( + false, + undefined, + expect.any(Object), + ); + expect(listAuditEvents).not.toHaveBeenCalled(); + }, + ); }); diff --git a/src/gateway/server-methods/audit.ts b/src/gateway/server-methods/audit.ts index 5fa04d28e329..688ee9fef9a4 100644 --- a/src/gateway/server-methods/audit.ts +++ b/src/gateway/server-methods/audit.ts @@ -3,11 +3,17 @@ import { ErrorCodes, errorShape, formatValidationErrors, + type AuditActivityEventV1, type AuditEvent, + validateAuditActivityListParams, validateAuditListParams, } from "../../../packages/gateway-protocol/src/index.js"; import { listAuditEvents } from "../../audit/audit-event-store.js"; -import type { AuditEventRecord } from "../../audit/audit-event-types.js"; +import type { + AgentRunAuditEventRecord, + AuditEventRecord, + ToolActionAuditEventRecord, +} from "../../audit/audit-event-types.js"; import type { GatewayRequestHandlers } from "./types.js"; const DEFAULT_AUDIT_LIST_LIMIT = 100; @@ -24,24 +30,48 @@ function parseAuditCursor(cursor: string | undefined): number | undefined | null return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; } -function mapAuditEvent(event: AuditEventRecord): AuditEvent { +/** Preserve the shipped audit.list result shape for run/tool-only clients. */ +function mapLegacyAuditEvent( + event: AgentRunAuditEventRecord | ToolActionAuditEventRecord, +): AuditEvent { + const { schemaVersion: _schemaVersion, actorType, actorId, ...legacyEvent } = event; return { - eventId: event.eventId, - sequence: event.sequence, - sourceSequence: event.sourceSequence, - occurredAt: event.occurredAt, - kind: event.kind, - action: event.action, - status: event.status, - ...(event.errorCode ? { errorCode: event.errorCode } : {}), - actor: { type: event.actorType, id: event.actorId }, - agentId: event.agentId, - ...(event.sessionKey ? { sessionKey: event.sessionKey } : {}), - ...(event.sessionId ? { sessionId: event.sessionId } : {}), - runId: event.runId, - ...(event.toolCallId ? { toolCallId: event.toolCallId } : {}), - ...(event.toolName ? { toolName: event.toolName } : {}), - redaction: "metadata_only", + ...legacyEvent, + actor: { type: actorType, id: actorId }, + }; +} + +function mapAuditActivityEvent(event: AuditEventRecord): AuditActivityEventV1 { + if (event.kind === "agent_run") { + const { actorType, actorId, ...activity } = event; + return { ...activity, eventType: "agent_run", actor: { type: actorType, id: actorId } }; + } + if (event.kind === "tool_action") { + const { actorType, actorId, ...activity } = event; + return { ...activity, eventType: "tool_action", actor: { type: actorType, id: actorId } }; + } + if (event.direction === "inbound") { + const { actorType, actorId, ...activity } = event; + const actor = + actorType === "channel_sender" + ? { type: "channel_sender" as const, id: actorId } + : { type: "system" as const, id: actorId }; + return { ...activity, eventType: "inbound_message", actor }; + } + const { actorType, actorId, ...activity } = event; + return { ...activity, eventType: "outbound_message", actor: { type: actorType, id: actorId } }; +} + +function invalidRangeOrCursor(params: { cursor?: string; after?: number; before?: number }): { + cursor?: number; + invalid: boolean; +} { + const cursor = parseAuditCursor(params.cursor); + return { + ...(cursor !== undefined && cursor !== null ? { cursor } : {}), + invalid: + cursor === null || + (params.after !== undefined && params.before !== undefined && params.after > params.before), }; } @@ -58,11 +88,8 @@ export const auditHandlers: GatewayRequestHandlers = { ); return; } - const cursor = parseAuditCursor(params.cursor); - if ( - cursor === null || - (params.after !== undefined && params.before !== undefined && params.after > params.before) - ) { + const parsed = invalidRangeOrCursor(params); + if (parsed.invalid) { respond( false, undefined, @@ -72,7 +99,7 @@ export const auditHandlers: GatewayRequestHandlers = { } const page = listAuditEvents({ limit: Math.min(params.limit ?? DEFAULT_AUDIT_LIST_LIMIT, MAX_AUDIT_LIST_LIMIT), - ...(cursor !== undefined ? { cursor } : {}), + ...(parsed.cursor !== undefined ? { cursor: parsed.cursor } : {}), filters: { ...(params.agentId ? { agentId: params.agentId } : {}), ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), @@ -84,10 +111,59 @@ export const auditHandlers: GatewayRequestHandlers = { }, }); respond(true, { - events: page.events.map(mapAuditEvent), + events: page.events.map((event) => { + if (event.kind === "message") { + throw new Error("legacy audit.list cannot project message records"); + } + return mapLegacyAuditEvent(event); + }), + ...(page.nextCursor !== undefined ? { nextCursor: String(page.nextCursor) } : {}), + }); + }, + "audit.activity.list": ({ params, respond }) => { + if (!validateAuditActivityListParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid audit.activity.list params: ${formatValidationErrors( + validateAuditActivityListParams.errors, + )}`, + ), + ); + return; + } + const parsed = invalidRangeOrCursor(params); + if (parsed.invalid) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid audit.activity.list range or cursor"), + ); + return; + } + const page = listAuditEvents({ + limit: Math.min(params.limit ?? DEFAULT_AUDIT_LIST_LIMIT, MAX_AUDIT_LIST_LIMIT), + ...(parsed.cursor !== undefined ? { cursor: parsed.cursor } : {}), + filters: { + includeMessages: true, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + ...(params.runId ? { runId: params.runId } : {}), + ...(params.kind ? { kind: params.kind } : {}), + ...(params.status ? { status: params.status } : {}), + ...(params.direction ? { direction: params.direction } : {}), + ...(params.channel ? { channel: params.channel } : {}), + ...(params.after !== undefined ? { after: params.after } : {}), + ...(params.before !== undefined ? { before: params.before } : {}), + }, + }); + respond(true, { + events: page.events.map(mapAuditActivityEvent), ...(page.nextCursor !== undefined ? { nextCursor: String(page.nextCursor) } : {}), }); }, }; -export const testApi = { mapAuditEvent, parseAuditCursor }; +export const testApi = { mapAuditActivityEvent, mapLegacyAuditEvent, parseAuditCursor }; diff --git a/src/gateway/server-runtime-subscriptions.test.ts b/src/gateway/server-runtime-subscriptions.test.ts index c21e2f95a8cd..99853ec71ddf 100644 --- a/src/gateway/server-runtime-subscriptions.test.ts +++ b/src/gateway/server-runtime-subscriptions.test.ts @@ -1,6 +1,10 @@ // Tests for gateway runtime subscription wiring. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { emitAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js"; +import { + emitAgentAuditEvent, + emitAgentEvent, + resetAgentEventsForTest, +} from "../infra/agent-events.js"; import type { SubsystemLogger } from "../logging/subsystem.js"; import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js"; import { @@ -34,20 +38,26 @@ const mockLog: SubsystemLogger = { const auditTestState = vi.hoisted(() => ({ enabled: true, + messageMode: "off" as "off" | "direct" | "all", created: 0, + recorded: 0, stopped: 0, })); vi.mock("../audit/audit-config.js", () => ({ isAuditLedgerEnabled: () => auditTestState.enabled, + resolveAuditMessageMode: () => auditTestState.messageMode, })); -vi.mock("../audit/agent-event-audit.js", () => ({ - createAgentEventAuditRecorder: () => { +vi.mock("../audit/audit-recorder.js", () => ({ + createAuditEventRecorder: () => { auditTestState.created += 1; return { - record: vi.fn(), + record: vi.fn(() => { + auditTestState.recorded += 1; + }), recordTool: vi.fn(), + recordMessage: vi.fn(), stop: vi.fn(async () => { auditTestState.stopped += 1; }), @@ -97,7 +107,9 @@ describe("startGatewayEventSubscriptions", () => { beforeEach(() => { vi.clearAllMocks(); auditTestState.enabled = true; + auditTestState.messageMode = "off"; auditTestState.created = 0; + auditTestState.recorded = 0; auditTestState.stopped = 0; installInMemoryTaskRegistryRuntime(); }); @@ -116,18 +128,29 @@ describe("startGatewayEventSubscriptions", () => { unsubs = startGatewayEventSubscriptions(createParams()); expect(auditTestState.created).toBe(1); + emitAgentAuditEvent({ runId: "enabled-audit", stream: "lifecycle", data: { phase: "start" } }); + expect(auditTestState.recorded).toBe(1); await unsubs.agentUnsub(); expect(auditTestState.stopped).toBe(1); }); - it("creates no audit recorder when audit.enabled is false", async () => { + it("keeps retention maintenance but creates no producers when audit.enabled is false", async () => { auditTestState.enabled = false; unsubs = startGatewayEventSubscriptions(createParams()); - expect(auditTestState.created).toBe(0); + expect(auditTestState.created).toBe(1); + emitAgentAuditEvent({ + runId: "disabled-private", + stream: "lifecycle", + data: { phase: "start" }, + }); + emitAgentEvent({ runId: "disabled-public", stream: "lifecycle", data: { phase: "start" } }); + expect(auditTestState.recorded).toBe(0); + await vi.waitFor(() => expect(warn).toHaveBeenCalledOnce()); + warn.mockClear(); // Disabled wiring must still unsubscribe cleanly. await unsubs.agentUnsub(); - expect(auditTestState.stopped).toBe(0); + expect(auditTestState.stopped).toBe(1); }); it("logs lazy agent event module failures", async () => { diff --git a/src/gateway/server-runtime-subscriptions.ts b/src/gateway/server-runtime-subscriptions.ts index d42339810a8f..08a4561dfb2f 100644 --- a/src/gateway/server-runtime-subscriptions.ts +++ b/src/gateway/server-runtime-subscriptions.ts @@ -1,7 +1,8 @@ // Gateway event subscription wiring for agent, heartbeat, transcript, and lifecycle broadcasts. import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { createAgentEventAuditRecorder } from "../audit/agent-event-audit.js"; -import { isAuditLedgerEnabled } from "../audit/audit-config.js"; +import { isAuditLedgerEnabled, resolveAuditMessageMode } from "../audit/audit-config.js"; +import { createAuditEventRecorder } from "../audit/audit-recorder.js"; +import { onTrustedMessageAuditEvent } from "../audit/message-audit-events.js"; import { getRuntimeConfig } from "../config/io.js"; import { clearAgentRunContext, onAgentAuditEvent, onAgentEvent } from "../infra/agent-events.js"; import { onTrustedToolExecutionEvent } from "../infra/diagnostic-events.js"; @@ -59,18 +60,24 @@ export function startGatewayEventSubscriptions(params: { chatAbortControllers: Map; restartRecoveryCandidates: Map; }) { - // audit.enabled=false stops ledger writes entirely; reads over existing - // records keep working. Resolved once at gateway startup like the other - // runtime subscriptions. - const auditRecorder = isAuditLedgerEnabled(getRuntimeConfig()) - ? createAgentEventAuditRecorder() - : undefined; - const unsubscribePrivateAuditEvents = auditRecorder + // The worker always runs retention maintenance. audit.enabled only controls + // producer subscriptions, so disabling collection cannot strand expired rows. + const runtimeConfig = getRuntimeConfig(); + const auditEnabled = isAuditLedgerEnabled(runtimeConfig); + const auditMessageMode = resolveAuditMessageMode(runtimeConfig); + const auditRecorder = createAuditEventRecorder({ + messageMode: auditEnabled ? auditMessageMode : "off", + }); + const unsubscribePrivateAuditEvents = auditEnabled ? onAgentAuditEvent(auditRecorder.record) : undefined; - const unsubscribeToolAuditEvents = auditRecorder + const unsubscribeToolAuditEvents = auditEnabled ? onTrustedToolExecutionEvent(auditRecorder.recordTool) : undefined; + const unsubscribeMessageAuditEvents = + auditEnabled && auditMessageMode !== "off" + ? onTrustedMessageAuditEvent(auditRecorder.recordMessage) + : undefined; const getAgentEventHandler = createLazyPromise( () => { // Lazy-load heavy chat modules only after the first agent event reaches the gateway. @@ -236,7 +243,9 @@ export function startGatewayEventSubscriptions(params: { }; const unsubscribeAgentEvents = onAgentEvent((evt) => { - auditRecorder?.record(evt); + if (auditEnabled) { + auditRecorder.record(evt); + } const lifecyclePhase = evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase @@ -291,7 +300,8 @@ export function startGatewayEventSubscriptions(params: { unsubscribeAgentEvents(); unsubscribePrivateAuditEvents?.(); unsubscribeToolAuditEvents?.(); - await auditRecorder?.stop(); + unsubscribeMessageAuditEvents?.(); + await auditRecorder.stop(); }; const heartbeatUnsub = onHeartbeatEvent((evt) => { diff --git a/src/infra/outbound/deliver-types.ts b/src/infra/outbound/deliver-types.ts index ef89f68189c0..400c7681f860 100644 --- a/src/infra/outbound/deliver-types.ts +++ b/src/infra/outbound/deliver-types.ts @@ -19,6 +19,21 @@ export type OutboundDeliveryResult = { meta?: Record; }; +/** Count platform sends without double-counting equivalent receipt representations. */ +export function countPhysicalOutboundSends(results: readonly OutboundDeliveryResult[]): number { + return results.reduce((count, result) => { + const receipt = result.receipt; + if (!receipt) { + return count + 1; + } + // Parts and platform ids describe the same sends. Prefer parts so aggregate + // receipts preserve multiplicity without counting both representations. + const receiptCount = + receipt.parts.length > 0 ? receipt.parts.length : receipt.platformMessageIds.length; + return count + Math.max(1, receiptCount); + }, 0); +} + /** Reason a payload was intentionally not sent after normalization or hooks. */ export type OutboundPayloadDeliverySuppressionReason = | "cancelled_by_message_sending_hook" @@ -30,6 +45,7 @@ export type OutboundPayloadDeliverySuppressionReason = /** Delivery phase where a failure occurred. */ export type OutboundDeliveryFailureStage = "platform_send" | "queue" | "unknown"; +export type OutboundPayloadDeliveryKind = "text" | "media" | "other"; export const PLATFORM_MESSAGE_NOT_DISPATCHED_ERROR_CODE = "OPENCLAW_PLATFORM_MESSAGE_NOT_DISPATCHED"; @@ -59,6 +75,8 @@ export type OutboundPayloadDeliveryOutcome = index: number; status: "sent"; results: OutboundDeliveryResult[]; + /** Effective post-hook, post-render payload kind. */ + deliveryKind?: OutboundPayloadDeliveryKind; } | { index: number; @@ -75,6 +93,10 @@ export type OutboundPayloadDeliveryOutcome = error: unknown; sentBeforeError: boolean; stage: OutboundDeliveryFailureStage; + /** Identified platform sends from this payload before its terminal failure. */ + results?: OutboundDeliveryResult[]; + /** Effective post-hook, post-render payload kind when platform delivery began. */ + deliveryKind?: OutboundPayloadDeliveryKind; }; /** Error carrying partial delivery results when an outbound send fails mid-batch. */ diff --git a/src/infra/outbound/deliver.queue-integration.test.ts b/src/infra/outbound/deliver.queue-integration.test.ts index a0bf067d57a3..3935200a04f3 100644 --- a/src/infra/outbound/deliver.queue-integration.test.ts +++ b/src/infra/outbound/deliver.queue-integration.test.ts @@ -1,4 +1,9 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + onTrustedMessageAuditEvent, + resetMessageAuditEventsForTest, + type TrustedMessageAuditEvent, +} from "../../audit/message-audit-events.js"; import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { createEmptyPluginRegistry } from "../../plugins/registry.js"; @@ -92,6 +97,7 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send }); beforeEach(() => { + resetMessageAuditEventsForTest(); tmpDir = fixtures.tmpDir(); setActivePluginRegistry( createTestRegistry([ @@ -105,6 +111,7 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send }); afterEach(() => { + resetMessageAuditEventsForTest(); releasePinnedPluginChannelRegistry(); setActivePluginRegistry(createEmptyPluginRegistry()); }); @@ -133,19 +140,60 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send expect(sendMatrix).toHaveBeenCalledTimes(2); }); - it("drain does not replay an unknown_after_send entry when no adapter reconciliation is available", async () => { + it("drain reports every payload unknown when an interrupted mixed batch cannot be reconciled", async () => { + const auditEvents: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => auditEvents.push(event)); const sendMatrix = createPartialSendFailure(); await deliverPartialMatrixBatch(sendMatrix, tmpDir); + expect(auditEvents).toEqual([]); const beforeDrain = await loadPendingDeliveries(tmpDir); expect(beforeDrain[0]?.recoveryState).toBe("unknown_after_send"); const deliver = vi.fn(async () => {}); await drainMatrixReconnect({ deliver, stateDir: tmpDir }); + unsubscribe(); expect(deliver).not.toHaveBeenCalled(); expect(await loadPendingDeliveries(tmpDir)).toHaveLength(0); + expect(auditEvents).toHaveLength(2); + expect(auditEvents.map((event) => event.sourceId)).toEqual([ + `message:outbound:queue:${beforeDrain[0]?.id}:payload:0`, + `message:outbound:queue:${beforeDrain[0]?.id}:payload:1`, + ]); + expect(auditEvents.map((event) => event.outcome)).toEqual(["unknown", "unknown"]); + expect(auditEvents.map((event) => event.resultCount)).toEqual([0, 0]); + }); + + it("does not retain a pre-send suppression across an ambiguous crash boundary", async () => { + const auditEvents: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => auditEvents.push(event)); + process.env.OPENCLAW_STATE_DIR = tmpDir; + const sendMatrix = vi.fn().mockRejectedValueOnce(new Error("ambiguous provider failure")); + + await expect( + deliverOutboundPayloads({ + cfg: {} as OpenClawConfig, + channel: "matrix", + to: "!room:example", + payloads: [{ text: "NO_REPLY" }, { text: "visible" }], + deps: { matrix: sendMatrix }, + queuePolicy: "required", + }), + ).rejects.toThrow("ambiguous provider failure"); + + const beforeDrain = await loadPendingDeliveries(tmpDir); + expect(beforeDrain).toHaveLength(1); + expect(beforeDrain[0]?.recoveryState).toBe("send_attempt_started"); + + const deliver = vi.fn(async () => {}); + await drainMatrixReconnect({ deliver, stateDir: tmpDir }); + unsubscribe(); + + expect(deliver).not.toHaveBeenCalled(); + expect(auditEvents.map((event) => event.outcome)).toEqual(["unknown", "unknown"]); + expect(auditEvents.map((event) => event.resultCount)).toEqual([0, 0]); }); it("retains retryable send-attempt state when an adapter fails before returning a result", async () => { diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index e14ffad995e1..f23e1c423bd9 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -2,6 +2,11 @@ // checks, adapter sends, transcript mirroring, and payload outcomes. import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + onTrustedMessageAuditEvent, + resetMessageAuditEventsForTest, + type TrustedMessageAuditEvent, +} from "../../audit/message-audit-events.js"; import { chunkText } from "../../auto-reply/chunk.js"; import { createMessageReceiptFromOutboundResults } from "../../channels/message/receipt.js"; import type { ChannelMessageSendTextContext } from "../../channels/message/types.js"; @@ -306,6 +311,7 @@ describe("deliverOutboundPayloads", () => { beforeEach(() => { resetDiagnosticEventsForTest(); + resetMessageAuditEventsForTest(); releasePinnedPluginChannelRegistry(); setActivePluginRegistry(defaultRegistry); mocks.appendAssistantMessageToSessionTranscript.mockClear(); @@ -348,6 +354,7 @@ describe("deliverOutboundPayloads", () => { afterEach(() => { resetDiagnosticEventsForTest(); + resetMessageAuditEventsForTest(); releasePinnedPluginChannelRegistry(); setActivePluginRegistry(emptyRegistry); }); @@ -1418,21 +1425,36 @@ describe("deliverOutboundPayloads", () => { }); it("requires durable queue writes when requested", async () => { + const events: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => events.push(event)); queueMocks.enqueueDelivery.mockRejectedValueOnce(new Error("queue offline")); const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1" }); - await expect( - deliverOutboundPayloads({ - cfg: {}, - channel: "matrix", - to: "!room:example", - payloads: [{ text: "hi" }], - deps: { matrix: sendMatrix }, - queuePolicy: "required", - }), - ).rejects.toThrow("queue offline"); + try { + await expect( + deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:example", + payloads: [{ text: "hi" }], + deps: { matrix: sendMatrix }, + queuePolicy: "required", + }), + ).rejects.toThrow("queue offline"); + } finally { + unsubscribe(); + } expect(sendMatrix).not.toHaveBeenCalled(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + action: "message.outbound.finished", + status: "failed", + outcome: "failed", + failureStage: "queue", + resultCount: 0, + }); + expect(JSON.stringify(events)).not.toContain("queue offline"); }); it("falls back to direct send when best-effort queue writes fail", async () => { @@ -2122,6 +2144,107 @@ describe("deliverOutboundPayloads", () => { ).not.toContain("secret delivery body"); }); + it("emits one metadata-only audit terminal for a successful logical payload", async () => { + const events: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => events.push(event)); + const sendMatrix = vi.fn().mockResolvedValue({ + messageId: "platform-message-1", + roomId: "!room:example", + }); + + try { + await deliverOutboundPayloads({ + cfg: matrixChunkConfig, + channel: "matrix", + to: "!room:target", + accountId: "account-1", + payloads: [{ text: "secret delivery body" }], + deps: { matrix: sendMatrix }, + session: { + key: "secret-session-key", + agentId: "agent-main", + conversationType: "direct", + }, + replyPayloadSendingHook: { + kind: "final", + runId: "run-1", + context: { channelId: "matrix" }, + }, + }); + } finally { + unsubscribe(); + } + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + sourceId: "message:outbound:queue:mock-queue-id:payload:0", + kind: "message", + action: "message.outbound.finished", + status: "succeeded", + actorType: "agent", + actorId: "agent-main", + agentId: "agent-main", + runId: "run-1", + direction: "outbound", + channel: "matrix", + // Policy conversationType alone must not claim "direct" for a room + // target; only declared destination facts or a matching route may. + conversationKind: "unknown", + outcome: "sent", + deliveryKind: "text", + resultCount: 1, + accountId: "account-1", + targetId: "!room:target", + conversationId: "!room:example", + messageId: "platform-message-1", + }); + expect(typeof events[0]?.durationMs).toBe("number"); + expect(JSON.stringify(events)).not.toContain("secret delivery body"); + expect(JSON.stringify(events)).not.toContain("secret-session-key"); + }); + + it("audits one durable terminal per logical payload in a mixed batch", async () => { + const events: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => events.push(event)); + const sendMatrix = vi.fn().mockResolvedValue({ + messageId: "visible", + roomId: "!room:example", + }); + + try { + await deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:example", + payloads: [{ text: "NO_REPLY" }, { text: "visible reply" }], + deps: { matrix: sendMatrix }, + }); + } finally { + unsubscribe(); + } + + expect(sendMatrix).toHaveBeenCalledTimes(1); + expect(events).toHaveLength(2); + expect(events.map((event) => event.outcome)).toEqual(["suppressed", "sent"]); + expect(events[0]).toMatchObject({ + sourceId: "message:outbound:queue:mock-queue-id:payload:0", + status: "blocked", + actorType: "system", + actorId: "gateway", + conversationKind: "unknown", + reasonCode: "no_visible_payload", + resultCount: 0, + }); + expect(events[0]).not.toHaveProperty("deliveryKind"); + expect(events[1]).toMatchObject({ + sourceId: "message:outbound:queue:mock-queue-id:payload:1", + status: "succeeded", + outcome: "sent", + resultCount: 1, + messageId: "visible", + }); + }); + it("keeps requester session channel authoritative for delivery media policy", async () => { const resolveMediaAccessSpy = vi.spyOn( mediaCapabilityModule, @@ -2302,6 +2425,8 @@ describe("deliverOutboundPayloads", () => { }); it("scopes media access after reply payload hooks add local media", async () => { + const auditEvents: TrustedMessageAuditEvent[] = []; + const unsubscribeAudit = onTrustedMessageAuditEvent((event) => auditEvents.push(event)); hookMocks.runner.hasHooks.mockImplementation( (hookName?: string) => hookName === "reply_payload_sending", ); @@ -2334,6 +2459,7 @@ describe("deliverOutboundPayloads", () => { context: { channelId: "matrix", conversationId: "!room:example" }, }, }); + unsubscribeAudit(); const [mediaAccessOptions] = requireMockCall(resolveMediaAccessSpy, "media access") as [ { @@ -2348,6 +2474,8 @@ describe("deliverOutboundPayloads", () => { const sendOptions = requireMatrixSendCall(sendMatrix)[2] as Record; expect(sendOptions.mediaUrl).toBe("file:///tmp/hook-added.png"); expect(typeof sendOptions.mediaReadFile).toBe("function"); + expect(auditEvents).toHaveLength(1); + expect(auditEvents[0]).toMatchObject({ outcome: "sent", deliveryKind: "media" }); resolveMediaAccessSpy.mockRestore(); }); @@ -2587,10 +2715,10 @@ describe("deliverOutboundPayloads", () => { expect(results).toHaveLength(1); expect(results[0]?.channel).toBe("matrix"); expect(results[0]?.messageId).toBe("visible"); - expect(payloadOutcomes).toHaveLength(1); - const payloadOutcome = payloadOutcomes[0] as { index?: unknown; status?: unknown } | undefined; - expect(payloadOutcome?.index).toBe(1); - expect(payloadOutcome?.status).toBe("sent"); + expect(payloadOutcomes).toMatchObject([ + { index: 0, status: "suppressed", reason: "no_visible_payload" }, + { index: 1, status: "sent" }, + ]); }); it("strips internal runtime scaffolding added by message_sending hooks before delivery", async () => { @@ -4089,6 +4217,41 @@ describe("deliverOutboundPayloads", () => { expect(warnMessage).toContain("db connection lost"); }); + it("emits a stable terminal when best-effort marker fallback ack precedes provider rejection", async () => { + const events: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => events.push(event)); + queueMocks.markDeliveryPlatformSendAttemptStarted.mockRejectedValueOnce( + new Error("pre-send marker offline"), + ); + const sendMatrix = vi.fn().mockRejectedValueOnce(new Error("provider rejected send")); + + try { + await expect( + deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:example", + payloads: [{ text: "secret body" }], + deps: { matrix: sendMatrix }, + queuePolicy: "best_effort", + }), + ).rejects.toThrow("provider rejected send"); + } finally { + unsubscribe(); + } + + expect(queueMocks.ackDelivery).toHaveBeenCalledWith("mock-queue-id", undefined); + expect(queueMocks.failDelivery).not.toHaveBeenCalled(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + sourceId: "message:outbound:queue:mock-queue-id:payload:0", + outcome: "failed", + failureStage: "platform_send", + }); + expect(JSON.stringify(events)).not.toContain("secret body"); + expect(JSON.stringify(events)).not.toContain("provider rejected send"); + }); + it("writes raw payloads to the queue before normalization", async () => { const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m-raw", roomId: "!room:example" }); const rawPayloads: DeliverOutboundPayload[] = [ @@ -4985,6 +5148,8 @@ describe("deliverOutboundPayloads", () => { }); it("fails sends when required delivery pinning fails", async () => { + const events: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => events.push(event)); const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" }); const pinDeliveredMessage = vi.fn().mockRejectedValue(new Error("pin denied")); setActivePluginRegistry( @@ -5000,14 +5165,73 @@ describe("deliverOutboundPayloads", () => { ]), ); - await expect( - deliverOutboundPayloads({ - cfg: {}, - channel: "matrix", - to: "!room:1", - payloads: [{ text: "hello", delivery: { pin: { enabled: true, required: true } } }], - }), - ).rejects.toThrow("pin denied"); + try { + await expect( + deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:1", + payloads: [{ text: "hello", delivery: { pin: { enabled: true, required: true } } }], + skipQueue: true, + }), + ).rejects.toThrow("pin denied"); + } finally { + unsubscribe(); + } + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + status: "failed", + errorCode: "message_delivery_partial_failure", + outcome: "failed", + failureStage: "platform_send", + deliveryKind: "text", + resultCount: 1, + messageId: "mx-1", + }); + expect(JSON.stringify(events)).not.toContain("pin denied"); + }); + + it("keeps adapter side effects unknown when required pinning has no message identity", async () => { + const events: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => events.push(event)); + const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "" }); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { deliveryMode: "direct", sendText }, + }), + }, + ]), + ); + + try { + await expect( + deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:1", + payloads: [{ text: "hello", delivery: { pin: { enabled: true, required: true } } }], + skipQueue: true, + }), + ).rejects.toThrow("no delivered message id was returned"); + } finally { + unsubscribe(); + } + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + status: "unknown", + outcome: "unknown", + failureStage: "platform_send", + resultCount: 0, + }); + expect(events[0]).not.toHaveProperty("errorCode"); + expect(events[0]).not.toHaveProperty("deliveryKind"); }); it("pins the first delivered text chunk for chunked payloads", async () => { diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index e67a64a3ee7c..c055295dea14 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -1,5 +1,6 @@ // Outbound delivery core runs plugin hooks, queue durability, channel adapter // sends, commit hooks, diagnostics, transcript mirroring, and payload outcomes. +import { hasTrustedMessageAuditListeners } from "../../audit/message-audit-events.js"; import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { runReplyPayloadSendingHook } from "../../auto-reply/reply/reply-payload-sending-hook.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; @@ -58,6 +59,7 @@ import { type OutboundDeliveryFailureStage, type OutboundDeliveryResult, type OutboundPayloadDeliveryOutcome, + type OutboundPayloadDeliveryKind, type OutboundPayloadDeliverySuppressionReason, } from "./deliver-types.js"; import { @@ -86,6 +88,12 @@ import { type OutboundMessageSendOverrides, } from "./message-plan.js"; import type { DeliveryMirror } from "./mirror.js"; +import { + completedOutboundAuditTerminals, + emitOutboundAuditTerminals, + failedOutboundAuditTerminals, + uniformOutboundAuditTerminals, +} from "./outbound-audit.js"; import { createOutboundPayloadPlan, summarizeOutboundPayloadForTransport, @@ -801,7 +809,7 @@ function sessionKeyForDeliveryDiagnostics(params: { function deliveryKindForPayload( payload: ReplyPayload, payloadSummary: NormalizedOutboundPayload, -): DiagnosticMessageDeliveryKind { +): OutboundPayloadDeliveryKind { if (payloadSummary.mediaUrls.length > 0 || payload.mediaUrl || payload.mediaUrls?.length) { return "media"; } @@ -1338,8 +1346,25 @@ export async function deliverOutboundPayloads( export async function deliverOutboundPayloadsInternal( params: DeliverOutboundPayloadsParams, ): Promise { + const auditStartedAt = Date.now(); const { channel, to, payloads } = params; + const emitPreQueueFailure = (): void => { + // Recovery owns the stable queue terminal for replayed intents. + if (params.deliveryQueueId !== undefined) { + return; + } + emitOutboundAuditTerminals({ + context: params, + terminals: () => + uniformOutboundAuditTerminals(params.payloads.length, { + outcome: "failed", + failureStage: "queue", + }), + startedAt: auditStartedAt, + }); + }; if (params.requireUnknownSendReconciliation === true && payloads.length !== 1) { + emitPreQueueFailure(); throw new Error( `Required durable message send is unsupported for ${channel}: unknown-send reconciliation requires exactly one payload`, ); @@ -1353,6 +1378,7 @@ export async function deliverOutboundPayloadsInternal( phase: "live", }); if (admission.status === "permanent_rejection") { + emitPreQueueFailure(); throw new Error(admission.reason); } } @@ -1391,6 +1417,7 @@ export async function deliverOutboundPayloadsInternal( gatewayClientScopes: params.gatewayClientScopes, }).catch((err: unknown) => { if (queuePolicy === "required") { + emitPreQueueFailure(); throw err; } return null; @@ -1407,13 +1434,13 @@ export async function deliverOutboundPayloadsInternal( } if (!queueId) { - return await deliverOutboundPayloadsWithQueueCleanup(params, null); + return await deliverOutboundPayloadsWithQueueCleanup(params, null, auditStartedAt); } // Hold the same in-process claim used by recovery/drain while the live send // owns this queue entry. const claimResult = await withActiveDeliveryClaim(queueId, () => - deliverOutboundPayloadsWithQueueCleanup(params, queueId), + deliverOutboundPayloadsWithQueueCleanup(params, queueId, auditStartedAt), ); if (claimResult.status === "claimed-by-other-owner") { return []; @@ -1424,6 +1451,7 @@ export async function deliverOutboundPayloadsInternal( async function deliverOutboundPayloadsWithQueueCleanup( params: DeliverOutboundPayloadsParams, queueId: string | null, + auditStartedAt: number, ): Promise { // Wrap onError to detect partial failures under bestEffort mode. // When bestEffort is true, per-payload errors are caught and passed to onError @@ -1432,6 +1460,11 @@ async function deliverOutboundPayloadsWithQueueCleanup( let hadPartialFailure = false; let lastPayloadError: unknown; let partialFailuresAreProvenNotSent = true; + const ownsAuditTerminal = params.deliveryQueueId === undefined; + const auditPayloadOutcomes = + ownsAuditTerminal && hasTrustedMessageAuditListeners() + ? ([] as OutboundPayloadDeliveryOutcome[]) + : undefined; const queuePolicy = params.queuePolicy ?? "best_effort"; const platformQueueId = queueId ?? params.deliveryQueueId; const platformQueuePolicy = queueId ? queuePolicy : (params.queuePolicy ?? "required"); @@ -1443,6 +1476,19 @@ async function deliverOutboundPayloadsWithQueueCleanup( let platformSendRoute: PlatformSendRoute | undefined; let deliveredResults: OutboundDeliveryResult[] = []; let commitHooksRun = false; + const emitTerminals = ( + terminals: Parameters[0]["terminals"], + ): void => { + if (!ownsAuditTerminal) { + return; + } + emitOutboundAuditTerminals({ + context: params, + terminals, + startedAt: auditStartedAt, + ...(queueId ? { queueId } : {}), + }); + }; const runCommitHooksAfterAck = async (): Promise => { if ( queuedPostSendState !== "acked" || @@ -1504,6 +1550,14 @@ async function deliverOutboundPayloadsWithQueueCleanup( partialFailuresAreProvenNotSent &&= isProvenDeliveryNotSentError(err); params.onError?.(err, payload); }, + ...(auditPayloadOutcomes + ? { + onPayloadDeliveryOutcome: (outcome: OutboundPayloadDeliveryOutcome) => { + auditPayloadOutcomes.push(outcome); + params.onPayloadDeliveryOutcome?.(outcome); + }, + } + : {}), onDeliveryResult: async (result) => { deliveredResults.push(result); if (queueId && queuedPostSendState === undefined) { @@ -1523,6 +1577,20 @@ async function deliverOutboundPayloadsWithQueueCleanup( if (!params.deferCommitHooks) { await runOutboundDeliveryCommitHooks(results); } + emitTerminals(() => + hadPartialFailure + ? failedOutboundAuditTerminals({ + payloadCount: params.payloads.length, + results, + payloadOutcomes: auditPayloadOutcomes ?? [], + failureStage: "platform_send", + }) + : completedOutboundAuditTerminals({ + payloadCount: params.payloads.length, + results, + payloadOutcomes: auditPayloadOutcomes ?? [], + }), + ); return results; } if (queueId) { @@ -1550,6 +1618,14 @@ async function deliverOutboundPayloadsWithQueueCleanup( // Direct ack is the fallback when the post-send marker cannot be // written. Once the row is gone, recovery cannot run these hooks. await runCommitHooksAfterAck(); + emitTerminals(() => + failedOutboundAuditTerminals({ + payloadCount: params.payloads.length, + results, + payloadOutcomes: auditPayloadOutcomes ?? [], + failureStage: "platform_send", + }), + ); } } else { const postSendState = @@ -1600,6 +1676,13 @@ async function deliverOutboundPayloadsWithQueueCleanup( if (acked) { queuedPostSendState = "acked"; await runCommitHooksAfterAck(); + emitTerminals(() => + completedOutboundAuditTerminals({ + payloadCount: params.payloads.length, + results, + payloadOutcomes: auditPayloadOutcomes ?? [], + }), + ); } } } @@ -1610,7 +1693,19 @@ async function deliverOutboundPayloadsWithQueueCleanup( } if (queueId) { if (isDeliveryAbortError(err)) { - await ackDelivery(queueId).catch(() => {}); + const acked = await ackDelivery(queueId) + .then(() => true) + .catch(() => false); + if (acked) { + emitTerminals(() => + failedOutboundAuditTerminals({ + payloadCount: params.payloads.length, + results: deliveredResults, + payloadOutcomes: auditPayloadOutcomes ?? [], + failureStage: "queue", + }), + ); + } } else if (!platformResultsReturned) { const sendEvidence = deliveredResults.length > 0 || @@ -1633,6 +1728,27 @@ async function deliverOutboundPayloadsWithQueueCleanup( ); } await runCommitHooksAfterAck(); + if (queuedPostSendState === "acked") { + emitTerminals(() => + failedOutboundAuditTerminals({ + payloadCount: params.payloads.length, + results: deliveredResults, + payloadOutcomes: auditPayloadOutcomes ?? [], + failureStage: err instanceof OutboundDeliveryError ? err.stage : "platform_send", + }), + ); + } + } else if (queuedPreSendState === "acked") { + // The best-effort marker fallback removed the durable row before + // provider I/O, so this owner must emit the stable queue terminal. + emitTerminals(() => + failedOutboundAuditTerminals({ + payloadCount: params.payloads.length, + results: deliveredResults, + payloadOutcomes: auditPayloadOutcomes ?? [], + failureStage: err instanceof OutboundDeliveryError ? err.stage : "platform_send", + }), + ); } else { const recordFailure = isProvenDeliveryNotSentError(err) ? failDeliveryBeforePlatformSend @@ -1644,6 +1760,15 @@ async function deliverOutboundPayloadsWithQueueCleanup( }); } } + } else { + emitTerminals(() => + failedOutboundAuditTerminals({ + payloadCount: params.payloads.length, + results: deliveredResults, + payloadOutcomes: auditPayloadOutcomes ?? [], + failureStage: err instanceof OutboundDeliveryError ? err.stage : "platform_send", + }), + ); } throw err; } @@ -1925,14 +2050,25 @@ async function deliverOutboundPayloadsCore( }; const normalizedPayloads = normalizePayloadsForChannelDelivery(outboundPayloadPlan, handler); const payloadOutcomes: OutboundPayloadDeliveryOutcome[] = []; + const effectiveDeliveryKinds = new Map(); const recordPayloadOutcome = (outcome: OutboundPayloadDeliveryOutcome): void => { - payloadOutcomes.push(outcome); - params.onPayloadDeliveryOutcome?.(outcome); + const deliveryKind = effectiveDeliveryKinds.get(outcome.index); + const recordedOutcome = + deliveryKind && outcome.status !== "suppressed" ? { ...outcome, deliveryKind } : outcome; + payloadOutcomes.push(recordedOutcome); + params.onPayloadDeliveryOutcome?.(recordedOutcome); }; - if (normalizedPayloads.length === 0 && payloads.length > 0) { - payloads.forEach((_payload, index) => { + if (normalizedPayloads.length === 0) { + for (const [index] of payloads.entries()) { recordPayloadOutcome(suppressedPayloadOutcome({ index, reason: "no_visible_payload" })); - }); + } + } else { + const normalizedPayloadIndexes = new Set(normalizedPayloads.map((entry) => entry.index)); + for (const [index] of payloads.entries()) { + if (!normalizedPayloadIndexes.has(index)) { + recordPayloadOutcome(suppressedPayloadOutcome({ index, reason: "no_visible_payload" })); + } + } } const deliveredMirrorPayloads: NormalizedOutboundPayload[] = []; const recordDeliveredMirrorPayload = ( @@ -1979,6 +2115,7 @@ async function deliverOutboundPayloadsCore( ); } for (const { index: payloadIndex, payload } of normalizedPayloads) { + const payloadResultStartIndex = results.length; let payloadSummary = buildPayloadSummary(payload); let deliveryKind: DiagnosticMessageDeliveryKind = "other"; let deliveryStartedAt = 0; @@ -2103,7 +2240,9 @@ async function deliverOutboundPayloadsCore( } payloadSummary = buildPayloadSummary(effectivePayload); const deliveryHandler = await getDeliveryHandler(payloadSummary.mediaUrls); - startDeliveryDiagnostics(deliveryKindForPayload(effectivePayload, payloadSummary)); + const effectiveDeliveryKind = deliveryKindForPayload(effectivePayload, payloadSummary); + effectiveDeliveryKinds.set(payloadIndex, effectiveDeliveryKind); + startDeliveryDiagnostics(effectiveDeliveryKind); params.onPayload?.(payloadSummary); const replyToResolution = resolveCurrentReplyTo(effectivePayload); @@ -2149,7 +2288,11 @@ async function deliverOutboundPayloadsCore( ); continue; } - recordPayloadOutcome({ index: payloadIndex, status: "sent", results: deliveredResults }); + recordPayloadOutcome({ + index: payloadIndex, + status: "sent", + results: deliveredResults, + }); recordDeliveredMirrorPayload(payloadSummary, deliveredResults); await maybePinDeliveredMessage({ handler: deliveryHandler, @@ -2307,19 +2450,6 @@ async function deliverOutboundPayloadsCore( lastMessageId = delivery.messageId; } } - await maybePinDeliveredMessage({ - handler: deliveryHandler, - payload: effectivePayload, - target: deliveryTarget, - messageId: firstMessageId, - gatewayClientScopes: params.gatewayClientScopes, - }); - await maybeNotifyAfterDeliveredPayload({ - handler: deliveryHandler, - payload: effectivePayload, - target: deliveryTarget, - results: results.slice(beforeCount), - }); const deliveredResults = results.slice(beforeCount); if (deliveredResults.length > 0) { recordPayloadOutcome({ @@ -2336,6 +2466,19 @@ async function deliverOutboundPayloadsCore( }), ); } + await maybePinDeliveredMessage({ + handler: deliveryHandler, + payload: effectivePayload, + target: deliveryTarget, + messageId: firstMessageId, + gatewayClientScopes: params.gatewayClientScopes, + }); + await maybeNotifyAfterDeliveredPayload({ + handler: deliveryHandler, + payload: effectivePayload, + target: deliveryTarget, + results: deliveredResults, + }); completeDeliveryDiagnostics(results.length - beforeCount); emitMessageSent({ success: results.length > beforeCount, @@ -2346,12 +2489,14 @@ async function deliverOutboundPayloadsCore( // A rejected adapter has no final return to reconcile with its progress // results. Keep the results, but never match them to a later payload. reportedResults = []; + const failedPayloadResults = results.slice(payloadResultStartIndex); recordPayloadOutcome({ index: payloadIndex, status: "failed", error: err, - sentBeforeError: results.length > 0, + sentBeforeError: failedPayloadResults.length > 0, stage: "platform_send", + results: failedPayloadResults, }); errorDeliveryDiagnostics(err); emitMessageSent({ diff --git a/src/infra/outbound/delivery-queue-recovery.ts b/src/infra/outbound/delivery-queue-recovery.ts index 62a35b62b3da..378a45a9b8cc 100644 --- a/src/infra/outbound/delivery-queue-recovery.ts +++ b/src/infra/outbound/delivery-queue-recovery.ts @@ -42,6 +42,12 @@ import { type QueuedDelivery, type QueuedDeliveryPayload, } from "./delivery-queue-storage.js"; +import { + completedOutboundAuditTerminals, + emitOutboundAuditTerminals, + failedOutboundAuditTerminals, + uniformOutboundAuditTerminals, +} from "./outbound-audit.js"; export { computeBackoffMs }; @@ -121,6 +127,40 @@ function createEmptyRecoverySummary(): RecoverySummary { }; } +function emitQueuedAuditTerminals( + entry: QueuedDelivery, + terminals: Parameters[0]["terminals"], +): void { + emitOutboundAuditTerminals({ + context: entry, + terminals, + startedAt: entry.enqueuedAt, + queueId: entry.id, + }); +} + +function queuedDeadLetterAuditTerminals(entry: QueuedDelivery) { + const ambiguous = + entry.recoveryState === "send_attempt_started" || entry.recoveryState === "unknown_after_send"; + if (ambiguous) { + return uniformOutboundAuditTerminals(entry.payloads.length, { + outcome: "unknown", + failureStage: "queue", + }); + } + return uniformOutboundAuditTerminals(entry.payloads.length, { + outcome: "failed", + failureStage: "queue", + }); +} + +function queuedUnknownAuditTerminals(entry: QueuedDelivery) { + return uniformOutboundAuditTerminals(entry.payloads.length, { + outcome: "unknown", + failureStage: "queue", + }); +} + export async function withActiveDeliveryClaim( entryId: string, fn: () => Promise, @@ -196,6 +236,7 @@ async function applyRecoveryDeliveryAdmission(params: { params.stateDir, ); if (result.status === "failed") { + emitQueuedAuditTerminals(params.entry, () => queuedDeadLetterAuditTerminals(params.entry)); params.log.warn( `${params.logLabel}: entry ${params.entry.id} permanently rejected before recovery: ${admission.reason}`, ); @@ -364,11 +405,13 @@ async function moveEntryToFailedWithLogging( entryId: string, log: RecoveryLogger, stateDir?: string, -): Promise { +): Promise { try { await moveToFailed(entryId, stateDir); + return true; } catch (err) { log.error(`Failed to move entry ${entryId} to failed/: ${String(err)}`); + return false; } } @@ -457,6 +500,14 @@ async function drainQueuedEntry(opts: { reconciliation, log: opts.log, }); + const result = buildReconciledSentResult(entry, reconciliation); + emitQueuedAuditTerminals(entry, () => + completedOutboundAuditTerminals({ + payloadCount: entry.payloads.length, + results: [result], + payloadOutcomes: [], + }), + ); opts.onRecovered?.(entry); opts.log.info(`Delivery entry ${entry.id} reconciled unknown_after_send as already sent`); return "recovered"; @@ -506,6 +557,7 @@ async function drainQueuedEntry(opts: { } try { await moveToFailed(entry.id, opts.stateDir); + emitQueuedAuditTerminals(entry, () => queuedUnknownAuditTerminals(entry)); return "moved-to-failed"; } catch (moveErr) { if (getErrnoCode(moveErr) === "ENOENT") { @@ -526,6 +578,11 @@ async function drainQueuedEntry(opts: { } } }; + const collectPayloadOutcome = (outcome: OutboundPayloadDeliveryOutcome): void => { + if (!payloadOutcomes.includes(outcome)) { + payloadOutcomes.push(outcome); + } + }; const runCommitHooksAfterAck = async (): Promise => { if (postSendState !== "acked" || commitHooksRun || deliveredResults.length === 0) { return; @@ -536,7 +593,7 @@ async function drainQueuedEntry(opts: { try { const result = await opts.deliver({ ...buildRecoveryDeliverParams(entry, opts.cfg, opts.stateDir), - onPayloadDeliveryOutcome: (outcome) => payloadOutcomes.push(outcome), + onPayloadDeliveryOutcome: collectPayloadOutcome, onDeliveryResult: async (deliveryResult) => { collectResults([deliveryResult]); postSendState ??= await persistRecoveredPostSendState({ @@ -566,6 +623,14 @@ async function drainQueuedEntry(opts: { ); if (postSendState === "acked") { await runCommitHooksAfterAck(); + emitQueuedAuditTerminals(entry, () => + failedOutboundAuditTerminals({ + payloadCount: entry.payloads.length, + results: deliveredResults, + payloadOutcomes, + failureStage: "platform_send", + }), + ); } } else { const recordFailure = failedOutcomes.every((outcome) => @@ -605,6 +670,13 @@ async function drainQueuedEntry(opts: { } } await runCommitHooksAfterAck(); + emitQueuedAuditTerminals(entry, () => + completedOutboundAuditTerminals({ + payloadCount: entry.payloads.length, + results, + payloadOutcomes, + }), + ); opts.onRecovered?.(entry); return "recovered"; } catch (err) { @@ -634,15 +706,44 @@ async function drainQueuedEntry(opts: { } if (postSendState === "acked") { await runCommitHooksAfterAck(); + emitQueuedAuditTerminals(entry, () => + failedOutboundAuditTerminals({ + payloadCount: entry.payloads.length, + results: deliveredResults, + payloadOutcomes, + failureStage: isOutboundDeliveryError(err) ? err.stage : "platform_send", + }), + ); } opts.log.warn( `Delivery entry ${entry.id} partially sent before recovery failed; preserving unknown_after_send`, ); return "failed"; } + if (!(await loadPendingDelivery(entry.id, opts.stateDir))) { + // A best-effort pre-send marker fallback may ack the row before provider + // I/O. Recovery then owns the stable queue terminal on provider rejection. + emitQueuedAuditTerminals(entry, () => + failedOutboundAuditTerminals({ + payloadCount: entry.payloads.length, + results: deliveredResults, + payloadOutcomes, + failureStage: isOutboundDeliveryError(err) ? err.stage : "platform_send", + }), + ); + return "failed"; + } if (isPermanentDeliveryError(errMsg)) { try { await moveToFailed(entry.id, opts.stateDir); + emitQueuedAuditTerminals(entry, () => + failedOutboundAuditTerminals({ + payloadCount: entry.payloads.length, + results: deliveredResults, + payloadOutcomes, + failureStage: "queue", + }), + ); return "moved-to-failed"; } catch (moveErr) { if (getErrnoCode(moveErr) === "ENOENT") { @@ -736,6 +837,9 @@ export async function drainPendingDeliveries(opts: { } throw err; } + emitQueuedAuditTerminals(currentEntry, () => + queuedDeadLetterAuditTerminals(currentEntry), + ); opts.log.warn( `${opts.logLabel}: entry ${currentEntry.id} exceeded max retries and was moved to failed/`, ); @@ -846,7 +950,16 @@ export async function recoverPendingDeliveries(opts: { opts.log.warn( `Delivery ${currentEntry.id} exceeded max retries (${currentEntry.retryCount}/${MAX_RETRIES}) — moving to failed/`, ); - await moveEntryToFailedWithLogging(currentEntry.id, opts.log, opts.stateDir); + const movedToFailed = await moveEntryToFailedWithLogging( + currentEntry.id, + opts.log, + opts.stateDir, + ); + if (movedToFailed) { + emitQueuedAuditTerminals(currentEntry, () => + queuedDeadLetterAuditTerminals(currentEntry), + ); + } summary.skippedMaxRetries += 1; continue; } diff --git a/src/infra/outbound/delivery-queue.recovery.test.ts b/src/infra/outbound/delivery-queue.recovery.test.ts index 8519e23c880c..ff38ba3a75a1 100644 --- a/src/infra/outbound/delivery-queue.recovery.test.ts +++ b/src/infra/outbound/delivery-queue.recovery.test.ts @@ -2,6 +2,11 @@ // reconciliation, commit hooks, and retry budget deferral. import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + onTrustedMessageAuditEvent, + resetMessageAuditEventsForTest, + type TrustedMessageAuditEvent, +} from "../../audit/message-audit-events.js"; import { openOpenClawStateDatabase } from "../../state/openclaw-state-db.js"; import { RECOVERY_REPLAY_SPACING_MS } from "../delivery-recovery.shared.js"; import { @@ -11,6 +16,7 @@ import { } from "./deliver-types.js"; import { attachOutboundDeliveryCommitHook } from "./delivery-commit-hooks.js"; import { + ackDelivery, enqueueDelivery, loadPendingDeliveries, markDeliveryPlatformOutcomeUnknown, @@ -60,6 +66,7 @@ describe("delivery-queue recovery", () => { const baseCfg = {}; beforeEach(() => { + resetMessageAuditEventsForTest(); resolveOutboundChannelMessageAdapterMock.mockReset(); }); @@ -124,6 +131,8 @@ describe("delivery-queue recovery", () => { }); it("permanently rejects provider-blocked rows before backoff or reconciliation", async () => { + const auditEvents: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => auditEvents.push(event)); const id = await enqueueDelivery( { channel: "slack", @@ -150,6 +159,7 @@ describe("delivery-queue recovery", () => { const deliver = vi.fn(); const { result } = await runRecovery({ deliver }); + unsubscribe(); expect(admitDeferredDelivery).toHaveBeenCalledWith( expect.objectContaining({ @@ -169,6 +179,13 @@ describe("delivery-queue recovery", () => { }); expect(readOutboundQueueStatus(tmpDir(), id)).toBe("failed"); expect(readQueuedEntry(tmpDir(), id).lastError).toBe("unsupported_enterprise_slack_delivery"); + expect(auditEvents).toHaveLength(1); + expect(auditEvents[0]).toMatchObject({ + sourceId: `message:outbound:queue:${id}:payload:0`, + status: "unknown", + outcome: "unknown", + failureStage: "queue", + }); resolveOutboundChannelMessageAdapterMock.mockReturnValue({ durableFinal: { admitDeferredDelivery: () => ({ status: "allowed" }) }, @@ -253,6 +270,8 @@ describe("delivery-queue recovery", () => { }); it("moves entries that exceeded max retries to failed/", async () => { + const auditEvents: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => auditEvents.push(event)); const id = await enqueueDelivery( { channel: "demo-channel-a", to: "+1", payloads: [{ text: "a" }] }, tmpDir(), @@ -261,14 +280,48 @@ describe("delivery-queue recovery", () => { const deliver = vi.fn(); const { result } = await runRecovery({ deliver }); + unsubscribe(); expect(deliver).not.toHaveBeenCalled(); expect(result.skippedMaxRetries).toBe(1); expect(result.deferredBackoff).toBe(0); expect(readOutboundQueueStatus(tmpDir(), id)).toBe("failed"); + expect(auditEvents).toHaveLength(1); + expect(auditEvents[0]).toMatchObject({ + sourceId: `message:outbound:queue:${id}:payload:0`, + outcome: "failed", + failureStage: "queue", + }); + }); + + it("audits max-retry deadletters as unknown when platform send may have started", async () => { + const auditEvents: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => auditEvents.push(event)); + const id = await enqueueDelivery( + { channel: "demo-channel-a", to: "+1", payloads: [{ text: "a" }] }, + tmpDir(), + ); + setQueuedEntryState(tmpDir(), id, { + retryCount: MAX_RETRIES, + platformSendStartedAt: Date.now(), + recoveryState: "send_attempt_started", + }); + + const { result } = await runRecovery({ deliver: vi.fn() }); + unsubscribe(); + + expect(result.skippedMaxRetries).toBe(1); + expect(auditEvents).toHaveLength(1); + expect(auditEvents[0]).toMatchObject({ + sourceId: `message:outbound:queue:${id}:payload:0`, + outcome: "unknown", + failureStage: "queue", + }); }); it("increments retryCount on failed recovery attempt", async () => { + const auditEvents: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => auditEvents.push(event)); await enqueueDelivery( { channel: "demo-channel-c", to: "#ch", payloads: [{ text: "x" }] }, tmpDir(), @@ -276,6 +329,7 @@ describe("delivery-queue recovery", () => { const deliver = vi.fn().mockRejectedValue(new Error("network down")); const { result } = await runRecovery({ deliver }); + unsubscribe(); expect(result.failed).toBe(1); expect(result.recovered).toBe(0); @@ -284,6 +338,7 @@ describe("delivery-queue recovery", () => { expect(entries).toHaveLength(1); expect(entries[0]?.retryCount).toBe(1); expect(entries[0]?.lastError).toBe("network down"); + expect(auditEvents).toEqual([]); }); it("keeps a repeated pre-connect recovery failure replayable", async () => { @@ -567,6 +622,8 @@ describe("delivery-queue recovery", () => { }); it("moves entries abandoned after platform send may have started to failed without reconciliation", async () => { + const auditEvents: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => auditEvents.push(event)); const id = await enqueueDelivery( { channel: "demo-channel-a", to: "+1", payloads: [{ text: "maybe sent" }] }, tmpDir(), @@ -580,6 +637,7 @@ describe("delivery-queue recovery", () => { const deliver = vi.fn().mockResolvedValue([]); const log = createRecoveryLog(); const { result } = await runRecovery({ deliver, log }); + unsubscribe(); expect(deliver).not.toHaveBeenCalled(); expect(result).toEqual({ @@ -591,6 +649,48 @@ describe("delivery-queue recovery", () => { expect(await loadPendingDeliveries(tmpDir())).toHaveLength(0); expect(readOutboundQueueStatus(tmpDir(), id)).toBe("failed"); expectMockMessageContaining(log.warn, "unknown_after_send"); + expect(auditEvents).toHaveLength(1); + expect(auditEvents[0]).toMatchObject({ + sourceId: `message:outbound:queue:${id}:payload:0`, + status: "unknown", + outcome: "unknown", + failureStage: "queue", + }); + }); + + it("reports every payload unknown when a multi-payload send is crash-ambiguous", async () => { + const auditEvents: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => auditEvents.push(event)); + const id = await enqueueDelivery( + { + channel: "demo-channel-a", + to: "+1", + payloads: [{ text: "sent" }, { text: "hidden" }], + }, + tmpDir(), + ); + setQueuedEntryState(tmpDir(), id, { + retryCount: 0, + platformSendStartedAt: Date.now(), + recoveryState: "unknown_after_send", + }); + + await runRecovery({ deliver: vi.fn() }); + unsubscribe(); + + expect(auditEvents).toHaveLength(2); + expect(auditEvents[0]).toMatchObject({ + sourceId: `message:outbound:queue:${id}:payload:0`, + status: "unknown", + outcome: "unknown", + resultCount: 0, + }); + expect(auditEvents[1]).toMatchObject({ + sourceId: `message:outbound:queue:${id}:payload:1`, + status: "unknown", + outcome: "unknown", + resultCount: 0, + }); }); it("moves started entries without reconciliation to failed instead of blindly replaying", async () => { @@ -663,6 +763,8 @@ describe("delivery-queue recovery", () => { }); it("acks unknown-after-send entries reconciled as already sent before commit hooks", async () => { + const auditEvents: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => auditEvents.push(event)); const id = await enqueueDelivery( { channel: "demo-channel-a", @@ -707,6 +809,7 @@ describe("delivery-queue recovery", () => { const deliver = vi.fn().mockResolvedValue([]); const { result } = await runRecovery({ deliver }); + unsubscribe(); expect(deliver).not.toHaveBeenCalled(); expect(result).toEqual({ @@ -758,6 +861,14 @@ describe("delivery-queue recovery", () => { expect(afterCommitInput.result?.messageId).toBe("platform-1"); expect(order).toEqual(["afterCommit"]); expect(await loadPendingDeliveries(tmpDir())).toHaveLength(0); + expect(auditEvents).toHaveLength(1); + expect(auditEvents[0]).toMatchObject({ + sourceId: `message:outbound:queue:${id}:payload:0`, + status: "succeeded", + outcome: "sent", + messageId: "platform-1", + resultCount: 1, + }); }); it("moves unknown-after-send entries to failed when adapter reports not sent", async () => { @@ -1120,6 +1231,49 @@ describe("delivery-queue recovery", () => { } }); + it("owns the stable terminal when recovery fallback ack precedes provider rejection", async () => { + const auditEvents: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => auditEvents.push(event)); + const id = await enqueueDelivery( + { + channel: "demo-channel-a", + to: "+1", + queuePolicy: "best_effort", + payloads: [{ text: "secret" }], + }, + tmpDir(), + ); + const deliver = vi.fn( + async (params: { + onPayloadDeliveryOutcome?: (outcome: OutboundPayloadDeliveryOutcome) => void; + }) => { + await ackDelivery(id, tmpDir()); + params.onPayloadDeliveryOutcome?.({ + index: 0, + status: "failed", + error: new Error("provider rejected send"), + sentBeforeError: false, + stage: "platform_send", + }); + throw new Error("provider rejected send"); + }, + ); + + const { result } = await runRecovery({ deliver }); + unsubscribe(); + + expect(result).toMatchObject({ recovered: 0, failed: 1 }); + expect(await loadPendingDeliveries(tmpDir())).toHaveLength(0); + expect(auditEvents).toHaveLength(1); + expect(auditEvents[0]).toMatchObject({ + sourceId: `message:outbound:queue:${id}:payload:0`, + outcome: "failed", + failureStage: "platform_send", + }); + expect(JSON.stringify(auditEvents)).not.toContain("secret"); + expect(JSON.stringify(auditEvents)).not.toContain("provider rejected send"); + }); + it("runs recovered commit hooks when marker fallback ack precedes a partial failure", async () => { await enqueueDelivery( { diff --git a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts index 70d9e2ed9496..a89fde8ce589 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -101,6 +101,7 @@ const mocks = vi.hoisted(() => ({ callGatewayLeastPrivilege: vi.fn(), randomIdempotencyKey: vi.fn(() => "idem-gateway-action"), maybeApplyTtsToPayload: vi.fn(async (params: { payload: unknown }) => params.payload), + prepareOutboundMirrorRoute: vi.fn(), })); vi.mock("./channel-resolution.js", () => ({ @@ -148,7 +149,12 @@ vi.mock("../../channels/plugins/bootstrap-registry.js", () => ({ vi.mock("./message-action-threading.js", async () => { const { createOutboundThreadingMock } = await import("./message-action-threading.test-helpers.js"); - return createOutboundThreadingMock(); + const threading = createOutboundThreadingMock(); + mocks.prepareOutboundMirrorRoute.mockImplementation(threading.prepareOutboundMirrorRoute); + return { + ...threading, + prepareOutboundMirrorRoute: mocks.prepareOutboundMirrorRoute, + }; }); function createAlwaysConfiguredPluginConfig(account: Record = { enabled: true }) { @@ -312,6 +318,7 @@ describe("runMessageAction plugin dispatch", () => { mocks.maybeApplyTtsToPayload.mockImplementation( async (params: { payload: unknown }) => params.payload, ); + mocks.prepareOutboundMirrorRoute.mockClear(); }); describe("alias-based plugin action dispatch", () => { @@ -2206,6 +2213,17 @@ describe("runMessageAction plugin dispatch", () => { handledBy: "core", payload: { ok: true }, }); + mocks.prepareOutboundMirrorRoute.mockResolvedValueOnce({ + resolvedThreadId: undefined, + outboundRoute: { + sessionKey: "agent:main:cardchat:channel:test-card", + baseSessionKey: "agent:main:cardchat:channel:test-card", + peer: { kind: "channel", id: "test-card" }, + chatType: "channel", + from: "cardchat:channel:test-card", + to: "channel:test-card", + }, + }); setActivePluginRegistry( createTestRegistry([ { @@ -2245,6 +2263,7 @@ describe("runMessageAction plugin dispatch", () => { clientName: "cli", mode: "cli", }, + agentId: "main", dryRun: false, }); @@ -2254,6 +2273,11 @@ describe("runMessageAction plugin dispatch", () => { expect(mocks.callGatewayLeastPrivilege).not.toHaveBeenCalled(); const executeCall = readMockCallArg(mocks.executeSendAction, "execute send call"); expectRecordFields(executeCall, { message: "Deployment trend" }, "execute send call"); + expectRecordFields( + readRecordField(executeCall, "ctx", "execute send context"), + { conversationType: "channel" }, + "execute send context", + ); expectRecordFields( readRecordField(executeCall, "payload", "execute send payload"), { text: "Deployment trend", presentation }, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index a3121ce69037..18bb91373029 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -1254,6 +1254,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise { to: "123456", content: "hi", requesterSessionKey: "agent:main:directchat:group:ops", + conversationType: "channel", requesterAccountId: "work", requesterSenderId: "attacker", mirror: { @@ -232,6 +233,8 @@ describe("sendMessage", () => { deliveryParams.session, { key: "agent:main:directchat:group:ops", + conversationType: "group", + conversationKind: "channel", requesterAccountId: "work", requesterSenderId: "attacker", }, diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index f1b1dd192b6a..11e13d57f6ec 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -1,6 +1,7 @@ // Outbound message entrypoint resolves channel/target, durable capability // requirements, payload plans, gateway fallback, and optional mirroring. import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; +import type { ChatType } from "../../channels/chat-type.js"; import { deriveDurableFinalDeliveryRequirements } from "../../channels/message/capabilities.js"; import { sendDurableMessageBatch, @@ -76,6 +77,8 @@ type MessageSendParams = { gifPlayback?: boolean; forceDocument?: boolean; accountId?: string; + /** Known destination conversation kind prepared by the caller. */ + conversationType?: ChatType; replyToId?: string; threadId?: string | number; dryRun?: boolean; @@ -380,6 +383,7 @@ export async function sendMessage(params: MessageSendParams): Promise { + beforeEach(() => resetMessageAuditEventsForTest()); + afterEach(() => resetMessageAuditEventsForTest()); + + it("keeps mixed logical payloads distinct under one durable queue intent", () => { + const events: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => events.push(event)); + try { + emitOutboundAuditTerminals({ + context: { + channel: "matrix", + to: "!room:target", + payloads: [{ text: "suppressed" }, { text: "sent" }], + session: { conversationKind: "channel" }, + mirror: { sessionKey: "secret-session", agentId: "mirror-agent", isGroup: true }, + }, + terminals: () => + completedOutboundAuditTerminals({ + payloadCount: 2, + results: [{ channel: "matrix", messageId: "platform-1" }], + payloadOutcomes: [ + { index: 0, status: "suppressed", reason: "no_visible_payload" }, + { + index: 1, + status: "sent", + deliveryKind: "media", + results: [{ channel: "matrix", messageId: "platform-1" }], + }, + ], + }), + startedAt: Date.now(), + queueId: "queue-1", + }); + } finally { + unsubscribe(); + } + + expect(events).toHaveLength(2); + expect(events.map((event) => event.sourceId)).toEqual([ + "message:outbound:queue:queue-1:payload:0", + "message:outbound:queue:queue-1:payload:1", + ]); + expect(events.map((event) => event.outcome)).toEqual(["suppressed", "sent"]); + expect(events[0]).toMatchObject({ + status: "blocked", + actorType: "agent", + actorId: "mirror-agent", + agentId: "mirror-agent", + conversationKind: "channel", + resultCount: 0, + }); + expect(events[0]).not.toHaveProperty("deliveryKind"); + expect(events[1]).toMatchObject({ + status: "succeeded", + deliveryKind: "media", + messageId: "platform-1", + resultCount: 1, + }); + expect(JSON.stringify(events)).not.toContain("secret-session"); + }); + + it("does not resolve terminal metadata without an active listener", () => { + const resolveTerminals = vi.fn(() => []); + emitOutboundAuditTerminals({ + context: { channel: "matrix", to: "!room:target", payloads: [{ text: "secret" }] }, + terminals: resolveTerminals, + startedAt: Date.now(), + }); + expect(resolveTerminals).not.toHaveBeenCalled(); + }); + + it("isolates terminal projection failures from delivery", () => { + const unsubscribe = onTrustedMessageAuditEvent(() => {}); + try { + expect(() => + emitOutboundAuditTerminals({ + context: { channel: "matrix", to: "!room:target", payloads: [{ text: "sent" }] }, + terminals: () => { + throw new Error("bad terminal projection"); + }, + startedAt: Date.now(), + }), + ).not.toThrow(); + + expect(() => + emitOutboundAuditTerminals({ + context: { channel: "matrix", to: "!room:target", payloads: [{ text: "sent" }] }, + terminals: uniformOutboundAuditTerminals(1, { + outcome: "sent", + results: [ + { + channel: "matrix", + messageId: "platform-1", + receipt: {} as never, + }, + ], + }), + startedAt: Date.now(), + }), + ).not.toThrow(); + } finally { + unsubscribe(); + } + }); + + it("preserves unknown delivery state without inventing a failure code", () => { + const events: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => events.push(event)); + try { + emitOutboundAuditTerminals({ + context: { channel: "matrix", to: "!room:target", payloads: [{ text: "sent?" }] }, + terminals: uniformOutboundAuditTerminals(1, { + outcome: "unknown", + failureStage: "platform_send", + }), + startedAt: Date.now(), + }); + } finally { + unsubscribe(); + } + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + status: "unknown", + outcome: "unknown", + failureStage: "platform_send", + resultCount: 0, + }); + expect(events[0]).not.toHaveProperty("errorCode"); + }); + + it("treats a missing adapter identity as unknown rather than a proven suppression", () => { + const events: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => events.push(event)); + try { + emitOutboundAuditTerminals({ + context: { channel: "matrix", to: "!room:target", payloads: [{ text: "sent?" }] }, + terminals: completedOutboundAuditTerminals({ + payloadCount: 1, + results: [], + payloadOutcomes: [ + { index: 0, status: "suppressed", reason: "adapter_returned_no_identity" }, + ], + }), + startedAt: Date.now(), + }); + } finally { + unsubscribe(); + } + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + status: "unknown", + outcome: "unknown", + failureStage: "platform_send", + resultCount: 0, + }); + expect(events[0]).not.toHaveProperty("reasonCode"); + expect(events[0]).not.toHaveProperty("deliveryKind"); + }); + + it("counts physical sends once across receipt representations and result fallbacks", () => { + const events: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => events.push(event)); + try { + emitOutboundAuditTerminals({ + context: { channel: "matrix", to: "!room:target", payloads: [{ text: "batch" }] }, + terminals: uniformOutboundAuditTerminals(1, { + outcome: "sent", + results: [ + { + channel: "matrix", + messageId: "aggregate-with-parts", + receipt: { + primaryPlatformMessageId: "part-1", + platformMessageIds: ["part-1", "part-2"], + parts: [ + { platformMessageId: "part-1", kind: "text", index: 0 }, + { platformMessageId: "part-2", kind: "text", index: 1 }, + ], + sentAt: Date.now(), + }, + }, + { + channel: "matrix", + messageId: "aggregate-with-ids", + receipt: { + platformMessageIds: ["id-1", "id-2", "id-3"], + parts: [], + sentAt: Date.now(), + }, + }, + { channel: "matrix", messageId: "single-result" }, + ], + }), + startedAt: Date.now(), + }); + } finally { + unsubscribe(); + } + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + status: "succeeded", + outcome: "sent", + resultCount: 6, + }); + }); + + function conversationKindFor( + context: Omit[0]["context"], "payloads">, + ): string | undefined { + const events: TrustedMessageAuditEvent[] = []; + const unsubscribe = onTrustedMessageAuditEvent((event) => events.push(event)); + try { + emitOutboundAuditTerminals({ + context: { ...context, payloads: [{ text: "x" }] }, + terminals: uniformOutboundAuditTerminals(1, { + outcome: "sent", + results: [{ channel: context.channel, messageId: "m-1" }], + }), + startedAt: Date.now(), + }); + } finally { + unsubscribe(); + } + return events[0]?.conversationKind; + } + + it("classifies direct only from a session route naming this exact destination", () => { + expect( + conversationKindFor({ + channel: "matrix", + to: "@user:server", + session: { key: "agent:main:matrix:default:direct:@user:server" }, + }), + ).toBe("direct"); + expect( + conversationKindFor({ + channel: "slack", + to: "channel:C0AGENT", + session: { key: "agent:main:slack:channel:c0agent" }, + }), + ).toBe("channel"); + // Channel-name-prefixed targets (telegram:999) must match route peer 999. + expect( + conversationKindFor({ + channel: "telegram", + to: "telegram:999", + session: { key: "agent:main:telegram:default:direct:999" }, + }), + ).toBe("direct"); + // An explicit group target must never validate a direct route. + expect( + conversationKindFor({ + channel: "slack", + to: "group:123", + session: { key: "agent:main:slack:default:direct:123" }, + }), + ).toBe("unknown"); + // The full canonical prefix grammar applies: room: is a group fact even + // when the direct route's peer id literally contains the prefix. + expect( + conversationKindFor({ + channel: "matrix", + to: "room:123", + session: { key: "agent:main:matrix:default:direct:room:123" }, + }), + ).toBe("unknown"); + // Nested provider+kind prefixes normalize in layers: discord:dm:123 -> 123. + expect( + conversationKindFor({ + channel: "discord", + to: "discord:dm:123", + session: { key: "agent:main:discord:dm:123" }, + }), + ).toBe("direct"); + // direct: is in the kind map even though the canonical strip default omits it. + expect( + conversationKindFor({ + channel: "whatsapp", + to: "direct:+15551234567", + session: { key: "agent:main:whatsapp:default:direct:+15551234567" }, + }), + ).toBe("direct"); + }); + + it("does not classify by a policy session that names another conversation", () => { + // Native command acting on a WhatsApp DM session, response delivered to a + // Matrix room: the acted-on session must not stamp the destination "direct". + expect( + conversationKindFor({ + channel: "matrix", + to: "!room:server", + session: { + key: "agent:main:matrix:default:direct:@user:server", + policyKey: "agent:main:whatsapp:default:direct:+15551234567", + conversationType: "direct", + }, + }), + ).toBe("unknown"); + }); + + it("lets weak origin facts escalate to group but never to direct", () => { + expect( + conversationKindFor({ + channel: "matrix", + to: "!room:server", + mirror: { sessionKey: "control-session", agentId: "a", isGroup: true }, + }), + ).toBe("group"); + expect( + conversationKindFor({ + channel: "matrix", + to: "!room:server", + mirror: { sessionKey: "control-session", agentId: "a", isGroup: false }, + }), + ).toBe("unknown"); + }); +}); diff --git a/src/infra/outbound/outbound-audit.ts b/src/infra/outbound/outbound-audit.ts new file mode 100644 index 000000000000..d5bf15061132 --- /dev/null +++ b/src/infra/outbound/outbound-audit.ts @@ -0,0 +1,445 @@ +import type { + AuditMessageConversationKind, + AuditMessageDeliveryKind, + AuditMessageFailureStage, + AuditOutboundMessageSuppressedReasonCode, +} from "../../audit/audit-event-types.js"; +import { + emitTrustedMessageAuditEvent, + hasTrustedMessageAuditListeners, +} from "../../audit/message-audit-events.js"; +// Projects one metadata-only audit terminal per logical outbound payload. +import type { ReplyPayload } from "../../auto-reply/types.js"; +import { + normalizeSessionPeerId, + parseSessionDeliveryRoute, +} from "../../sessions/session-key-utils.js"; +import { + resolveTargetPrefixedChannel, + stripTargetKindPrefix, + stripTargetProviderPrefix, +} from "./channel-target-prefix.js"; +import { + countPhysicalOutboundSends, + type OutboundDeliveryResult, + type OutboundPayloadDeliveryOutcome, +} from "./deliver-types.js"; +import type { DeliveryMirror } from "./mirror.js"; +import type { OutboundSessionContext } from "./session-context.js"; +import type { OutboundChannel } from "./targets.js"; + +type OutboundAuditDeliveryContext = { + channel: Exclude; + to: string; + accountId?: string; + payloads: readonly ReplyPayload[]; + replyPayloadSendingHook?: { runId?: string }; + session?: OutboundSessionContext; + mirror?: DeliveryMirror; +}; + +export type OutboundAuditTerminal = + | { + outcome: "sent"; + results: readonly OutboundDeliveryResult[]; + deliveryKind?: AuditMessageDeliveryKind; + } + | { + outcome: "suppressed"; + reasonCode: AuditOutboundMessageSuppressedReasonCode; + results?: readonly OutboundDeliveryResult[]; + } + | { + outcome: "failed"; + failureStage: AuditMessageFailureStage; + results?: readonly OutboundDeliveryResult[]; + sentBeforeError?: boolean; + deliveryKind?: AuditMessageDeliveryKind; + } + | { + outcome: "unknown"; + failureStage: AuditMessageFailureStage; + results?: readonly OutboundDeliveryResult[]; + sentBeforeError?: boolean; + }; + +export type IndexedOutboundAuditTerminal = { + payloadIndex: number; + terminal: OutboundAuditTerminal; +}; + +export function outboundQueueAuditSourceId(queueId: string, payloadIndex: number): string { + return `message:outbound:queue:${queueId}:payload:${payloadIndex}`; +} + +function outcomesByPayload( + outcomes: readonly OutboundPayloadDeliveryOutcome[], +): Map { + const indexed = new Map(); + for (const outcome of outcomes) { + const history = indexed.get(outcome.index) ?? []; + history.push(outcome); + indexed.set(outcome.index, history); + } + return indexed; +} + +function sentResults( + history: readonly OutboundPayloadDeliveryOutcome[], +): readonly OutboundDeliveryResult[] { + const sent = history.findLast( + (outcome): outcome is Extract => + outcome.status === "sent", + ); + return sent?.results ?? []; +} + +function hasUnknownAdapterSideEffect(history: readonly OutboundPayloadDeliveryOutcome[]): boolean { + return history.some( + (outcome) => + outcome.status === "suppressed" && outcome.reason === "adapter_returned_no_identity", + ); +} + +export function completedOutboundAuditTerminals(params: { + payloadCount: number; + results: readonly OutboundDeliveryResult[]; + payloadOutcomes: readonly OutboundPayloadDeliveryOutcome[]; +}): IndexedOutboundAuditTerminal[] { + const indexed = outcomesByPayload(params.payloadOutcomes); + return Array.from({ length: params.payloadCount }, (_, payloadIndex) => { + const history = indexed.get(payloadIndex) ?? []; + const latest = history.at(-1); + if (hasUnknownAdapterSideEffect(history)) { + return { + payloadIndex, + terminal: { outcome: "unknown", failureStage: "platform_send" }, + }; + } + if (latest?.status === "sent") { + return { + payloadIndex, + terminal: { + outcome: "sent", + results: latest.results, + ...(latest.deliveryKind ? { deliveryKind: latest.deliveryKind } : {}), + }, + }; + } + if (latest?.status === "suppressed") { + if (latest.reason === "adapter_returned_no_identity") { + return { + payloadIndex, + terminal: { outcome: "unknown", failureStage: "platform_send" }, + }; + } + return { + payloadIndex, + terminal: { outcome: "suppressed", reasonCode: latest.reason }, + }; + } + // Core delivery reports every original payload, including normalization + // suppressions. The single-payload fallback supports legacy recovery senders. + if (params.payloadCount === 1 && params.results.length > 0) { + return { payloadIndex, terminal: { outcome: "sent", results: params.results } }; + } + return { + payloadIndex, + terminal: { outcome: "suppressed", reasonCode: "no_visible_payload" }, + }; + }); +} + +export function failedOutboundAuditTerminals(params: { + payloadCount: number; + results: readonly OutboundDeliveryResult[]; + payloadOutcomes: readonly OutboundPayloadDeliveryOutcome[]; + failureStage: AuditMessageFailureStage; +}): IndexedOutboundAuditTerminal[] { + const indexed = outcomesByPayload(params.payloadOutcomes); + return Array.from({ length: params.payloadCount }, (_, payloadIndex) => { + const history = indexed.get(payloadIndex) ?? []; + const latest = history.at(-1); + if (hasUnknownAdapterSideEffect(history)) { + return { + payloadIndex, + terminal: { outcome: "unknown", failureStage: "platform_send" }, + }; + } + if (latest?.status === "sent") { + return { + payloadIndex, + terminal: { + outcome: "sent", + results: latest.results, + ...(latest.deliveryKind ? { deliveryKind: latest.deliveryKind } : {}), + }, + }; + } + if (latest?.status === "suppressed") { + if (latest.reason === "adapter_returned_no_identity") { + return { + payloadIndex, + terminal: { outcome: "unknown", failureStage: "platform_send" }, + }; + } + return { + payloadIndex, + terminal: { outcome: "suppressed", reasonCode: latest.reason }, + }; + } + const failedResults = latest?.status === "failed" ? (latest.results ?? []) : []; + const payloadResults = failedResults.length > 0 ? failedResults : sentResults(history); + const fallbackResults = params.payloadCount === 1 ? params.results : []; + const results = payloadResults.length > 0 ? payloadResults : fallbackResults; + return { + payloadIndex, + terminal: { + outcome: "failed", + failureStage: latest?.status === "failed" ? latest.stage : params.failureStage, + results, + sentBeforeError: + results.length > 0 || (latest?.status === "failed" && latest.sentBeforeError), + ...(latest?.status === "failed" && latest.deliveryKind + ? { deliveryKind: latest.deliveryKind } + : {}), + }, + }; + }); +} + +export function uniformOutboundAuditTerminals( + payloadCount: number, + terminal: OutboundAuditTerminal, +): IndexedOutboundAuditTerminal[] { + return Array.from({ length: payloadCount }, (_, payloadIndex) => ({ payloadIndex, terminal })); +} + +// Canonical target-kind prefixes (see channel-target-prefix.ts) mapped to the +// session-route kinds they may prove. An explicit target kind is a destination +// fact: "group:123" must never validate a direct:123 route, or direct mode +// over-collects. Provider/channel prefixes carry no kind information. +const TARGET_KIND_TO_ROUTE_KINDS: Record< + string, + readonly ("channel" | "direct" | "dm" | "group")[] +> = { + channel: ["channel"], + conversation: ["channel"], + thread: ["channel"], + group: ["group"], + room: ["group"], + direct: ["direct", "dm"], + dm: ["direct", "dm"], + user: ["direct", "dm"], +}; +const TARGET_PREFIX_RE = /^\s*([a-z][a-z0-9_-]*):/i; + +/** True when a parsed session route provably names this delivery's destination. */ +function routeNamesDestination( + route: ReturnType, + context: OutboundAuditDeliveryContext, +): route is NonNullable> { + if (!route || route.channel !== context.channel.toLowerCase()) { + return false; + } + // Targets can nest a provider prefix around a kind prefix ("discord:dm:123", + // alias "tg:123"), so strip the provider layer first — including registered + // plugin aliases proven to belong to this channel — and evaluate the kind on + // what remains. + const aliasChannel = resolveTargetPrefixedChannel(context.to); + const providerPrefixes = + aliasChannel === context.channel.toLowerCase() + ? [context.channel, TARGET_PREFIX_RE.exec(context.to)?.[1] ?? context.channel] + : [context.channel]; + const withoutProvider = stripTargetProviderPrefix(context.to, ...providerPrefixes); + const prefix = TARGET_PREFIX_RE.exec(withoutProvider)?.[1]?.toLowerCase(); + const allowedRouteKinds = prefix ? TARGET_KIND_TO_ROUTE_KINDS[prefix] : undefined; + if (allowedRouteKinds && !allowedRouteKinds.includes(route.peerKind)) { + return false; + } + const candidates = [ + context.to, + withoutProvider, + stripTargetKindPrefix(withoutProvider, Object.keys(TARGET_KIND_TO_ROUTE_KINDS)), + ]; + return candidates.some((candidate) => { + const normalized = normalizeSessionPeerId({ + channel: route.channel, + peerKind: route.peerKind, + peerId: candidate, + }); + return normalized !== "" && normalized.toLowerCase() === route.peerId.toLowerCase(); + }); +} + +// Privacy gate: a false "direct" over-collects under audit.messages="direct", +// so "direct" requires destination proof (caller-declared kind or a session +// route that names this exact channel+peer). Weaker origin/policy signals may +// only classify away from collection ("group"), never toward "direct". +function resolveConversationKind( + context: OutboundAuditDeliveryContext, +): AuditMessageConversationKind { + if (context.session?.conversationKind) { + // Declared destination facts only; see OutboundSessionContext.conversationKind. + return context.session.conversationKind; + } + const routeCandidates = [ + context.session?.policyKey, + context.session?.key, + context.mirror?.sessionKey, + ]; + for (const candidate of routeCandidates) { + const route = parseSessionDeliveryRoute(candidate); + if (routeNamesDestination(route, context)) { + return route.peerKind === "dm" || route.peerKind === "direct" ? "direct" : route.peerKind; + } + } + if (context.session?.conversationType === "group" || context.mirror?.isGroup === true) { + return "group"; + } + return "unknown"; +} + +function firstIdentifier(...values: Array): string | undefined { + for (const value of values) { + const normalized = value?.trim(); + // "unknown"/"suppressed" are adapter sentinel messageIds (telegram/slack + // outbound adapters), not platform identifiers; treating them as real ids + // would pseudonymize a constant and corrupt correlation refs. + if (normalized && normalized !== "unknown" && normalized !== "suppressed") { + return normalized; + } + } + return undefined; +} + +function resolveResultIdentifiers(results: readonly OutboundDeliveryResult[]): { + conversationId?: string; + messageId?: string; +} { + const last = results.at(-1); + if (!last) { + return {}; + } + const conversationId = firstIdentifier( + last.conversationId, + last.chatId, + last.channelId, + last.roomId, + last.toJid, + ); + const messageId = firstIdentifier( + last.messageId, + last.receipt?.primaryPlatformMessageId, + last.receipt?.platformMessageIds.at(-1), + ); + return { + ...(conversationId ? { conversationId } : {}), + ...(messageId ? { messageId } : {}), + }; +} + +/** + * Emits only after the owning lifecycle has made the delivery terminal. + * Queue retries share one source id, so recovery cannot duplicate the final row. + */ +function emitOutboundAuditTerminal(params: { + context: OutboundAuditDeliveryContext; + terminal: OutboundAuditTerminal; + startedAt: number; + sourceId?: string; + payloadIndex: number; +}): void { + try { + const { context, terminal } = params; + const results = terminal.results ?? []; + const agentId = context.session?.agentId ?? context.mirror?.agentId; + const identifiers = resolveResultIdentifiers(results); + const sentBeforeError = + (terminal.outcome === "failed" || terminal.outcome === "unknown") && + terminal.sentBeforeError === true; + const terminalFields = + terminal.outcome === "sent" + ? { + status: "succeeded" as const, + outcome: "sent" as const, + ...(terminal.deliveryKind ? { deliveryKind: terminal.deliveryKind } : {}), + } + : terminal.outcome === "suppressed" + ? { + status: "blocked" as const, + outcome: "suppressed" as const, + reasonCode: terminal.reasonCode, + } + : terminal.outcome === "unknown" + ? { + status: "unknown" as const, + outcome: "unknown" as const, + failureStage: terminal.failureStage, + } + : { + status: "failed" as const, + outcome: "failed" as const, + errorCode: + results.length > 0 || sentBeforeError + ? ("message_delivery_partial_failure" as const) + : ("message_delivery_failed" as const), + failureStage: terminal.failureStage, + ...(terminal.deliveryKind ? { deliveryKind: terminal.deliveryKind } : {}), + }; + emitTrustedMessageAuditEvent({ + ...(params.sourceId ? { sourceId: params.sourceId } : {}), + kind: "message", + action: "message.outbound.finished", + occurredAt: Date.now(), + ...terminalFields, + actorType: agentId ? "agent" : "system", + actorId: agentId ?? "gateway", + ...(agentId ? { agentId } : {}), + ...(context.replyPayloadSendingHook?.runId + ? { runId: context.replyPayloadSendingHook.runId } + : {}), + direction: "outbound", + channel: context.channel, + conversationKind: resolveConversationKind(context), + durationMs: Math.max(0, Date.now() - params.startedAt), + resultCount: countPhysicalOutboundSends(results), + ...(context.accountId ? { accountId: context.accountId } : {}), + targetId: context.to, + ...identifiers, + }); + } catch { + // Audit observers cannot alter delivery or queue semantics. + } +} + +/** Emits only after the owning lifecycle has made each logical payload terminal. */ +export function emitOutboundAuditTerminals(params: { + context: OutboundAuditDeliveryContext; + terminals: + | readonly IndexedOutboundAuditTerminal[] + | (() => readonly IndexedOutboundAuditTerminal[]); + startedAt: number; + queueId?: string; +}): void { + if (!hasTrustedMessageAuditListeners()) { + return; + } + let terminals: readonly IndexedOutboundAuditTerminal[]; + try { + terminals = typeof params.terminals === "function" ? params.terminals() : params.terminals; + } catch { + return; + } + for (const indexed of terminals) { + emitOutboundAuditTerminal({ + context: params.context, + terminal: indexed.terminal, + startedAt: params.startedAt, + payloadIndex: indexed.payloadIndex, + ...(params.queueId + ? { sourceId: outboundQueueAuditSourceId(params.queueId, indexed.payloadIndex) } + : {}), + }); + } +} diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index 4a0b2c466e85..d5905f17d6fd 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -350,6 +350,7 @@ describe("executeSendAction", () => { channel: "demo-outbound", params: {}, sessionKey: "agent:main:directchat:group:ops", + conversationType: "channel", requesterAccountId: "source-account", requesterSenderId: "attacker", accountId: "destination-account", @@ -361,6 +362,7 @@ describe("executeSendAction", () => { expectSingleCallFields(mocks.sendMessage, { requesterSessionKey: "agent:main:directchat:group:ops", + conversationType: "channel", requesterAccountId: "source-account", requesterSenderId: "attacker", accountId: "destination-account", diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index 3ffee0047957..b9597cab71d5 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -2,6 +2,7 @@ // message/poll path while preserving media policy and transcript mirrors. import type { AgentToolResult } from "../../agents/runtime/index.js"; import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; +import type { ChatType } from "../../channels/chat-type.js"; import type { InboundEventKind } from "../../channels/inbound-event/kind.js"; import type { ConversationReadInvocationOrigin } from "../../channels/plugins/conversation-read-origin.js"; import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js"; @@ -57,6 +58,8 @@ export type OutboundSendContext = { mediaAccess?: OutboundMediaAccess; mediaReadFile?: OutboundMediaReadFile; accountId?: string | null; + /** Known destination conversation kind prepared by the caller. */ + conversationType?: ChatType; sessionId?: string; inboundEventKind?: InboundEventKind; gateway?: OutboundGatewayContext; @@ -134,6 +137,7 @@ async function sendCoreMessage(params: { asVoice: params.asVoice, channel: params.ctx.channel || undefined, accountId: params.ctx.accountId ?? undefined, + conversationType: params.ctx.conversationType, replyToId: params.replyToId, threadId: params.threadId, gifPlayback: params.gifPlayback, diff --git a/src/infra/outbound/session-context.test.ts b/src/infra/outbound/session-context.test.ts index eb12be857641..093dea40f51b 100644 --- a/src/infra/outbound/session-context.test.ts +++ b/src/infra/outbound/session-context.test.ts @@ -127,6 +127,7 @@ describe("buildOutboundSessionContext", () => { ).toEqual({ key: "agent:main:generic", conversationType: "group", + conversationKind: "channel", }); expect( @@ -136,6 +137,7 @@ describe("buildOutboundSessionContext", () => { }), ).toEqual({ conversationType: "direct", + conversationKind: "direct", }); }); @@ -149,6 +151,7 @@ describe("buildOutboundSessionContext", () => { ).toEqual({ key: "agent:main:generic", conversationType: "group", + conversationKind: "group", }); expect( buildOutboundSessionContext({ @@ -157,6 +160,48 @@ describe("buildOutboundSessionContext", () => { }), ).toEqual({ conversationType: "direct", + conversationKind: "direct", + }); + }); + + it("derives direct conversation type from a canonical delivery session", () => { + expect( + buildOutboundSessionContext({ + cfg: {} as never, + sessionKey: "agent:main:discord:dm:U123", + }), + ).toEqual({ + key: "agent:main:discord:dm:U123", + conversationType: "direct", + }); + }); + + it("never derives the audit conversation kind from session-key parsing", () => { + // A policy key can name an acted-on session that is not the delivery + // destination; conversationKind must stay unset without declared facts. + expect( + buildOutboundSessionContext({ + cfg: {} as never, + sessionKey: "agent:main:discord:dm:U123", + policySessionKey: "agent:main:whatsapp:default:direct:+15551234567", + }), + ).toEqual({ + key: "agent:main:discord:dm:U123", + policyKey: "agent:main:whatsapp:default:direct:+15551234567", + conversationType: "direct", + }); + }); + + it("keeps an explicit conversation type authoritative over a direct fallback", () => { + expect( + buildOutboundSessionContext({ + cfg: {} as never, + conversationType: "channel", + isGroup: false, + }), + ).toEqual({ + conversationType: "group", + conversationKind: "channel", }); }); diff --git a/src/infra/outbound/session-context.ts b/src/infra/outbound/session-context.ts index 8e539e3e33f4..d3f1ca40a068 100644 --- a/src/infra/outbound/session-context.ts +++ b/src/infra/outbound/session-context.ts @@ -4,6 +4,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { parseSessionDeliveryRoute } from "../../routing/session-key.js"; import type { SilentReplyConversationType } from "../../shared/silent-reply-policy.js"; export type OutboundSessionContext = { @@ -35,6 +36,13 @@ export type OutboundSessionContext = { policyKey?: string; /** Explicit conversation type for policy resolution when a session key is generic. */ conversationType?: SilentReplyConversationType; + /** + * Caller-declared destination conversation kind for metadata-only audit + * projection. Never derived from session-key parsing: policy keys can name + * an acted-on session that is not the delivery destination, and a wrong + * "direct" here over-collects under audit.messages="direct". + */ + conversationKind?: "direct" | "group" | "channel"; /** Active agent id used for workspace-scoped media roots. */ agentId?: string; /** Originating account id used for requester-scoped group policy resolution. */ @@ -65,7 +73,20 @@ export function buildOutboundSessionContext(params: { }): OutboundSessionContext | undefined { const key = normalizeOptionalString(params.sessionKey); const policyKey = normalizeOptionalString(params.policySessionKey); - const normalizedChatType = normalizeChatType(params.conversationType ?? undefined); + const deliveryRoute = parseSessionDeliveryRoute(policyKey ?? key); + const declaredChatType = normalizeChatType(params.conversationType ?? undefined); + const normalizedChatType = declaredChatType ?? normalizeChatType(deliveryRoute?.peerKind); + // conversationKind feeds the metadata-only audit projection and must carry + // only caller-declared destination facts. Session-key parses can name a + // policy/acted-on session that is not this delivery's destination (native + // command target overrides); a guessed "direct" would over-collect under + // audit.messages="direct". Destination-gated parsing lives in outbound-audit. + const conversationKind = + declaredChatType ?? + (params.isGroup === true ? "group" : params.isGroup === false ? "direct" : undefined); + // conversationType keeps the historical policy derivation (declared type, + // then session-key parse, then isGroup) and intentionally folds channels + // into groups for silent-reply policy. const conversationType: SilentReplyConversationType | undefined = normalizedChatType === "group" || normalizedChatType === "channel" ? "group" @@ -92,6 +113,7 @@ export function buildOutboundSessionContext(params: { !key && !policyKey && !conversationType && + !conversationKind && !agentId && !requesterAccountId && !requesterSenderId && @@ -105,6 +127,7 @@ export function buildOutboundSessionContext(params: { ...(key ? { key } : {}), ...(policyKey ? { policyKey } : {}), ...(conversationType ? { conversationType } : {}), + ...(conversationKind ? { conversationKind } : {}), ...(agentId ? { agentId } : {}), ...(requesterAccountId ? { requesterAccountId } : {}), ...(requesterSenderId ? { requesterSenderId } : {}), diff --git a/src/infra/startup-migration-checkpoint.test.ts b/src/infra/startup-migration-checkpoint.test.ts index c814db93b770..8b9dc11a932f 100644 --- a/src/infra/startup-migration-checkpoint.test.ts +++ b/src/infra/startup-migration-checkpoint.test.ts @@ -3,7 +3,10 @@ import { mkdirSync } from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; -import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { + closeOpenClawStateDatabaseForTest, + OPENCLAW_STATE_SCHEMA_VERSION, +} from "../state/openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import { requireNodeSqlite } from "./node-sqlite.js"; import { @@ -118,11 +121,11 @@ describe("startup migration checkpoint", () => { const dbPath = resolveOpenClawStateSqlitePath(env); mkdirSync(path.dirname(dbPath), { recursive: true }); const db = new sqlite.DatabaseSync(dbPath); - db.exec("PRAGMA user_version = 2;"); + db.exec(`PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION + 1};`); db.close(); expect(() => acquireStartupMigrationLease({ env, nowMs: 1000, owner: "first" })).toThrow( - "newer schema version 2", + `newer schema version ${OPENCLAW_STATE_SCHEMA_VERSION + 1}`, ); const verify = new sqlite.DatabaseSync(dbPath, { readOnly: true }); diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index 1f80a7c64337..de1485f64da1 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -12,6 +12,7 @@ import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state- import { closeOpenClawStateDatabaseForTest, openOpenClawStateDatabase, + OPENCLAW_STATE_SCHEMA_VERSION, } from "../state/openclaw-state-db.js"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { @@ -374,6 +375,66 @@ function createEnv(stateDir: string): NodeJS.ProcessEnv { }; } +async function createLegacyAuditLedger(stateDir: string): Promise { + const databasePath = path.join(stateDir, "state", "openclaw.sqlite"); + await fs.mkdir(path.dirname(databasePath), { recursive: true }); + const db = new DatabaseSync(databasePath); + try { + db.exec(` + PRAGMA user_version = 1; + CREATE TABLE audit_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + source_id TEXT NOT NULL UNIQUE, + source_sequence INTEGER NOT NULL, + occurred_at INTEGER NOT NULL, + kind TEXT NOT NULL, + action TEXT NOT NULL, + status TEXT NOT NULL, + error_code TEXT, + actor_type TEXT NOT NULL, + actor_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + session_key TEXT, + session_id TEXT, + run_id TEXT NOT NULL, + tool_call_id TEXT, + tool_name TEXT + ); + INSERT INTO audit_events ( + sequence, + event_id, + source_id, + source_sequence, + occurred_at, + kind, + action, + status, + actor_type, + actor_id, + agent_id, + run_id + ) VALUES ( + 3, + 'event-before-v2', + 'run-before-v2:1:100:agent.run.started', + 1, + 100, + 'agent_run', + 'agent.run.started', + 'started', + 'agent', + 'main', + 'main', + 'run-before-v2' + ); + `); + } finally { + db.close(); + } + return databasePath; +} + async function createLegacyStateFixture(params?: { includePreKey?: boolean }) { const root = await createTempDir(); const stateDir = path.join(root, ".openclaw"); @@ -1744,6 +1805,52 @@ describe("state migrations", () => { expect(migrateLegacyState).toHaveBeenCalledOnce(); }); + it("previews and repairs the released audit ledger before other state migrations", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const databasePath = await createLegacyAuditLedger(stateDir); + const cfg = createConfig(); + + const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + expect(detected.preview).toContain( + "- Shared SQLite schema: audit event ledger → versioned message lifecycle schema", + ); + + const result = await runLegacyStateMigrations({ detected, config: cfg, env }); + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toContain( + "Migrated shared state audit event ledger → versioned message lifecycle schema", + ); + + closeOpenClawStateDatabaseForTest(); + const db = new DatabaseSync(databasePath); + try { + expect( + db + .prepare( + "SELECT sequence, event_id, source_id, schema_version FROM audit_events WHERE event_id = ?", + ) + .get("event-before-v2"), + ).toEqual({ + sequence: 3, + event_id: "event-before-v2", + source_id: "run-before-v2:1:100:agent.run.started", + schema_version: 1, + }); + expect(db.prepare("PRAGMA user_version").get()).toEqual({ + user_version: OPENCLAW_STATE_SCHEMA_VERSION, + }); + } finally { + db.close(); + } + + const after = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + expect(after.preview).not.toContain( + "- Shared SQLite schema: audit event ledger → versioned message lifecycle schema", + ); + }); + it("does not run plugin doctor migrations after shared state schema repair fails", async () => { const root = await createTempDir(); const stateDir = path.join(root, ".openclaw"); @@ -1753,7 +1860,7 @@ describe("state migrations", () => { await fs.mkdir(path.dirname(stateDbPath), { recursive: true }); const db = new DatabaseSync(stateDbPath); try { - db.exec("PRAGMA user_version = 2;"); + db.exec(`PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION + 1};`); } finally { db.close(); } @@ -1787,6 +1894,34 @@ describe("state migrations", () => { expect(migrateLegacyState).not.toHaveBeenCalled(); }); + it("does not mutate other legacy state after shared schema repair fails", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const cfg = createConfig(); + const stateDbPath = path.join(stateDir, "state", "openclaw.sqlite"); + const voiceWakePath = path.join(stateDir, "settings", "voicewake.json"); + await fs.mkdir(path.dirname(stateDbPath), { recursive: true }); + await fs.mkdir(path.dirname(voiceWakePath), { recursive: true }); + await fs.writeFile(voiceWakePath, JSON.stringify({ triggers: ["leave-me"] }), "utf8"); + const db = new DatabaseSync(stateDbPath); + try { + db.exec(`PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION + 1};`); + } finally { + db.close(); + } + + const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root }); + + expect(result.migrated).toBe(false); + expect(result.skipped).toBe(false); + expect(result.changes).toStrictEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("Failed migrating shared state database schema"); + await expect(fs.readFile(voiceWakePath, "utf8")).resolves.toContain("leave-me"); + await expect(fs.stat(`${voiceWakePath}.migrated`)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("reports plugin detector failures in read-only legacy state detection", async () => { const root = await createTempDir(); const stateDir = path.join(root, ".openclaw"); diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 51e0ed8c7571..14f26c641bdc 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -4411,7 +4411,13 @@ export async function detectLegacyStateMigrations(params: { ); } if (stateSchemaMigrations.length > 0) { - preview.push("- Shared SQLite schema: agent database registry primary key → agent_id,path"); + for (const migration of stateSchemaMigrations) { + preview.push( + migration.kind === "agent-databases-composite-primary-key" + ? "- Shared SQLite schema: agent database registry primary key → agent_id,path" + : "- Shared SQLite schema: audit event ledger → versioned message lifecycle schema", + ); + } preview.push( "- Rerun doctor after shared SQLite schema repair to detect plugin state migrations", ); @@ -5913,6 +5919,15 @@ export async function autoMigrateLegacyState(params: { const stateSchema = repairOpenClawStateDatabaseSchema({ env: { ...env, OPENCLAW_STATE_DIR: stateDir }, }); + if (stateSchema.warnings.length > 0) { + return { + migrated: stateDirResult.migrated || stateSchema.changes.length > 0, + skipped: false, + changes: [...stateDirResult.changes, ...stateSchema.changes], + warnings: [...stateDirResult.warnings, ...stateSchema.warnings], + ...(stateDirResult.notices?.length ? { notices: stateDirResult.notices } : {}), + }; + } const pluginDoctorConfig = params.pluginDoctorConfig ?? params.cfg; const pluginSessionStoreAgentIds = listPluginDoctorSessionStoreAgentIds({ config: pluginDoctorConfig, diff --git a/src/state/openclaw-agent-db.ts b/src/state/openclaw-agent-db.ts index f88c04441154..3d5965041d2d 100644 --- a/src/state/openclaw-agent-db.ts +++ b/src/state/openclaw-agent-db.ts @@ -154,8 +154,10 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string): runSqliteImmediateTransactionSync(db, () => { // Ownership and version checks must share the write transaction with the // schema update; concurrent openers must not overwrite another agent. - assertSupportedAgentSchemaVersion(db, pathname); + // Role/ownership gates before version: user_version is only meaningful + // within one schema role, and the global state DB now carries version 2. assertExistingSchemaOwner(readExistingSchemaMeta(db), agentId, pathname); + assertSupportedAgentSchemaVersion(db, pathname); // Version 1 keyed sources by path/source. Stable IDs keep FTS rowids valid // across VACUUM and make update/delete trigger lookups constant-time. migrateMemoryIndexSourcesIdentity(db); diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 7a5b97a2c7be..26e1222c5f9c 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -81,25 +81,46 @@ export interface ApnsRegistrations { } export interface AuditEvents { + account_ref: string | null; action: string; actor_id: string; actor_type: string; - agent_id: string; + agent_id: string | null; + channel: string | null; + conversation_kind: string | null; + conversation_ref: string | null; + delivery_kind: string | null; + direction: string | null; + duration_ms: number | null; error_code: string | null; event_id: string; + failure_stage: string | null; kind: string; + message_outcome: string | null; + message_ref: string | null; occurred_at: number; - run_id: string; + reason_code: string | null; + result_count: number | null; + run_id: string | null; + schema_version: Generated; sequence: Generated; session_id: string | null; session_key: string | null; source_id: string; source_sequence: number; status: string; + target_ref: string | null; tool_call_id: string | null; tool_name: string | null; } +export interface AuditIdentityKeys { + created_at: number; + id: Generated; + key: Uint8Array; + key_id: string; +} + export interface AuthProfileState { state_json: string; store_key: string; @@ -1102,6 +1123,7 @@ export interface DB { android_notification_recent_packages: AndroidNotificationRecentPackages; apns_registrations: ApnsRegistrations; audit_events: AuditEvents; + audit_identity_keys: AuditIdentityKeys; auth_profile_state: AuthProfileState; auth_profile_stores: AuthProfileStores; backup_runs: BackupRuns; diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index 2e51d7ef7675..a3d1d735fe96 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { DatabaseSync } from "node:sqlite"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; import { readCronRunLogEntriesSync } from "../cron/run-log.js"; @@ -19,7 +20,10 @@ import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js"; import { closeOpenClawStateDatabaseForTest, + detectOpenClawStateDatabaseSchemaMigrations, openOpenClawStateDatabase, + OPENCLAW_STATE_SCHEMA_VERSION, + repairOpenClawStateDatabaseSchema, runOpenClawStateWriteTransaction, } from "./openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "./openclaw-state-db.paths.js"; @@ -52,6 +56,174 @@ function statfsFixture(type: number): ReturnType { }; } +function createLegacyAuditStateDatabase(stateDir: string): string { + const databasePath = path.join(stateDir, "state", "openclaw.sqlite"); + fs.mkdirSync(path.dirname(databasePath), { recursive: true }); + const { DatabaseSync } = requireNodeSqlite(); + const db = new DatabaseSync(databasePath); + try { + db.exec(` + PRAGMA user_version = 1; + CREATE TABLE schema_meta ( + meta_key TEXT NOT NULL PRIMARY KEY, + role TEXT NOT NULL, + schema_version INTEGER NOT NULL, + agent_id TEXT, + app_version TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + INSERT INTO schema_meta ( + meta_key, + role, + schema_version, + created_at, + updated_at + ) VALUES ('primary', 'global', 1, 10, 10); + CREATE TABLE audit_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + source_id TEXT NOT NULL UNIQUE, + source_sequence INTEGER NOT NULL, + occurred_at INTEGER NOT NULL, + kind TEXT NOT NULL, + action TEXT NOT NULL, + status TEXT NOT NULL, + error_code TEXT, + actor_type TEXT NOT NULL, + actor_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + session_key TEXT, + session_id TEXT, + run_id TEXT NOT NULL, + tool_call_id TEXT, + tool_name TEXT + ); + CREATE INDEX idx_audit_events_time + ON audit_events(occurred_at DESC, sequence DESC); + CREATE INDEX idx_audit_events_agent_sequence + ON audit_events(agent_id, sequence DESC); + CREATE INDEX idx_audit_events_session_sequence + ON audit_events(session_key, sequence DESC); + CREATE INDEX idx_audit_events_run_sequence + ON audit_events(run_id, sequence DESC); + CREATE INDEX idx_audit_events_kind_sequence + ON audit_events(kind, sequence DESC); + CREATE INDEX idx_audit_events_status_sequence + ON audit_events(status, sequence DESC); + INSERT INTO audit_events ( + sequence, + event_id, + source_id, + source_sequence, + occurred_at, + kind, + action, + status, + actor_type, + actor_id, + agent_id, + run_id + ) VALUES ( + 7, + 'event-legacy', + 'run-legacy:1:100:agent.run.started', + 1, + 100, + 'agent_run', + 'agent.run.started', + 'started', + 'agent', + 'main', + 'main', + 'run-legacy' + ); + UPDATE sqlite_sequence SET seq = 40 WHERE name = 'audit_events'; + `); + } finally { + db.close(); + } + return databasePath; +} + +function createCanonicalAuditStateDatabase(stateDir: string): string { + const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir } }); + const databasePath = database.path; + closeOpenClawStateDatabaseForTest(); + return databasePath; +} + +function rebuildAuditEventsTable( + db: DatabaseSync, + transformCreateSql: (sql: string) => string, +): void { + const table = db + .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'audit_events'") + .get() as { sql?: unknown } | undefined; + if (typeof table?.sql !== "string") { + throw new Error("missing audit_events table SQL"); + } + const indexes = db + .prepare( + `SELECT sql + FROM sqlite_master + WHERE type = 'index' + AND tbl_name = 'audit_events' + AND sql IS NOT NULL + ORDER BY name`, + ) + .all() as Array<{ sql?: unknown }>; + const transformedCreateSql = transformCreateSql(table.sql); + if (transformedCreateSql === table.sql) { + throw new Error("audit_events test schema transform did not change the table"); + } + db.exec("DROP TABLE audit_events"); + db.exec(transformedCreateSql); + for (const index of indexes) { + if (typeof index.sql !== "string") { + throw new Error("missing audit_events index SQL"); + } + db.exec(index.sql); + } +} + +function insertAuditMarker( + db: DatabaseSync, + eventId: string, + sourceId: string, + sequence = 7, +): void { + db.prepare( + `INSERT INTO audit_events ( + sequence, event_id, source_id, source_sequence, occurred_at, kind, action, status, + actor_type, actor_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + sequence, + eventId, + sourceId, + sequence, + 100, + "message", + "message.inbound.processed", + "succeeded", + "system", + "gateway", + ); +} + +function expectNoncanonicalAuditSchemaRejected(stateDir: string, databasePath: string): void { + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + expect(detectOpenClawStateDatabaseSchemaMigrations(options)).toEqual([ + { kind: "audit-events-v2", path: databasePath }, + ]); + expect(() => openOpenClawStateDatabase(options)).toThrow(/noncanonical audit event schema/); + expect(repairOpenClawStateDatabaseSchema(options)).toEqual({ + changes: [], + warnings: [expect.stringContaining("cannot be repaired automatically")], + }); +} + afterAll(() => { cleanupTempDirs(stateDbTempDirs); }); @@ -93,6 +265,408 @@ describe("openclaw state database", () => { expect(database.path).toBe(path.join(stateDir, "state", "openclaw.sqlite")); }); + it("migrates the released audit ledger to message-compatible attribution exactly once", () => { + const stateDir = createTempStateDir(); + const databasePath = createLegacyAuditStateDatabase(stateDir); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + + expect(detectOpenClawStateDatabaseSchemaMigrations(options)).toEqual([ + { kind: "audit-events-v2", path: databasePath }, + ]); + expect(() => openOpenClawStateDatabase(options)).toThrow(/legacy audit event schema/); + + expect(repairOpenClawStateDatabaseSchema(options)).toEqual({ + changes: ["Migrated shared state audit event ledger → versioned message lifecycle schema"], + warnings: [], + }); + expect(repairOpenClawStateDatabaseSchema(options)).toEqual({ changes: [], warnings: [] }); + expect(detectOpenClawStateDatabaseSchemaMigrations(options)).toEqual([]); + + const { DatabaseSync } = requireNodeSqlite(); + const db = new DatabaseSync(databasePath); + try { + const columns = db.prepare("PRAGMA table_info(audit_events)").all() as Array<{ + name: string; + notnull: number; + }>; + const nullability = new Map(columns.map((column) => [column.name, column.notnull === 0])); + expect(nullability.get("schema_version")).toBe(false); + expect(nullability.get("source_sequence")).toBe(false); + expect(nullability.get("actor_id")).toBe(false); + expect(nullability.get("agent_id")).toBe(true); + expect(nullability.get("run_id")).toBe(true); + expect(columns.map((column) => column.name)).toEqual( + expect.arrayContaining([ + "direction", + "channel", + "conversation_kind", + "message_outcome", + "reason_code", + "delivery_kind", + "failure_stage", + "duration_ms", + "result_count", + "account_ref", + "conversation_ref", + "message_ref", + "target_ref", + ]), + ); + expect(db.prepare("SELECT * FROM audit_events").get()).toMatchObject({ + sequence: 7, + event_id: "event-legacy", + source_id: "run-legacy:1:100:agent.run.started", + schema_version: 1, + source_sequence: 1, + agent_id: "main", + run_id: "run-legacy", + channel: null, + direction: null, + }); + + db.prepare( + `INSERT INTO audit_events ( + event_id, + source_id, + source_sequence, + occurred_at, + kind, + action, + status, + actor_type, + actor_id, + direction, + channel, + conversation_kind, + account_ref + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + "event-message", + "message-source", + 2, + 200, + "message", + "message.received", + "succeeded", + "channel_sender", + "hmac-sha256:v1:sender", + "inbound", + "telegram", + "direct", + "hmac-sha256:v1:account", + ); + expect( + db + .prepare( + "SELECT sequence, schema_version, source_sequence, actor_id, agent_id, run_id FROM audit_events WHERE event_id = ?", + ) + .get("event-message"), + ).toEqual({ + sequence: 41, + schema_version: 1, + source_sequence: 2, + actor_id: "hmac-sha256:v1:sender", + agent_id: null, + run_id: null, + }); + const indexNames = ( + db.prepare("PRAGMA index_list(audit_events)").all() as Array<{ name: string }> + ).map((index) => index.name); + expect(indexNames).toEqual( + expect.arrayContaining([ + "idx_audit_events_time", + "idx_audit_events_agent_sequence", + "idx_audit_events_session_sequence", + "idx_audit_events_run_sequence", + "idx_audit_events_kind_sequence", + "idx_audit_events_status_sequence", + "idx_audit_events_channel_sequence", + "idx_audit_events_direction_sequence", + ]), + ); + expect(readSqliteNumberPragma(db, "user_version")).toBe(OPENCLAW_STATE_SCHEMA_VERSION); + expect( + db.prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'").get(), + ).toEqual({ schema_version: OPENCLAW_STATE_SCHEMA_VERSION }); + expect(() => + db + .prepare( + "INSERT INTO audit_identity_keys (id, key_id, key, created_at) VALUES (1, ?, ?, ?)", + ) + .run("key-v1", new Uint8Array([1, 2, 3]), 100), + ).not.toThrow(); + expect(() => + db + .prepare( + "INSERT INTO audit_identity_keys (id, key_id, key, created_at) VALUES (2, ?, ?, ?)", + ) + .run("key-v2", new Uint8Array([4, 5, 6]), 200), + ).toThrow(); + } finally { + db.close(); + } + }); + + it("preserves an empty audit ledger's sequence high-water mark", () => { + const stateDir = createTempStateDir(); + const databasePath = createLegacyAuditStateDatabase(stateDir); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const { DatabaseSync } = requireNodeSqlite(); + const legacy = new DatabaseSync(databasePath); + legacy.exec( + "DELETE FROM audit_events; UPDATE sqlite_sequence SET seq = 73 WHERE name = 'audit_events';", + ); + legacy.close(); + + expect(repairOpenClawStateDatabaseSchema(options).warnings).toEqual([]); + + const migrated = new DatabaseSync(databasePath); + try { + migrated + .prepare( + `INSERT INTO audit_events ( + event_id, source_id, source_sequence, occurred_at, kind, action, status, + actor_type, actor_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + "event-after-empty-migration", + "source-after-empty-migration", + 1, + 200, + "message", + "message.inbound.processed", + "succeeded", + "system", + "gateway", + ); + expect( + migrated + .prepare("SELECT sequence FROM audit_events WHERE event_id = ?") + .get("event-after-empty-migration"), + ).toEqual({ sequence: 74 }); + } finally { + migrated.close(); + } + }); + + it("refuses an audit sequence high-water mark outside the supported cursor range", () => { + const stateDir = createTempStateDir(); + const databasePath = createLegacyAuditStateDatabase(stateDir); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const { DatabaseSync } = requireNodeSqlite(); + const legacy = new DatabaseSync(databasePath); + legacy.exec("UPDATE sqlite_sequence SET seq = 9007199254740992 WHERE name = 'audit_events';"); + legacy.close(); + + expect(repairOpenClawStateDatabaseSchema(options)).toEqual({ + changes: [], + warnings: [expect.stringContaining("exceeds the supported integer range")], + }); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + preserved + .prepare( + "SELECT CAST(seq AS TEXT) AS seq FROM sqlite_sequence WHERE name = 'audit_events'", + ) + .get(), + ).toEqual({ seq: "9007199254740992" }); + expect( + preserved.prepare("SELECT event_id FROM audit_events WHERE sequence = 7").get(), + ).toEqual({ event_id: "event-legacy" }); + } finally { + preserved.close(); + } + }); + + it("lets normal open create an audit ledger for a pre-v2 database", () => { + const stateDir = createTempStateDir(); + const databasePath = createLegacyAuditStateDatabase(stateDir); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const { DatabaseSync } = requireNodeSqlite(); + const legacy = new DatabaseSync(databasePath); + legacy.exec("DROP TABLE audit_events"); + legacy.close(); + + expect(detectOpenClawStateDatabaseSchemaMigrations(options)).toEqual([]); + expect(repairOpenClawStateDatabaseSchema(options)).toEqual({ changes: [], warnings: [] }); + const beforeOpen = new DatabaseSync(databasePath, { readOnly: true }); + expect(readSqliteNumberPragma(beforeOpen, "user_version")).toBe(1); + beforeOpen.close(); + + const opened = openOpenClawStateDatabase(options); + expect( + opened.db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'audit_events'") + .get(), + ).toEqual({ name: "audit_events" }); + expect(readSqliteNumberPragma(opened.db, "user_version")).toBe(OPENCLAW_STATE_SCHEMA_VERSION); + }); + + it("refuses to rebuild a noncanonical audit table with unknown data columns", () => { + const stateDir = createTempStateDir(); + const databasePath = createLegacyAuditStateDatabase(stateDir); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const { DatabaseSync } = requireNodeSqlite(); + const customized = new DatabaseSync(databasePath); + customized.exec("ALTER TABLE audit_events ADD COLUMN operator_note TEXT;"); + customized + .prepare("UPDATE audit_events SET operator_note = ? WHERE event_id = ?") + .run("preserve-me", "event-legacy"); + customized.close(); + + const result = repairOpenClawStateDatabaseSchema(options); + expect(result.changes).toEqual([]); + expect(result.warnings).toEqual([expect.stringContaining("cannot be repaired automatically")]); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + preserved + .prepare("SELECT operator_note FROM audit_events WHERE event_id = ?") + .get("event-legacy"), + ).toEqual({ operator_note: "preserve-me" }); + } finally { + preserved.close(); + } + }); + + it("refuses a v2 audit ledger without source identity uniqueness", () => { + const stateDir = createTempStateDir(); + const databasePath = createCanonicalAuditStateDatabase(stateDir); + const { DatabaseSync } = requireNodeSqlite(); + const malformed = new DatabaseSync(databasePath); + rebuildAuditEventsTable(malformed, (sql) => + sql.replace("source_id TEXT NOT NULL UNIQUE", "source_id TEXT NOT NULL"), + ); + insertAuditMarker(malformed, "event-duplicate-source-1", "duplicate-source", 7); + insertAuditMarker(malformed, "event-duplicate-source-2", "duplicate-source", 8); + malformed.close(); + + expectNoncanonicalAuditSchemaRejected(stateDir, databasePath); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + preserved + .prepare("SELECT COUNT(*) AS count FROM audit_events WHERE source_id = ?") + .get("duplicate-source"), + ).toEqual({ count: 2 }); + } finally { + preserved.close(); + } + }); + + it.each([ + ["a non-primary sequence", "sequence INTEGER"], + ["a sequence without AUTOINCREMENT", "sequence INTEGER PRIMARY KEY"], + ])("refuses a v2 audit ledger with %s", (_label, sequenceDeclaration) => { + const stateDir = createTempStateDir(); + const databasePath = createCanonicalAuditStateDatabase(stateDir); + const { DatabaseSync } = requireNodeSqlite(); + const malformed = new DatabaseSync(databasePath); + rebuildAuditEventsTable(malformed, (sql) => + sql.replace("sequence INTEGER PRIMARY KEY AUTOINCREMENT", sequenceDeclaration), + ); + insertAuditMarker(malformed, "event-sequence-shape", "source-sequence-shape"); + malformed.close(); + + expectNoncanonicalAuditSchemaRejected(stateDir, databasePath); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + preserved + .prepare("SELECT sequence FROM audit_events WHERE event_id = ?") + .get("event-sequence-shape"), + ).toEqual({ sequence: 7 }); + } finally { + preserved.close(); + } + }); + + it("refuses a v2 audit ledger with an extra column without dropping its data", () => { + const stateDir = createTempStateDir(); + const databasePath = createCanonicalAuditStateDatabase(stateDir); + const { DatabaseSync } = requireNodeSqlite(); + const malformed = new DatabaseSync(databasePath); + malformed.exec("ALTER TABLE audit_events ADD COLUMN operator_note TEXT"); + insertAuditMarker(malformed, "event-v2-custom-column", "source-v2-custom-column"); + malformed + .prepare("UPDATE audit_events SET operator_note = ? WHERE event_id = ?") + .run("preserve-v2", "event-v2-custom-column"); + malformed.close(); + + expectNoncanonicalAuditSchemaRejected(stateDir, databasePath); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + preserved + .prepare("SELECT operator_note FROM audit_events WHERE event_id = ?") + .get("event-v2-custom-column"), + ).toEqual({ operator_note: "preserve-v2" }); + } finally { + preserved.close(); + } + }); + + it("refuses to recreate a missing v2 audit ledger", () => { + const stateDir = createTempStateDir(); + const databasePath = createCanonicalAuditStateDatabase(stateDir); + const { DatabaseSync } = requireNodeSqlite(); + const malformed = new DatabaseSync(databasePath); + malformed.exec("DROP TABLE audit_events"); + malformed.close(); + + expectNoncanonicalAuditSchemaRejected(stateDir, databasePath); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + preserved + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'audit_events'") + .get(), + ).toBeUndefined(); + } finally { + preserved.close(); + } + }); + + it("refuses a malformed audit identity key singleton table", () => { + const stateDir = createTempStateDir(); + const databasePath = createCanonicalAuditStateDatabase(stateDir); + const { DatabaseSync } = requireNodeSqlite(); + const malformed = new DatabaseSync(databasePath); + malformed.exec(` + DROP TABLE audit_identity_keys; + CREATE TABLE audit_identity_keys ( + id INTEGER NOT NULL PRIMARY KEY CHECK (id > 0), + key_id TEXT NOT NULL, + key BLOB NOT NULL, + created_at INTEGER NOT NULL + ); + `); + malformed + .prepare("INSERT INTO audit_identity_keys (id, key_id, key, created_at) VALUES (?, ?, ?, ?)") + .run(2, "malformed-key", new Uint8Array([1, 2, 3]), 100); + malformed.close(); + + expectNoncanonicalAuditSchemaRejected(stateDir, databasePath); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect(preserved.prepare("SELECT id, key_id FROM audit_identity_keys").get()).toEqual({ + id: 2, + key_id: "malformed-key", + }); + } finally { + preserved.close(); + } + }); + it("creates the bounded skill curator tables", () => { const stateDir = createTempStateDir(); const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir } }); @@ -881,7 +1455,7 @@ describe("openclaw state database", () => { expect(readSqliteNumberPragma(database.db, "busy_timeout")).toBe(30_000); expect(readSqliteNumberPragma(database.db, "foreign_keys")).toBe(1); expect(readSqliteNumberPragma(database.db, "synchronous")).toBe(1); - expect(readSqliteNumberPragma(database.db, "user_version")).toBe(1); + expect(readSqliteNumberPragma(database.db, "user_version")).toBe(OPENCLAW_STATE_SCHEMA_VERSION); expect(readSqliteNumberPragma(database.db, "wal_autocheckpoint")).toBe(1000); const journalMode = database.db.prepare("PRAGMA journal_mode").get() as | { journal_mode?: string } @@ -916,7 +1490,7 @@ describe("openclaw state database", () => { database.db, stateDb.selectFrom("schema_meta").select(["role", "schema_version"]), ), - ).toEqual({ role: "global", schema_version: 1 }); + ).toEqual({ role: "global", schema_version: OPENCLAW_STATE_SCHEMA_VERSION }); }); it("refuses to open newer global schema versions", () => { @@ -925,14 +1499,14 @@ describe("openclaw state database", () => { fs.mkdirSync(path.dirname(databasePath), { recursive: true }); const { DatabaseSync } = requireNodeSqlite(); const db = new DatabaseSync(databasePath); - db.exec("PRAGMA user_version = 2;"); + db.exec(`PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION + 1};`); db.close(); expect(() => openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir }, }), - ).toThrow(/newer schema version 2/); + ).toThrow(new RegExp(`newer schema version ${OPENCLAW_STATE_SCHEMA_VERSION + 1}`)); }); it("does not chmod shared parent directories for explicit database paths", () => { @@ -963,7 +1537,7 @@ describe("openclaw state database", () => { expect(first.db.isOpen).toBe(true); expect(second.db.isOpen).toBe(true); expect(openOpenClawStateDatabase({ path: firstPath })).toBe(first); - expect(readSqliteNumberPragma(first.db, "user_version")).toBe(1); + expect(readSqliteNumberPragma(first.db, "user_version")).toBe(OPENCLAW_STATE_SCHEMA_VERSION); }); it("keys explicit relative paths by resolved database pathname", () => { diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index 89582a84ee69..8dce6331bffd 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -31,7 +31,7 @@ import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.generated.js" * tables, private file permissions, cached handles, and audit rows for * migrations/backups that operate on local state. */ -export const OPENCLAW_STATE_SCHEMA_VERSION = 1; +export const OPENCLAW_STATE_SCHEMA_VERSION = 2; /** Shared timeout used by state and agent SQLite handles before surfacing busy errors. */ export const OPENCLAW_SQLITE_BUSY_TIMEOUT_MS = 30_000; const OPENCLAW_STATE_DIR_MODE = 0o700; @@ -50,10 +50,15 @@ export type OpenClawStateDatabaseOptions = { path?: string; }; -export type OpenClawStateDatabaseSchemaMigration = { - kind: "agent-databases-composite-primary-key"; - path: string; -}; +export type OpenClawStateDatabaseSchemaMigration = + | { + kind: "agent-databases-composite-primary-key"; + path: string; + } + | { + kind: "audit-events-v2"; + path: string; + }; const cachedDatabases = new Map(); @@ -258,13 +263,376 @@ function repairAgentDatabasesCompositePrimaryKey(db: DatabaseSync): boolean { return true; } -function assertCanonicalStateSchemaShape(db: DatabaseSync, pathname: string): void { - if (hasCanonicalAgentDatabasesPrimaryKey(db)) { +const AUDIT_EVENT_STATE_SCHEMA_VERSION = 2; + +const AUDIT_EVENT_LEGACY_COLUMNS = [ + "sequence", + "event_id", + "source_id", + "source_sequence", + "occurred_at", + "kind", + "action", + "status", + "error_code", + "actor_type", + "actor_id", + "agent_id", + "session_key", + "session_id", + "run_id", + "tool_call_id", + "tool_name", +] as const; + +const AUDIT_EVENT_V2_COLUMNS = [ + "sequence", + "event_id", + "source_id", + "schema_version", + "source_sequence", + "occurred_at", + "kind", + "action", + "status", + "error_code", + "actor_type", + "actor_id", + "agent_id", + "session_key", + "session_id", + "run_id", + "tool_call_id", + "tool_name", + "direction", + "channel", + "conversation_kind", + "message_outcome", + "reason_code", + "delivery_kind", + "failure_stage", + "duration_ms", + "result_count", + "account_ref", + "conversation_ref", + "message_ref", + "target_ref", +] as const; + +type TableColumnInfo = { + name?: unknown; + notnull?: unknown; + pk?: unknown; +}; + +function tableColumnInfo(db: DatabaseSync, tableName: string): TableColumnInfo[] { + return db.prepare(`PRAGMA table_info(${tableName})`).all() as TableColumnInfo[]; +} + +function tableHasExactColumns( + db: DatabaseSync, + tableName: string, + expected: readonly string[], +): boolean { + const names = tableColumnInfo(db, tableName).map((column) => column.name); + return names.length === expected.length && names.every((name, index) => name === expected[index]); +} + +function tableHasRequiredColumns( + db: DatabaseSync, + tableName: string, + required: readonly string[], +): boolean { + const columns = new Map(tableColumnInfo(db, tableName).map((column) => [column.name, column])); + return required.every((name) => Number(columns.get(name)?.notnull ?? 0) === 1); +} + +function tableSql(db: DatabaseSync, tableName: string): string | undefined { + const row = db + .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(tableName) as { sql?: unknown } | undefined; + return typeof row?.sql === "string" ? row.sql : undefined; +} + +function tableHasUniqueColumn(db: DatabaseSync, tableName: string, columnName: string): boolean { + const indexes = db.prepare(`PRAGMA index_list(${tableName})`).all() as Array<{ + name?: unknown; + unique?: unknown; + }>; + return indexes.some((index) => { + if (Number(index.unique ?? 0) !== 1 || typeof index.name !== "string") { + return false; + } + const escaped = index.name.replaceAll("'", "''"); + const columns = db.prepare(`PRAGMA index_info('${escaped}')`).all() as Array<{ + name?: unknown; + }>; + return columns.length === 1 && columns[0]?.name === columnName; + }); +} + +function hasCanonicalAuditEventTable( + db: DatabaseSync, + expectedColumns: readonly string[], + requiredColumns: readonly string[], +): boolean { + const sql = tableSql(db, "audit_events")?.toLowerCase(); + return ( + tableHasExactColumns(db, "audit_events", expectedColumns) && + tablePrimaryKeyColumns(db, "audit_events").join(",") === "sequence" && + tableHasRequiredColumns(db, "audit_events", requiredColumns) && + typeof sql === "string" && + /\bsequence\s+integer\s+primary\s+key\s+autoincrement\b/.test(sql) && + tableHasUniqueColumn(db, "audit_events", "event_id") && + tableHasUniqueColumn(db, "audit_events", "source_id") + ); +} + +function hasCanonicalAuditIdentityKeyTable(db: DatabaseSync): boolean { + if (!tableExists(db, "audit_identity_keys")) { + return false; + } + const sql = tableSql(db, "audit_identity_keys")?.toLowerCase(); + return ( + tableHasExactColumns(db, "audit_identity_keys", ["id", "key_id", "key", "created_at"]) && + tablePrimaryKeyColumns(db, "audit_identity_keys").join(",") === "id" && + tableHasRequiredColumns(db, "audit_identity_keys", ["id", "key_id", "key", "created_at"]) && + typeof sql === "string" && + /\bcheck\s*\(\s*id\s*=\s*1\s*\)/.test(sql) + ); +} + +function hasCanonicalAuditEventsSchema(db: DatabaseSync): boolean { + if (!tableExists(db, "audit_events")) { + return ( + readSqliteUserVersion(db) < AUDIT_EVENT_STATE_SCHEMA_VERSION && + !tableExists(db, "audit_identity_keys") + ); + } + return ( + hasCanonicalAuditEventTable(db, AUDIT_EVENT_V2_COLUMNS, [ + "event_id", + "source_id", + "schema_version", + "source_sequence", + "occurred_at", + "kind", + "action", + "status", + "actor_type", + "actor_id", + ]) && hasCanonicalAuditIdentityKeyTable(db) + ); +} + +function canRepairLegacyAuditEventsSchema(db: DatabaseSync): boolean { + // Our own transactional repair cannot leave audit_events_migration_new + // behind, so an existing one is foreign data; fail closed rather than let + // repair silently drop it. + if ( + !tableExists(db, "audit_events") || + tableExists(db, "audit_events_migration_new") || + tableHasColumn(db, "audit_events", "schema_version") + ) { + return false; + } + const identityTableIsSafe = + !tableExists(db, "audit_identity_keys") || hasCanonicalAuditIdentityKeyTable(db); + return ( + identityTableIsSafe && + hasCanonicalAuditEventTable(db, AUDIT_EVENT_LEGACY_COLUMNS, [ + "event_id", + "source_id", + "source_sequence", + "occurred_at", + "kind", + "action", + "status", + "actor_type", + "actor_id", + "agent_id", + "run_id", + ]) + ); +} + +function readAuditEventSequenceHighWater(db: DatabaseSync): number | undefined { + if (!tableExists(db, "sqlite_sequence")) { + return undefined; + } + const row = db + .prepare("SELECT CAST(seq AS TEXT) AS seq FROM sqlite_sequence WHERE name = 'audit_events'") + .get() as { seq?: unknown } | undefined; + if (row === undefined) { + return undefined; + } + if (typeof row.seq !== "string" || !/^\d+$/.test(row.seq)) { + throw new Error("audit event sequence high-water mark is invalid"); + } + const sequence = BigInt(row.seq); + if (sequence > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error("audit event sequence high-water mark exceeds the supported integer range"); + } + return Number(sequence); +} + +function restoreAuditEventSequenceHighWater(db: DatabaseSync, sequence: number | undefined): void { + if (sequence === undefined) { return; } - throw new Error( - `OpenClaw state database ${pathname} has a legacy agent database registry schema; run openclaw doctor --fix to migrate it.`, - ); + db.prepare("DELETE FROM sqlite_sequence WHERE name = 'audit_events'").run(); + db.prepare("INSERT INTO sqlite_sequence (name, seq) VALUES ('audit_events', ?)").run(sequence); +} + +function repairAuditEventsSchema(db: DatabaseSync): boolean { + if (hasCanonicalAuditEventsSchema(db) || !canRepairLegacyAuditEventsSchema(db)) { + return false; + } + const sequenceHighWater = readAuditEventSequenceHighWater(db); + // This is the only shipped legacy shape. The surrounding doctor transaction + // rolls back the table swap and sequence restore together on any bad row. + // canRepairLegacyAuditEventsSchema refuses foreign audit_events_migration_new + // tables, so this CREATE never clobbers existing data. + db.exec(` + CREATE TABLE audit_events_migration_new ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + source_id TEXT NOT NULL UNIQUE, + schema_version INTEGER NOT NULL DEFAULT 1, + source_sequence INTEGER NOT NULL, + occurred_at INTEGER NOT NULL, + kind TEXT NOT NULL, + action TEXT NOT NULL, + status TEXT NOT NULL, + error_code TEXT, + actor_type TEXT NOT NULL, + actor_id TEXT NOT NULL, + agent_id TEXT, + session_key TEXT, + session_id TEXT, + run_id TEXT, + tool_call_id TEXT, + tool_name TEXT, + direction TEXT, + channel TEXT, + conversation_kind TEXT, + message_outcome TEXT, + reason_code TEXT, + delivery_kind TEXT, + failure_stage TEXT, + duration_ms INTEGER, + result_count INTEGER, + account_ref TEXT, + conversation_ref TEXT, + message_ref TEXT, + target_ref TEXT + ); + INSERT INTO audit_events_migration_new ( + sequence, + event_id, + source_id, + schema_version, + source_sequence, + occurred_at, + kind, + action, + status, + error_code, + actor_type, + actor_id, + agent_id, + session_key, + session_id, + run_id, + tool_call_id, + tool_name + ) + SELECT + sequence, + event_id, + source_id, + 1, + source_sequence, + occurred_at, + kind, + action, + status, + error_code, + actor_type, + actor_id, + agent_id, + session_key, + session_id, + run_id, + tool_call_id, + tool_name + FROM audit_events; + DROP TABLE audit_events; + ALTER TABLE audit_events_migration_new RENAME TO audit_events; + CREATE INDEX idx_audit_events_time + ON audit_events(occurred_at DESC, sequence DESC); + CREATE INDEX idx_audit_events_agent_sequence + ON audit_events(agent_id, sequence DESC); + CREATE INDEX idx_audit_events_session_sequence + ON audit_events(session_key, sequence DESC); + CREATE INDEX idx_audit_events_run_sequence + ON audit_events(run_id, sequence DESC); + CREATE INDEX idx_audit_events_kind_sequence + ON audit_events(kind, sequence DESC); + CREATE INDEX idx_audit_events_status_sequence + ON audit_events(status, sequence DESC); + CREATE INDEX idx_audit_events_channel_sequence + ON audit_events(channel, sequence DESC); + CREATE INDEX idx_audit_events_direction_sequence + ON audit_events(direction, sequence DESC); + CREATE TABLE IF NOT EXISTS audit_identity_keys ( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1), + key_id TEXT NOT NULL, + key BLOB NOT NULL, + created_at INTEGER NOT NULL + ); + `); + // AUTOINCREMENT is part of the stable cursor contract. Rebuilding an empty + // or sparsely retained table must not reuse a sequence already handed out. + restoreAuditEventSequenceHighWater(db, sequenceHighWater); + return true; +} + +function markCurrentStateSchemaVersion(db: DatabaseSync): void { + // Pre-v2 databases can legitimately predate the audit table. Leave their + // version untouched so normal open can create the complete v2 schema first. + if (!tableExists(db, "audit_events")) { + return; + } + db.exec(`PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION};`); + if ( + tableExists(db, "schema_meta") && + ["meta_key", "schema_version", "updated_at"].every((column) => + tableHasColumn(db, "schema_meta", column), + ) + ) { + db.prepare( + "UPDATE schema_meta SET schema_version = ?, updated_at = ? WHERE meta_key = 'primary'", + ).run(OPENCLAW_STATE_SCHEMA_VERSION, Date.now()); + } +} + +function assertCanonicalStateSchemaShape(db: DatabaseSync, pathname: string): void { + if (!hasCanonicalAgentDatabasesPrimaryKey(db)) { + throw new Error( + `OpenClaw state database ${pathname} has a legacy agent database registry schema; run openclaw doctor --fix to migrate it.`, + ); + } + if (!hasCanonicalAuditEventsSchema(db)) { + if (canRepairLegacyAuditEventsSchema(db)) { + throw new Error( + `OpenClaw state database ${pathname} has a legacy audit event schema; run openclaw doctor --fix to migrate it.`, + ); + } + throw new Error( + `OpenClaw state database ${pathname} has a noncanonical audit event schema that cannot be repaired automatically; restore the canonical audit_events shape before retrying.`, + ); + } } export function detectOpenClawStateDatabaseSchemaMigrations( @@ -277,9 +645,14 @@ export function detectOpenClawStateDatabaseSchemaMigrations( const sqlite = requireNodeSqlite(); const db = new sqlite.DatabaseSync(pathname, { readOnly: true }); try { - return hasCanonicalAgentDatabasesPrimaryKey(db) - ? [] - : [{ kind: "agent-databases-composite-primary-key", path: pathname }]; + const migrations: OpenClawStateDatabaseSchemaMigration[] = []; + if (!hasCanonicalAgentDatabasesPrimaryKey(db)) { + migrations.push({ kind: "agent-databases-composite-primary-key", path: pathname }); + } + if (!hasCanonicalAuditEventsSchema(db)) { + migrations.push({ kind: "audit-events-v2", path: pathname }); + } + return migrations; } finally { db.close(); } @@ -300,15 +673,21 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase db.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`); try { assertSupportedSchemaVersion(db, pathname); - const repaired = runSqliteImmediateTransactionSync(db, () => - repairAgentDatabasesCompositePrimaryKey(db), - ); - return repaired - ? { - changes: [`Migrated shared state agent database registry primary key → agent_id,path`], - warnings: [], - } - : { changes: [], warnings: [] }; + const changes = runSqliteImmediateTransactionSync(db, () => { + const applied: string[] = []; + if (repairAgentDatabasesCompositePrimaryKey(db)) { + applied.push(`Migrated shared state agent database registry primary key → agent_id,path`); + } + if (repairAuditEventsSchema(db)) { + applied.push( + `Migrated shared state audit event ledger → versioned message lifecycle schema`, + ); + } + assertCanonicalStateSchemaShape(db, pathname); + markCurrentStateSchemaVersion(db); + return applied; + }); + return { changes, warnings: [] }; } catch (err) { return { changes: [], @@ -957,38 +1336,40 @@ function ensureSchema(db: DatabaseSync, pathname: string): void { assertSupportedSchemaVersion(db, pathname); ensureAdditiveStateColumns(db); assertCanonicalStateSchemaShape(db, pathname); - db.exec(OPENCLAW_STATE_SCHEMA_SQL); - // Retired node_pairing_* tables were created by earlier schema revisions but - // never had a shipped writer (the node surface lives on device_pairing_paired - // records), so dropping the always-empty tables is safe, not destructive. - db.exec("DROP TABLE IF EXISTS node_pairing_pending; DROP TABLE IF EXISTS node_pairing_paired;"); - ensureAdditiveStateColumns(db); - db.exec(`PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION};`); const now = Date.now(); const kysely = getNodeSqliteKysely(db); - executeSqliteQuerySync( - db, - kysely - .insertInto("schema_meta") - .values({ - meta_key: "primary", - role: "global", - schema_version: OPENCLAW_STATE_SCHEMA_VERSION, - agent_id: null, - app_version: null, - created_at: now, - updated_at: now, - }) - .onConflict((conflict) => - conflict.column("meta_key").doUpdateSet({ + runSqliteImmediateTransactionSync(db, () => { + db.exec(OPENCLAW_STATE_SCHEMA_SQL); + // Retired node_pairing_* tables were created by earlier schema revisions but + // never had a shipped writer (the node surface lives on device_pairing_paired + // records), so dropping the always-empty tables is safe, not destructive. + db.exec("DROP TABLE IF EXISTS node_pairing_pending; DROP TABLE IF EXISTS node_pairing_paired;"); + db.exec(`PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION};`); + executeSqliteQuerySync( + db, + kysely + .insertInto("schema_meta") + .values({ + meta_key: "primary", role: "global", schema_version: OPENCLAW_STATE_SCHEMA_VERSION, agent_id: null, app_version: null, + created_at: now, updated_at: now, - }), - ), - ); + }) + .onConflict((conflict) => + conflict.column("meta_key").doUpdateSet({ + role: "global", + schema_version: OPENCLAW_STATE_SCHEMA_VERSION, + agent_id: null, + app_version: null, + updated_at: now, + }), + ), + ); + }); + ensureAdditiveStateColumns(db); } function resolveDatabasePath(options: OpenClawStateDatabaseOptions = {}): string { diff --git a/src/state/openclaw-state-schema.generated.ts b/src/state/openclaw-state-schema.generated.ts index 886a9c007153..9ab8fe3ea8f1 100644 --- a/src/state/openclaw-state-schema.generated.ts +++ b/src/state/openclaw-state-schema.generated.ts @@ -69,6 +69,7 @@ CREATE TABLE IF NOT EXISTS audit_events ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, source_id TEXT NOT NULL UNIQUE, + schema_version INTEGER NOT NULL DEFAULT 1, source_sequence INTEGER NOT NULL, occurred_at INTEGER NOT NULL, kind TEXT NOT NULL, @@ -77,12 +78,25 @@ CREATE TABLE IF NOT EXISTS audit_events ( error_code TEXT, actor_type TEXT NOT NULL, actor_id TEXT NOT NULL, - agent_id TEXT NOT NULL, + agent_id TEXT, session_key TEXT, session_id TEXT, - run_id TEXT NOT NULL, + run_id TEXT, tool_call_id TEXT, - tool_name TEXT + tool_name TEXT, + direction TEXT, + channel TEXT, + conversation_kind TEXT, + message_outcome TEXT, + reason_code TEXT, + delivery_kind TEXT, + failure_stage TEXT, + duration_ms INTEGER, + result_count INTEGER, + account_ref TEXT, + conversation_ref TEXT, + message_ref TEXT, + target_ref TEXT ); CREATE INDEX IF NOT EXISTS idx_audit_events_time @@ -103,6 +117,19 @@ CREATE INDEX IF NOT EXISTS idx_audit_events_kind_sequence CREATE INDEX IF NOT EXISTS idx_audit_events_status_sequence ON audit_events(status, sequence DESC); +CREATE INDEX IF NOT EXISTS idx_audit_events_channel_sequence + ON audit_events(channel, sequence DESC); + +CREATE INDEX IF NOT EXISTS idx_audit_events_direction_sequence + ON audit_events(direction, sequence DESC); + +CREATE TABLE IF NOT EXISTS audit_identity_keys ( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1), + key_id TEXT NOT NULL, + key BLOB NOT NULL, + created_at INTEGER NOT NULL +); + CREATE TABLE IF NOT EXISTS session_state_events ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, dedupe_key TEXT UNIQUE, diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 61c85eb4a58e..f3c1124a7fe7 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS audit_events ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, source_id TEXT NOT NULL UNIQUE, + schema_version INTEGER NOT NULL DEFAULT 1, source_sequence INTEGER NOT NULL, occurred_at INTEGER NOT NULL, kind TEXT NOT NULL, @@ -72,12 +73,25 @@ CREATE TABLE IF NOT EXISTS audit_events ( error_code TEXT, actor_type TEXT NOT NULL, actor_id TEXT NOT NULL, - agent_id TEXT NOT NULL, + agent_id TEXT, session_key TEXT, session_id TEXT, - run_id TEXT NOT NULL, + run_id TEXT, tool_call_id TEXT, - tool_name TEXT + tool_name TEXT, + direction TEXT, + channel TEXT, + conversation_kind TEXT, + message_outcome TEXT, + reason_code TEXT, + delivery_kind TEXT, + failure_stage TEXT, + duration_ms INTEGER, + result_count INTEGER, + account_ref TEXT, + conversation_ref TEXT, + message_ref TEXT, + target_ref TEXT ); CREATE INDEX IF NOT EXISTS idx_audit_events_time @@ -98,6 +112,19 @@ CREATE INDEX IF NOT EXISTS idx_audit_events_kind_sequence CREATE INDEX IF NOT EXISTS idx_audit_events_status_sequence ON audit_events(status, sequence DESC); +CREATE INDEX IF NOT EXISTS idx_audit_events_channel_sequence + ON audit_events(channel, sequence DESC); + +CREATE INDEX IF NOT EXISTS idx_audit_events_direction_sequence + ON audit_events(direction, sequence DESC); + +CREATE TABLE IF NOT EXISTS audit_identity_keys ( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1), + key_id TEXT NOT NULL, + key BLOB NOT NULL, + created_at INTEGER NOT NULL +); + CREATE TABLE IF NOT EXISTS session_state_events ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, dedupe_key TEXT UNIQUE,