From 618fba92bf010a4417ac07c979dfc1ca64df0165 Mon Sep 17 00:00:00 2001
From: Josh Avant <830519+joshavant@users.noreply.github.com>
Date: Tue, 4 Aug 2026 20:05:15 -0500
Subject: [PATCH] feat(audit): add execution identity inspection (#117034)
* feat(audit): add opt-in execution identity inspection
* fix(audit): gate recovery identity retention
* fix(audit): keep recovery identity type private
* test(audit): type internal recovery fixture
* test(audit): split recovery identity coverage
* docs(audit): define operator read trust boundary
* test(qa): register identity scenario child
* fix(audit): enforce shared identity retention bounds
* fix(audit): seal public ingress identity boundary
* fix(audit): keep ingress guard lint-clean
* fix(gateway): preserve advertised method order
* chore(protocol): sync advertised method order
* fix(protocol): encode audit selector invariants
* test(audit): prove exact execution guard
* fix(audit): keep identity storage lazy
---
.../openclaw/app/gateway/GatewayProtocol.kt | 1 +
.../OpenClawProtocol/GatewayModels.swift | 357 ++++++++
config/knip.all-exports.config.ts | 2 +
docs/cli/audit.md | 131 ++-
docs/cli/config.md | 1 +
docs/gateway/audit.md | 148 +++-
docs/gateway/configuration-reference.md | 12 +-
docs/gateway/operator-scopes.md | 9 +-
docs/gateway/protocol.md | 12 +-
extensions/discord/src/voice/ingress.test.ts | 49 ++
.../src/schema-export-registry.ts | 9 +
.../gateway-protocol/src/schema-modules.ts | 1 +
packages/gateway-protocol/src/schema/agent.ts | 3 +
.../src/schema/audit-run.test.ts | 160 ++++
.../gateway-protocol/src/schema/audit-run.ts | 317 +++++++
.../protocol-schema-fragment-operations.ts | 10 +
.../src/validator-registry.ts | 5 +
.../agent-run-identity-inspection.yaml | 37 +
scripts/check-protocol-registry.mjs | 4 +-
.../agent-command-execution-identity.ts | 51 ++
.../agent-command.live-model-switch.test.ts | 75 ++
src/agents/agent-command.ts | 75 +-
src/agents/command/types.ts | 12 +-
...-recovery-state.execution-identity.test.ts | 218 +++++
.../main-session-recovery-state.test.ts | 80 +-
src/agents/main-session-recovery-state.ts | 14 +
.../main-session-recovery-store.test.ts | 13 +
src/agents/main-session-recovery-types.ts | 13 +
src/agents/main-session-restart-dispatch.ts | 15 +
.../main-session-restart-recovery.test.ts | 87 +-
src/audit/audit-config.test.ts | 21 +-
src/audit/audit-config.ts | 5 +
src/audit/audit-event-writer.test.ts | 517 ++++++++++-
src/audit/audit-event-writer.ts | 77 +-
src/audit/audit-event-writer.worker.ts | 49 +-
src/audit/audit-events.test.ts | 5 +
src/audit/audit-identity.ts | 45 +-
src/audit/audit-recorder.test.ts | 1 +
src/audit/audit-recorder.ts | 3 +
.../execution-identity-admission.test.ts | 235 +++++
src/audit/execution-identity-admission.ts | 400 +++++++++
src/audit/execution-identity-context-build.ts | 147 ++++
src/audit/execution-identity-context.test.ts | 831 ++++++++++++++++++
src/audit/execution-identity-context.ts | 726 +++++++++++++++
src/cli/program/core-command-descriptors.ts | 2 +-
src/cli/program/register.audit.ts | 8 +-
src/commands/agent-exec.test.ts | 57 ++
src/commands/agent-exec.ts | 12 +
src/commands/agent-local-audit.ts | 24 +
src/commands/agent-via-gateway.test.ts | 61 ++
src/commands/agent-via-gateway.ts | 25 +-
src/commands/agent.test.ts | 62 ++
src/commands/audit.test-support.ts | 17 +
src/commands/audit.test.ts | 222 +++++
src/commands/audit.ts | 316 +++++++
src/config/schema.help.core.ts | 2 +
src/config/schema.labels.ts | 1 +
.../sessions/main-session-recovery.types.ts | 8 +
src/config/types.base.ts | 5 +
...od-schema.audit-execution-identity.test.ts | 27 +
src/config/zod-schema.root-shape.ts | 1 +
src/gateway/boot.test.ts | 6 +-
src/gateway/boot.ts | 5 +-
src/gateway/method-scopes.test.ts | 11 +
.../methods/core-descriptors.since.test.ts | 1 +
src/gateway/methods/core-descriptors.ts | 2 +
src/gateway/server-methods-list.test.ts | 9 +-
.../agent-request-preflight.test.ts | 23 +-
.../server-methods/agent-request-preflight.ts | 11 +
.../server-methods/agent-request-types.ts | 1 +
.../agent-restart-recovery-context.test.ts | 98 ++-
.../agent-restart-recovery-context.ts | 29 +-
.../agent-run-execution-phase.ts | 14 +-
src/gateway/server-methods/audit.test.ts | 66 +-
src/gateway/server-methods/audit.ts | 46 +-
src/gateway/server-node-events.test.ts | 19 +
.../server-runtime-subscriptions.test.ts | 26 +
src/gateway/server-runtime-subscriptions.ts | 5 +
.../agent-runtime-ingress-contract.test.ts | 19 +
src/state/openclaw-state-db-contract.ts | 6 +
src/state/openclaw-state-db.generated.d.ts | 11 +
src/state/openclaw-state-db.test.ts | 36 +-
src/state/openclaw-state-db.ts | 63 +-
src/state/openclaw-state-schema.sql | 15 +
.../runtime/agent-run-identity-inspection.ts | 525 +++++++++++
.../agent-run-identity-repeated-turn-child.ts | 45 +
86 files changed, 6713 insertions(+), 212 deletions(-)
create mode 100644 extensions/discord/src/voice/ingress.test.ts
create mode 100644 packages/gateway-protocol/src/schema/audit-run.test.ts
create mode 100644 packages/gateway-protocol/src/schema/audit-run.ts
create mode 100644 qa/scenarios/runtime/agent-run-identity-inspection.yaml
create mode 100644 src/agents/agent-command-execution-identity.ts
create mode 100644 src/agents/main-session-recovery-state.execution-identity.test.ts
create mode 100644 src/audit/execution-identity-admission.test.ts
create mode 100644 src/audit/execution-identity-admission.ts
create mode 100644 src/audit/execution-identity-context-build.ts
create mode 100644 src/audit/execution-identity-context.test.ts
create mode 100644 src/audit/execution-identity-context.ts
create mode 100644 src/commands/agent-local-audit.ts
create mode 100644 src/config/zod-schema.audit-execution-identity.test.ts
create mode 100644 src/plugin-sdk/agent-runtime-ingress-contract.test.ts
create mode 100644 test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts
create mode 100644 test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt
index 68ae109258c9..7578c6a48f09 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt
@@ -507,6 +507,7 @@ enum class GatewayMethod(
HooksStatus("hooks.status"),
TasksRetry("tasks.retry"),
TasksDismiss("tasks.dismiss"),
+ AuditRunInspect("audit.run.inspect"),
}
enum class GatewayEvent(
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
index 63ae69cbd4a2..00f7afbf4a8a 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
@@ -2665,6 +2665,7 @@ public struct AgentParams: Codable, Sendable {
public let bootstrapcontextrunkind: AnyCodable?
public let acpturnsource: String?
public let internalruntimehandoffid: String?
+ public let internalexecutionidentityretry: Bool?
public let execapprovalfollowupexpectedsessionid: String?
public let internalevents: [[String: AnyCodable]]?
public let inputprovenance: [String: AnyCodable]?
@@ -2713,6 +2714,7 @@ public struct AgentParams: Codable, Sendable {
bootstrapcontextrunkind: AnyCodable? = nil,
acpturnsource: String? = nil,
internalruntimehandoffid: String? = nil,
+ internalexecutionidentityretry: Bool? = nil,
execapprovalfollowupexpectedsessionid: String? = nil,
internalevents: [[String: AnyCodable]]? = nil,
inputprovenance: [String: AnyCodable]? = nil,
@@ -2760,6 +2762,7 @@ public struct AgentParams: Codable, Sendable {
self.bootstrapcontextrunkind = bootstrapcontextrunkind
self.acpturnsource = acpturnsource
self.internalruntimehandoffid = internalruntimehandoffid
+ self.internalexecutionidentityretry = internalexecutionidentityretry
self.execapprovalfollowupexpectedsessionid = execapprovalfollowupexpectedsessionid
self.internalevents = internalevents
self.inputprovenance = inputprovenance
@@ -2809,6 +2812,7 @@ public struct AgentParams: Codable, Sendable {
case bootstrapcontextrunkind = "bootstrapContextRunKind"
case acpturnsource = "acpTurnSource"
case internalruntimehandoffid = "internalRuntimeHandoffId"
+ case internalexecutionidentityretry = "internalExecutionIdentityRetry"
case execapprovalfollowupexpectedsessionid = "execApprovalFollowupExpectedSessionId"
case internalevents = "internalEvents"
case inputprovenance = "inputProvenance"
@@ -8528,6 +8532,322 @@ public struct AuditActivityListResult: Codable, Sendable {
}
}
+public struct ExecutionIdentityContextV1: Codable, Sendable {
+ public let schemaversion: Double
+ public let contextid: String
+ public let executionid: String
+ public let runid: String
+ public let createdat: Int
+ public let trustdomain: [String: AnyCodable]
+ public let invoker: [String: AnyCodable]
+ public let ingress: [String: AnyCodable]
+ public let agentprincipal: [String: AnyCodable]
+ public let agentdefinition: [String: AnyCodable]
+ public let runtimeinstance: [String: AnyCodable]
+ public let representedsubject: [String: AnyCodable]?
+ public let sponsor: [String: AnyCodable]?
+ public let applicablegrants: [[String: AnyCodable]]
+ public let assurance: [[String: AnyCodable]]
+ public let lineage: [String: AnyCodable]?
+ public let coveragestate: AnyCodable
+ public let missingevidence: [String]
+
+ public init(
+ schemaversion: Double,
+ contextid: String,
+ executionid: String,
+ runid: String,
+ createdat: Int,
+ trustdomain: [String: AnyCodable],
+ invoker: [String: AnyCodable],
+ ingress: [String: AnyCodable],
+ agentprincipal: [String: AnyCodable],
+ agentdefinition: [String: AnyCodable],
+ runtimeinstance: [String: AnyCodable],
+ representedsubject: [String: AnyCodable]? = nil,
+ sponsor: [String: AnyCodable]? = nil,
+ applicablegrants: [[String: AnyCodable]],
+ assurance: [[String: AnyCodable]],
+ lineage: [String: AnyCodable]? = nil,
+ coveragestate: AnyCodable,
+ missingevidence: [String])
+ {
+ self.schemaversion = schemaversion
+ self.contextid = contextid
+ self.executionid = executionid
+ self.runid = runid
+ self.createdat = createdat
+ self.trustdomain = trustdomain
+ self.invoker = invoker
+ self.ingress = ingress
+ self.agentprincipal = agentprincipal
+ self.agentdefinition = agentdefinition
+ self.runtimeinstance = runtimeinstance
+ self.representedsubject = representedsubject
+ self.sponsor = sponsor
+ self.applicablegrants = applicablegrants
+ self.assurance = assurance
+ self.lineage = lineage
+ self.coveragestate = coveragestate
+ self.missingevidence = missingevidence
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case schemaversion = "schemaVersion"
+ case contextid = "contextId"
+ case executionid = "executionId"
+ case runid = "runId"
+ case createdat = "createdAt"
+ case trustdomain = "trustDomain"
+ case invoker
+ case ingress
+ case agentprincipal = "agentPrincipal"
+ case agentdefinition = "agentDefinition"
+ case runtimeinstance = "runtimeInstance"
+ case representedsubject = "representedSubject"
+ case sponsor
+ case applicablegrants = "applicableGrants"
+ case assurance
+ case lineage
+ case coveragestate = "coverageState"
+ case missingevidence = "missingEvidence"
+ }
+}
+
+public struct DecisionReceiptV1: Codable, Sendable {
+ public let schemaversion: Double
+ public let receiptid: String
+ public let contextid: String
+ public let executionid: String
+ public let runid: String
+ public let actionid: String?
+ public let occurredat: Int
+ public let action: [String: AnyCodable]
+ public let decision: [String: AnyCodable]
+ public let enforcement: [String: AnyCodable]
+ public let source: [String: AnyCodable]
+ public let missingevidence: [String]
+ public let remediation: [[String: AnyCodable]]
+
+ public init(
+ schemaversion: Double,
+ receiptid: String,
+ contextid: String,
+ executionid: String,
+ runid: String,
+ actionid: String? = nil,
+ occurredat: Int,
+ action: [String: AnyCodable],
+ decision: [String: AnyCodable],
+ enforcement: [String: AnyCodable],
+ source: [String: AnyCodable],
+ missingevidence: [String],
+ remediation: [[String: AnyCodable]])
+ {
+ self.schemaversion = schemaversion
+ self.receiptid = receiptid
+ self.contextid = contextid
+ self.executionid = executionid
+ self.runid = runid
+ self.actionid = actionid
+ self.occurredat = occurredat
+ self.action = action
+ self.decision = decision
+ self.enforcement = enforcement
+ self.source = source
+ self.missingevidence = missingevidence
+ self.remediation = remediation
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case schemaversion = "schemaVersion"
+ case receiptid = "receiptId"
+ case contextid = "contextId"
+ case executionid = "executionId"
+ case runid = "runId"
+ case actionid = "actionId"
+ case occurredat = "occurredAt"
+ case action
+ case decision
+ case enforcement
+ case source
+ case missingevidence = "missingEvidence"
+ case remediation
+ }
+}
+
+public struct AuditRunIdentityPresentV1: Codable, Sendable {
+ public let state: String
+ public let context: ExecutionIdentityContextV1
+
+ public init(
+ state: String,
+ context: ExecutionIdentityContextV1)
+ {
+ self.state = state
+ self.context = context
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case state
+ case context
+ }
+}
+
+public struct AuditRunIdentityUnknownV1: Codable, Sendable {
+ public let state: String
+ public let reasoncode: String
+ public let missingevidence: [String]
+ public let remediation: [[String: AnyCodable]]
+
+ public init(
+ state: String,
+ reasoncode: String,
+ missingevidence: [String],
+ remediation: [[String: AnyCodable]])
+ {
+ self.state = state
+ self.reasoncode = reasoncode
+ self.missingevidence = missingevidence
+ self.remediation = remediation
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case state
+ case reasoncode = "reasonCode"
+ case missingevidence = "missingEvidence"
+ case remediation
+ }
+}
+
+public struct AuditRunIdentityUnsupportedV1: Codable, Sendable {
+ public let state: String
+ public let reasoncode: String
+ public let missingevidence: [String]
+ public let remediation: [[String: AnyCodable]]
+
+ public init(
+ state: String,
+ reasoncode: String,
+ missingevidence: [String],
+ remediation: [[String: AnyCodable]])
+ {
+ self.state = state
+ self.reasoncode = reasoncode
+ self.missingevidence = missingevidence
+ self.remediation = remediation
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case state
+ case reasoncode = "reasonCode"
+ case missingevidence = "missingEvidence"
+ case remediation
+ }
+}
+
+public struct AuditRunIdentityAmbiguousV1: Codable, Sendable {
+ public let state: String
+ public let reasoncode: String
+ public let candidates: [[String: AnyCodable]]
+ public let missingevidence: [String]
+ public let remediation: [[String: AnyCodable]]
+
+ public init(
+ state: String,
+ reasoncode: String,
+ candidates: [[String: AnyCodable]],
+ missingevidence: [String],
+ remediation: [[String: AnyCodable]])
+ {
+ self.state = state
+ self.reasoncode = reasoncode
+ self.candidates = candidates
+ self.missingevidence = missingevidence
+ self.remediation = remediation
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case state
+ case reasoncode = "reasonCode"
+ case candidates
+ case missingevidence = "missingEvidence"
+ case remediation
+ }
+}
+
+public struct AuditRunInspectParams: Codable, Sendable {
+ public let runid: String?
+ public let executionid: String?
+ public let executioncursor: String?
+ public let executionlimit: Int?
+ public let decisioncursor: String?
+ public let decisionlimit: Int?
+
+ public init(
+ runid: String? = nil,
+ executionid: String? = nil,
+ executioncursor: String? = nil,
+ executionlimit: Int? = nil,
+ decisioncursor: String? = nil,
+ decisionlimit: Int? = nil)
+ {
+ self.runid = runid
+ self.executionid = executionid
+ self.executioncursor = executioncursor
+ self.executionlimit = executionlimit
+ self.decisioncursor = decisioncursor
+ self.decisionlimit = decisionlimit
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case runid = "runId"
+ case executionid = "executionId"
+ case executioncursor = "executionCursor"
+ case executionlimit = "executionLimit"
+ case decisioncursor = "decisionCursor"
+ case decisionlimit = "decisionLimit"
+ }
+}
+
+public struct AuditRunInspectResult: Codable, Sendable {
+ public let schemaversion: Double
+ public let run: [String: AnyCodable]
+ public let identity: AuditRunIdentityV1
+ public let decisions: [DecisionReceiptV1]
+ public let coverage: [String: AnyCodable]
+ public let nextdecisioncursor: String?
+ public let nextexecutioncursor: String?
+
+ public init(
+ schemaversion: Double,
+ run: [String: AnyCodable],
+ identity: AuditRunIdentityV1,
+ decisions: [DecisionReceiptV1],
+ coverage: [String: AnyCodable],
+ nextdecisioncursor: String? = nil,
+ nextexecutioncursor: String? = nil)
+ {
+ self.schemaversion = schemaversion
+ self.run = run
+ self.identity = identity
+ self.decisions = decisions
+ self.coverage = coverage
+ self.nextdecisioncursor = nextdecisioncursor
+ self.nextexecutioncursor = nextexecutioncursor
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case schemaversion = "schemaVersion"
+ case run
+ case identity
+ case decisions
+ case coverage
+ case nextdecisioncursor = "nextDecisionCursor"
+ case nextexecutioncursor = "nextExecutionCursor"
+ }
+}
+
public struct AuditEvent: Codable, Sendable {
public let eventid: String
public let sequence: Int
@@ -17990,6 +18310,43 @@ public enum AuditActivityEventV1: Codable, Sendable {
}
}
+public enum AuditRunIdentityV1: Codable, Sendable {
+ case present(AuditRunIdentityPresentV1)
+ case unknown(AuditRunIdentityUnknownV1)
+ case unsupported(AuditRunIdentityUnsupportedV1)
+ case ambiguous(AuditRunIdentityAmbiguousV1)
+
+ private enum CodingKeys: String, CodingKey {
+ case discriminator = "state"
+ }
+
+ 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 "present": self = try .present(AuditRunIdentityPresentV1(from: decoder))
+ case "unknown": self = try .unknown(AuditRunIdentityUnknownV1(from: decoder))
+ case "unsupported": self = try .unsupported(AuditRunIdentityUnsupportedV1(from: decoder))
+ case "ambiguous": self = try .ambiguous(AuditRunIdentityAmbiguousV1(from: decoder))
+ default:
+ throw DecodingError.dataCorruptedError(
+ forKey: .discriminator,
+ in: container,
+ debugDescription: "Unknown AuditRunIdentityV1 discriminator value"
+ )
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ switch self {
+ case .present(let value): try value.encode(to: encoder)
+ case .unknown(let value): try value.encode(to: encoder)
+ case .unsupported(let value): try value.encode(to: encoder)
+ case .ambiguous(let value): try value.encode(to: encoder)
+ }
+ }
+}
+
public enum ApprovalPresentation: Codable, Sendable {
case exec(ExecApprovalPresentation)
case plugin(PluginApprovalPresentation)
diff --git a/config/knip.all-exports.config.ts b/config/knip.all-exports.config.ts
index 2237d238841f..da7d84465d7b 100644
--- a/config/knip.all-exports.config.ts
+++ b/config/knip.all-exports.config.ts
@@ -51,6 +51,8 @@ const ROOT_TEST_ENTRY_GLOBS = [
"test/e2e/qa-lab/runtime/agent-bundle-mcp-tools-docker-client.ts!",
"test/e2e/qa-lab/runtime/docker-e2e-lane.ts!",
"test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts!",
+ // The identity scenario spawns this process-isolated repeated-turn driver by path.
+ "test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts!",
// Invoked directly by the Docker image-auth scenario.
"test/e2e/qa-lab/runtime/openai-image-auth-docker-client.ts!",
"test/e2e/qa-lab/runtime/system-agent-first-run-docker-client.ts!",
diff --git a/docs/cli/audit.md b/docs/cli/audit.md
index 0314a3839521..85bde77e3dde 100644
--- a/docs/cli/audit.md
+++ b/docs/cli/audit.md
@@ -9,14 +9,28 @@ title: "Audit records"
# `openclaw audit`
-Query the Gateway's metadata-only audit ledger for agent runs, tool actions, and
-opt-in message lifecycle records.
+Query the Gateway's metadata-only activity ledger, discover executions that
+share a run correlation, or inspect immutable identity context for one exact
+agent execution.
-The ledger is on by default for run and tool events. Set
-[`logging.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 `logging.audit.messages` to `direct` or
-`all` and restart the Gateway to record them. Existing records stay queryable until they expire (30 days).
+Run and tool activity records are on by default. Execution identity is
+separately off by default on fresh installs and upgrades. Enable it explicitly:
+
+```bash
+openclaw config set logging.audit.executionIdentity true
+openclaw gateway restart
+```
+
+Identity collection requires `logging.audit.enabled` to remain enabled.
+Message records are also separately disabled by default; set
+`logging.audit.messages` to `direct` or `all` and restart the Gateway to
+record them. Existing records stay queryable until they expire (30 days).
+
+Direct local commands use the same bounded writer lifecycle as the Gateway.
+`openclaw agent exec` deletes its temporary state directory by default, so its
+audit evidence is intentionally discarded with the rest of that isolated run.
+Use `agent exec --state-dir
` when the run state must remain available,
+and inspect it through a Gateway using that same state directory.
The ledger is separate from conversation transcripts: it records identity,
ordering, provenance, action, status, and normalized outcome codes, but never
@@ -30,6 +44,10 @@ openclaw audit
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 --run 8c69f72e-8b11-4c54-98d5-1a3dd67450c3 --explain
+openclaw audit --execution 5da4c4c3-e1c9-4c95-a17d-6e5c10fd45cf --explain
+openclaw audit --execution 5da4c4c3-e1c9-4c95-a17d-6e5c10fd45cf --explain --json
+openclaw audit --run 8c69f72e-8b11-4c54-98d5-1a3dd67450c3 --explain --json
openclaw audit --kind tool_action --limit 50 --json
openclaw audit --kind message --direction outbound --channel telegram --json
```
@@ -38,7 +56,8 @@ openclaw audit --kind message --direction outbound --channel telegram --json
- `--agent `: exact agent id
- `--session `: exact session key
-- `--run `: exact run id
+- `--run `: exact run id; filters activity unless `--explain` is also set
+- `--execution `: exact execution id; requires `--explain`
- `--kind `: `agent_run`, `tool_action`, or `message`
- `--status `: `started`, `succeeded`, `failed`, `cancelled`,
`timed_out`, `blocked`, or `unknown`
@@ -46,8 +65,14 @@ openclaw audit --kind message --direction outbound --channel telegram --json
- `--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
+- `--limit `: activity page size from 1 to 500 (default `100`), decision
+ page size from 1 to 100, or ambiguous execution-candidate page size from 1
+ to 50 with `--explain` (default `50`)
+- `--cursor `: continue an activity, decision, or ambiguous
+ execution-candidate page
+- `--explain`: inspect immutable execution identity and run-admission reasoning;
+ requires exactly one of `--run` or `--execution` and accepts only `--limit`,
+ `--cursor`, and `--json`
- `--json`: print the bounded page as JSON
The CLI queries the versioned activity RPC so one command shows the complete
@@ -63,6 +88,74 @@ channels, outcomes, and stable HMAC references can correlate activity. Protect
them with the same access controls and retention practices as other operator
records.
+The Gateway intentionally exposes retained execution-identity diagnostics to
+every client with `operator.read` in its operator domain. That scope is a
+trusted read-only boundary, not hostile multi-tenant isolation. Use separate
+Gateway trust domains when operators must not share audit identity data.
+
+## Discover and explain executions
+
+Every admitted outer turn receives an opaque `executionId`. `contextId`
+identifies its immutable evidence record; the existing `runId` stays a
+possibly shared session, routing, or recovery correlation. Use `--run
+--explain` to discover retained executions rather than query the best-effort
+activity list. One match resolves directly. Multiple matches return
+`ambiguous`, list at most 50 candidates, and tell you to select one explicitly:
+
+```bash
+openclaw audit --execution --explain
+```
+
+OpenClaw never silently selects the first or latest execution. The exact text
+view renders these sections:
+
+1. **Identity**: trust domain, invoker, ingress, agent principal, agent
+ definition, runtime instance, represented subject, and sponsor.
+2. **Authority**: applicable grants and assurance evidence.
+3. **Lineage**: parent context or an explicit absent, unknown, or unsupported
+ state.
+4. **Decisions**: the bounded run-admission receipt page.
+5. **Missing evidence** and **Next steps**.
+
+Every field includes `present`, `absent`, `unknown`, or `unsupported`; the CLI
+does not infer a user from a session key, device id, display name, or shared
+credential. A direct local run currently shows authoritative `local-cli`
+ingress, an absent invoker, and
+`unattributed` coverage. Its admission receipt says `not-applicable` because no
+identity-aware policy or grant evaluation was proven.
+
+JSON output is the Gateway result without lossy reformatting. An exact result contains one
+bounded V1 context (maximum 16 KiB), up to 100 decision receipts, coverage and
+missing-evidence codes, and an optional `nextDecisionCursor`. An ambiguous run
+result instead contains at most 50 execution candidates and an optional
+`nextExecutionCursor`. Sensitive domain,
+runtime, invoker, assurance, ingress-source, and grant references are
+installation-local HMAC projections. Configured agent ids and exact run ids
+remain visible, as do context and execution ids, so redirected output is still
+private operator data.
+
+An older Gateway produces an explicit `unsupported` result with
+`gateway_upgrade_required` and an upgrade-and-rerun next step. The CLI never
+reconstructs identity from legacy audit rows. A current Gateway distinguishes
+an unknown run, an unavailable pre-feature, disabled, or failed context write,
+an expired context, and a corrupt context without claiming that missing
+best-effort activity proves no execution. A newly admitted run can also be
+temporarily unavailable while its bounded identity envelope waits in the audit
+writer queue; retry inspection after the run or normal process shutdown.
+Admission never waits for writer readiness, schema or HMAC-key initialization,
+SQLite, or persistence.
+
+Once a context is older than 30 days, the CLI returns no fields or admission
+decisions from it. While bounded cleanup is pending, the result is `unsupported`
+with an expiry-and-rerun next step. After cleanup it can become `unknown` if no
+separately retained activity remains; this absence does not prove that the run
+did not occur. Startup and hourly maintenance prune at most 1,024 identity
+contexts per tick and continue when collection is disabled. Queue saturation,
+worker/storage failure, cleanup failure, or abrupt process termination can lose
+best-effort evidence but never block or abort the agent run. Normal Gateway and
+direct-local CLI shutdown flushes accepted work when its writer lifecycle
+permits.
+
## Recorded events
The Gateway projects trusted lifecycle streams into six actions:
@@ -124,6 +217,24 @@ openclaw gateway call audit.activity.list --params '{"channel":"telegram","limit
The result is `{ "events": AuditActivityEventV1[], "nextCursor"?: string }`.
Results are newest first and limited to 500 records per request.
+`audit.run.inspect` also requires `operator.read`:
+
+```bash
+openclaw gateway call audit.run.inspect \
+ --params '{"runId":"8c69f72e-8b11-4c54-98d5-1a3dd67450c3","decisionLimit":50}'
+
+openclaw gateway call audit.run.inspect \
+ --params '{"executionId":"5da4c4c3-e1c9-4c95-a17d-6e5c10fd45cf","decisionLimit":50}'
+```
+
+Its result is `{ "schemaVersion": 1, "run": ..., "identity": ..., "decisions":
+..., "coverage": ..., "nextDecisionCursor"?: ..., "nextExecutionCursor"?: ... }`.
+The closed request accepts exactly one of `executionId` or `runId`.
+`decisionLimit` is 1–100 and `decisionCursor` is optional. Run discovery also
+accepts `executionLimit` from 1–50 and an optional `executionCursor`. A run
+with multiple retained executions returns the typed `ambiguous` identity state
+and no identity context or decisions until the caller selects an execution id.
+
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`,
diff --git a/docs/cli/config.md b/docs/cli/config.md
index 7bfa1365f8e0..f145d9a159e7 100644
--- a/docs/cli/config.md
+++ b/docs/cli/config.md
@@ -33,6 +33,7 @@ openclaw config get browser.executablePath
openclaw config set browser.executablePath "/usr/bin/google-chrome"
openclaw config set browser.profiles.work.executablePath "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
openclaw config set agents.defaults.heartbeat.every "2h"
+openclaw config set logging.audit.executionIdentity true
openclaw config set 'agents.entries.main.tools.exec.node' "node-id-or-name"
openclaw config set agents.defaults.models '{"openai/gpt-5.4":{}}' --strict-json --merge
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN
diff --git a/docs/gateway/audit.md b/docs/gateway/audit.md
index be9e816fff55..4593687798d3 100644
--- a/docs/gateway/audit.md
+++ b/docs/gateway/audit.md
@@ -20,6 +20,110 @@ normalized outcome codes. It never stores prompts, message bodies, tool
arguments, tool results, attachments, filenames, URLs, command output, or raw
error text.
+The Gateway also keeps an adjacent execution identity context for newly
+admitted agent runs. This context is authoritative for the identity facts it
+contains; it does not make the activity ledger lossless and does not turn audit
+records into authorization evidence.
+
+## Run identity inspection
+
+Execution identity recording is off by default, including on fresh installs
+and upgrades. Enable it explicitly, then restart the Gateway:
+
+```bash
+openclaw config set logging.audit.executionIdentity true
+openclaw gateway restart
+```
+
+Collection requires both `logging.audit.enabled` and
+`logging.audit.executionIdentity` to be true. Setting either to `false`
+stops new contexts after restart; no environment-variable alias or silent
+migration enables the feature. Retained contexts remain inspectable until
+their 30-day expiry.
+
+After session work admission succeeds, OpenClaw validates and freezes
+one bounded identity envelope, immediately offers it to the existing audit
+writer queue, and continues the run without waiting for writer readiness,
+SQLite, or persistence. The worker initializes schema and HMAC-key state,
+pseudonymizes raw references, constructs the immutable context, validates its
+canonical bytes, and persists it. An accepted envelope can therefore be
+temporarily unavailable to inspection while queued work finishes.
+
+Persistence remains best-effort. Queue saturation, worker or storage failure,
+and process crashes can lose evidence; they log only a bounded operational
+warning and never abort the run. Normal Gateway and direct-local CLI shutdown
+flushes accepted work when the writer lifecycle permits, but abrupt termination
+can still lose queued evidence.
+
+When identity collection is enabled, restart recovery stores only the safe
+execution/context/run ids and timestamp with its existing private recovery
+owner. A later ambiguous retry references that token instead of rebuilding
+identity from the new process. When collection or the audit ledger is disabled,
+recovery creates, stores, and propagates no new identity token. If the original
+queued context was lost, exact inspection stays explicitly unavailable; the
+retry never manufactures replacement evidence. Raw identity references are not
+stored in the recovery token.
+
+Each admitted outer turn receives a new opaque `executionId`; `contextId`
+identifies its immutable evidence record, while the existing `runId` remains a
+possibly shared routing, session, or recovery correlation. Query one exact
+execution with `audit.run.inspect` or
+[`openclaw audit --execution --explain`](/cli/audit). Use `--run
+--explain` to discover executions for a run correlation. One retained match
+resolves directly. Multiple matches return `ambiguous` with at most 50
+candidate execution ids and require exact selection; OpenClaw never chooses the
+first or latest execution silently. The result explicitly states the evidence
+state for these fields:
+
+- trust domain, invoker, and ingress;
+- agent principal, agent definition, and runtime instance;
+- represented subject and sponsor;
+- applicable grants and assurance evidence;
+- parent or child lineage when available.
+
+The foundation records direct local CLI ingress and Gateway boot-system ingress
+at their authoritative producers. Generic public ingress remains explicitly
+unknown when its boundary cannot prove a more specific source; OpenClaw never
+infers ingress or invoker identity from a session key. A direct local execution
+is `unattributed`: the Gateway cell, local CLI ingress, configured agent, and
+runtime binding are present, but no durable invoker principal is supplied at
+this boundary. A run becomes
+`attribution-only` only when an authoritative ingress supplies an invoker fact.
+Neither state means that identity affected an allow or deny decision.
+
+Each present context currently projects one run-admission receipt. Its outcome
+is `not-applicable`, its policy and grant references are empty, and its reason
+states that no identity-aware policy or grant evaluation was proven. This is
+an explanation of admission evidence, not an enforcement claim.
+
+Run inspection returns successful typed diagnostics instead of inventing
+facts:
+
+- `unknown`: the selected run or execution is not known, or expected context is
+ corrupt or unreadable;
+- `unsupported`: best-effort activity shows the run, but no context is
+ available, as with a pre-feature, disabled, or failed context write. A
+ context just beyond retention also uses this state while its bounded cleanup
+ is pending, with an explicit expiry remediation;
+- `ambiguous`: a `runId` has multiple retained executions; select a candidate
+ `executionId` before inspecting identity or decisions;
+- `unattributed`: the supported run has no usable invoker principal;
+- `attribution-only`: invoker attribution exists but was not evaluated for
+ authorization.
+
+The method requires `operator.read`. Requests are closed and select exactly one
+`executionId` or `runId`. Decision pages contain at most 100 receipts;
+ambiguous run-discovery pages contain at most 50 candidate executions. Both use
+bounded cursors.
+
+Every client with `operator.read` in the same Gateway operator domain may
+receive this retained identity category. This is intentional: the scope already
+covers logs and session reads, collection is explicit opt-in, retained
+references are bounded and pseudonymized, and optional display labels are
+secret-redacted. `operator.read` is not a hostile multi-tenant isolation
+boundary; use separate Gateway trust domains when operators must not share this
+diagnostic data.
+
## Record families
Run and tool events are recorded whenever auditing is enabled (the default).
@@ -89,6 +193,17 @@ 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.
+Execution identity contexts use the same installation-local key owner with a
+separate HMAC domain. Raw runtime, invoker, ingress-source, assurance, and grant
+references exist only in a deeply frozen, in-process worker message capped at
+16 KiB and 16 entries in each grant/assurance array. The worker replaces them with keyed
+pseudonyms before persistence; they are never stored, exported, inspected, or
+logged. Configured agent ids plus context, execution, and run ids remain
+operator-visible.
+Contexts never contain prompt or message text, command bodies, arguments,
+paths, credentials, environment values, or arbitrary plugin payloads. Each
+encoded context is also capped at 16 KiB.
+
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
@@ -100,8 +215,8 @@ 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.
+ plugin-local or direct-send paths that bypass shared durable delivery, a
+ dropped admission envelope, and crash-lost queued work can 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
@@ -123,6 +238,31 @@ 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.
+Execution identity contexts also live in the shared state database. Canonical
+rows are keyed by unique execution and context ids; `runId` is a non-unique,
+indexed correlation. Their
+additive table is created lazily on first use without a schema-version bump.
+Fresh and upgraded installations do not populate identity contexts until an
+operator enables collection.
+First-use schema creation, HMAC-key access, canonical context construction, and
+all SQLite work happen in the audit worker, never in agent admission.
+Contexts are retained for 30 days and capped at 100,000 rows. Exact-execution
+inspection and run discovery never return a context, candidate, or admission
+decision after that context is older than 30 days, even if physical cleanup
+has not run. Expired
+rows are pruned during Gateway startup, hourly audit maintenance, and later
+context writes, with at most 1,024 identity-context rows removed per write or
+maintenance tick. Maintenance continues when collection is disabled. An older
+build ignores this table.
+
+Immediately after expiry, inspection can report the run as `unsupported` while
+the expired row still proves only that its identity context became unavailable;
+no expired fields or decisions are returned. After bounded cleanup, the same
+lookup can become `unknown` if no separately retained best-effort activity
+remains. That transition does not prove the run did not occur. These limits
+make the inspector an operational diagnostic surface, not a compliance
+archive.
+
## Querying
- CLI: [`openclaw audit`](/cli/audit) with filters for agent, session, run,
@@ -131,6 +271,10 @@ rows and their ledger sequences are preserved.
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).
+- Identity RPC: `audit.run.inspect` (requires `operator.read`) accepts one
+ `executionId` for exact inspection or one `runId` for bounded discovery. It
+ returns the immutable V1 context and admission receipt for an exact match, or
+ a typed ambiguous candidate page when a run has multiple executions.
## Related
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index 813969d4ff3c..a1c24bd9b659 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -1215,6 +1215,7 @@ Notes:
logging: {
audit: {
enabled: true,
+ executionIdentity: false,
messages: "off", // off | direct | all
},
},
@@ -1239,6 +1240,11 @@ and coverage limits.
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.
+- `executionIdentity`: retain bounded attribution context for exact execution
+ inspection (default: `false`). This privacy-sensitive metadata is disabled
+ on fresh installs and upgrades. Collection requires `enabled: true`; use
+ `openclaw config set logging.audit.executionIdentity true`, then restart the
+ Gateway. There is no environment-variable alias.
- `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
@@ -1250,9 +1256,9 @@ A root-level `audit` block is retired; the canonical path is `logging.audit`.
The root config object is strict, so an old top-level `audit` block is rejected.
Run [`openclaw doctor --fix`](/cli/doctor) to move it to `logging.audit`.
-The running Gateway captures `logging.audit.enabled` and
-`logging.audit.messages` at startup;
-restart it after changing either setting. Message coverage currently includes
+The running Gateway captures `logging.audit.enabled`,
+`logging.audit.executionIdentity`, and `logging.audit.messages` at startup;
+restart it after changing any of these settings. 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
diff --git a/docs/gateway/operator-scopes.md b/docs/gateway/operator-scopes.md
index 209427653587..e6a8531103d5 100644
--- a/docs/gateway/operator-scopes.md
+++ b/docs/gateway/operator-scopes.md
@@ -31,7 +31,7 @@ require the `node` role.
| Scope | Meaning |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `operator.read` | Read-only status, lists, catalog, logs, session reads, and other non-mutating calls. |
+| `operator.read` | Read-only status, lists, catalog, logs, session reads, retained audit and execution-identity diagnostics, and other non-mutating calls. |
| `operator.write` | Mutating operator actions: sending messages, invoking tools, updating talk/voice settings, node command relay. Also satisfies `operator.read`. |
| `operator.admin` | Administrative access. Satisfies every `operator.*` scope. Required for config mutation, updates, native hooks, reserved namespaces, and high-risk approvals. |
| `operator.pairing` | Device and node pairing management: list, approve, reject, remove, rotate, revoke. |
@@ -78,6 +78,13 @@ independent of the connecting client's `client.id` or `client.mode`. Client
identity can still affect connection and device-auth policy, but it neither
grants nor removes session mutation authority.
+`audit.run.inspect` intentionally uses `operator.read`. Every client with that
+scope in a Gateway operator domain may receive the retained execution-identity
+context, including bounded pseudonymized references and secret-redacted display
+labels. `operator.read` is not a per-user or hostile multi-tenant privacy
+boundary. Operators who must keep this data separate need separate Gateway
+trust domains.
+
## Device pairing approvals
Device pairing records are the durable source of approved roles and scopes.
diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md
index 14f440bcba45..689e9b083b20 100644
--- a/docs/gateway/protocol.md
+++ b/docs/gateway/protocol.md
@@ -602,7 +602,7 @@ methods. Treat this as feature discovery, not a full enumeration of
- `agents.list` returns gateway-visible agent entries, including effective model/runtime metadata and optional semantic `kind` (`agent` or `system`). Clients advertise the `agent-kind` handshake capability to receive the complete typed roster; clients without it keep the legacy selector-safe roster without system rows. Kind-aware clients exclude `system` rows from ordinary selectors while retaining them in diagnostic views. Older v4 gateways may return rows without `kind`.
- `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.activity.list` returns the versioned metadata-only activity ledger; `audit.list` remains the compatibility-safe run/tool RPC.
+ - `audit.activity.list` returns the versioned metadata-only activity ledger; `audit.run.inspect` discovers execution ids or inspects one exact execution identity context; `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.
@@ -841,6 +841,16 @@ recording is separately controlled by `logging.audit.messages` and defaults to
recording is disabled, `audit.activity.list` keeps serving records written
earlier until they expire.
+`audit.run.inspect` also requires `operator.read`. Its closed request selects
+exactly one `executionId` for exact inspection or one `runId` for bounded
+execution discovery. One run match resolves directly; multiple matches return
+an explicit `ambiguous` result with at most 50 candidates and require exact
+execution selection. Decision pages contain at most 100 receipts. Execution
+identity collection is separately off by default and requires
+`logging.audit.executionIdentity: true` plus an enabled audit ledger after
+Gateway restart. Missing best-effort evidence never proves that a run did not
+occur.
+
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
diff --git a/extensions/discord/src/voice/ingress.test.ts b/extensions/discord/src/voice/ingress.test.ts
new file mode 100644
index 000000000000..4753cb752c8e
--- /dev/null
+++ b/extensions/discord/src/voice/ingress.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it, vi } from "vitest";
+
+type MockIngressInput = { message?: string; sessionKey?: string; runId?: string };
+
+const mocks = vi.hoisted(() => ({
+ agentCommandFromIngress: vi.fn(async (_input: MockIngressInput) => ({
+ payloads: [{ text: "spoken" }],
+ })),
+}));
+
+vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
+ agentCommandFromIngress: mocks.agentCommandFromIngress,
+}));
+
+import { runDiscordVoiceAgentTurn } from "./ingress.js";
+
+describe("Discord voice ingress execution correlation", () => {
+ it("admits sequential same-session turns without inventing a public run id", async () => {
+ const entry = {
+ guildId: "guild-1",
+ channelId: "channel-1",
+ route: { agentId: "main", sessionKey: "agent:main:discord:channel:channel-1" },
+ };
+ const shared = {
+ entry: entry as never,
+ userId: "user-1",
+ cfg: {} as never,
+ discordConfig: {} as never,
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
+ context: { senderIsOwner: false, speakerLabel: "Guest" },
+ fetchGuildName: vi.fn(async () => "Guild"),
+ speakerContext: {} as never,
+ };
+
+ await runDiscordVoiceAgentTurn({ ...shared, message: "first turn" });
+ await runDiscordVoiceAgentTurn({ ...shared, message: "second turn" });
+
+ expect(mocks.agentCommandFromIngress).toHaveBeenCalledTimes(2);
+ const inputs = mocks.agentCommandFromIngress.mock.calls.map(([input]) => input);
+ expect(inputs.map((input) => input.message)).toEqual(["first turn", "second turn"]);
+ expect(inputs.map((input) => input.sessionKey)).toEqual([
+ "agent:main:discord:channel:channel-1",
+ "agent:main:discord:channel:channel-1",
+ ]);
+ for (const input of inputs) {
+ expect(input).not.toHaveProperty("runId");
+ }
+ });
+});
diff --git a/packages/gateway-protocol/src/schema-export-registry.ts b/packages/gateway-protocol/src/schema-export-registry.ts
index 644dcf0b5058..4e6af9e35988 100644
--- a/packages/gateway-protocol/src/schema-export-registry.ts
+++ b/packages/gateway-protocol/src/schema-export-registry.ts
@@ -255,6 +255,15 @@ export {
AuditActivityListResultSchema,
AuditActivityOutboundMessageV1Schema,
AuditActivityToolActionV1Schema,
+ ExecutionIdentityContextV1Schema,
+ DecisionReceiptV1Schema,
+ AuditRunIdentityPresentV1Schema,
+ AuditRunIdentityUnknownV1Schema,
+ AuditRunIdentityUnsupportedV1Schema,
+ AuditRunIdentityAmbiguousV1Schema,
+ AuditRunIdentityV1Schema,
+ AuditRunInspectParamsSchema,
+ AuditRunInspectResultSchema,
AuditEventSchema,
AuditListParamsSchema,
AuditListResultSchema,
diff --git a/packages/gateway-protocol/src/schema-modules.ts b/packages/gateway-protocol/src/schema-modules.ts
index ad8acac6708c..9da78b14138e 100644
--- a/packages/gateway-protocol/src/schema-modules.ts
+++ b/packages/gateway-protocol/src/schema-modules.ts
@@ -6,6 +6,7 @@ export * from "./schema/agents-workspace.js";
export * from "./schema/artifacts.js";
export * from "./schema/approvals.js";
export * from "./schema/audit-activity.js";
+export * from "./schema/audit-run.js";
export * from "./schema/audit.js";
export * from "./schema/board.js";
export * from "./schema/users.js";
diff --git a/packages/gateway-protocol/src/schema/agent.ts b/packages/gateway-protocol/src/schema/agent.ts
index 5c40074860a6..09eee97a0adf 100644
--- a/packages/gateway-protocol/src/schema/agent.ts
+++ b/packages/gateway-protocol/src/schema/agent.ts
@@ -321,6 +321,9 @@ export const AgentParamsSchema = closedObject({
),
acpTurnSource: Type.Optional(Type.Literal("manual_spawn")),
internalRuntimeHandoffId: Type.Optional(NonEmptyString),
+ // Enabled backend recovery supplies only capture/retry mode. Disabled collection omits it;
+ // the private token, when present, remains in durable session state.
+ internalExecutionIdentityRetry: Type.Optional(Type.Boolean()),
execApprovalFollowupExpectedSessionId: Type.Optional(NonEmptyString),
internalEvents: Type.Optional(Type.Array(AgentInternalEventSchema)),
inputProvenance: Type.Optional(InputProvenanceSchema),
diff --git a/packages/gateway-protocol/src/schema/audit-run.test.ts b/packages/gateway-protocol/src/schema/audit-run.test.ts
new file mode 100644
index 000000000000..eb4b965b8865
--- /dev/null
+++ b/packages/gateway-protocol/src/schema/audit-run.test.ts
@@ -0,0 +1,160 @@
+import { Compile } from "typebox/compile";
+import { describe, expect, it } from "vitest";
+import { validateAuditRunInspectParams, validateExecutionIdentityContextV1 } from "../index.js";
+import {
+ AuditRunInspectParamsSchema,
+ AuditRunInspectResultSchema,
+ DecisionReceiptV1Schema,
+ type ExecutionIdentityContextV1,
+} from "./audit-run.js";
+
+const hmacRef = `hmac-sha256:v1:${"a".repeat(32)}:${"b".repeat(64)}`;
+
+function context(): ExecutionIdentityContextV1 {
+ return {
+ schemaVersion: 1,
+ contextId: "context-1",
+ executionId: "execution-1",
+ runId: "run-1",
+ createdAt: 1,
+ trustDomain: { kind: "gateway-cell", domainRef: hmacRef, state: "present" },
+ invoker: { state: "absent" },
+ ingress: {
+ kind: "local-cli",
+ boundary: "agent-command.local",
+ state: "present",
+ },
+ agentPrincipal: { kind: "agent", domainRef: hmacRef, principalRef: "main" },
+ agentDefinition: { definitionRef: "main", state: "present" },
+ runtimeInstance: { runtimeRef: hmacRef, kind: "embedded", state: "present" },
+ applicableGrants: [],
+ assurance: [{ kind: "runtime-binding", evidenceRef: hmacRef, strength: "boundary-verified" }],
+ coverageState: "unattributed",
+ missingEvidence: ["invoker.principal"],
+ };
+}
+
+describe("audit run inspection protocol", () => {
+ it("accepts the bounded V1 context and truthful admission receipt", () => {
+ const identity = context();
+ expect(validateExecutionIdentityContextV1(identity)).toBe(true);
+ expect(Buffer.byteLength(JSON.stringify(identity), "utf8")).toBeLessThan(16 * 1024);
+
+ const validateReceipt = Compile(DecisionReceiptV1Schema);
+ expect(
+ validateReceipt.Check({
+ schemaVersion: 1,
+ receiptId: "receipt-1",
+ contextId: identity.contextId,
+ executionId: identity.executionId,
+ runId: identity.runId,
+ occurredAt: identity.createdAt,
+ action: { family: "run", operation: "admission" },
+ decision: {
+ outcome: "not-applicable",
+ reasonCode: "run_admission_identity_not_evaluated",
+ },
+ enforcement: {
+ coverageState: "unattributed",
+ policyRefs: [],
+ grantRefs: [],
+ contextFieldsUsed: [],
+ },
+ source: {
+ owner: "agent-command",
+ recordRef: identity.contextId,
+ decisionBoundary: "agent-command.run-admission",
+ },
+ missingEvidence: ["invoker.principal"],
+ remediation: [{ code: "none", text: "No enforcement is claimed." }],
+ }),
+ ).toBe(true);
+ });
+
+ it("closes request objects and enforces exact-run query bounds", () => {
+ expect(validateAuditRunInspectParams({ runId: "run-1", decisionLimit: 100 })).toBe(true);
+ expect(validateAuditRunInspectParams({ executionId: "execution-1" })).toBe(true);
+ expect(validateAuditRunInspectParams({ runId: "run-1", executionLimit: 50 })).toBe(true);
+ expect(validateAuditRunInspectParams({ runId: "run-1", executionLimit: 51 })).toBe(false);
+ expect(validateAuditRunInspectParams({})).toBe(false);
+ expect(validateAuditRunInspectParams({ runId: "run-1", executionId: "execution-1" })).toBe(
+ false,
+ );
+ expect(validateAuditRunInspectParams({ executionId: "execution-1", executionLimit: 2 })).toBe(
+ false,
+ );
+ expect(validateAuditRunInspectParams({ runId: "", decisionLimit: 50 })).toBe(false);
+ expect(validateAuditRunInspectParams({ runId: "run-1", decisionLimit: 101 })).toBe(false);
+ expect(validateAuditRunInspectParams({ runId: "run-1", extra: true })).toBe(false);
+ });
+
+ it("exports selector and discovery-pagination invariants for generated clients", () => {
+ const validate = Compile(AuditRunInspectParamsSchema);
+
+ expect(validate.Check({ runId: "run-1", executionCursor: "cursor-1" })).toBe(true);
+ expect(validate.Check({ executionId: "execution-1", decisionLimit: 100 })).toBe(true);
+ expect(validate.Check({})).toBe(false);
+ expect(validate.Check({ runId: "run-1", executionId: "execution-1" })).toBe(false);
+ expect(validate.Check({ executionId: "execution-1", executionCursor: "cursor-1" })).toBe(false);
+ expect(validate.Check({ executionId: "execution-1", executionLimit: 2 })).toBe(false);
+ });
+
+ it("accepts bounded ambiguous run discovery without selecting an execution", () => {
+ const validate = Compile(AuditRunInspectResultSchema);
+ expect(
+ validate.Check({
+ schemaVersion: 1,
+ run: { runId: "run-1", status: "known" },
+ identity: {
+ state: "ambiguous",
+ reasonCode: "execution_selection_required",
+ candidates: [
+ { executionId: "execution-1", contextId: "context-1", createdAt: 1 },
+ { executionId: "execution-2", contextId: "context-2", createdAt: 2 },
+ ],
+ missingEvidence: ["execution.selection"],
+ remediation: [{ code: "select_execution_id", text: "Select one exact execution." }],
+ },
+ decisions: [],
+ coverage: { state: "unknown", missingEvidence: ["execution.selection"] },
+ }),
+ ).toBe(true);
+ });
+
+ it("rejects malformed, oversized, and open-ended context payloads", () => {
+ expect(validateExecutionIdentityContextV1({ ...context(), extra: true })).toBe(false);
+ expect(
+ validateExecutionIdentityContextV1({
+ ...context(),
+ missingEvidence: Array.from({ length: 17 }, (_, index) => `missing-${index}`),
+ }),
+ ).toBe(false);
+ expect(
+ validateExecutionIdentityContextV1({
+ ...context(),
+ runtimeInstance: { ...context().runtimeInstance, kind: "mystery" },
+ }),
+ ).toBe(false);
+ });
+
+ it.each(["unknown", "unsupported"] as const)(
+ "accepts a typed %s diagnostic without inventing identity",
+ (state) => {
+ const validate = Compile(AuditRunInspectResultSchema);
+ expect(
+ validate.Check({
+ schemaVersion: 1,
+ run: { runId: "run-1", status: state === "unknown" ? "unknown" : "known" },
+ identity: {
+ state,
+ reasonCode: `${state}_identity`,
+ missingEvidence: ["identity.context"],
+ remediation: [{ code: "retry", text: "Retry after checking the run id." }],
+ },
+ decisions: [],
+ coverage: { state, missingEvidence: ["identity.context"] },
+ }),
+ ).toBe(true);
+ },
+ );
+});
diff --git a/packages/gateway-protocol/src/schema/audit-run.ts b/packages/gateway-protocol/src/schema/audit-run.ts
new file mode 100644
index 000000000000..d3cda6b5057e
--- /dev/null
+++ b/packages/gateway-protocol/src/schema/audit-run.ts
@@ -0,0 +1,317 @@
+// Versioned exact-execution identity and run-discovery projections.
+import { type Static, Type } from "typebox";
+import { closedObject } from "./closed-object.js";
+
+const ExecutionIdentityRefSchema = Type.String({ minLength: 1, maxLength: 256 });
+const ExecutionIdentityDisplayLabelSchema = Type.String({ maxLength: 128 });
+const ExecutionIdentityEvidenceStateSchema = Type.Union([
+ Type.Literal("present"),
+ Type.Literal("absent"),
+ Type.Literal("unknown"),
+ Type.Literal("unsupported"),
+]);
+const ExecutionIdentityContextCoverageStateSchema = Type.Union([
+ Type.Literal("attribution-only"),
+ Type.Literal("unattributed"),
+ Type.Literal("unknown"),
+ Type.Literal("unsupported"),
+]);
+const ExecutionIdentityDecisionCoverageStateSchema = Type.Union([
+ Type.Literal("enforced"),
+ Type.Literal("attribution-only"),
+ Type.Literal("unattributed"),
+ Type.Literal("unknown"),
+ Type.Literal("unsupported"),
+]);
+const ExecutionIdentityRefArraySchema = Type.Array(ExecutionIdentityRefSchema, { maxItems: 16 });
+
+const ExecutionIdentityPrincipalKindSchema = Type.Union([
+ Type.Literal("person"),
+ Type.Literal("agent"),
+ Type.Literal("service"),
+ Type.Literal("schedule"),
+ Type.Literal("webhook"),
+ Type.Literal("system"),
+ Type.Literal("local-account"),
+ Type.Literal("runtime"),
+]);
+
+export const PrincipalRefV1Schema = closedObject({
+ kind: ExecutionIdentityPrincipalKindSchema,
+ domainRef: ExecutionIdentityRefSchema,
+ principalRef: ExecutionIdentityRefSchema,
+ displayLabel: Type.Optional(ExecutionIdentityDisplayLabelSchema),
+});
+
+const PrincipalFactV1Schema = closedObject({
+ principal: PrincipalRefV1Schema,
+ state: ExecutionIdentityEvidenceStateSchema,
+});
+
+const SponsorFactV1Schema = closedObject({
+ principal: PrincipalRefV1Schema,
+ relationshipRef: Type.Optional(ExecutionIdentityRefSchema),
+ state: ExecutionIdentityEvidenceStateSchema,
+});
+
+const AssuranceKindSchema = Type.Union([
+ Type.Literal("durable-profile"),
+ Type.Literal("trusted-proxy"),
+ Type.Literal("tailscale-whois"),
+ Type.Literal("device-proof"),
+ Type.Literal("channel-admission"),
+ Type.Literal("local-process"),
+ Type.Literal("spawn-lineage"),
+ Type.Literal("worker-admission"),
+ Type.Literal("runtime-binding"),
+ Type.Literal("other"),
+]);
+
+const AssuranceEvidenceV1Schema = closedObject({
+ kind: AssuranceKindSchema,
+ evidenceRef: ExecutionIdentityRefSchema,
+ strength: Type.Union([
+ Type.Literal("self-asserted"),
+ Type.Literal("boundary-verified"),
+ Type.Literal("cryptographic"),
+ ]),
+});
+
+const ExecutionIdentityIngressKindSchema = Type.Union([
+ Type.Literal("local-cli"),
+ Type.Literal("gateway-client"),
+ Type.Literal("channel"),
+ Type.Literal("api"),
+ Type.Literal("schedule"),
+ Type.Literal("webhook"),
+ Type.Literal("task"),
+ Type.Literal("subagent"),
+ Type.Literal("acp"),
+ Type.Literal("worker"),
+ Type.Literal("plugin"),
+ Type.Literal("recovery"),
+ Type.Literal("system"),
+]);
+
+const ExecutionIdentityRuntimeKindSchema = Type.Union([
+ Type.Literal("gateway"),
+ Type.Literal("embedded"),
+ Type.Literal("worker"),
+ Type.Literal("plugin-harness"),
+ Type.Literal("acp"),
+]);
+
+export const ExecutionIdentityContextV1Schema = closedObject({
+ schemaVersion: Type.Literal(1),
+ contextId: ExecutionIdentityRefSchema,
+ executionId: ExecutionIdentityRefSchema,
+ runId: ExecutionIdentityRefSchema,
+ createdAt: Type.Integer({ minimum: 0 }),
+ trustDomain: closedObject({
+ kind: Type.Literal("gateway-cell"),
+ domainRef: ExecutionIdentityRefSchema,
+ state: ExecutionIdentityEvidenceStateSchema,
+ }),
+ invoker: closedObject({
+ principal: Type.Optional(PrincipalRefV1Schema),
+ state: ExecutionIdentityEvidenceStateSchema,
+ }),
+ ingress: closedObject({
+ kind: ExecutionIdentityIngressKindSchema,
+ sourceRef: Type.Optional(ExecutionIdentityRefSchema),
+ boundary: ExecutionIdentityRefSchema,
+ state: ExecutionIdentityEvidenceStateSchema,
+ }),
+ agentPrincipal: PrincipalRefV1Schema,
+ agentDefinition: closedObject({
+ definitionRef: ExecutionIdentityRefSchema,
+ revisionRef: Type.Optional(ExecutionIdentityRefSchema),
+ state: ExecutionIdentityEvidenceStateSchema,
+ }),
+ runtimeInstance: closedObject({
+ runtimeRef: ExecutionIdentityRefSchema,
+ kind: ExecutionIdentityRuntimeKindSchema,
+ state: ExecutionIdentityEvidenceStateSchema,
+ }),
+ representedSubject: Type.Optional(PrincipalFactV1Schema),
+ sponsor: Type.Optional(SponsorFactV1Schema),
+ applicableGrants: Type.Array(
+ closedObject({
+ grantRef: ExecutionIdentityRefSchema,
+ state: ExecutionIdentityEvidenceStateSchema,
+ }),
+ { maxItems: 16 },
+ ),
+ assurance: Type.Array(AssuranceEvidenceV1Schema, { maxItems: 16 }),
+ lineage: Type.Optional(
+ closedObject({
+ parentContextId: Type.Optional(ExecutionIdentityRefSchema),
+ parentExecutionId: Type.Optional(ExecutionIdentityRefSchema),
+ parentRunId: Type.Optional(ExecutionIdentityRefSchema),
+ parentAgentPrincipal: Type.Optional(PrincipalRefV1Schema),
+ delegationRef: Type.Optional(ExecutionIdentityRefSchema),
+ depth: Type.Integer({ minimum: 0, maximum: 64 }),
+ }),
+ ),
+ coverageState: ExecutionIdentityContextCoverageStateSchema,
+ missingEvidence: ExecutionIdentityRefArraySchema,
+});
+
+const ExecutionIdentityRemediationV1Schema = closedObject({
+ code: ExecutionIdentityRefSchema,
+ text: Type.String({ minLength: 1, maxLength: 512 }),
+});
+
+export const DecisionReceiptV1Schema = closedObject({
+ schemaVersion: Type.Literal(1),
+ receiptId: ExecutionIdentityRefSchema,
+ contextId: ExecutionIdentityRefSchema,
+ executionId: ExecutionIdentityRefSchema,
+ runId: ExecutionIdentityRefSchema,
+ actionId: Type.Optional(ExecutionIdentityRefSchema),
+ occurredAt: Type.Integer({ minimum: 0 }),
+ action: closedObject({
+ family: ExecutionIdentityRefSchema,
+ operation: ExecutionIdentityRefSchema,
+ resourceRef: Type.Optional(ExecutionIdentityRefSchema),
+ targetRef: Type.Optional(ExecutionIdentityRefSchema),
+ summary: Type.Optional(Type.String({ maxLength: 512 })),
+ }),
+ decision: closedObject({
+ outcome: Type.Union([
+ Type.Literal("allowed"),
+ Type.Literal("denied"),
+ Type.Literal("not-applicable"),
+ Type.Literal("unknown"),
+ ]),
+ reasonCode: ExecutionIdentityRefSchema,
+ }),
+ enforcement: closedObject({
+ coverageState: ExecutionIdentityDecisionCoverageStateSchema,
+ evaluatorRef: Type.Optional(ExecutionIdentityRefSchema),
+ policyRefs: ExecutionIdentityRefArraySchema,
+ grantRefs: ExecutionIdentityRefArraySchema,
+ contextFieldsUsed: ExecutionIdentityRefArraySchema,
+ }),
+ source: closedObject({
+ owner: ExecutionIdentityRefSchema,
+ recordRef: ExecutionIdentityRefSchema,
+ decisionBoundary: ExecutionIdentityRefSchema,
+ }),
+ missingEvidence: ExecutionIdentityRefArraySchema,
+ remediation: Type.Array(ExecutionIdentityRemediationV1Schema, { maxItems: 8 }),
+});
+
+export const AuditRunIdentityPresentV1Schema = closedObject({
+ state: Type.Literal("present"),
+ context: ExecutionIdentityContextV1Schema,
+});
+
+export const AuditRunIdentityUnknownV1Schema = closedObject({
+ state: Type.Literal("unknown"),
+ reasonCode: ExecutionIdentityRefSchema,
+ missingEvidence: ExecutionIdentityRefArraySchema,
+ remediation: Type.Array(ExecutionIdentityRemediationV1Schema, { maxItems: 8 }),
+});
+
+export const AuditRunIdentityUnsupportedV1Schema = closedObject({
+ state: Type.Literal("unsupported"),
+ reasonCode: ExecutionIdentityRefSchema,
+ missingEvidence: ExecutionIdentityRefArraySchema,
+ remediation: Type.Array(ExecutionIdentityRemediationV1Schema, { maxItems: 8 }),
+});
+
+export const AuditRunIdentityAmbiguousV1Schema = closedObject({
+ state: Type.Literal("ambiguous"),
+ reasonCode: ExecutionIdentityRefSchema,
+ candidates: Type.Array(
+ closedObject({
+ executionId: ExecutionIdentityRefSchema,
+ contextId: ExecutionIdentityRefSchema,
+ createdAt: Type.Integer({ minimum: 0 }),
+ }),
+ { maxItems: 50 },
+ ),
+ missingEvidence: ExecutionIdentityRefArraySchema,
+ remediation: Type.Array(ExecutionIdentityRemediationV1Schema, { maxItems: 8 }),
+});
+
+export const AuditRunIdentityV1Schema = Type.Union([
+ AuditRunIdentityPresentV1Schema,
+ AuditRunIdentityUnknownV1Schema,
+ AuditRunIdentityUnsupportedV1Schema,
+ AuditRunIdentityAmbiguousV1Schema,
+]);
+
+const AuditRunDecisionPageParams = {
+ decisionCursor: Type.Optional(ExecutionIdentityRefSchema),
+ decisionLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
+};
+
+export const AuditRunInspectParamsSchema = Type.Object(
+ {
+ runId: Type.Optional(ExecutionIdentityRefSchema),
+ executionId: Type.Optional(ExecutionIdentityRefSchema),
+ executionCursor: Type.Optional(ExecutionIdentityRefSchema),
+ executionLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
+ ...AuditRunDecisionPageParams,
+ },
+ {
+ additionalProperties: false,
+ // Keep exact selection and run discovery mutually exclusive in the exported
+ // wire schema so generated clients cannot construct server-rejected requests.
+ oneOf: [
+ { required: ["runId"], not: { required: ["executionId"] } },
+ {
+ required: ["executionId"],
+ not: {
+ anyOf: [
+ { required: ["runId"] },
+ { required: ["executionCursor"] },
+ { required: ["executionLimit"] },
+ ],
+ },
+ },
+ ],
+ },
+);
+
+export const AuditRunInspectResultSchema = closedObject({
+ schemaVersion: Type.Literal(1),
+ run: closedObject({
+ runId: Type.Optional(ExecutionIdentityRefSchema),
+ executionId: Type.Optional(ExecutionIdentityRefSchema),
+ status: Type.Union([Type.Literal("known"), Type.Literal("unknown")]),
+ }),
+ identity: AuditRunIdentityV1Schema,
+ decisions: Type.Array(DecisionReceiptV1Schema, { maxItems: 100 }),
+ coverage: closedObject({
+ state: ExecutionIdentityDecisionCoverageStateSchema,
+ missingEvidence: ExecutionIdentityRefArraySchema,
+ }),
+ nextDecisionCursor: Type.Optional(ExecutionIdentityRefSchema),
+ nextExecutionCursor: Type.Optional(ExecutionIdentityRefSchema),
+});
+
+export type PrincipalRefV1 = Static;
+export type ExecutionIdentityContextV1 = Static;
+export type DecisionReceiptV1 = Static;
+export type AuditRunIdentityV1 = Static;
+type AuditRunDecisionPage = {
+ decisionCursor?: string;
+ decisionLimit?: number;
+};
+export type AuditRunInspectParams =
+ | (AuditRunDecisionPage & {
+ runId: string;
+ executionId?: never;
+ executionCursor?: string;
+ executionLimit?: number;
+ })
+ | (AuditRunDecisionPage & {
+ executionId: string;
+ runId?: never;
+ executionCursor?: never;
+ executionLimit?: never;
+ });
+export type AuditRunInspectResult = Static;
diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts
index c4d3a3b5f041..20a29d854fac 100644
--- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts
+++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts
@@ -1,4 +1,5 @@
import * as auditActivity from "./audit-activity.js";
+import * as auditRun from "./audit-run.js";
import * as audit from "./audit.js";
import * as config from "./config.js";
import * as openclaw from "./openclaw.js";
@@ -14,6 +15,15 @@ export const OperationsProtocolSchemas = {
AuditActivityEventV1: auditActivity.AuditActivityEventV1Schema,
AuditActivityListParams: auditActivity.AuditActivityListParamsSchema,
AuditActivityListResult: auditActivity.AuditActivityListResultSchema,
+ ExecutionIdentityContextV1: auditRun.ExecutionIdentityContextV1Schema,
+ DecisionReceiptV1: auditRun.DecisionReceiptV1Schema,
+ AuditRunIdentityPresentV1: auditRun.AuditRunIdentityPresentV1Schema,
+ AuditRunIdentityUnknownV1: auditRun.AuditRunIdentityUnknownV1Schema,
+ AuditRunIdentityUnsupportedV1: auditRun.AuditRunIdentityUnsupportedV1Schema,
+ AuditRunIdentityAmbiguousV1: auditRun.AuditRunIdentityAmbiguousV1Schema,
+ AuditRunIdentityV1: auditRun.AuditRunIdentityV1Schema,
+ AuditRunInspectParams: auditRun.AuditRunInspectParamsSchema,
+ AuditRunInspectResult: auditRun.AuditRunInspectResultSchema,
AuditEvent: audit.AuditEventSchema,
AuditListParams: audit.AuditListParamsSchema,
AuditListResult: audit.AuditListResultSchema,
diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts
index e26ab724ffbc..f2f382368d13 100644
--- a/packages/gateway-protocol/src/validator-registry.ts
+++ b/packages/gateway-protocol/src/validator-registry.ts
@@ -2,6 +2,7 @@ import { lazyCompile as compile } from "./protocol-validator.js";
import * as S from "./schema-modules.js";
import type {
AuditActivityListParams,
+ AuditRunInspectParams,
WebPushSubscribeParams,
WebPushTestParams,
WebPushUnsubscribeParams,
@@ -84,6 +85,10 @@ export const validateAgentParams = compile(S.AgentParamsSchema);
export const validateAuditActivityListParams = compile(
S.AuditActivityListParamsSchema,
);
+export const validateAuditRunInspectParams = compile(
+ S.AuditRunInspectParamsSchema,
+);
+export const validateExecutionIdentityContextV1 = compile(S.ExecutionIdentityContextV1Schema);
export const validateAuditListParams = compile(S.AuditListParamsSchema);
export const validateUsersListParams = compile(S.UsersListParamsSchema);
export const validateUsersSelfParams = compile(S.UsersSelfParamsSchema);
diff --git a/qa/scenarios/runtime/agent-run-identity-inspection.yaml b/qa/scenarios/runtime/agent-run-identity-inspection.yaml
new file mode 100644
index 000000000000..eb4fc68761f4
--- /dev/null
+++ b/qa/scenarios/runtime/agent-run-identity-inspection.yaml
@@ -0,0 +1,37 @@
+title: Agent-run execution identity inspection
+
+scenario:
+ id: agent-run-identity-inspection
+ surface: gateway
+ coverage:
+ secondary:
+ - gateway.identity-and-presence-apis
+ objective: Verify an operator can inspect one local execution, discover repeated same-session executions, and select each immutable identity context through the audit CLI before and after Gateway restart.
+ successCriteria:
+ - A real local agent turn against the deterministic mock provider records one bounded execution identity context.
+ - Fresh and existing-install restarts keep execution-identity storage absent before explicit opt-in, and global audit disable prevents new identity rows.
+ - The audit CLI renders text and JSON projections for the exact run, including every identity field and the truthful non-enforcement admission receipt.
+ - A replacement Gateway process returns byte-equivalent normalized identity context JSON for the same run.
+ - Two real public-ingress turns that omit runId share the session correlation but retain distinct execution and context ids.
+ - Run discovery reports those turns as ambiguous and both text and JSON exact-execution selection return the intended turn.
+ docsRefs:
+ - docs/gateway/audit.md
+ - docs/cli/audit.md
+ - docs/concepts/qa-e2e-automation.md
+ codeRefs:
+ - src/agents/agent-command.ts
+ - src/agents/agent-command-execution-identity.ts
+ - src/audit/execution-identity-admission.ts
+ - src/audit/audit-event-writer.ts
+ - src/audit/execution-identity-context.ts
+ - src/gateway/server-methods/audit.ts
+ - src/commands/audit.ts
+ - test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts
+ - test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts
+ execution:
+ kind: script
+ path: test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts
+ summary: Starts an ephemeral Gateway and mock provider, runs local and repeated public-ingress turns, proves run ambiguity plus exact text/JSON selection, replaces the Gateway process, and compares normalized context bytes.
+ args:
+ - --artifact-base
+ - ${outputDir}
diff --git a/scripts/check-protocol-registry.mjs b/scripts/check-protocol-registry.mjs
index b74854d3e9d8..ff5e82434853 100644
--- a/scripts/check-protocol-registry.mjs
+++ b/scripts/check-protocol-registry.mjs
@@ -111,8 +111,8 @@ const ownerModules = [
...schemaModulesSource.matchAll(/^export \* from "\.\/schema\/([^"]+)\.js";$/gmu),
].map((match) => match[1]);
check(
- ownerModules.length === 53 && new Set(ownerModules).size === ownerModules.length,
- "schema-modules.ts must contain one unique 53-module owner list",
+ ownerModules.length === 54 && new Set(ownerModules).size === ownerModules.length,
+ "schema-modules.ts must contain one unique 54-module owner list",
);
check(
schemaModulesSource.split("\n").filter(Boolean).length === ownerModules.length,
diff --git a/src/agents/agent-command-execution-identity.ts b/src/agents/agent-command-execution-identity.ts
new file mode 100644
index 000000000000..9e1605eed7da
--- /dev/null
+++ b/src/agents/agent-command-execution-identity.ts
@@ -0,0 +1,51 @@
+import { isExecutionIdentityCollectionEnabled } from "../audit/audit-config.js";
+import {
+ enqueueExecutionIdentityContextAtAdmission,
+ type ExecutionIdentityAdmissionFacts,
+} from "../audit/execution-identity-admission.js";
+import type { OpenClawConfig } from "../config/types.openclaw.js";
+import type { AgentCommandOpts } from "./command/types.js";
+
+type AgentCommandAdmissionIngress = ExecutionIdentityAdmissionFacts["ingress"];
+
+const LOCAL_CLI_ADMISSION_INGRESS: AgentCommandAdmissionIngress = {
+ kind: "local-cli",
+ boundary: "agent-command.local",
+ state: "present",
+};
+
+function systemIngress(boundary: string): AgentCommandAdmissionIngress {
+ return { kind: "system", boundary, state: "present" };
+}
+
+function recordAgentCommandExecutionIdentity(params: {
+ admission?: AgentCommandOpts["executionIdentityAdmission"];
+ agentId: string;
+ cfg: OpenClawConfig;
+ ingress: AgentCommandAdmissionIngress;
+ runId: string;
+ runtimeKind: ExecutionIdentityAdmissionFacts["runtime"]["kind"];
+}): void {
+ // Session work admission owns these facts. Queue acceptance is not persistence;
+ // audit loss must never become run loss.
+ enqueueExecutionIdentityContextAtAdmission(
+ {
+ runId: params.runId,
+ agentId: params.agentId,
+ ingress: params.ingress,
+ runtime: { kind: params.runtimeKind },
+ },
+ {
+ enabled: isExecutionIdentityCollectionEnabled(params.cfg),
+ ...(params.admission
+ ? { token: params.admission.token, retryOnly: params.admission.retryOnly }
+ : {}),
+ },
+ );
+}
+
+export const executionIdentity = {
+ localIngress: LOCAL_CLI_ADMISSION_INGRESS,
+ record: recordAgentCommandExecutionIdentity,
+ systemIngress,
+};
diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts
index 6cb9942f1b48..00149f82f01d 100644
--- a/src/agents/agent-command.live-model-switch.test.ts
+++ b/src/agents/agent-command.live-model-switch.test.ts
@@ -116,12 +116,18 @@ const state = vi.hoisted(() => ({
storePathMock: undefined as string | undefined,
resolvedSessionKeyMock: undefined as string | undefined,
trajectoryRecorderParamsMock: vi.fn(),
+ enqueueExecutionIdentityContextAtAdmissionMock: vi.fn(),
}));
vi.mock("./model-fallback-runner.js", () => ({
runWithModelFallback: (params: unknown) => state.runWithModelFallbackMock(params),
}));
+vi.mock("../audit/execution-identity-admission.js", () => ({
+ enqueueExecutionIdentityContextAtAdmission: (...args: unknown[]) =>
+ state.enqueueExecutionIdentityContextAtAdmissionMock(...args),
+}));
+
vi.mock("./command/attempt-execution.runtime.js", () => ({
buildAcpResult: (...args: unknown[]) => state.buildAcpResultMock(...args),
createAcpToolLifecycleTracker: () => ({
@@ -652,11 +658,13 @@ vi.mock("../acp/control-plane/manager.js", () => ({
}));
let agentCommand: typeof import("./agent-command.js").agentCommand;
+let agentCommandFromSystem: typeof import("./agent-command.js").agentCommandFromSystem;
let agentCommandTesting: typeof import("./agent-command.js").testing;
beforeAll(async () => {
const mod = await import("./agent-command.js");
agentCommand ??= mod.agentCommand;
+ agentCommandFromSystem ??= mod.agentCommandFromSystem;
agentCommandTesting ??= mod.testing;
});
@@ -781,6 +789,17 @@ async function runBasicAgentCommand() {
});
}
+async function runSystemAgentCommand() {
+ await agentCommandFromSystem(
+ {
+ message: "boot",
+ sessionKey: "agent:main:boot",
+ deliver: false,
+ },
+ { boundary: "gateway.boot" },
+ );
+}
+
function runDiscordDelivery(overrides: Partial[0]> = {}) {
return agentCommand({
message: "hello",
@@ -1062,6 +1081,62 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
);
});
+ it("keeps collection off by default without blocking local execution", async () => {
+ setupSuccessfulAttempt();
+
+ await runBasicAgentCommand();
+
+ expect(state.enqueueExecutionIdentityContextAtAdmissionMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" },
+ }),
+ { enabled: false },
+ );
+ expect(state.runAgentAttemptMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("records authoritative local and system ingress only after explicit opt-in", async () => {
+ state.runtimeConfigMock = {
+ ...state.defaultRuntimeConfig,
+ logging: { audit: { executionIdentity: true } },
+ };
+ setupSuccessfulAttempt();
+
+ await runBasicAgentCommand();
+ await runSystemAgentCommand();
+
+ expect(state.enqueueExecutionIdentityContextAtAdmissionMock).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" },
+ }),
+ { enabled: true },
+ );
+ expect(state.enqueueExecutionIdentityContextAtAdmissionMock).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ ingress: { kind: "system", boundary: "gateway.boot", state: "present" },
+ }),
+ { enabled: true },
+ );
+ });
+
+ it.each([
+ ["local CLI", runBasicAgentCommand],
+ ["system", runSystemAgentCommand],
+ ])("keeps %s runs nonblocking when evidence cannot be queued", async (_name, run) => {
+ state.runtimeConfigMock = {
+ ...state.defaultRuntimeConfig,
+ logging: { audit: { executionIdentity: true } },
+ };
+ state.enqueueExecutionIdentityContextAtAdmissionMock.mockReturnValue(undefined);
+ setupSuccessfulAttempt();
+
+ await run();
+
+ expect(state.runAgentAttemptMock).toHaveBeenCalledTimes(1);
+ });
+
it("forwards the auth profile bound to the configured default model", async () => {
state.runtimeConfigMock = {
agents: {
diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts
index 1fe747fcd311..9bf2fbbc1eb8 100644
--- a/src/agents/agent-command.ts
+++ b/src/agents/agent-command.ts
@@ -24,6 +24,7 @@ import { ensureSessionDiffBaseline } from "../sessions/session-diff-baseline.js"
import { beginSessionWorkAdmission } from "../sessions/session-lifecycle-admission.js";
import { classifySessionStateActor } from "../sessions/session-state-events.js";
import { sessionDeliveryChannel, type DeliveryContext } from "../utils/delivery-context.shared.js";
+import { executionIdentity } from "./agent-command-execution-identity.js";
import { runWithAgentCommandRecoveryOwner } from "./agent-command-recovery-owner.js";
import {
buildCurrentRunRestartRecoveryClaim,
@@ -49,7 +50,11 @@ import { loadSessionStoreRuntime, resolveAgentCommandDeps } from "./command/runt
import { persistSessionEntry, prepareCurrentRunDelivery } from "./command/session-helpers.js";
import { prepareEmbeddedSessionState } from "./command/session-preparation.js";
import { clearRotatedSessionMetadata } from "./command/session.js";
-import type { AgentCommandIngressOpts, AgentCommandOpts } from "./command/types.js";
+import type {
+ AgentCommandGatewayIngressOpts,
+ AgentCommandIngressOpts,
+ AgentCommandOpts,
+} from "./command/types.js";
import {
removeInternalSessionEffectsSession,
resolveInternalSessionEffectsTarget,
@@ -60,11 +65,14 @@ import type { AgentRunSessionTarget } from "./run-session-target.js";
import { createAgentRunRestartAbortError } from "./run-termination.js";
import { measureAgentStartup } from "./startup-timing.js";
+type AgentCommandAdmissionIngress = Parameters[0]["ingress"];
+
const log = createSubsystemLogger("agents/agent-command");
async function agentCommandInternal(
prepared: Awaited>,
initialOpts: AgentCommandOpts,
+ admissionIngress: AgentCommandAdmissionIngress,
runtime: RuntimeEnv = defaultRuntime,
deps?: CliDeps,
) {
@@ -223,6 +231,14 @@ async function agentCommandInternal(
},
});
return await sessionWorkAdmission.run(async () => {
+ executionIdentity.record({
+ admission: opts.executionIdentityAdmission,
+ agentId: sessionAgentId,
+ cfg,
+ ingress: admissionIngress,
+ runId,
+ runtimeKind: !isRawModelRun && acpResolution?.kind === "ready" ? "acp" : "embedded",
+ });
if (sessionStore && sessionKey && !suppressVisibleSessionEffects) {
try {
await repairPendingAssistantTranscriptTurns({
@@ -577,9 +593,9 @@ async function agentCommandInternal(
}
}
-/** Runs an agent turn from CLI/runtime options against the resolved session and model policy. */
-export async function agentCommand(
+async function agentCommandWithAdmissionIngress(
opts: AgentCommandOpts,
+ admissionIngress: AgentCommandAdmissionIngress,
runtime: RuntimeEnv = defaultRuntime,
deps?: CliDeps,
) {
@@ -601,11 +617,8 @@ export async function agentCommand(
opts: {
...opts,
lifecycleGeneration,
- // agentCommand is the trusted-operator entrypoint used by CLI/local flows.
- // Ingress callers must opt into owner identity explicitly via
- // agentCommandFromIngress so network-facing paths cannot inherit this default by accident.
+ // Only the local entrypoint may inherit trusted-operator defaults.
senderIsOwner: opts.senderIsOwner ?? true,
- // Local/CLI callers are trusted by default for per-run model overrides.
allowModelOverride: opts.allowModelOverride ?? true,
},
prepare: async (preparedOpts) =>
@@ -613,14 +626,39 @@ export async function agentCommand(
prepareAgentCommandExecution(preparedOpts, runtime),
),
run: async (prepared) =>
- await agentCommandInternal(prepared, prepared.opts, runtime, resolvedDeps),
+ await agentCommandInternal(
+ prepared,
+ prepared.opts,
+ admissionIngress,
+ runtime,
+ resolvedDeps,
+ ),
}),
),
);
}
+export async function agentCommand(
+ opts: AgentCommandOpts,
+ runtime: RuntimeEnv = defaultRuntime,
+ deps?: CliDeps,
+) {
+ const { localIngress } = executionIdentity;
+ return await agentCommandWithAdmissionIngress(opts, localIngress, runtime, deps);
+}
+
+export async function agentCommandFromSystem(
+ opts: AgentCommandOpts,
+ admission: { boundary: string },
+ runtime: RuntimeEnv = defaultRuntime,
+ deps?: CliDeps,
+) {
+ const ingress = executionIdentity.systemIngress(admission.boundary);
+ return await agentCommandWithAdmissionIngress(opts, ingress, runtime, deps);
+}
+
async function agentCommandFromIngressInternal(
- opts: AgentCommandIngressOpts,
+ opts: AgentCommandGatewayIngressOpts,
runtime: RuntimeEnv = defaultRuntime,
deps?: CliDeps,
recovery?: {
@@ -643,7 +681,14 @@ async function agentCommandFromIngressInternal(
},
prepare: async (preparedOpts) => await prepareAgentCommandExecution(preparedOpts, runtime),
restoreAdmittedRecovery: recovery?.restoreAdmittedRecovery,
- run: async (prepared) => await agentCommandInternal(prepared, prepared.opts, runtime, deps),
+ run: async (prepared) =>
+ await agentCommandInternal(
+ prepared,
+ prepared.opts,
+ { kind: "api", boundary: "agent-command.from-ingress", state: "unknown" },
+ runtime,
+ deps,
+ ),
});
if (result) {
@@ -660,12 +705,18 @@ export async function agentCommandFromIngress(
runtime: RuntimeEnv = defaultRuntime,
deps?: CliDeps,
) {
- return await agentCommandFromIngressInternal(opts, runtime, deps);
+ // Plugin SDK callers may be plain JavaScript. Enforce the private recovery
+ // boundary at runtime so extra or inherited properties cannot author audit identity.
+ return await agentCommandFromIngressInternal(
+ { ...opts, executionIdentityAdmission: undefined },
+ runtime,
+ deps,
+ );
}
/** Internal Gateway entrypoint that restores a rejected restart-recovery admission. */
export async function agentCommandFromGatewayIngress(
- opts: AgentCommandIngressOpts,
+ opts: AgentCommandGatewayIngressOpts,
runtime: RuntimeEnv,
deps: CliDeps | undefined,
recovery: {
diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts
index 4235b2868164..5eccb85f4dc4 100644
--- a/src/agents/command/types.ts
+++ b/src/agents/command/types.ts
@@ -5,6 +5,7 @@ import type { FastMode } from "@openclaw/normalization-core/string-coerce";
import type { AgentInternalEvent } from "../../agents/internal-events.js";
import type { SpawnedRunMetadata } from "../../agents/spawned-context.js";
import type { PromptMode } from "../../agents/system-prompt.types.js";
+import type { ExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js";
import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js";
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.public.js";
import type { MediaFact } from "../../media/media-facts.js";
@@ -192,6 +193,11 @@ export type AgentCommandOpts = {
mainRestartRecoveryOwnerLease?: MainSessionRecoveryOwnerLease;
/** Gateway already consumed this automatic recovery run's durable reservation. */
mainRestartRecoveryAdmitted?: boolean;
+ /** Private recovery correlation; public ingress callers cannot author identity evidence. */
+ executionIdentityAdmission?: {
+ token: ExecutionIdentityAdmissionToken;
+ retryOnly: boolean;
+ };
/** Called when the actual run model is selected, including fallback retries. */
onActiveModelSelected?: (ctx: { provider: string; model: string }) => void | Promise;
/** Called when every candidate in the run's model fallback chain failed. */
@@ -215,10 +221,14 @@ export type AgentCommandOpts = {
/** Restricted option surface for external ingress callsites. */
export type AgentCommandIngressOpts = Omit<
AgentCommandOpts,
- "senderIsOwner" | "allowModelOverride"
+ "senderIsOwner" | "allowModelOverride" | "executionIdentityAdmission"
> & {
/** Trusted sender identity bit for command/channel-action auth; defaults false for ingress. */
senderIsOwner?: boolean;
/** Ingress callsites must always pass explicit model-override authorization state. */
allowModelOverride: boolean;
};
+
+/** Gateway-only ingress extends the public Plugin SDK surface with private recovery correlation. */
+export type AgentCommandGatewayIngressOpts = AgentCommandIngressOpts &
+ Pick;
diff --git a/src/agents/main-session-recovery-state.execution-identity.test.ts b/src/agents/main-session-recovery-state.execution-identity.test.ts
new file mode 100644
index 000000000000..c93df634bb14
--- /dev/null
+++ b/src/agents/main-session-recovery-state.execution-identity.test.ts
@@ -0,0 +1,218 @@
+import { describe, expect, it } from "vitest";
+import type {
+ InternalSessionEntry as SessionEntry,
+ MainRestartRecoveryState,
+} from "../config/sessions.js";
+import { transitionMainSessionRecovery } from "./main-session-recovery-state.js";
+
+const executionIdentity = (runId: string) => ({
+ tokenVersion: 1 as const,
+ contextId: `context-${runId}`,
+ executionId: `execution-${runId}`,
+ runId,
+ createdAt: 1,
+});
+
+function recoveryState(
+ overrides: Partial = {},
+): MainRestartRecoveryState {
+ return {
+ cycleId: "cycle-1",
+ revision: 1,
+ chargedAttempts: 0,
+ ...overrides,
+ };
+}
+
+function interruptedEntry(overrides: Partial = {}): SessionEntry {
+ return {
+ sessionId: "session-1",
+ updatedAt: 100,
+ status: "running",
+ abortedLastRun: true,
+ mainRestartRecovery: recoveryState(),
+ ...overrides,
+ };
+}
+
+function observe(entry: SessionEntry, lifecycleGeneration: string) {
+ const result = transitionMainSessionRecovery(entry, {
+ kind: "observe",
+ cycleId: "unused-cycle",
+ lifecycleGeneration,
+ sessionKey: "agent:main:main",
+ });
+ if (result.kind !== "observed") {
+ throw new Error("expected recovery observation");
+ }
+ return result.view;
+}
+
+function claimForeground(entry: SessionEntry) {
+ return transitionMainSessionRecovery(entry, {
+ kind: "claim_foreground",
+ cycleId: "unused",
+ lifecycleGeneration: "generation-1",
+ sessionId: "session-1",
+ sessionKey: "agent:main:main",
+ claimId: "foreground-1",
+ });
+}
+
+describe("main session recovery execution identity state", () => {
+ it("captures identity at reservation and clears it with a cancelled reservation", () => {
+ const entry = interruptedEntry();
+ const prepared = transitionMainSessionRecovery(entry, {
+ kind: "prepare_attempt",
+ attempt: 1,
+ lifecycleGeneration: "generation-1",
+ now: 200,
+ observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 },
+ runId: "recovery-1",
+ executionIdentity: { state: "enabled", token: executionIdentity("recovery-1") },
+ });
+ expect(prepared.kind).toBe("reserved");
+ if (prepared.kind !== "reserved") {
+ throw new Error("expected reservation");
+ }
+ expect(prepared.reservation).toMatchObject({
+ executionIdentityAdmission: {
+ kind: "capture",
+ token: executionIdentity("recovery-1"),
+ },
+ });
+
+ expect(
+ transitionMainSessionRecovery(entry, {
+ kind: "prepare_attempt",
+ attempt: 1,
+ lifecycleGeneration: "generation-1",
+ now: 201,
+ observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 },
+ runId: "recovery-2",
+ executionIdentity: { state: "enabled", token: executionIdentity("recovery-2") },
+ }),
+ ).toEqual({ kind: "rejected", reason: "stale_revision" });
+ expect(entry.mainRestartRecovery?.reservation).toMatchObject({
+ runId: "recovery-1",
+ attempt: 1,
+ });
+
+ expect(claimForeground(entry).kind).toBe("foreground_claimed");
+ expect(
+ transitionMainSessionRecovery(entry, {
+ kind: "cancel_reservation",
+ reservation: prepared.reservation,
+ }),
+ ).toEqual({ kind: "applied" });
+ expect(entry.mainRestartRecovery).toMatchObject({
+ chargedAttempts: 0,
+ foregroundClaims: {
+ lifecycleGeneration: "generation-1",
+ tokens: ["foreground-1"],
+ },
+ });
+ expect(entry.mainRestartRecovery?.reservation).toBeUndefined();
+ expect(entry.mainRestartRecovery?.executionIdentity).toBeUndefined();
+ expect(observe(entry, "generation-1")).toEqual({ status: "blocked" });
+ });
+
+ it("reuses captured identity after an ambiguous dispatch", () => {
+ const entry = interruptedEntry();
+ const prepared = transitionMainSessionRecovery(entry, {
+ kind: "prepare_attempt",
+ attempt: 1,
+ lifecycleGeneration: "generation-1",
+ now: 200,
+ observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 },
+ runId: "recovery-1",
+ executionIdentity: { state: "enabled", token: executionIdentity("recovery-1") },
+ });
+ if (prepared.kind !== "reserved") {
+ throw new Error("expected reservation");
+ }
+
+ expect(
+ transitionMainSessionRecovery(entry, {
+ kind: "abandon_reservation",
+ reservation: prepared.reservation,
+ }),
+ ).toEqual({ kind: "applied" });
+ expect(entry.mainRestartRecovery).toMatchObject({ chargedAttempts: 1 });
+ expect(entry.mainRestartRecovery?.reservation).toBeUndefined();
+ expect(observe(entry, "generation-1")).toMatchObject({
+ status: "recoverable",
+ nextAttempt: 2,
+ });
+ const retry = transitionMainSessionRecovery(entry, {
+ kind: "prepare_attempt",
+ attempt: 2,
+ lifecycleGeneration: "generation-1",
+ now: 300,
+ observation: {
+ sessionId: "session-1",
+ cycleId: "cycle-1",
+ revision: entry.mainRestartRecovery!.revision,
+ },
+ runId: "recovery-1",
+ executionIdentity: {
+ state: "enabled",
+ token: { ...executionIdentity("replacement"), runId: "recovery-1" },
+ },
+ });
+ expect(retry).toMatchObject({
+ kind: "reserved",
+ reservation: {
+ executionIdentityAdmission: {
+ kind: "retry-reference",
+ token: executionIdentity("recovery-1"),
+ },
+ },
+ });
+ });
+
+ it("keeps disabled recovery identity out of durable state and reservations", () => {
+ const entry = interruptedEntry();
+
+ const prepared = transitionMainSessionRecovery(entry, {
+ kind: "prepare_attempt",
+ attempt: 1,
+ lifecycleGeneration: "generation-1",
+ now: 200,
+ observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 },
+ runId: "recovery-1",
+ executionIdentity: { state: "disabled" },
+ });
+
+ expect(prepared).toMatchObject({ kind: "reserved" });
+ if (prepared.kind !== "reserved") {
+ throw new Error("expected reservation");
+ }
+ expect(prepared.reservation.executionIdentityAdmission).toBeUndefined();
+ expect(entry.mainRestartRecovery?.executionIdentity).toBeUndefined();
+ });
+
+ it("does not propagate a previously retained token while collection is disabled", () => {
+ const retained = executionIdentity("recovery-1");
+ const entry = interruptedEntry({
+ mainRestartRecovery: recoveryState({ executionIdentity: retained }),
+ });
+
+ const prepared = transitionMainSessionRecovery(entry, {
+ kind: "prepare_attempt",
+ attempt: 1,
+ lifecycleGeneration: "generation-1",
+ now: 200,
+ observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 },
+ runId: "recovery-1",
+ executionIdentity: { state: "disabled" },
+ });
+
+ expect(prepared).toMatchObject({ kind: "reserved" });
+ if (prepared.kind !== "reserved") {
+ throw new Error("expected reservation");
+ }
+ expect(prepared.reservation.executionIdentityAdmission).toBeUndefined();
+ expect(entry.mainRestartRecovery?.executionIdentity).toEqual(retained);
+ });
+});
diff --git a/src/agents/main-session-recovery-state.test.ts b/src/agents/main-session-recovery-state.test.ts
index c7cbef35c988..a9b02eb4ed58 100644
--- a/src/agents/main-session-recovery-state.test.ts
+++ b/src/agents/main-session-recovery-state.test.ts
@@ -8,7 +8,6 @@ import { projectMainSessionRecoveryLifecycle } from "./main-session-recovery-lif
import { transitionMainSessionRecovery } from "./main-session-recovery-state.js";
const sessionKey = "agent:main:main";
-
function recoveryState(
overrides: Partial = {},
): MainRestartRecoveryState {
@@ -164,56 +163,6 @@ describe("main session recovery state", () => {
]);
});
- it("charges at reservation and refunds only the matching reservation", () => {
- const entry = interruptedEntry();
- const prepared = transitionMainSessionRecovery(entry, {
- kind: "prepare_attempt",
- attempt: 1,
- lifecycleGeneration: "generation-1",
- now: 200,
- observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 },
- runId: "recovery-1",
- });
- expect(prepared.kind).toBe("reserved");
- if (prepared.kind !== "reserved") {
- throw new Error("expected reservation");
- }
-
- expect(
- transitionMainSessionRecovery(entry, {
- kind: "prepare_attempt",
- attempt: 1,
- lifecycleGeneration: "generation-1",
- now: 201,
- observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 },
- runId: "recovery-2",
- }),
- ).toEqual({ kind: "rejected", reason: "stale_revision" });
- expect(entry.mainRestartRecovery?.reservation).toMatchObject({
- runId: "recovery-1",
- attempt: 1,
- });
-
- const claim = claimForeground(entry);
- expect(claim.kind).toBe("foreground_claimed");
-
- expect(
- transitionMainSessionRecovery(entry, {
- kind: "cancel_reservation",
- reservation: prepared.reservation,
- }),
- ).toEqual({ kind: "applied" });
- expect(entry.mainRestartRecovery).toMatchObject({
- chargedAttempts: 0,
- foregroundClaims: {
- lifecycleGeneration: "generation-1",
- tokens: ["foreground-1"],
- },
- });
- expect(entry.mainRestartRecovery?.reservation).toBeUndefined();
- expect(observe(entry, "generation-1")).toEqual({ status: "blocked" });
- });
-
it("rejects foreground work after the automatic recovery budget is exhausted", () => {
const entry = interruptedEntry({
mainRestartRecovery: recoveryState({ chargedAttempts: 3 }),
@@ -348,34 +297,6 @@ describe("main session recovery state", () => {
expect(entry.mainRestartRecovery?.reservation).toBeUndefined();
});
- it("releases an ambiguous dispatch reservation without refunding its charge", () => {
- const entry = interruptedEntry();
- const prepared = transitionMainSessionRecovery(entry, {
- kind: "prepare_attempt",
- attempt: 1,
- lifecycleGeneration: "generation-1",
- now: 200,
- observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 },
- runId: "recovery-1",
- });
- if (prepared.kind !== "reserved") {
- throw new Error("expected reservation");
- }
-
- expect(
- transitionMainSessionRecovery(entry, {
- kind: "abandon_reservation",
- reservation: prepared.reservation,
- }),
- ).toEqual({ kind: "applied" });
- expect(entry.mainRestartRecovery).toMatchObject({ chargedAttempts: 1 });
- expect(entry.mainRestartRecovery?.reservation).toBeUndefined();
- expect(observe(entry, "generation-1")).toMatchObject({
- status: "recoverable",
- nextAttempt: 2,
- });
- });
-
it("moves a reservation into the lifecycle fence during Gateway admission", () => {
const entry = interruptedEntry({
pendingFinalDelivery: { kind: "replayable", text: " captured reply ", createdAt: 1 },
@@ -613,6 +534,7 @@ describe("main session recovery state", () => {
now: 500,
observation: oldObservation,
runId: "stale-run",
+ executionIdentity: { state: "disabled" },
}),
).toEqual({ kind: "rejected", reason: "stale_cycle" });
});
diff --git a/src/agents/main-session-recovery-state.ts b/src/agents/main-session-recovery-state.ts
index 95d7a0e168eb..2fffe9672be9 100644
--- a/src/agents/main-session-recovery-state.ts
+++ b/src/agents/main-session-recovery-state.ts
@@ -347,8 +347,17 @@ export function transitionMainSessionRecovery(
if (command.attempt !== state.chargedAttempts + 1) {
return { kind: "rejected", reason: "stale_revision" };
}
+ const executionIdentityAdmission =
+ command.executionIdentity.state === "disabled"
+ ? undefined
+ : state.executionIdentity
+ ? ({ kind: "retry-reference", token: state.executionIdentity } as const)
+ : ({ kind: "capture", token: command.executionIdentity.token } as const);
updateRecoveryState(entry, state, {
chargedAttempts: command.attempt,
+ ...(executionIdentityAdmission?.kind === "capture"
+ ? { executionIdentity: executionIdentityAdmission.token }
+ : {}),
reservation: {
runId: command.runId,
attempt: command.attempt,
@@ -364,6 +373,7 @@ export function transitionMainSessionRecovery(
lifecycleGeneration: command.lifecycleGeneration,
runId: command.runId,
attempt: command.attempt,
+ ...(executionIdentityAdmission ? { executionIdentityAdmission } : {}),
},
};
}
@@ -387,6 +397,10 @@ export function transitionMainSessionRecovery(
? Math.max(0, command.reservation.attempt - 1)
: state.chargedAttempts,
reservation: undefined,
+ ...(command.kind === "cancel_reservation" &&
+ command.reservation.executionIdentityAdmission?.kind === "capture"
+ ? { executionIdentity: undefined }
+ : {}),
});
return { kind: "applied" };
}
diff --git a/src/agents/main-session-recovery-store.test.ts b/src/agents/main-session-recovery-store.test.ts
index dffcf6d2f621..9260b7cd6b1d 100644
--- a/src/agents/main-session-recovery-store.test.ts
+++ b/src/agents/main-session-recovery-store.test.ts
@@ -20,6 +20,17 @@ import {
} from "./main-session-recovery-store.js";
const sessionKey = "agent:main:main";
+const executionIdentity = (runId: string) => ({
+ tokenVersion: 1 as const,
+ contextId: `context-${runId}`,
+ executionId: `execution-${runId}`,
+ runId,
+ createdAt: 1,
+});
+const enabledExecutionIdentity = (runId: string) => ({
+ state: "enabled" as const,
+ token: executionIdentity(runId),
+});
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("main session recovery store", () => {
@@ -104,6 +115,7 @@ describe("main session recovery store", () => {
now: 200,
observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 },
runId: "recovery-1",
+ executionIdentity: enabledExecutionIdentity("recovery-1"),
},
target: { sessionKey: targetSessionKey, storePath },
});
@@ -210,6 +222,7 @@ describe("main session recovery store", () => {
now: 400,
observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 },
runId: "stale-recovery",
+ executionIdentity: enabledExecutionIdentity("stale-recovery"),
});
expect(result.transition).toEqual({ kind: "rejected", reason: "session_replaced" });
diff --git a/src/agents/main-session-recovery-types.ts b/src/agents/main-session-recovery-types.ts
index 7a86dd6f52e1..b2d091f4afd1 100644
--- a/src/agents/main-session-recovery-types.ts
+++ b/src/agents/main-session-recovery-types.ts
@@ -1,8 +1,17 @@
import type {
InternalSessionEntry as SessionEntry,
+ MainRestartRecoveryState,
RestartRecoveryRun,
} from "../config/sessions.js";
+type MainSessionRecoveryExecutionIdentity = NonNullable<
+ MainRestartRecoveryState["executionIdentity"]
+>;
+
+type MainSessionRecoveryExecutionIdentityAdmission =
+ | { kind: "capture"; token: MainSessionRecoveryExecutionIdentity }
+ | { kind: "retry-reference"; token: MainSessionRecoveryExecutionIdentity };
+
export type MainSessionRecoveryObservation = {
sessionId: string;
cycleId: string;
@@ -15,6 +24,7 @@ export type MainSessionRecoveryReservation = {
lifecycleGeneration: string;
runId: string;
attempt: number;
+ executionIdentityAdmission?: MainSessionRecoveryExecutionIdentityAdmission;
};
export type MainSessionRecoveryOwnerClaim = {
@@ -85,6 +95,9 @@ export type MainSessionRecoveryCommand =
now: number;
observation: MainSessionRecoveryObservation;
runId: string;
+ executionIdentity:
+ | { state: "disabled" }
+ | { state: "enabled"; token: MainSessionRecoveryExecutionIdentity };
}
| {
kind: "cancel_reservation" | "abandon_reservation";
diff --git a/src/agents/main-session-restart-dispatch.ts b/src/agents/main-session-restart-dispatch.ts
index 047b7b1c22d4..d320e2eea3e3 100644
--- a/src/agents/main-session-restart-dispatch.ts
+++ b/src/agents/main-session-restart-dispatch.ts
@@ -1,6 +1,8 @@
import { randomUUID } from "node:crypto";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { GatewayClientRequestError } from "../../packages/gateway-client/src/index.js";
+import { isExecutionIdentityCollectionEnabled } from "../audit/audit-config.js";
+import { createExecutionIdentityAdmissionToken } from "../audit/execution-identity-admission.js";
import { sanitizePendingFinalDeliveryText } from "../auto-reply/reply/pending-final-delivery.js";
import type { SessionEntry } from "../config/sessions.js";
import {
@@ -418,6 +420,7 @@ export async function resumeMainSession(params: {
}
const recoveryRunId = claimedRunId && claimedRunId !== sourceRunId ? claimedRunId : randomUUID();
const reusingRecoveryRunId = recoveryRunId === claimedRunId;
+ const executionIdentityCollectionEnabled = isExecutionIdentityCollectionEnabled(params.cfg);
const dispatchSessionKey = params.canonicalSessionKey ?? params.sessionKey;
const recoverySessionKeys = Array.from(new Set([dispatchSessionKey, params.sessionKey]));
let reservation: MainSessionRecoveryReservation | undefined;
@@ -445,6 +448,12 @@ export async function resumeMainSession(params: {
now: Date.now(),
observation: params.observation,
runId: recoveryRunId,
+ executionIdentity: executionIdentityCollectionEnabled
+ ? {
+ state: "enabled",
+ token: createExecutionIdentityAdmissionToken(recoveryRunId),
+ }
+ : { state: "disabled" },
},
requireWriteSuccess: true,
shouldContinue: params.shouldContinue,
@@ -511,6 +520,12 @@ export async function resumeMainSession(params: {
...(params.sessionWorkAdmissionHandoffId
? { internalRuntimeHandoffId: params.sessionWorkAdmissionHandoffId }
: {}),
+ ...(reservation.executionIdentityAdmission
+ ? {
+ internalExecutionIdentityRetry:
+ reservation.executionIdentityAdmission.kind === "retry-reference",
+ }
+ : {}),
idempotencyKey: recoveryRunId,
deliver:
Boolean(deliveryContext) &&
diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts
index 89313709c540..06aa9251a23d 100644
--- a/src/agents/main-session-restart-recovery.test.ts
+++ b/src/agents/main-session-restart-recovery.test.ts
@@ -97,6 +97,9 @@ const discordDeliveryContext = {
channel: "discord",
to: "discord:dm:123",
} as const;
+const executionIdentityEnabledConfig = {
+ logging: { audit: { executionIdentity: true } },
+} satisfies OpenClawConfig;
vi.mock("../gateway/call.js", () => ({
callGateway: vi.fn(async () => ({ runId: "run-resumed" })),
@@ -1177,6 +1180,27 @@ describe("main-session-restart-recovery", () => {
expect(sourceClaimAtDispatch).toBe("control-ui-run");
});
+ it.each([
+ ["fresh default config", undefined],
+ ["upgrade config without the new setting", {}],
+ ["explicit collection disable", { logging: { audit: { executionIdentity: false } } }],
+ ["disabled audit ledger", { logging: { audit: { enabled: false, executionIdentity: true } } }],
+ ] satisfies Array<[string, OpenClawConfig | undefined]>)(
+ "stores no recovery identity with %s",
+ async (_label, cfg) => {
+ const sessionsDir = await makeSessionsDir();
+ const storePath = path.join(sessionsDir, "sessions.json");
+ await writeMainSession({ sessionsDir });
+ await writeCompletedToolTranscript(sessionsDir);
+
+ await expectRecovery({ recovered: 1, failed: 0, skipped: 0 }, cfg);
+
+ const entry = loadSessionEntry({ sessionKey: "agent:main:main", storePath });
+ expect(entry?.mainRestartRecovery?.executionIdentity).toBeUndefined();
+ expect(gatewayParams()).not.toHaveProperty("internalExecutionIdentityRetry");
+ },
+ );
+
it("retains one stable transcript-only claim across ambiguous dispatch rejection", async () => {
const sessionsDir = await makeSessionsDir();
const storePath = path.join(sessionsDir, "sessions.json");
@@ -1188,7 +1212,7 @@ describe("main-session-restart-recovery", () => {
await writeCompletedToolTranscript(sessionsDir);
vi.mocked(callGateway).mockRejectedValueOnce(new Error("gateway unavailable"));
- await expectRecovery({ recovered: 0, failed: 1, skipped: 0 }, {});
+ await expectRecovery({ recovered: 0, failed: 1, skipped: 0 }, executionIdentityEnabledConfig);
const firstRecoveryRunId = (
vi.mocked(callGateway).mock.calls[0]?.[0].params as { idempotencyKey?: unknown } | undefined
@@ -1205,8 +1229,21 @@ describe("main-session-restart-recovery", () => {
status: "running",
});
expect(pending?.mainRestartRecovery?.reservation).toBeUndefined();
+ const executionIdentity = pending?.mainRestartRecovery?.executionIdentity;
+ expect(executionIdentity).toMatchObject({
+ tokenVersion: 1,
+ contextId: expect.any(String),
+ executionId: expect.any(String),
+ runId: firstRecoveryRunId,
+ createdAt: expect.any(Number),
+ });
+ const firstRequest = vi.mocked(callGateway).mock.calls[0]?.[0];
+ expect(firstRequest).toBeDefined();
+ expect((firstRequest!.params as Record).internalExecutionIdentityRetry).toBe(
+ false,
+ );
- await expectRecovery({ recovered: 1, failed: 0, skipped: 0 }, {});
+ await expectRecovery({ recovered: 1, failed: 0, skipped: 0 }, executionIdentityEnabledConfig);
const runIds = vi
.mocked(callGateway)
.mock.calls.map(([request]) =>
@@ -1216,13 +1253,55 @@ describe("main-session-restart-recovery", () => {
)
.filter((runId) => runId !== undefined);
expect(runIds).toEqual([firstRecoveryRunId, firstRecoveryRunId]);
- expect(loadSessionEntry({ sessionKey: "agent:main:main", storePath })).toMatchObject({
+ const recovered = loadSessionEntry({ sessionKey: "agent:main:main", storePath });
+ expect(recovered).toMatchObject({
abortedLastRun: false,
- mainRestartRecovery: { chargedAttempts: 2 },
+ mainRestartRecovery: { chargedAttempts: 2, executionIdentity },
restartRecoveryDeliveryRunId: firstRecoveryRunId,
restartRecoveryDeliverySourceRunId: "control-ui-run",
status: "running",
});
+ const agentRequests = vi
+ .mocked(callGateway)
+ .mock.calls.map(([request]) => request)
+ .filter((request) => request.method === "agent");
+ expect(agentRequests[1]).toBeDefined();
+ expect(
+ (agentRequests[1]!.params as Record).internalExecutionIdentityRetry,
+ ).toBe(true);
+ });
+
+ it("does not propagate a retained recovery token after collection is disabled", async () => {
+ const sessionsDir = await makeSessionsDir();
+ const storePath = path.join(sessionsDir, "sessions.json");
+ await writeMainSession({
+ sessionsDir,
+ restartRecoveryDeliveryRunId: "control-ui-run",
+ restartRecoveryDeliverySourceRunId: "control-ui-run",
+ });
+ await writeCompletedToolTranscript(sessionsDir);
+ vi.mocked(callGateway).mockRejectedValueOnce(new Error("gateway unavailable"));
+
+ await expectRecovery({ recovered: 0, failed: 1, skipped: 0 }, executionIdentityEnabledConfig);
+ const retained = loadSessionEntry({ sessionKey: "agent:main:main", storePath })
+ ?.mainRestartRecovery?.executionIdentity;
+ expect(retained).toBeDefined();
+
+ await expectRecovery(
+ { recovered: 1, failed: 0, skipped: 0 },
+ { logging: { audit: { executionIdentity: false } } },
+ );
+
+ const requests = vi
+ .mocked(callGateway)
+ .mock.calls.map(([request]) => request)
+ .filter((request) => request.method === "agent");
+ expect(requests).toHaveLength(2);
+ expect(requests[1]?.params).not.toHaveProperty("internalExecutionIdentityRetry");
+ expect(
+ loadSessionEntry({ sessionKey: "agent:main:main", storePath })?.mainRestartRecovery
+ ?.executionIdentity,
+ ).toEqual(retained);
});
it("retries reservation cleanup after a transient session-store failure", async () => {
diff --git a/src/audit/audit-config.test.ts b/src/audit/audit-config.test.ts
index 6ca8f2ff8474..af85a66bb95b 100644
--- a/src/audit/audit-config.test.ts
+++ b/src/audit/audit-config.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
-import { isAuditLedgerEnabled, resolveAuditMessageMode } from "./audit-config.js";
+import {
+ isAuditLedgerEnabled,
+ isExecutionIdentityCollectionEnabled,
+ resolveAuditMessageMode,
+} from "./audit-config.js";
describe("isAuditLedgerEnabled", () => {
it("defaults to enabled without config or audit section", () => {
@@ -22,4 +26,19 @@ describe("isAuditLedgerEnabled", () => {
expect(resolveAuditMessageMode({ logging: { audit: { messages: "direct" } } })).toBe("direct");
expect(resolveAuditMessageMode({ logging: { audit: { messages: "all" } } })).toBe("all");
});
+
+ it("keeps execution identity off until both switches are explicitly enabled", () => {
+ expect(isExecutionIdentityCollectionEnabled(undefined)).toBe(false);
+ expect(isExecutionIdentityCollectionEnabled({ logging: { audit: {} } })).toBe(false);
+ expect(
+ isExecutionIdentityCollectionEnabled({
+ logging: { audit: { enabled: true, executionIdentity: true } },
+ }),
+ ).toBe(true);
+ expect(
+ isExecutionIdentityCollectionEnabled({
+ logging: { audit: { enabled: false, executionIdentity: true } },
+ }),
+ ).toBe(false);
+ });
});
diff --git a/src/audit/audit-config.ts b/src/audit/audit-config.ts
index 9fe205ea7a95..f67657eaa4ac 100644
--- a/src/audit/audit-config.ts
+++ b/src/audit/audit-config.ts
@@ -12,6 +12,11 @@ export function isAuditLedgerEnabled(cfg: OpenClawConfig | undefined): boolean {
return cfg?.logging?.audit?.enabled !== false;
}
+/** Execution identity is retained only after an explicit startup-scoped opt-in. */
+export function isExecutionIdentityCollectionEnabled(cfg: OpenClawConfig | undefined): boolean {
+ return isAuditLedgerEnabled(cfg) && cfg?.logging?.audit?.executionIdentity === true;
+}
+
/** Message metadata remains an explicit opt-in inside the default-on ledger. */
export function resolveAuditMessageMode(cfg: OpenClawConfig | undefined): AuditMessageMode {
return cfg?.logging?.audit?.messages ?? "off";
diff --git a/src/audit/audit-event-writer.test.ts b/src/audit/audit-event-writer.test.ts
index 7eb0d25e476b..0a9fd39c2fa8 100644
--- a/src/audit/audit-event-writer.test.ts
+++ b/src/audit/audit-event-writer.test.ts
@@ -1,14 +1,57 @@
-import { afterAll, afterEach, describe, expect, it } from "vitest";
-import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js";
+import { afterEach, describe, expect, it } from "vitest";
+import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
-import { listAuditEvents } from "./audit-event-store.js";
+import { listAuditEvents, recordAuditEvent } from "./audit-event-store.js";
import type { AuditEventInput } from "./audit-event-types.js";
import { createAuditEventWriter } from "./audit-event-writer.js";
+import {
+ configureExecutionIdentityAdmissionSink,
+ createExecutionIdentityAdmissionToken,
+ enqueueExecutionIdentityContextAtAdmission,
+ type ExecutionIdentityAdmissionEnvelope,
+ type ExecutionIdentityAdmissionFacts,
+} from "./execution-identity-admission.js";
+import {
+ inspectExecutionIdentityRun,
+ processExecutionIdentityAdmissionWork,
+} from "./execution-identity-context.js";
-const tempDirs: string[] = [];
+function captureExecutionIdentityAdmissionEnvelope(
+ facts: ExecutionIdentityAdmissionFacts,
+ options: {
+ contextId?: string;
+ executionId?: string;
+ now?: number;
+ runtimeInstanceId?: string;
+ } = {},
+) {
+ let captured: ExecutionIdentityAdmissionEnvelope | undefined;
+ const clear = configureExecutionIdentityAdmissionSink((work) => {
+ if (work.kind === "capture") {
+ captured = work.envelope;
+ }
+ return true;
+ });
+ const result = enqueueExecutionIdentityContextAtAdmission(facts, {
+ ...options,
+ enabled: true,
+ });
+ clear();
+ if (!result || !captured) {
+ throw new Error("expected admission envelope");
+ }
+ return captured;
+}
+
+function persistExecutionIdentityAdmissionEnvelope(
+ envelope: ExecutionIdentityAdmissionEnvelope,
+ options: Parameters[1] = {},
+) {
+ return processExecutionIdentityAdmissionWork({ kind: "capture", envelope }, options);
+}
function input(): AuditEventInput {
return {
@@ -25,17 +68,55 @@ function input(): AuditEventInput {
};
}
+function captureWork(envelope: ExecutionIdentityAdmissionEnvelope) {
+ return { kind: "capture" as const, envelope };
+}
+
afterEach(() => {
closeOpenClawStateDatabaseForTest();
});
-
-afterAll(() => {
- cleanupTempDirs(tempDirs);
-});
+const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("audit event worker", () => {
+ it("keeps first-use identity storage absent during maintenance without admission", async () => {
+ const stateDir = tempDirs.make("openclaw-audit-writer-");
+ const database = { env: { OPENCLAW_STATE_DIR: stateDir } };
+ const errors: string[] = [];
+ const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) });
+
+ await writer.ready;
+ await writer.stop();
+
+ expect(errors).toEqual([]);
+ expect(
+ openOpenClawStateDatabase(database)
+ .db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
+ .get("execution_identity_contexts"),
+ ).toBeUndefined();
+ });
+
+ it("keeps an established current store identity-free during maintenance", async () => {
+ const stateDir = tempDirs.make("openclaw-audit-writer-");
+ const database = { env: { OPENCLAW_STATE_DIR: stateDir } };
+ recordAuditEvent(input(), database);
+ closeOpenClawStateDatabaseForTest();
+ const errors: string[] = [];
+ const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) });
+
+ await writer.ready;
+ await writer.stop();
+
+ expect(errors).toEqual([]);
+ expect(listAuditEvents({ database, limit: 10 }).events).toHaveLength(1);
+ expect(
+ openOpenClawStateDatabase(database)
+ .db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
+ .get("execution_identity_contexts"),
+ ).toBeUndefined();
+ });
+
it("returns immediately under SQLite contention and flushes before stop", async () => {
- const stateDir = makeTempDir(tempDirs, "openclaw-audit-writer-");
+ const stateDir = tempDirs.make("openclaw-audit-writer-");
const database = { env: { OPENCLAW_STATE_DIR: stateDir } };
const errors: string[] = [];
const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) });
@@ -51,4 +132,422 @@ describe("audit event worker", () => {
expect(errors).toEqual([]);
expect(listAuditEvents({ database, limit: 10 }).events).toHaveLength(1);
});
+
+ it("keeps first-use identity admission prompt under a held write lock", async () => {
+ const stateDir = tempDirs.make("openclaw-audit-writer-");
+ const database = { env: { OPENCLAW_STATE_DIR: stateDir } };
+ const { db } = openOpenClawStateDatabase(database);
+ db.exec("DELETE FROM audit_identity_keys;");
+ db.exec("BEGIN IMMEDIATE");
+ const errors: string[] = [];
+ const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) });
+ const clearSink = configureExecutionIdentityAdmissionSink(writer.recordExecutionIdentity);
+ const admittedAt = Date.now();
+
+ const startedAt = performance.now();
+ expect(
+ enqueueExecutionIdentityContextAtAdmission(
+ {
+ runId: "held-lock-run",
+ agentId: "main",
+ ingress: {
+ kind: "local-cli",
+ boundary: "agent-command.local",
+ state: "present",
+ rawSourceRef: "raw-ingress-secret",
+ },
+ runtime: { kind: "embedded" },
+ invoker: { kind: "local-account", rawPrincipalRef: "raw-principal-secret" },
+ },
+ {
+ enabled: true,
+ contextId: "held-lock-context",
+ executionId: "held-lock-execution",
+ now: admittedAt,
+ runtimeInstanceId: "raw-runtime-secret",
+ },
+ ),
+ ).toEqual({
+ candidateContextId: "held-lock-context",
+ candidateExecutionId: "held-lock-execution",
+ accepted: true,
+ });
+ expect(performance.now() - startedAt).toBeLessThan(250);
+ expect(
+ db.prepare("SELECT name FROM sqlite_schema WHERE name = 'execution_identity_contexts'").get(),
+ ).toBeUndefined();
+ expect(db.prepare("SELECT COUNT(*) AS count FROM audit_identity_keys").get()).toEqual({
+ count: 0,
+ });
+
+ db.exec("ROLLBACK");
+ clearSink();
+ await writer.stop();
+ expect(errors).toEqual([]);
+ expect(
+ inspectExecutionIdentityRun({ runId: "held-lock-run" }, { ...database, now: admittedAt }),
+ ).toMatchObject({
+ identity: {
+ state: "present",
+ context: {
+ contextId: "held-lock-context",
+ executionId: "held-lock-execution",
+ runId: "held-lock-run",
+ createdAt: admittedAt,
+ ingress: {
+ kind: "local-cli",
+ boundary: "agent-command.local",
+ state: "present",
+ },
+ runtimeInstance: { kind: "embedded", state: "present" },
+ },
+ },
+ });
+ const persisted = db
+ .prepare("SELECT context_json FROM execution_identity_contexts WHERE run_id = ?")
+ .get("held-lock-run") as { context_json: string };
+ for (const raw of ["raw-ingress-secret", "raw-principal-secret", "raw-runtime-secret"]) {
+ expect(persisted.context_json).not.toContain(raw);
+ expect(JSON.stringify(errors)).not.toContain(raw);
+ }
+ });
+
+ it("prunes expired identity contexts at startup without a new run", async () => {
+ const stateDir = tempDirs.make("openclaw-audit-writer-");
+ const database = { env: { OPENCLAW_STATE_DIR: stateDir } };
+ persistExecutionIdentityAdmissionEnvelope(
+ captureExecutionIdentityAdmissionEnvelope(
+ {
+ runId: "expired-before-startup",
+ agentId: "main",
+ ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" },
+ runtime: { kind: "embedded" },
+ },
+ { now: 0, runtimeInstanceId: "runtime-1" },
+ ),
+ { ...database, now: 0 },
+ );
+ closeOpenClawStateDatabaseForTest();
+
+ const errors: string[] = [];
+ const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) });
+ await writer.ready;
+
+ expect(
+ openOpenClawStateDatabase(database)
+ .db.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts")
+ .get(),
+ ).toEqual({ count: 0 });
+ await writer.stop();
+ expect(errors).toEqual([]);
+ });
+
+ it("uses one pending limit across audit events and identity envelopes", async () => {
+ const stateDir = tempDirs.make("openclaw-audit-writer-");
+ const database = { env: { OPENCLAW_STATE_DIR: stateDir } };
+ const { db } = openOpenClawStateDatabase(database);
+ db.exec("BEGIN IMMEDIATE");
+ const errors: string[] = [];
+ const writer = createAuditEventWriter({
+ stateDir,
+ maxPending: 1,
+ onError: (error) => errors.push(error),
+ });
+ expect(writer.record(input())).toBe(true);
+ expect(
+ writer.recordExecutionIdentity(
+ captureWork(
+ captureExecutionIdentityAdmissionEnvelope(
+ {
+ runId: "queue-full-run",
+ agentId: "main",
+ ingress: { kind: "local-cli", boundary: "agent-command.local" },
+ runtime: { kind: "embedded" },
+ },
+ { runtimeInstanceId: "runtime-1" },
+ ),
+ ),
+ ),
+ ).toBe(false);
+ expect(errors).toContain("audit event queue is full (1); dropping metadata");
+ db.exec("ROLLBACK");
+ await writer.stop();
+ expect(listAuditEvents({ database, limit: 10 }).events).toHaveLength(1);
+ });
+
+ it("preserves exact-envelope idempotency and safely reports every canonical conflict", async () => {
+ const stateDir = tempDirs.make("openclaw-audit-writer-");
+ const database = { env: { OPENCLAW_STATE_DIR: stateDir } };
+ const errors: string[] = [];
+ const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) });
+ const admittedAt = Date.now();
+ const original = captureExecutionIdentityAdmissionEnvelope(
+ {
+ runId: "ordered-run",
+ agentId: "main",
+ ingress: { kind: "system", boundary: "gateway.boot", state: "present" },
+ runtime: { kind: "embedded" },
+ },
+ {
+ contextId: "ordered-context",
+ executionId: "ordered-execution",
+ now: admittedAt,
+ runtimeInstanceId: "runtime-1",
+ },
+ );
+ const factConflict = captureExecutionIdentityAdmissionEnvelope(
+ {
+ runId: "ordered-run",
+ agentId: "other",
+ ingress: {
+ kind: "local-cli",
+ boundary: "agent-command.local",
+ state: "present",
+ rawSourceRef: "raw-conflict-source",
+ },
+ runtime: { kind: "embedded" },
+ invoker: { kind: "local-account", rawPrincipalRef: "raw-conflict-principal" },
+ },
+ {
+ contextId: "ordered-context",
+ executionId: "ordered-execution",
+ now: admittedAt,
+ runtimeInstanceId: "runtime-1",
+ },
+ );
+ const contextIdConflict = { ...original, contextId: "conflicting-context" };
+ const createdAtConflict = { ...original, createdAt: admittedAt + 1 };
+
+ const startedAt = performance.now();
+ expect(writer.recordExecutionIdentity(captureWork(original))).toBe(true);
+ expect(writer.recordExecutionIdentity(captureWork(original))).toBe(true);
+ expect(
+ writer.recordExecutionIdentity({
+ kind: "retry-reference",
+ token: createExecutionIdentityAdmissionToken(original.runId, {
+ contextId: original.contextId,
+ executionId: original.executionId,
+ now: original.createdAt,
+ }),
+ }),
+ ).toBe(true);
+ expect(writer.recordExecutionIdentity(captureWork(contextIdConflict))).toBe(true);
+ expect(writer.recordExecutionIdentity(captureWork(createdAtConflict))).toBe(true);
+ expect(writer.recordExecutionIdentity(captureWork(factConflict))).toBe(true);
+ expect(performance.now() - startedAt).toBeLessThan(250);
+ await writer.stop();
+
+ expect(errors).toEqual([
+ "audit execution identity context conflict",
+ "audit execution identity context conflict",
+ "audit execution identity context conflict",
+ ]);
+ expect(
+ inspectExecutionIdentityRun({ runId: "ordered-run" }, { ...database, now: admittedAt }),
+ ).toMatchObject({
+ identity: {
+ state: "present",
+ context: {
+ contextId: "ordered-context",
+ agentDefinition: { definitionRef: "main" },
+ ingress: { kind: "system", boundary: "gateway.boot", state: "present" },
+ },
+ },
+ });
+ const persisted = openOpenClawStateDatabase(database)
+ .db.prepare("SELECT context_json FROM execution_identity_contexts WHERE run_id = ?")
+ .get("ordered-run") as { context_json: string };
+ for (const raw of ["raw-conflict-source", "raw-conflict-principal"]) {
+ expect(persisted.context_json).not.toContain(raw);
+ expect(JSON.stringify(errors)).not.toContain(raw);
+ }
+ });
+
+ it("reports a lost durable recovery reference safely without blocking the caller", async () => {
+ const stateDir = tempDirs.make("openclaw-audit-writer-");
+ const errors: string[] = [];
+ const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) });
+ const token = createExecutionIdentityAdmissionToken("raw-run-not-a-secret", {
+ contextId: "context-missing",
+ executionId: "execution-missing",
+ now: 100,
+ });
+
+ const startedAt = performance.now();
+ expect(writer.recordExecutionIdentity({ kind: "retry-reference", token })).toBe(true);
+ expect(performance.now() - startedAt).toBeLessThan(250);
+ await writer.stop();
+
+ expect(errors).toContain("audit execution identity recovery evidence unavailable");
+ expect(JSON.stringify(errors)).not.toContain(token.contextId);
+ expect(JSON.stringify(errors)).not.toContain(token.executionId);
+ expect(JSON.stringify(errors)).not.toContain(token.runId);
+ expect(
+ openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir } })
+ .db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
+ .get("execution_identity_contexts"),
+ ).toBeUndefined();
+ });
+
+ it("keeps unavailable worker, schema, and insert failures off the admission path", async () => {
+ const envelope = captureExecutionIdentityAdmissionEnvelope(
+ {
+ runId: "nonblocking-failure-run",
+ agentId: "main",
+ ingress: { kind: "local-cli", boundary: "agent-command.local" },
+ runtime: { kind: "embedded" },
+ },
+ { runtimeInstanceId: "runtime-1" },
+ );
+
+ const unavailableErrors: string[] = [];
+ const unavailableWriter = createAuditEventWriter({
+ workerUrl: new URL("./missing-audit-event-writer.worker.ts", import.meta.url),
+ onError: (error) => unavailableErrors.push(error),
+ });
+ await unavailableWriter.ready;
+ const unavailableStartedAt = performance.now();
+ expect(unavailableWriter.recordExecutionIdentity(captureWork(envelope))).toBe(false);
+ expect(performance.now() - unavailableStartedAt).toBeLessThan(250);
+ await unavailableWriter.stop();
+ expect(unavailableErrors).toContain("audit event writer is unavailable; dropping metadata");
+
+ const schemaStateDir = tempDirs.make("openclaw-audit-writer-");
+ const schemaDatabase = { env: { OPENCLAW_STATE_DIR: schemaStateDir } };
+ openOpenClawStateDatabase(schemaDatabase).db.exec(`
+ CREATE VIEW execution_identity_contexts AS
+ SELECT 'context' AS context_id, 'run' AS run_id, 0 AS created_at,
+ 'unattributed' AS coverage_state, 2 AS context_bytes, '{}' AS context_json;
+ `);
+ closeOpenClawStateDatabaseForTest();
+ const schemaErrors: string[] = [];
+ const schemaWriter = createAuditEventWriter({
+ stateDir: schemaStateDir,
+ onError: (error) => schemaErrors.push(error),
+ });
+ const schemaStartedAt = performance.now();
+ expect(schemaWriter.recordExecutionIdentity(captureWork(envelope))).toBe(true);
+ expect(performance.now() - schemaStartedAt).toBeLessThan(250);
+ await schemaWriter.stop();
+ expect(schemaErrors).toContain("audit execution identity persistence failed");
+
+ const insertStateDir = tempDirs.make("openclaw-audit-writer-");
+ const insertDatabase = { env: { OPENCLAW_STATE_DIR: insertStateDir } };
+ persistExecutionIdentityAdmissionEnvelope(
+ captureExecutionIdentityAdmissionEnvelope(
+ {
+ runId: "insert-failure-setup",
+ agentId: "main",
+ ingress: { kind: "local-cli", boundary: "agent-command.local" },
+ runtime: { kind: "embedded" },
+ },
+ { runtimeInstanceId: "runtime-setup" },
+ ),
+ insertDatabase,
+ );
+ const insertDb = openOpenClawStateDatabase(insertDatabase).db;
+ insertDb.exec(`
+ CREATE TRIGGER reject_identity_insert
+ BEFORE INSERT ON execution_identity_contexts
+ BEGIN
+ SELECT RAISE(ABORT, 'raw-trigger-secret');
+ END;
+ `);
+ const insertErrors: string[] = [];
+ const insertWriter = createAuditEventWriter({
+ stateDir: insertStateDir,
+ onError: (error) => insertErrors.push(error),
+ });
+ const insertStartedAt = performance.now();
+ expect(insertWriter.recordExecutionIdentity(captureWork(envelope))).toBe(true);
+ expect(performance.now() - insertStartedAt).toBeLessThan(250);
+ await insertWriter.stop();
+ expect(insertErrors).toContain("audit execution identity persistence failed");
+ expect(JSON.stringify(insertErrors)).not.toContain("raw-trigger-secret");
+ insertDb.exec("DROP TRIGGER reject_identity_insert;");
+ expect(
+ inspectExecutionIdentityRun({ runId: envelope.runId }, insertDatabase).identity,
+ ).toMatchObject({ state: "unknown", reasonCode: "run_not_found" });
+ });
+
+ it("keeps malformed, serialization, key, and persistence failures nonblocking and redaction-safe", async () => {
+ const stateDir = tempDirs.make("openclaw-audit-writer-");
+ const database = { env: { OPENCLAW_STATE_DIR: stateDir } };
+ const errors: string[] = [];
+ const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) });
+ const rawSecret = "raw-worker-message-secret";
+ const unserializable = {
+ ...captureExecutionIdentityAdmissionEnvelope(
+ {
+ runId: "serialization-run",
+ agentId: "main",
+ ingress: { kind: "local-cli", boundary: "agent-command.local" },
+ runtime: { kind: "embedded" },
+ },
+ { runtimeInstanceId: "runtime-1" },
+ ),
+ ingress: {
+ kind: "local-cli",
+ boundary: "agent-command.local",
+ state: "present",
+ rawSourceRef: () => rawSecret,
+ },
+ };
+ expect(writer.recordExecutionIdentity(captureWork(unserializable as never))).toBe(false);
+ await writer.stop();
+ expect(errors).toContain("audit execution identity envelope could not be queued");
+ expect(JSON.stringify(errors)).not.toContain(rawSecret);
+
+ const malformedErrors: string[] = [];
+ const malformedWriter = createAuditEventWriter({
+ stateDir,
+ onError: (error) => malformedErrors.push(error),
+ });
+ expect(malformedWriter.recordExecutionIdentity({ rawSecret } as never)).toBe(true);
+ await malformedWriter.stop();
+ expect(malformedErrors).toContain("audit execution identity envelope rejected");
+ expect(JSON.stringify(malformedErrors)).not.toContain(rawSecret);
+
+ closeOpenClawStateDatabaseForTest();
+ persistExecutionIdentityAdmissionEnvelope(
+ captureExecutionIdentityAdmissionEnvelope(
+ {
+ runId: "before-key-loss",
+ agentId: "main",
+ ingress: { kind: "local-cli", boundary: "agent-command.local" },
+ runtime: { kind: "embedded" },
+ },
+ { runtimeInstanceId: "runtime-1" },
+ ),
+ database,
+ );
+ openOpenClawStateDatabase(database).db.exec("DELETE FROM audit_identity_keys;");
+ closeOpenClawStateDatabaseForTest();
+ const keyErrors: string[] = [];
+ const keyWriter = createAuditEventWriter({
+ stateDir,
+ onError: (error) => keyErrors.push(error),
+ });
+ expect(
+ keyWriter.recordExecutionIdentity(
+ captureWork(
+ captureExecutionIdentityAdmissionEnvelope(
+ {
+ runId: "after-key-loss",
+ agentId: "main",
+ ingress: { kind: "local-cli", boundary: "agent-command.local" },
+ runtime: { kind: "embedded" },
+ },
+ { runtimeInstanceId: rawSecret },
+ ),
+ ),
+ ),
+ ).toBe(true);
+ await keyWriter.stop();
+ expect(keyErrors).toContain("audit execution identity key unavailable");
+ expect(JSON.stringify(keyErrors)).not.toContain(rawSecret);
+ expect(
+ inspectExecutionIdentityRun({ runId: "after-key-loss" }, database).identity,
+ ).toMatchObject({ state: "unknown", reasonCode: "run_not_found" });
+ });
});
diff --git a/src/audit/audit-event-writer.ts b/src/audit/audit-event-writer.ts
index 0c54dbca2b89..fcc6aab6ff0a 100644
--- a/src/audit/audit-event-writer.ts
+++ b/src/audit/audit-event-writer.ts
@@ -2,9 +2,12 @@
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
+import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { resolveStateDir } from "../config/paths.js";
+import { redactSensitiveText } from "../logging/redact.js";
import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "../state/openclaw-state-db.js";
import type { AuditEventInput } from "./audit-event-types.js";
+import type { ExecutionIdentityAdmissionWork } from "./execution-identity-admission.js";
const MAX_PENDING_AUDIT_EVENTS = 4_096;
// The worker can be synchronously blocked inside SQLite's busy timeout. Keep
@@ -21,9 +24,18 @@ type AuditWriterMessage =
export type AuditEventWriter = {
ready: Promise;
record: (input: AuditEventInput) => boolean;
+ /** Reports only queue acceptance; persistence succeeds or fails asynchronously. */
+ recordExecutionIdentity: (work: ExecutionIdentityAdmissionWork) => boolean;
stop: () => Promise;
};
+function formatAuditWriterError(error: unknown): string {
+ return truncateUtf16Safe(
+ redactSensitiveText(error instanceof Error ? error.message : String(error), { mode: "tools" }),
+ 512,
+ );
+}
+
function resolveAuditEventWriterUrl(currentModuleUrl = import.meta.url): URL {
const currentPath = fileURLToPath(currentModuleUrl);
const normalized = currentPath.replaceAll(path.sep, "/");
@@ -56,10 +68,11 @@ export function createAuditEventWriter(
execArgv: sourceWorkerExecArgv,
});
} catch (error) {
- options.onError?.(error instanceof Error ? error.message : String(error));
+ options.onError?.(formatAuditWriterError(error));
return {
ready: Promise.resolve(),
record: () => false,
+ recordExecutionIdentity: () => false,
stop: async () => {},
};
}
@@ -92,7 +105,41 @@ export function createAuditEventWriter(
finish?.();
};
const fail = (error: unknown) => {
- options.onError?.(error instanceof Error ? error.message : String(error));
+ options.onError?.(formatAuditWriterError(error));
+ };
+
+ const enqueue = (
+ message:
+ | { type: "record-event"; input: AuditEventInput }
+ | { type: "record-execution-identity"; work: ExecutionIdentityAdmissionWork },
+ ): boolean => {
+ if (stopped || unavailable || pending >= maxPending) {
+ if (!stopped) {
+ fail(
+ unavailable
+ ? "audit event writer is unavailable; dropping metadata"
+ : `audit event queue is full (${maxPending}); dropping metadata`,
+ );
+ }
+ return false;
+ }
+ pending += 1;
+ try {
+ // Node Worker.postMessage is not the browser Window API and has no targetOrigin.
+ // oxlint-disable-next-line unicorn/require-post-message-target-origin
+ worker.postMessage(message);
+ return true;
+ } catch (error) {
+ pending -= 1;
+ if (message.type === "record-execution-identity") {
+ fail("audit execution identity envelope could not be queued");
+ } else {
+ unavailable = true;
+ void worker.terminate();
+ fail(error);
+ }
+ return false;
+ }
};
worker.on("message", (message: AuditWriterMessage) => {
@@ -133,30 +180,8 @@ export function createAuditEventWriter(
return {
ready,
- record: (input) => {
- if (stopped || unavailable || pending >= maxPending) {
- if (!stopped) {
- fail(
- unavailable
- ? "audit event writer is unavailable; dropping metadata"
- : `audit event queue is full (${maxPending}); dropping metadata`,
- );
- }
- return false;
- }
- pending += 1;
- try {
- // Node Worker.postMessage is not the browser Window API and has no targetOrigin.
- // oxlint-disable-next-line unicorn/require-post-message-target-origin
- worker.postMessage({ type: "record", input });
- return true;
- } catch (error) {
- pending -= 1;
- unavailable = true;
- fail(error);
- return false;
- }
- },
+ record: (input) => enqueue({ type: "record-event", input }),
+ recordExecutionIdentity: (work) => enqueue({ type: "record-execution-identity", work }),
stop: async () => {
if (stopped) {
return;
diff --git a/src/audit/audit-event-writer.worker.ts b/src/audit/audit-event-writer.worker.ts
index 9b8ef012ffbf..25c2b1b58943 100644
--- a/src/audit/audit-event-writer.worker.ts
+++ b/src/audit/audit-event-writer.worker.ts
@@ -3,10 +3,17 @@ import { parentPort, workerData } from "node:worker_threads";
import { closeOpenClawStateDatabase } from "../state/openclaw-state-db.js";
import { pruneExpiredAuditEvents, recordAuditEvent } from "./audit-event-store.js";
import type { AuditEventInput } from "./audit-event-types.js";
+import {
+ processExecutionIdentityAdmissionWork,
+ pruneExpiredExecutionIdentityContexts,
+} from "./execution-identity-context.js";
const AUDIT_MAINTENANCE_INTERVAL_MS = 60 * 60_000;
-type AuditWriterRequest = { type: "record"; input: AuditEventInput } | { type: "stop" };
+type AuditWriterRequest =
+ | { type: "record-event"; input: AuditEventInput }
+ | { type: "record-execution-identity"; work: unknown }
+ | { type: "stop" };
const stateDir =
workerData && typeof workerData === "object" && typeof workerData.stateDir === "string"
@@ -18,12 +25,41 @@ if (!parentPort || !stateDir) {
const port = parentPort;
const database = { env: { OPENCLAW_STATE_DIR: stateDir } };
+function executionIdentityFailureMessage(error: unknown): string {
+ const message = error instanceof Error ? error.message : String(error);
+ if (
+ message.includes("audit identity key is missing") ||
+ message.includes("audit identity key is corrupt")
+ ) {
+ return "audit execution identity key unavailable";
+ }
+ if (message.includes("execution identity context conflict")) {
+ return "audit execution identity context conflict";
+ }
+ if (message.includes("execution identity recovery evidence unavailable")) {
+ return "audit execution identity recovery evidence unavailable";
+ }
+ if (
+ message.includes("admission envelope") ||
+ message.includes("admission work") ||
+ message.includes("admission token")
+ ) {
+ return "audit execution identity envelope rejected";
+ }
+ return "audit execution identity persistence failed";
+}
+
function reportMaintenance(): void {
try {
pruneExpiredAuditEvents({ database });
} catch (error) {
port.postMessage({ type: "maintenance-error", error: String(error) });
}
+ try {
+ pruneExpiredExecutionIdentityContexts({ database });
+ } catch (error) {
+ port.postMessage({ type: "maintenance-error", error: String(error) });
+ }
}
reportMaintenance();
@@ -31,7 +67,7 @@ const maintenanceTimer = setInterval(reportMaintenance, AUDIT_MAINTENANCE_INTERV
port.postMessage({ type: "ready" });
port.on("message", (message: AuditWriterRequest) => {
- if (message.type === "record") {
+ if (message.type === "record-event") {
try {
recordAuditEvent(message.input, database);
port.postMessage({ type: "recorded" });
@@ -40,6 +76,15 @@ port.on("message", (message: AuditWriterRequest) => {
}
return;
}
+ if (message.type === "record-execution-identity") {
+ try {
+ processExecutionIdentityAdmissionWork(message.work, database);
+ port.postMessage({ type: "recorded" });
+ } catch (error) {
+ port.postMessage({ type: "record-error", error: executionIdentityFailureMessage(error) });
+ }
+ return;
+ }
clearInterval(maintenanceTimer);
reportMaintenance();
try {
diff --git a/src/audit/audit-events.test.ts b/src/audit/audit-events.test.ts
index 6c908b3b7a76..f27c33c93bed 100644
--- a/src/audit/audit-events.test.ts
+++ b/src/audit/audit-events.test.ts
@@ -86,6 +86,7 @@ function captureAuditWriter(inputs: AuditEventInput[]): AuditEventWriter {
inputs.push(input);
return true;
},
+ recordExecutionIdentity: () => true,
stop: async () => {},
};
}
@@ -635,6 +636,7 @@ describe("agent activity audit projection", () => {
inputs.push(input);
return true;
},
+ recordExecutionIdentity: () => true,
stop: async () => {},
};
const recorder = createAgentEventAuditRecorder({ writer });
@@ -665,6 +667,7 @@ describe("agent activity audit projection", () => {
inputs.push(input);
return true;
},
+ recordExecutionIdentity: () => true,
stop: async () => {},
};
const recorder = createAgentEventAuditRecorder({ writer, terminalSettleMs: 60_000 });
@@ -694,6 +697,7 @@ describe("agent activity audit projection", () => {
inputs.push(input);
return true;
},
+ recordExecutionIdentity: () => true,
stop: async () => {},
};
const recorder = createAgentEventAuditRecorder({ writer, terminalSettleMs: 60_000 });
@@ -724,6 +728,7 @@ describe("agent activity audit projection", () => {
inputs.push(input);
return true;
},
+ recordExecutionIdentity: () => true,
stop: async () => {},
};
const recorder = createAgentEventAuditRecorder({ writer });
diff --git a/src/audit/audit-identity.ts b/src/audit/audit-identity.ts
index bd1391bbdcd7..498264a52762 100644
--- a/src/audit/audit-identity.ts
+++ b/src/audit/audit-identity.ts
@@ -12,7 +12,7 @@ import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-
type AuditIdentityDatabase = Pick<
OpenClawStateKyselyDatabase,
- "audit_events" | "audit_identity_keys"
+ "audit_events" | "audit_identity_keys" | "execution_identity_contexts"
>;
type AuditIdentityKeyRow = Pick<
Selectable,
@@ -24,8 +24,8 @@ 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
+// Only a top-level (depth-0) audit/evidence write may create the key: each
+// caller clears this cache on rollback, because a rolled-back outer transaction
// around a nested creation would leave a cached key that was never persisted.
const identityByDatabase = new WeakMap();
@@ -35,6 +35,7 @@ type AuditIdentityKey = {
};
type AuditIdentityKind = "account" | "actor" | "conversation" | "message" | "target";
+type ExecutionIdentityRefKind = "domain" | "evidence" | "grant" | "principal" | "runtime";
function registerAuditIdentityKeyForRedaction(key: Uint8Array): void {
const bytes = Buffer.from(key);
@@ -81,7 +82,18 @@ export function loadOrCreateAuditIdentityKey(db: DatabaseSync): AuditIdentityKey
db,
kysely.selectFrom("audit_events").select("sequence").where("kind", "=", "message").limit(1),
);
- if (retainedMessage) {
+ const hasExecutionContextTable = Boolean(
+ db /* sqlite-allow-raw -- Missing-key integrity must tolerate pre-ensure current-schema DBs. */
+ .prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?")
+ .get("execution_identity_contexts"),
+ );
+ const retainedExecutionContext = hasExecutionContextTable
+ ? executeSqliteQueryTakeFirstSync(
+ db,
+ kysely.selectFrom("execution_identity_contexts").select("context_id").limit(1),
+ )
+ : undefined;
+ if (retainedMessage || retainedExecutionContext) {
// 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");
@@ -147,3 +159,28 @@ export function pseudonymizeAuditIdentity(params: {
.digest("hex");
return `hmac-sha256:v1:${params.identity.keyId}:${digest}`;
}
+
+/** Project one execution-identity ref without retaining its raw owner value. */
+export function pseudonymizeExecutionIdentityRef(params: {
+ db: DatabaseSync;
+ kind: ExecutionIdentityRefKind;
+ scope: string;
+ value: string;
+}): string {
+ if (!params.scope || !params.value) {
+ throw new Error("execution identity HMAC scope and value must be non-empty");
+ }
+ const identity = loadOrCreateAuditIdentityKey(params.db);
+ const digest = createHmac("sha256", identity.key)
+ .update(
+ JSON.stringify([
+ "openclaw.audit.execution-identity.v1",
+ params.kind,
+ params.scope,
+ params.value,
+ ]),
+ "utf8",
+ )
+ .digest("hex");
+ return `hmac-sha256:v1:${identity.keyId}:${digest}`;
+}
diff --git a/src/audit/audit-recorder.test.ts b/src/audit/audit-recorder.test.ts
index 0a1681ab461d..af7f4511f1d6 100644
--- a/src/audit/audit-recorder.test.ts
+++ b/src/audit/audit-recorder.test.ts
@@ -12,6 +12,7 @@ function captureWriter(inputs: AuditEventInput[]): AuditEventWriter {
inputs.push(input);
return true;
},
+ recordExecutionIdentity: () => true,
stop: async () => {},
};
}
diff --git a/src/audit/audit-recorder.ts b/src/audit/audit-recorder.ts
index d2aa84aa5df0..30a50aa3aeb7 100644
--- a/src/audit/audit-recorder.ts
+++ b/src/audit/audit-recorder.ts
@@ -7,6 +7,7 @@ import {
} from "./agent-event-audit.js";
import type { AuditMessageMode } from "./audit-config.js";
import { createAuditEventWriter, type AuditEventWriter } from "./audit-event-writer.js";
+import type { ExecutionIdentityAdmissionWork } from "./execution-identity-admission.js";
import type { TrustedMessageAuditEvent } from "./message-audit-events.js";
const log = createSubsystemLogger("audit/events");
@@ -14,6 +15,7 @@ let persistenceFailureWarned = false;
type AuditEventRecorder = AgentEventAuditRecorder & {
recordMessage: (event: TrustedMessageAuditEvent) => void;
+ recordExecutionIdentity: (work: ExecutionIdentityAdmissionWork) => boolean;
};
export function createAuditEventRecorder(options: {
@@ -43,6 +45,7 @@ export function createAuditEventRecorder(options: {
return {
...agentRecorder,
+ recordExecutionIdentity: writer.recordExecutionIdentity,
recordMessage: (event) => {
if (options.messageMode === "off") {
return;
diff --git a/src/audit/execution-identity-admission.test.ts b/src/audit/execution-identity-admission.test.ts
new file mode 100644
index 000000000000..c52c39e46dc4
--- /dev/null
+++ b/src/audit/execution-identity-admission.test.ts
@@ -0,0 +1,235 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ configureExecutionIdentityAdmissionSink,
+ createExecutionIdentityAdmissionToken,
+ enqueueExecutionIdentityContextAtAdmission,
+ hasExecutionIdentityAdmissionSink,
+ parseExecutionIdentityAdmissionEnvelope,
+ type ExecutionIdentityAdmissionEnvelope,
+ type ExecutionIdentityAdmissionFacts,
+ type ExecutionIdentityAdmissionWork,
+} from "./execution-identity-admission.js";
+
+const ADMISSION_MAX_BYTES = 16 * 1024;
+const ADMISSION_MAX_ITEMS = 16;
+
+function facts(overrides: Partial = {}) {
+ return {
+ runId: "run-1",
+ agentId: "main",
+ ingress: { kind: "local-cli" as const, boundary: "agent-command.local" },
+ runtime: { kind: "embedded" as const },
+ ...overrides,
+ };
+}
+
+function captureEnvelope(
+ admissionFacts: ExecutionIdentityAdmissionFacts,
+ options: {
+ contextId?: string;
+ executionId?: string;
+ now?: number;
+ runtimeInstanceId?: string;
+ } = {},
+) {
+ let captured: ExecutionIdentityAdmissionEnvelope | undefined;
+ const clear = configureExecutionIdentityAdmissionSink((work) => {
+ if (work.kind === "capture") {
+ captured = work.envelope;
+ }
+ return true;
+ });
+ try {
+ const result = enqueueExecutionIdentityContextAtAdmission(admissionFacts, {
+ ...options,
+ enabled: true,
+ });
+ if (!result || !captured) {
+ throw new Error("expected admission envelope");
+ }
+ return captured;
+ } finally {
+ clear();
+ }
+}
+
+describe("execution identity admission envelope", () => {
+ it("captures a deterministic, deeply frozen, redacted envelope with fixed identity", () => {
+ const envelope = captureEnvelope(
+ facts({
+ invoker: {
+ kind: "local-account",
+ rawPrincipalRef: "raw-principal",
+ displayLabel: "Operator OPENAI_API_KEY=sk-1234567890abcdef",
+ },
+ applicableGrants: [
+ { rawGrantRef: "z", state: "present" },
+ { rawGrantRef: "a", state: "present" },
+ { rawGrantRef: "a", state: "present" },
+ ],
+ assurance: [
+ {
+ kind: "runtime-binding",
+ rawEvidenceRef: "z",
+ strength: "boundary-verified",
+ },
+ {
+ kind: "local-process",
+ rawEvidenceRef: "a",
+ strength: "boundary-verified",
+ },
+ ],
+ }),
+ {
+ contextId: "context-1",
+ executionId: "execution-1",
+ now: 123,
+ runtimeInstanceId: "runtime-1",
+ },
+ );
+
+ expect(envelope).toMatchObject({
+ envelopeVersion: 1,
+ contextId: "context-1",
+ executionId: "execution-1",
+ runId: "run-1",
+ createdAt: 123,
+ runtimeInstanceId: "runtime-1",
+ ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" },
+ });
+ expect(envelope.applicableGrants).toEqual([
+ { rawGrantRef: "a", state: "present" },
+ { rawGrantRef: "z", state: "present" },
+ ]);
+ expect(envelope.invoker?.displayLabel).not.toContain("sk-1234567890abcdef");
+ expect(Object.isFrozen(envelope)).toBe(true);
+ expect(Object.isFrozen(envelope.ingress)).toBe(true);
+ expect(Object.isFrozen(envelope.assurance)).toBe(true);
+ expect(parseExecutionIdentityAdmissionEnvelope(structuredClone(envelope))).toEqual(envelope);
+ expect(Buffer.byteLength(JSON.stringify(envelope), "utf8")).toBeLessThanOrEqual(
+ ADMISSION_MAX_BYTES,
+ );
+ });
+
+ it("rejects invalid owned facts, excess items, and oversized encoded envelopes", () => {
+ expect(() =>
+ captureEnvelope(facts({ runId: "" }), {
+ runtimeInstanceId: "runtime-1",
+ }),
+ ).toThrow("expected admission envelope");
+ expect(() =>
+ captureEnvelope(
+ facts({
+ applicableGrants: Array.from({ length: ADMISSION_MAX_ITEMS + 1 }, (_, index) => ({
+ rawGrantRef: `grant-${String(index)}`,
+ state: "present" as const,
+ })),
+ }),
+ { runtimeInstanceId: "runtime-1" },
+ ),
+ ).toThrow("expected admission envelope");
+ expect(() =>
+ captureEnvelope(
+ facts({
+ ingress: {
+ kind: "local-cli",
+ boundary: "agent-command.local",
+ rawSourceRef: "a".repeat(4_096),
+ },
+ invoker: {
+ kind: "local-account",
+ rawPrincipalRef: "b".repeat(4_096),
+ },
+ applicableGrants: [
+ { rawGrantRef: "c".repeat(4_096), state: "present" },
+ { rawGrantRef: "d".repeat(4_096), state: "present" },
+ ],
+ }),
+ { runtimeInstanceId: "e".repeat(4_096) },
+ ),
+ ).toThrow("expected admission envelope");
+ });
+
+ it("reports queue acceptance without claiming persistence and keeps failures nonblocking", () => {
+ const first = vi.fn(() => true);
+ const second = vi.fn(() => true);
+ const clearFirst = configureExecutionIdentityAdmissionSink(first);
+ const clearSecond = configureExecutionIdentityAdmissionSink(second);
+ clearFirst();
+ expect(hasExecutionIdentityAdmissionSink()).toBe(true);
+ expect(
+ enqueueExecutionIdentityContextAtAdmission(facts(), {
+ enabled: true,
+ contextId: "context-queued",
+ executionId: "execution-queued",
+ now: 1,
+ runtimeInstanceId: "runtime-1",
+ }),
+ ).toEqual({
+ candidateContextId: "context-queued",
+ candidateExecutionId: "execution-queued",
+ accepted: true,
+ });
+ expect(first).not.toHaveBeenCalled();
+ expect(second).toHaveBeenCalledOnce();
+ clearSecond();
+ expect(hasExecutionIdentityAdmissionSink()).toBe(false);
+ expect(() =>
+ enqueueExecutionIdentityContextAtAdmission(
+ facts({ ingress: { kind: "local-cli", boundary: "x", rawSourceRef: "raw-secret" } }),
+ { enabled: true },
+ ),
+ ).not.toThrow();
+ expect(enqueueExecutionIdentityContextAtAdmission(facts(), { enabled: false })).toBeUndefined();
+ });
+
+ it("allocates distinct execution identities for turns that share one run correlation", () => {
+ const work = vi.fn<(item: ExecutionIdentityAdmissionWork) => boolean>(() => true);
+ const clear = configureExecutionIdentityAdmissionSink(work);
+ try {
+ enqueueExecutionIdentityContextAtAdmission(facts({ runId: "session-1" }), {
+ enabled: true,
+ });
+ enqueueExecutionIdentityContextAtAdmission(facts({ runId: "session-1" }), {
+ enabled: true,
+ });
+ } finally {
+ clear();
+ }
+ const captures = work.mock.calls
+ .map(([item]) => item)
+ .filter((item) => item.kind === "capture");
+ expect(captures).toHaveLength(2);
+ expect(captures[0]!.envelope.runId).toBe("session-1");
+ expect(captures[1]!.envelope.runId).toBe("session-1");
+ expect(captures[0]!.envelope.executionId).not.toBe(captures[1]!.envelope.executionId);
+ expect(captures[0]!.envelope.contextId).not.toBe(captures[1]!.envelope.contextId);
+ });
+
+ it("queues only the safe token for a durable retry reference", () => {
+ const work = vi.fn<(item: ExecutionIdentityAdmissionWork) => boolean>(() => true);
+ const token = createExecutionIdentityAdmissionToken("run-recovery", {
+ contextId: "context-recovery",
+ executionId: "execution-recovery",
+ now: 123,
+ });
+ const clear = configureExecutionIdentityAdmissionSink(work);
+ try {
+ enqueueExecutionIdentityContextAtAdmission(
+ facts({
+ runId: "run-recovery",
+ ingress: {
+ kind: "api",
+ boundary: "agent-command.from-ingress",
+ rawSourceRef: "raw-private-reference",
+ },
+ }),
+ { enabled: true, token, retryOnly: true },
+ );
+ } finally {
+ clear();
+ }
+ expect(work).toHaveBeenCalledWith({ kind: "retry-reference", token });
+ expect(JSON.stringify(work.mock.calls)).not.toContain("raw-private-reference");
+ });
+});
diff --git a/src/audit/execution-identity-admission.ts b/src/audit/execution-identity-admission.ts
new file mode 100644
index 000000000000..b9597eb63660
--- /dev/null
+++ b/src/audit/execution-identity-admission.ts
@@ -0,0 +1,400 @@
+/** Bounded execution-identity facts captured at authoritative run admission. */
+import { randomUUID } from "node:crypto";
+import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
+import { type Static, Type } from "typebox";
+import { Value } from "typebox/value";
+import { redactSensitiveText } from "../logging/redact.js";
+import { createSubsystemLogger } from "../logging/subsystem.js";
+
+const EXECUTION_IDENTITY_ADMISSION_MAX_BYTES = 16 * 1024;
+const EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS = 16;
+const RAW_REF_MAX_LENGTH = 4_096;
+const PROCESS_RUNTIME_INSTANCE_ID = randomUUID();
+const log = createSubsystemLogger("audit/events");
+
+const boundedRef = () => Type.String({ minLength: 1, maxLength: 256 });
+const rawRef = () => Type.String({ minLength: 1, maxLength: RAW_REF_MAX_LENGTH });
+const evidenceState = () =>
+ Type.Union([
+ Type.Literal("present"),
+ Type.Literal("absent"),
+ Type.Literal("unknown"),
+ Type.Literal("unsupported"),
+ ]);
+const closedObject = [0]>(properties: T) =>
+ Type.Object(properties, { additionalProperties: false });
+
+const ExecutionIdentityAdmissionEnvelopeSchema = closedObject({
+ envelopeVersion: Type.Literal(1),
+ contextId: boundedRef(),
+ executionId: boundedRef(),
+ runId: boundedRef(),
+ createdAt: Type.Integer({ minimum: 0 }),
+ runtimeInstanceId: rawRef(),
+ agentId: boundedRef(),
+ ingress: closedObject({
+ kind: Type.Union([
+ Type.Literal("local-cli"),
+ Type.Literal("gateway-client"),
+ Type.Literal("channel"),
+ Type.Literal("api"),
+ Type.Literal("schedule"),
+ Type.Literal("webhook"),
+ Type.Literal("task"),
+ Type.Literal("subagent"),
+ Type.Literal("acp"),
+ Type.Literal("worker"),
+ Type.Literal("plugin"),
+ Type.Literal("recovery"),
+ Type.Literal("system"),
+ ]),
+ boundary: boundedRef(),
+ state: evidenceState(),
+ rawSourceRef: Type.Optional(rawRef()),
+ }),
+ runtime: closedObject({
+ kind: Type.Union([
+ Type.Literal("gateway"),
+ Type.Literal("embedded"),
+ Type.Literal("worker"),
+ Type.Literal("plugin-harness"),
+ Type.Literal("acp"),
+ ]),
+ }),
+ invoker: Type.Optional(
+ closedObject({
+ kind: Type.Union([
+ Type.Literal("person"),
+ Type.Literal("agent"),
+ Type.Literal("service"),
+ Type.Literal("schedule"),
+ Type.Literal("webhook"),
+ Type.Literal("system"),
+ Type.Literal("local-account"),
+ Type.Literal("runtime"),
+ ]),
+ rawPrincipalRef: rawRef(),
+ displayLabel: Type.Optional(Type.String({ maxLength: 128 })),
+ }),
+ ),
+ applicableGrants: Type.Array(closedObject({ rawGrantRef: rawRef(), state: evidenceState() }), {
+ maxItems: EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS,
+ }),
+ assurance: Type.Array(
+ closedObject({
+ kind: Type.Union([
+ Type.Literal("durable-profile"),
+ Type.Literal("trusted-proxy"),
+ Type.Literal("tailscale-whois"),
+ Type.Literal("device-proof"),
+ Type.Literal("channel-admission"),
+ Type.Literal("local-process"),
+ Type.Literal("spawn-lineage"),
+ Type.Literal("worker-admission"),
+ Type.Literal("runtime-binding"),
+ Type.Literal("other"),
+ ]),
+ rawEvidenceRef: rawRef(),
+ strength: Type.Union([
+ Type.Literal("self-asserted"),
+ Type.Literal("boundary-verified"),
+ Type.Literal("cryptographic"),
+ ]),
+ }),
+ { maxItems: EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS },
+ ),
+});
+
+const ExecutionIdentityAdmissionTokenSchema = closedObject({
+ tokenVersion: Type.Literal(1),
+ contextId: boundedRef(),
+ executionId: boundedRef(),
+ runId: boundedRef(),
+ createdAt: Type.Integer({ minimum: 0 }),
+});
+
+export type ExecutionIdentityAdmissionEnvelope = Static<
+ typeof ExecutionIdentityAdmissionEnvelopeSchema
+>;
+export type ExecutionIdentityAdmissionFacts = Omit<
+ ExecutionIdentityAdmissionEnvelope,
+ | "envelopeVersion"
+ | "contextId"
+ | "executionId"
+ | "createdAt"
+ | "runtimeInstanceId"
+ | "ingress"
+ | "applicableGrants"
+ | "assurance"
+> & {
+ ingress: Omit & {
+ state?: ExecutionIdentityAdmissionEnvelope["ingress"]["state"];
+ };
+ applicableGrants?: ExecutionIdentityAdmissionEnvelope["applicableGrants"];
+ assurance?: ExecutionIdentityAdmissionEnvelope["assurance"];
+};
+export type ExecutionIdentityAdmissionToken = Static;
+export type ExecutionIdentityAdmissionWork =
+ | { kind: "capture"; envelope: ExecutionIdentityAdmissionEnvelope }
+ | { kind: "retry-reference"; token: ExecutionIdentityAdmissionToken };
+type ExecutionIdentityAdmissionSink = (work: ExecutionIdentityAdmissionWork) => boolean;
+
+let admissionSink: ExecutionIdentityAdmissionSink | undefined;
+let admissionFailureWarned = false;
+
+function uniqueSorted(values: readonly T[], key: (value: T) => string): T[] {
+ return [...new Map(values.map((value) => [key(value), value])).values()].toSorted((a, b) => {
+ const left = key(a);
+ const right = key(b);
+ return left < right ? -1 : left > right ? 1 : 0;
+ });
+}
+
+function freezeEnvelope(value: T, seen = new WeakSet