mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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
This commit is contained in:
@@ -507,6 +507,7 @@ enum class GatewayMethod(
|
||||
HooksStatus("hooks.status"),
|
||||
TasksRetry("tasks.retry"),
|
||||
TasksDismiss("tasks.dismiss"),
|
||||
AuditRunInspect("audit.run.inspect"),
|
||||
}
|
||||
|
||||
enum class GatewayEvent(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!",
|
||||
|
||||
+121
-10
@@ -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 <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 <id>`: exact agent id
|
||||
- `--session <key>`: exact session key
|
||||
- `--run <id>`: exact run id
|
||||
- `--run <id>`: exact run id; filters activity unless `--explain` is also set
|
||||
- `--execution <id>`: exact execution id; requires `--explain`
|
||||
- `--kind <kind>`: `agent_run`, `tool_action`, or `message`
|
||||
- `--status <status>`: `started`, `succeeded`, `failed`, `cancelled`,
|
||||
`timed_out`, `blocked`, or `unknown`
|
||||
@@ -46,8 +65,14 @@ openclaw audit --kind message --direction outbound --channel telegram --json
|
||||
- `--channel <channel>`: exact message channel
|
||||
- `--after <timestamp>` / `--before <timestamp>`: inclusive ISO timestamp or
|
||||
Unix milliseconds
|
||||
- `--limit <count>`: page size from 1 to 500; default `100`
|
||||
- `--cursor <sequence>`: continue a previous newest-first query
|
||||
- `--limit <count>`: 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 <sequence>`: 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 <id>
|
||||
--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 <execution-id> --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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
+146
-2
@@ -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 <id> --explain`](/cli/audit). Use `--run <id>
|
||||
--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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -255,6 +255,15 @@ export {
|
||||
AuditActivityListResultSchema,
|
||||
AuditActivityOutboundMessageV1Schema,
|
||||
AuditActivityToolActionV1Schema,
|
||||
ExecutionIdentityContextV1Schema,
|
||||
DecisionReceiptV1Schema,
|
||||
AuditRunIdentityPresentV1Schema,
|
||||
AuditRunIdentityUnknownV1Schema,
|
||||
AuditRunIdentityUnsupportedV1Schema,
|
||||
AuditRunIdentityAmbiguousV1Schema,
|
||||
AuditRunIdentityV1Schema,
|
||||
AuditRunInspectParamsSchema,
|
||||
AuditRunInspectResultSchema,
|
||||
AuditEventSchema,
|
||||
AuditListParamsSchema,
|
||||
AuditListResultSchema,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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<typeof PrincipalRefV1Schema>;
|
||||
export type ExecutionIdentityContextV1 = Static<typeof ExecutionIdentityContextV1Schema>;
|
||||
export type DecisionReceiptV1 = Static<typeof DecisionReceiptV1Schema>;
|
||||
export type AuditRunIdentityV1 = Static<typeof AuditRunIdentityV1Schema>;
|
||||
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<typeof AuditRunInspectResultSchema>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AuditActivityListParams>(
|
||||
S.AuditActivityListParamsSchema,
|
||||
);
|
||||
export const validateAuditRunInspectParams = compile<AuditRunInspectParams>(
|
||||
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);
|
||||
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<Parameters<typeof agentCommand>[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: {
|
||||
|
||||
+63
-12
@@ -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<typeof executionIdentity.record>[0]["ingress"];
|
||||
|
||||
const log = createSubsystemLogger("agents/agent-command");
|
||||
|
||||
async function agentCommandInternal(
|
||||
prepared: Awaited<ReturnType<typeof prepareAgentCommandExecution>>,
|
||||
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: {
|
||||
|
||||
@@ -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<void>;
|
||||
/** 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<AgentCommandOpts, "executionIdentityAdmission">;
|
||||
|
||||
@@ -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> = {},
|
||||
): MainRestartRecoveryState {
|
||||
return {
|
||||
cycleId: "cycle-1",
|
||||
revision: 1,
|
||||
chargedAttempts: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function interruptedEntry(overrides: Partial<SessionEntry> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {},
|
||||
): 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" });
|
||||
});
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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<string, unknown>).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<string, unknown>).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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof processExecutionIdentityAdmissionWork>[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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>;
|
||||
record: (input: AuditEventInput) => boolean;
|
||||
/** Reports only queue acceptance; persistence succeeds or fails asynchronously. */
|
||||
recordExecutionIdentity: (work: ExecutionIdentityAdmissionWork) => boolean;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<AuditIdentityDatabase["audit_identity_keys"]>,
|
||||
@@ -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<DatabaseSync, AuditIdentityKey>();
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ function captureWriter(inputs: AuditEventInput[]): AuditEventWriter {
|
||||
inputs.push(input);
|
||||
return true;
|
||||
},
|
||||
recordExecutionIdentity: () => true,
|
||||
stop: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ExecutionIdentityAdmissionFacts> = {}) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 = <T extends Parameters<typeof Type.Object>[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<ExecutionIdentityAdmissionEnvelope["ingress"], "state"> & {
|
||||
state?: ExecutionIdentityAdmissionEnvelope["ingress"]["state"];
|
||||
};
|
||||
applicableGrants?: ExecutionIdentityAdmissionEnvelope["applicableGrants"];
|
||||
assurance?: ExecutionIdentityAdmissionEnvelope["assurance"];
|
||||
};
|
||||
export type ExecutionIdentityAdmissionToken = Static<typeof ExecutionIdentityAdmissionTokenSchema>;
|
||||
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<T>(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<T>(value: T, seen = new WeakSet<object>()): T {
|
||||
if (!value || typeof value !== "object" || seen.has(value as object)) {
|
||||
return value;
|
||||
}
|
||||
seen.add(value as object);
|
||||
for (const nested of Object.values(value as Record<string, unknown>)) {
|
||||
freezeEnvelope(nested, seen);
|
||||
}
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
function validateEnvelope(value: unknown): asserts value is ExecutionIdentityAdmissionEnvelope {
|
||||
if (
|
||||
!Value.Check(ExecutionIdentityAdmissionEnvelopeSchema, value) ||
|
||||
!Number.isSafeInteger(value.createdAt)
|
||||
) {
|
||||
throw new Error("execution identity admission envelope violates its bounded contract");
|
||||
}
|
||||
const encoded = JSON.stringify(value);
|
||||
if (Buffer.byteLength(encoded, "utf8") > EXECUTION_IDENTITY_ADMISSION_MAX_BYTES) {
|
||||
throw new Error("execution identity admission envelope exceeds 16 KiB");
|
||||
}
|
||||
}
|
||||
|
||||
function validateToken(value: unknown): asserts value is ExecutionIdentityAdmissionToken {
|
||||
if (
|
||||
!Value.Check(ExecutionIdentityAdmissionTokenSchema, value) ||
|
||||
!Number.isSafeInteger(value.createdAt)
|
||||
) {
|
||||
throw new Error("execution identity admission token violates its bounded contract");
|
||||
}
|
||||
}
|
||||
|
||||
/** Allocate the immutable correlation owned by one outer admitted turn. */
|
||||
export function createExecutionIdentityAdmissionToken(
|
||||
runId: string,
|
||||
options: { contextId?: string; executionId?: string; now?: number } = {},
|
||||
): ExecutionIdentityAdmissionToken {
|
||||
const token = {
|
||||
tokenVersion: 1 as const,
|
||||
contextId: options.contextId ?? randomUUID(),
|
||||
executionId: options.executionId ?? randomUUID(),
|
||||
runId,
|
||||
createdAt: options.now ?? Date.now(),
|
||||
};
|
||||
validateToken(token);
|
||||
return freezeEnvelope(token);
|
||||
}
|
||||
|
||||
export function parseExecutionIdentityAdmissionToken(
|
||||
value: unknown,
|
||||
): ExecutionIdentityAdmissionToken {
|
||||
validateToken(value);
|
||||
return freezeEnvelope({ ...value });
|
||||
}
|
||||
|
||||
function redactDisplayLabel(value: string): string {
|
||||
// The shared redactor's secret-prefix pass becomes stable on its second pass.
|
||||
// Stabilizing here lets the worker reject any altered structured-clone payload.
|
||||
return truncateUtf16Safe(
|
||||
redactSensitiveText(redactSensitiveText(value, { mode: "tools" }), { mode: "tools" }),
|
||||
128,
|
||||
);
|
||||
}
|
||||
|
||||
/** Capture owned admission facts without touching filesystem or database state. */
|
||||
function captureExecutionIdentityAdmissionEnvelope(
|
||||
facts: ExecutionIdentityAdmissionFacts,
|
||||
options: {
|
||||
contextId?: string;
|
||||
executionId?: string;
|
||||
now?: number;
|
||||
runtimeInstanceId?: string;
|
||||
token?: ExecutionIdentityAdmissionToken;
|
||||
} = {},
|
||||
): ExecutionIdentityAdmissionEnvelope {
|
||||
const token =
|
||||
options.token ??
|
||||
createExecutionIdentityAdmissionToken(facts.runId, {
|
||||
contextId: options.contextId,
|
||||
executionId: options.executionId,
|
||||
now: options.now,
|
||||
});
|
||||
validateToken(token);
|
||||
if (token.runId !== facts.runId) {
|
||||
throw new Error("execution identity admission token disagrees with the admitted run");
|
||||
}
|
||||
const runtimeInstanceId = options.runtimeInstanceId ?? PROCESS_RUNTIME_INSTANCE_ID;
|
||||
const assurance = facts.assurance ?? [
|
||||
{
|
||||
kind: "runtime-binding" as const,
|
||||
rawEvidenceRef: runtimeInstanceId,
|
||||
strength: "boundary-verified" as const,
|
||||
},
|
||||
];
|
||||
const envelope = {
|
||||
envelopeVersion: 1 as const,
|
||||
contextId: token.contextId,
|
||||
executionId: token.executionId,
|
||||
runId: token.runId,
|
||||
createdAt: token.createdAt,
|
||||
runtimeInstanceId,
|
||||
agentId: facts.agentId,
|
||||
ingress: { ...facts.ingress, state: facts.ingress.state ?? "present" },
|
||||
runtime: { ...facts.runtime },
|
||||
...(facts.invoker
|
||||
? {
|
||||
invoker: {
|
||||
...facts.invoker,
|
||||
...(facts.invoker.displayLabel !== undefined
|
||||
? { displayLabel: redactDisplayLabel(facts.invoker.displayLabel) }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
applicableGrants: uniqueSorted(
|
||||
facts.applicableGrants ?? [],
|
||||
(grant) => `${grant.rawGrantRef}\0${grant.state}`,
|
||||
).map((grant) => ({ rawGrantRef: grant.rawGrantRef, state: grant.state })),
|
||||
assurance: uniqueSorted(
|
||||
assurance,
|
||||
(item) => `${item.kind}\0${item.rawEvidenceRef}\0${item.strength}`,
|
||||
).map((item) => ({
|
||||
kind: item.kind,
|
||||
rawEvidenceRef: item.rawEvidenceRef,
|
||||
strength: item.strength,
|
||||
})),
|
||||
};
|
||||
validateEnvelope(envelope);
|
||||
return freezeEnvelope(envelope);
|
||||
}
|
||||
|
||||
/** Revalidate a structured-cloned worker message before any persistence work. */
|
||||
export function parseExecutionIdentityAdmissionEnvelope(
|
||||
value: unknown,
|
||||
): ExecutionIdentityAdmissionEnvelope {
|
||||
validateEnvelope(value);
|
||||
const parsed = captureExecutionIdentityAdmissionEnvelope(value, {
|
||||
token: createExecutionIdentityAdmissionToken(value.runId, {
|
||||
contextId: value.contextId,
|
||||
executionId: value.executionId,
|
||||
now: value.createdAt,
|
||||
}),
|
||||
runtimeInstanceId: value.runtimeInstanceId,
|
||||
});
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(value)) {
|
||||
throw new Error("execution identity admission envelope is not canonical");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Revalidate either bounded worker message before schema, key, or database work. */
|
||||
export function parseExecutionIdentityAdmissionWork(
|
||||
value: unknown,
|
||||
): ExecutionIdentityAdmissionWork {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error("execution identity admission work violates its bounded contract");
|
||||
}
|
||||
const work = value as { kind?: unknown; envelope?: unknown; token?: unknown };
|
||||
if (work.kind === "capture") {
|
||||
return freezeEnvelope({
|
||||
kind: "capture" as const,
|
||||
envelope: parseExecutionIdentityAdmissionEnvelope(work.envelope),
|
||||
});
|
||||
}
|
||||
if (work.kind === "retry-reference") {
|
||||
return freezeEnvelope({
|
||||
kind: "retry-reference" as const,
|
||||
token: parseExecutionIdentityAdmissionToken(work.token),
|
||||
});
|
||||
}
|
||||
throw new Error("execution identity admission work violates its bounded contract");
|
||||
}
|
||||
|
||||
/** Install the current process lifecycle's writer without creating a second queue. */
|
||||
export function configureExecutionIdentityAdmissionSink(
|
||||
sink: ExecutionIdentityAdmissionSink,
|
||||
): () => void {
|
||||
admissionSink = sink;
|
||||
return () => {
|
||||
if (admissionSink === sink) {
|
||||
admissionSink = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function hasExecutionIdentityAdmissionSink(): boolean {
|
||||
return admissionSink !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture and enqueue evidence. The returned ID is only a candidate until async persistence wins.
|
||||
*/
|
||||
export function enqueueExecutionIdentityContextAtAdmission(
|
||||
facts: ExecutionIdentityAdmissionFacts,
|
||||
options: {
|
||||
enabled: boolean;
|
||||
contextId?: string;
|
||||
executionId?: string;
|
||||
now?: number;
|
||||
runtimeInstanceId?: string;
|
||||
token?: ExecutionIdentityAdmissionToken;
|
||||
retryOnly?: boolean;
|
||||
},
|
||||
):
|
||||
| {
|
||||
candidateContextId: string;
|
||||
candidateExecutionId: string;
|
||||
accepted: boolean;
|
||||
}
|
||||
| undefined {
|
||||
if (!options.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const token =
|
||||
options.token ??
|
||||
createExecutionIdentityAdmissionToken(facts.runId, {
|
||||
contextId: options.contextId,
|
||||
executionId: options.executionId,
|
||||
now: options.now,
|
||||
});
|
||||
validateToken(token);
|
||||
const work: ExecutionIdentityAdmissionWork = options.retryOnly
|
||||
? { kind: "retry-reference", token }
|
||||
: {
|
||||
kind: "capture",
|
||||
envelope: captureExecutionIdentityAdmissionEnvelope(facts, {
|
||||
token,
|
||||
runtimeInstanceId: options.runtimeInstanceId,
|
||||
}),
|
||||
};
|
||||
if (!admissionSink) {
|
||||
throw new Error("audit writer unavailable");
|
||||
}
|
||||
return {
|
||||
candidateContextId: token.contextId,
|
||||
candidateExecutionId: token.executionId,
|
||||
accepted: admissionSink(work),
|
||||
};
|
||||
} catch {
|
||||
if (!admissionFailureWarned) {
|
||||
admissionFailureWarned = true;
|
||||
log.warn("audit execution identity admission evidence was not queued; continuing without it");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/** Worker-only canonical context construction and bounded value helpers. */
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ExecutionIdentityContextV1 } from "../../packages/gateway-protocol/src/index.js";
|
||||
import { validateExecutionIdentityContextV1 } from "../../packages/gateway-protocol/src/index.js";
|
||||
import { pseudonymizeExecutionIdentityRef } from "./audit-identity.js";
|
||||
import type { ExecutionIdentityAdmissionEnvelope } from "./execution-identity-admission.js";
|
||||
|
||||
const EXECUTION_IDENTITY_CONTEXT_MAX_BYTES = 16 * 1024;
|
||||
|
||||
export function ensureBoundedExecutionIdentityRef(
|
||||
value: string,
|
||||
label: string,
|
||||
maxLength = 256,
|
||||
): string {
|
||||
if (!value || value.length > maxLength) {
|
||||
throw new Error(`${label} must be between 1 and ${String(maxLength)} characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ensureRawRef(value: string, label: string): string {
|
||||
return ensureBoundedExecutionIdentityRef(value, label, 4_096);
|
||||
}
|
||||
|
||||
export function freezeExecutionIdentityContext<T>(value: T, seen = new WeakSet<object>()): T {
|
||||
if (!value || typeof value !== "object" || seen.has(value as object)) {
|
||||
return value;
|
||||
}
|
||||
seen.add(value as object);
|
||||
for (const nested of Object.values(value as Record<string, unknown>)) {
|
||||
freezeExecutionIdentityContext(nested, seen);
|
||||
}
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
function hmacRef(
|
||||
db: DatabaseSync,
|
||||
kind: Parameters<typeof pseudonymizeExecutionIdentityRef>[0]["kind"],
|
||||
scope: string,
|
||||
value: string,
|
||||
): string {
|
||||
return pseudonymizeExecutionIdentityRef({
|
||||
db,
|
||||
kind,
|
||||
scope: ensureBoundedExecutionIdentityRef(scope, "HMAC scope"),
|
||||
value: ensureRawRef(value, "HMAC value"),
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueSorted<T>(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;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildExecutionIdentityContext(
|
||||
db: DatabaseSync,
|
||||
envelope: ExecutionIdentityAdmissionEnvelope,
|
||||
fixed: { contextId: string; createdAt: number },
|
||||
): ExecutionIdentityContextV1 {
|
||||
const runId = ensureBoundedExecutionIdentityRef(envelope.runId, "run id");
|
||||
const executionId = ensureBoundedExecutionIdentityRef(envelope.executionId, "execution id");
|
||||
const agentId = ensureBoundedExecutionIdentityRef(envelope.agentId, "agent id");
|
||||
const contextId = ensureBoundedExecutionIdentityRef(fixed.contextId, "context id");
|
||||
const domainRef = hmacRef(db, "domain", "gateway-cell", "gateway-cell");
|
||||
const runtimeRef = hmacRef(
|
||||
db,
|
||||
"runtime",
|
||||
domainRef,
|
||||
ensureRawRef(envelope.runtimeInstanceId, "runtime instance id"),
|
||||
);
|
||||
const invoker = envelope.invoker
|
||||
? {
|
||||
state: "present" as const,
|
||||
principal: {
|
||||
kind: envelope.invoker.kind,
|
||||
domainRef,
|
||||
principalRef: hmacRef(
|
||||
db,
|
||||
"principal",
|
||||
`${domainRef}:${envelope.invoker.kind}`,
|
||||
envelope.invoker.rawPrincipalRef,
|
||||
),
|
||||
...(envelope.invoker.displayLabel !== undefined
|
||||
? { displayLabel: envelope.invoker.displayLabel }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: { state: "absent" as const };
|
||||
const assurance = uniqueSorted(
|
||||
envelope.assurance.map((item) => ({
|
||||
kind: item.kind,
|
||||
evidenceRef: hmacRef(db, "evidence", `${domainRef}:${item.kind}`, item.rawEvidenceRef),
|
||||
strength: item.strength,
|
||||
})),
|
||||
(item) => `${item.kind}\0${item.evidenceRef}\0${item.strength}`,
|
||||
);
|
||||
const applicableGrants = uniqueSorted(
|
||||
envelope.applicableGrants.map((grant) => ({
|
||||
grantRef: hmacRef(db, "grant", domainRef, grant.rawGrantRef),
|
||||
state: grant.state,
|
||||
})),
|
||||
(grant) => `${grant.grantRef}\0${grant.state}`,
|
||||
);
|
||||
const missingEvidence = envelope.invoker ? [] : ["invoker.principal"];
|
||||
const context: ExecutionIdentityContextV1 = {
|
||||
schemaVersion: 1,
|
||||
contextId,
|
||||
executionId,
|
||||
runId,
|
||||
createdAt: fixed.createdAt,
|
||||
trustDomain: { kind: "gateway-cell", domainRef, state: "present" },
|
||||
invoker,
|
||||
ingress: {
|
||||
kind: envelope.ingress.kind,
|
||||
boundary: ensureBoundedExecutionIdentityRef(envelope.ingress.boundary, "ingress boundary"),
|
||||
state: envelope.ingress.state,
|
||||
...(envelope.ingress.rawSourceRef
|
||||
? {
|
||||
sourceRef: hmacRef(
|
||||
db,
|
||||
"principal",
|
||||
`${domainRef}:ingress:${envelope.ingress.kind}`,
|
||||
envelope.ingress.rawSourceRef,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
agentPrincipal: { kind: "agent", domainRef, principalRef: agentId },
|
||||
agentDefinition: { definitionRef: agentId, state: "present" },
|
||||
runtimeInstance: { runtimeRef, kind: envelope.runtime.kind, state: "present" },
|
||||
applicableGrants,
|
||||
assurance,
|
||||
coverageState: envelope.invoker ? "attribution-only" : "unattributed",
|
||||
missingEvidence,
|
||||
};
|
||||
if (!validateExecutionIdentityContextV1(context)) {
|
||||
throw new Error("prepared execution identity context violates the V1 contract");
|
||||
}
|
||||
const encoded = JSON.stringify(context);
|
||||
if (Buffer.byteLength(encoded, "utf8") > EXECUTION_IDENTITY_CONTEXT_MAX_BYTES) {
|
||||
throw new Error("prepared execution identity context exceeds 16 KiB");
|
||||
}
|
||||
return freezeExecutionIdentityContext(context);
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
type OpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { recordAuditEvent } from "./audit-event-store.js";
|
||||
import {
|
||||
configureExecutionIdentityAdmissionSink,
|
||||
createExecutionIdentityAdmissionToken,
|
||||
enqueueExecutionIdentityContextAtAdmission,
|
||||
type ExecutionIdentityAdmissionEnvelope,
|
||||
type ExecutionIdentityAdmissionFacts,
|
||||
} from "./execution-identity-admission.js";
|
||||
import {
|
||||
inspectExecutionIdentityRun,
|
||||
processExecutionIdentityAdmissionWork,
|
||||
pruneExpiredExecutionIdentityContexts,
|
||||
} from "./execution-identity-context.js";
|
||||
|
||||
const RETENTION_MS = 30 * 24 * 60 * 60_000;
|
||||
|
||||
afterEach(() => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
});
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function databaseOptions() {
|
||||
return { env: { OPENCLAW_STATE_DIR: tempDirs.make("openclaw-identity-") } };
|
||||
}
|
||||
|
||||
function openIndependentStateDatabase(path: string): OpenClawStateDatabase {
|
||||
return {
|
||||
db: openNodeSqliteDatabase(path),
|
||||
path,
|
||||
walMaintenance: { checkpoint: () => true, close: () => true },
|
||||
};
|
||||
}
|
||||
|
||||
function facts(
|
||||
runId: string,
|
||||
overrides: Partial<ExecutionIdentityAdmissionFacts> = {},
|
||||
): ExecutionIdentityAdmissionFacts {
|
||||
return {
|
||||
runId,
|
||||
agentId: "main",
|
||||
ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" },
|
||||
runtime: { kind: "embedded" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function captureExecutionIdentityAdmissionEnvelope(
|
||||
admissionFacts: ExecutionIdentityAdmissionFacts,
|
||||
options: {
|
||||
now?: number;
|
||||
contextId?: string;
|
||||
executionId?: string;
|
||||
runtimeInstanceId?: string;
|
||||
} = {},
|
||||
): ExecutionIdentityAdmissionEnvelope {
|
||||
const { contextId, executionId, runtimeInstanceId, now } = options;
|
||||
let envelope: ExecutionIdentityAdmissionEnvelope | undefined;
|
||||
const clear = configureExecutionIdentityAdmissionSink((captured) => {
|
||||
if (captured.kind === "capture") {
|
||||
envelope = captured.envelope;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
const result = enqueueExecutionIdentityContextAtAdmission(admissionFacts, {
|
||||
enabled: true,
|
||||
...(contextId !== undefined ? { contextId } : {}),
|
||||
...(executionId !== undefined ? { executionId } : {}),
|
||||
...(runtimeInstanceId !== undefined ? { runtimeInstanceId } : {}),
|
||||
...(now !== undefined ? { now } : {}),
|
||||
});
|
||||
if (!result || !envelope) {
|
||||
throw new Error("expected admission envelope");
|
||||
}
|
||||
return envelope;
|
||||
} finally {
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
function persistExecutionIdentityAdmissionEnvelope(
|
||||
envelope: ExecutionIdentityAdmissionEnvelope,
|
||||
options: Parameters<typeof processExecutionIdentityAdmissionWork>[1] = {},
|
||||
) {
|
||||
return processExecutionIdentityAdmissionWork({ kind: "capture", envelope }, options);
|
||||
}
|
||||
|
||||
function prepareExecutionIdentityContextAtAdmission(
|
||||
admissionFacts: ExecutionIdentityAdmissionFacts,
|
||||
options: {
|
||||
database?: OpenClawStateDatabase;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
now?: number;
|
||||
contextId?: string;
|
||||
executionId?: string;
|
||||
runtimeInstanceId?: string;
|
||||
limits?: { maxRows: number; pruneBatchRows: number };
|
||||
} = {},
|
||||
) {
|
||||
const { contextId, executionId, runtimeInstanceId, now, limits, ...database } = options;
|
||||
const envelope = captureExecutionIdentityAdmissionEnvelope(admissionFacts, {
|
||||
...(contextId !== undefined ? { contextId } : {}),
|
||||
...(executionId !== undefined ? { executionId } : {}),
|
||||
...(runtimeInstanceId !== undefined ? { runtimeInstanceId } : {}),
|
||||
...(now !== undefined ? { now } : {}),
|
||||
});
|
||||
return persistExecutionIdentityAdmissionEnvelope(envelope, {
|
||||
...database,
|
||||
...(now !== undefined ? { now } : {}),
|
||||
...(limits !== undefined ? { limits } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("execution identity context storage", () => {
|
||||
it("replays one byte-identical canonical context idempotently across restart", () => {
|
||||
const database = databaseOptions();
|
||||
const envelope = captureExecutionIdentityAdmissionEnvelope(facts("run-1"), {
|
||||
now: 100,
|
||||
contextId: "context-1",
|
||||
executionId: "execution-1",
|
||||
runtimeInstanceId: "runtime-secret-1",
|
||||
});
|
||||
const first = persistExecutionIdentityAdmissionEnvelope(envelope, { ...database, now: 100 });
|
||||
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const second = persistExecutionIdentityAdmissionEnvelope(structuredClone(envelope), {
|
||||
...database,
|
||||
now: 999,
|
||||
});
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(first.coverageState).toBe("unattributed");
|
||||
expect(first.invoker).toEqual({ state: "absent" });
|
||||
expect(Object.isFrozen(first)).toBe(true);
|
||||
expect(Object.isFrozen(first.runtimeInstance)).toBe(true);
|
||||
expect(JSON.stringify(first)).not.toContain("runtime-secret-1");
|
||||
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const afterRestart = inspectExecutionIdentityRun(
|
||||
{ executionId: "execution-1" },
|
||||
{
|
||||
...database,
|
||||
now: 999,
|
||||
},
|
||||
);
|
||||
expect(afterRestart.identity).toEqual({ state: "present", context: first });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
difference: "contextId",
|
||||
mutate: (envelope: ExecutionIdentityAdmissionEnvelope) => ({
|
||||
...envelope,
|
||||
contextId: "context-conflicting",
|
||||
}),
|
||||
},
|
||||
{
|
||||
difference: "createdAt",
|
||||
mutate: (envelope: ExecutionIdentityAdmissionEnvelope) => ({
|
||||
...envelope,
|
||||
createdAt: envelope.createdAt + 1,
|
||||
}),
|
||||
},
|
||||
{
|
||||
difference: "identity facts",
|
||||
mutate: (envelope: ExecutionIdentityAdmissionEnvelope) => ({
|
||||
...envelope,
|
||||
agentId: "other",
|
||||
}),
|
||||
},
|
||||
])(
|
||||
"conflicts on a same-execution $difference and leaves canonical bytes unchanged",
|
||||
({ mutate }) => {
|
||||
const database = databaseOptions();
|
||||
const envelope = captureExecutionIdentityAdmissionEnvelope(facts("run-conflict"), {
|
||||
contextId: "context-original",
|
||||
executionId: "execution-original",
|
||||
now: 100,
|
||||
runtimeInstanceId: "runtime-1",
|
||||
});
|
||||
const original = persistExecutionIdentityAdmissionEnvelope(envelope, {
|
||||
...database,
|
||||
now: 100,
|
||||
});
|
||||
const originalRow = openOpenClawStateDatabase(database)
|
||||
.db.prepare("SELECT context_json FROM execution_identity_contexts WHERE execution_id = ?")
|
||||
.get("execution-original");
|
||||
|
||||
expect(() =>
|
||||
persistExecutionIdentityAdmissionEnvelope(mutate(envelope), { ...database, now: 101 }),
|
||||
).toThrow("execution identity context conflict");
|
||||
expect(
|
||||
openOpenClawStateDatabase(database)
|
||||
.db.prepare("SELECT context_json FROM execution_identity_contexts WHERE execution_id = ?")
|
||||
.get("execution-original"),
|
||||
).toEqual(originalRow);
|
||||
expect(
|
||||
inspectExecutionIdentityRun(
|
||||
{ executionId: "execution-original" },
|
||||
{ ...database, now: 101 },
|
||||
).identity,
|
||||
).toEqual({ state: "present", context: original });
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps distinct turns sharing one run correlation exactly inspectable", () => {
|
||||
const database = databaseOptions();
|
||||
const first = prepareExecutionIdentityContextAtAdmission(facts("session-run"), {
|
||||
...database,
|
||||
now: 100,
|
||||
contextId: "context-first",
|
||||
executionId: "execution-first",
|
||||
runtimeInstanceId: "runtime-1",
|
||||
});
|
||||
const second = prepareExecutionIdentityContextAtAdmission(facts("session-run"), {
|
||||
...database,
|
||||
now: 101,
|
||||
contextId: "context-second",
|
||||
executionId: "execution-second",
|
||||
runtimeInstanceId: "runtime-1",
|
||||
});
|
||||
|
||||
const discovery = inspectExecutionIdentityRun(
|
||||
{ runId: "session-run" },
|
||||
{ ...database, now: 101 },
|
||||
);
|
||||
expect(discovery).toMatchObject({
|
||||
run: { runId: "session-run", status: "known" },
|
||||
identity: {
|
||||
state: "ambiguous",
|
||||
reasonCode: "execution_selection_required",
|
||||
candidates: [
|
||||
{ executionId: "execution-first", contextId: "context-first" },
|
||||
{ executionId: "execution-second", contextId: "context-second" },
|
||||
],
|
||||
},
|
||||
decisions: [],
|
||||
});
|
||||
expect(
|
||||
inspectExecutionIdentityRun(
|
||||
{ runId: "session-run", executionLimit: 1 },
|
||||
{ ...database, now: 101 },
|
||||
),
|
||||
).toMatchObject({
|
||||
identity: {
|
||||
state: "ambiguous",
|
||||
candidates: [{ executionId: "execution-first" }],
|
||||
},
|
||||
nextExecutionCursor: "1",
|
||||
});
|
||||
expect(
|
||||
inspectExecutionIdentityRun(
|
||||
{ runId: "session-run", executionOffset: 1, executionLimit: 1 },
|
||||
{ ...database, now: 101 },
|
||||
),
|
||||
).toMatchObject({
|
||||
identity: {
|
||||
state: "ambiguous",
|
||||
candidates: [{ executionId: "execution-second" }],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
inspectExecutionIdentityRun({ executionId: "execution-first" }, { ...database, now: 101 })
|
||||
.identity,
|
||||
).toEqual({ state: "present", context: first });
|
||||
expect(
|
||||
inspectExecutionIdentityRun({ executionId: "execution-second" }, { ...database, now: 101 })
|
||||
.identity,
|
||||
).toEqual({ state: "present", context: second });
|
||||
});
|
||||
|
||||
it("confirms durable retries without manufacturing lost evidence", () => {
|
||||
const database = databaseOptions();
|
||||
const envelope = captureExecutionIdentityAdmissionEnvelope(facts("run-recovery"), {
|
||||
now: 100,
|
||||
contextId: "context-recovery",
|
||||
executionId: "execution-recovery",
|
||||
runtimeInstanceId: "runtime-original",
|
||||
});
|
||||
const original = processExecutionIdentityAdmissionWork(
|
||||
{ kind: "capture", envelope },
|
||||
{ ...database, now: 100 },
|
||||
);
|
||||
const token = createExecutionIdentityAdmissionToken("run-recovery", {
|
||||
now: 100,
|
||||
contextId: "context-recovery",
|
||||
executionId: "execution-recovery",
|
||||
});
|
||||
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
expect(
|
||||
processExecutionIdentityAdmissionWork({ kind: "retry-reference", token }, database),
|
||||
).toEqual(original);
|
||||
|
||||
const missingDatabase = databaseOptions();
|
||||
expect(() =>
|
||||
processExecutionIdentityAdmissionWork({ kind: "retry-reference", token }, missingDatabase),
|
||||
).toThrow("execution identity recovery evidence unavailable");
|
||||
expect(
|
||||
inspectExecutionIdentityRun({ executionId: "execution-recovery" }, missingDatabase),
|
||||
).toMatchObject({
|
||||
run: { executionId: "execution-recovery", status: "unknown" },
|
||||
identity: { state: "unknown", reasonCode: "execution_not_found" },
|
||||
decisions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("projects authoritative local CLI and system ingress without conflating them", () => {
|
||||
const database = databaseOptions();
|
||||
prepareExecutionIdentityContextAtAdmission(facts("run-local"), database);
|
||||
prepareExecutionIdentityContextAtAdmission(
|
||||
facts("run-system", {
|
||||
ingress: { kind: "system", boundary: "gateway.boot", state: "present" },
|
||||
}),
|
||||
database,
|
||||
);
|
||||
|
||||
expect(inspectExecutionIdentityRun({ runId: "run-local" }, database).identity).toMatchObject({
|
||||
state: "present",
|
||||
context: {
|
||||
ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" },
|
||||
},
|
||||
});
|
||||
expect(inspectExecutionIdentityRun({ runId: "run-system" }, database).identity).toMatchObject({
|
||||
state: "present",
|
||||
context: {
|
||||
ingress: { kind: "system", boundary: "gateway.boot", state: "present" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps inspection read-only and lets persistence create the additive table", () => {
|
||||
const database = databaseOptions();
|
||||
const reopened = openOpenClawStateDatabase(database);
|
||||
expect(
|
||||
reopened.db
|
||||
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
|
||||
.get("execution_identity_contexts"),
|
||||
).toBeUndefined();
|
||||
expect(inspectExecutionIdentityRun({ runId: "missing" }, database)).toMatchObject({
|
||||
run: { status: "unknown" },
|
||||
identity: { state: "unknown", reasonCode: "run_not_found" },
|
||||
});
|
||||
expect(
|
||||
reopened.db
|
||||
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
|
||||
.get("execution_identity_contexts"),
|
||||
).toBeUndefined();
|
||||
|
||||
prepareExecutionIdentityContextAtAdmission(facts("schema-restored"), database);
|
||||
expect(
|
||||
reopened.db
|
||||
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
|
||||
.get("execution_identity_contexts"),
|
||||
).toEqual({ name: "execution_identity_contexts" });
|
||||
expect(
|
||||
reopened.db
|
||||
.prepare("SELECT name FROM sqlite_schema WHERE type = 'index' AND name = ?")
|
||||
.get("execution_identity_contexts_run_created_idx"),
|
||||
).toEqual({ name: "execution_identity_contexts_run_created_idx" });
|
||||
});
|
||||
|
||||
it("keeps maintenance read-only until the first identity capture", () => {
|
||||
const database = databaseOptions();
|
||||
const opened = openOpenClawStateDatabase(database);
|
||||
expect(
|
||||
opened.db
|
||||
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
|
||||
.get("execution_identity_contexts"),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(pruneExpiredExecutionIdentityContexts({ database })).toBe(0);
|
||||
expect(
|
||||
opened.db
|
||||
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
|
||||
.get("execution_identity_contexts"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("records attribution only when an invoker fact is actually present", () => {
|
||||
const database = databaseOptions();
|
||||
const context = prepareExecutionIdentityContextAtAdmission(
|
||||
facts("run-attributed", {
|
||||
invoker: {
|
||||
kind: "local-account",
|
||||
rawPrincipalRef: "private-local-account",
|
||||
displayLabel: "Operator OPENAI_API_KEY=sk-1234567890abcdef",
|
||||
},
|
||||
applicableGrants: [
|
||||
{ rawGrantRef: "grant-z", state: "present" },
|
||||
{ rawGrantRef: "grant-a", state: "present" },
|
||||
],
|
||||
assurance: [
|
||||
{
|
||||
kind: "local-process",
|
||||
rawEvidenceRef: "private-process-evidence",
|
||||
strength: "boundary-verified",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ ...database, runtimeInstanceId: "private-runtime" },
|
||||
);
|
||||
const encoded = JSON.stringify(context);
|
||||
|
||||
expect(context.coverageState).toBe("attribution-only");
|
||||
expect(context.invoker.state).toBe("present");
|
||||
expect(context.missingEvidence).toEqual([]);
|
||||
expect(context.applicableGrants.map((grant) => grant.grantRef)).toEqual(
|
||||
context.applicableGrants.map((grant) => grant.grantRef).toSorted(),
|
||||
);
|
||||
for (const secret of [
|
||||
"private-local-account",
|
||||
"private-process-evidence",
|
||||
"private-runtime",
|
||||
"grant-a",
|
||||
"grant-z",
|
||||
"sk-1234567890abcdef",
|
||||
]) {
|
||||
expect(encoded).not.toContain(secret);
|
||||
}
|
||||
expect(context.invoker.principal?.principalRef).toMatch(/^hmac-sha256:v1:/u);
|
||||
});
|
||||
|
||||
it("declines recording instead of rotating a missing HMAC key with retained contexts", () => {
|
||||
const database = databaseOptions();
|
||||
prepareExecutionIdentityContextAtAdmission(facts("run-before-key-loss"), database);
|
||||
openOpenClawStateDatabase(database).db.exec("DELETE FROM audit_identity_keys;");
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
|
||||
expect(() =>
|
||||
prepareExecutionIdentityContextAtAdmission(facts("run-after-key-loss"), database),
|
||||
).toThrow("audit identity key is missing");
|
||||
});
|
||||
|
||||
it("skips new context rows when audit collection is disabled", () => {
|
||||
const database = databaseOptions();
|
||||
|
||||
expect(
|
||||
enqueueExecutionIdentityContextAtAdmission(facts("run-disabled"), { enabled: false }),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(
|
||||
openOpenClawStateDatabase(database)
|
||||
.db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
|
||||
.get("execution_identity_contexts"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps bounded retention maintenance available while collection is disabled", () => {
|
||||
const database = databaseOptions();
|
||||
prepareExecutionIdentityContextAtAdmission(facts("run-before-disable"), {
|
||||
...database,
|
||||
now: 0,
|
||||
runtimeInstanceId: "runtime-1",
|
||||
});
|
||||
expect(
|
||||
enqueueExecutionIdentityContextAtAdmission(facts("run-disabled"), { enabled: false }),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(pruneExpiredExecutionIdentityContexts({ database, now: RETENTION_MS + 1 })).toBe(1);
|
||||
expect(
|
||||
openOpenClawStateDatabase(database)
|
||||
.db.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts")
|
||||
.get(),
|
||||
).toEqual({ count: 0 });
|
||||
});
|
||||
|
||||
it("leaves the original context intact when a later worker write conflicts", () => {
|
||||
const database = databaseOptions();
|
||||
const envelope = captureExecutionIdentityAdmissionEnvelope(facts("run-best-effort"), {
|
||||
contextId: "context-best-effort",
|
||||
executionId: "execution-best-effort",
|
||||
runtimeInstanceId: "runtime-1",
|
||||
});
|
||||
persistExecutionIdentityAdmissionEnvelope(envelope, database);
|
||||
|
||||
expect(() =>
|
||||
persistExecutionIdentityAdmissionEnvelope(
|
||||
{ ...envelope, agentId: "conflicting-agent" },
|
||||
database,
|
||||
),
|
||||
).toThrow("execution identity context conflict");
|
||||
});
|
||||
|
||||
it("rolls back the worker write when insert-time retention cleanup fails", () => {
|
||||
const database = databaseOptions();
|
||||
prepareExecutionIdentityContextAtAdmission(facts("run-expired"), {
|
||||
...database,
|
||||
now: 0,
|
||||
runtimeInstanceId: "runtime-1",
|
||||
});
|
||||
openOpenClawStateDatabase(database).db.exec(`
|
||||
CREATE TRIGGER reject_identity_cleanup
|
||||
BEFORE DELETE ON execution_identity_contexts
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'cleanup unavailable');
|
||||
END;
|
||||
`);
|
||||
|
||||
expect(() =>
|
||||
prepareExecutionIdentityContextAtAdmission(facts("run-still-admitted"), {
|
||||
...database,
|
||||
now: RETENTION_MS + 1,
|
||||
runtimeInstanceId: "runtime-1",
|
||||
}),
|
||||
).toThrow("cleanup unavailable");
|
||||
});
|
||||
|
||||
it("stops projecting context and decisions immediately after the retention boundary", () => {
|
||||
const database = databaseOptions();
|
||||
const createdAt = 1_000;
|
||||
prepareExecutionIdentityContextAtAdmission(facts("run-retention"), {
|
||||
...database,
|
||||
now: createdAt,
|
||||
contextId: "expired-context-secret",
|
||||
executionId: "expired-execution-secret",
|
||||
runtimeInstanceId: "expired-runtime-secret",
|
||||
});
|
||||
|
||||
const immediatelyBefore = inspectExecutionIdentityRun(
|
||||
{ runId: "run-retention" },
|
||||
{ ...database, now: createdAt + RETENTION_MS - 1 },
|
||||
);
|
||||
expect(immediatelyBefore.identity).toMatchObject({
|
||||
state: "present",
|
||||
context: { contextId: "expired-context-secret" },
|
||||
});
|
||||
expect(immediatelyBefore.decisions).toHaveLength(1);
|
||||
expect(
|
||||
inspectExecutionIdentityRun(
|
||||
{ runId: "run-retention" },
|
||||
{ ...database, now: createdAt + RETENTION_MS },
|
||||
).identity.state,
|
||||
).toBe("present");
|
||||
|
||||
const immediatelyAfter = inspectExecutionIdentityRun(
|
||||
{ runId: "run-retention" },
|
||||
{ ...database, now: createdAt + RETENTION_MS + 1 },
|
||||
);
|
||||
expect(immediatelyAfter).toMatchObject({
|
||||
run: { status: "known" },
|
||||
identity: {
|
||||
state: "unsupported",
|
||||
reasonCode: "identity_context_unavailable",
|
||||
remediation: [
|
||||
expect.objectContaining({
|
||||
code: "run_again_after_expiry",
|
||||
text: expect.stringContaining("outside the 30-day window"),
|
||||
}),
|
||||
],
|
||||
},
|
||||
decisions: [],
|
||||
coverage: { state: "unsupported", missingEvidence: ["identity.context"] },
|
||||
});
|
||||
expect(JSON.stringify(immediatelyAfter)).not.toContain("expired-context-secret");
|
||||
expect(JSON.stringify(immediatelyAfter)).not.toContain("expired-runtime-secret");
|
||||
expect(JSON.stringify(immediatelyAfter)).not.toContain("run_admission_identity_not_evaluated");
|
||||
const exactAfter = inspectExecutionIdentityRun(
|
||||
{ executionId: "expired-execution-secret" },
|
||||
{ ...database, now: createdAt + RETENTION_MS + 1 },
|
||||
);
|
||||
expect(exactAfter).toMatchObject({
|
||||
run: { executionId: "expired-execution-secret", status: "known" },
|
||||
identity: { state: "unsupported", reasonCode: "identity_context_unavailable" },
|
||||
decisions: [],
|
||||
});
|
||||
expect(JSON.stringify(exactAfter)).not.toContain("expired-context-secret");
|
||||
expect(JSON.stringify(exactAfter)).not.toContain("expired-runtime-secret");
|
||||
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
expect(
|
||||
inspectExecutionIdentityRun(
|
||||
{ runId: "run-retention" },
|
||||
{ ...database, now: createdAt + RETENTION_MS + 1 },
|
||||
),
|
||||
).toEqual(immediatelyAfter);
|
||||
|
||||
expect(
|
||||
pruneExpiredExecutionIdentityContexts({
|
||||
database,
|
||||
now: createdAt + RETENTION_MS + 1,
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(
|
||||
inspectExecutionIdentityRun(
|
||||
{ runId: "run-retention" },
|
||||
{ ...database, now: createdAt + RETENTION_MS + 1 },
|
||||
),
|
||||
).toMatchObject({
|
||||
run: { status: "unknown" },
|
||||
identity: {
|
||||
state: "unknown",
|
||||
reasonCode: "run_not_found",
|
||||
remediation: [
|
||||
expect.objectContaining({ text: expect.stringContaining("not proof of no run") }),
|
||||
],
|
||||
},
|
||||
decisions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("prunes expired contexts in bounded maintenance batches without new inserts", () => {
|
||||
const database = databaseOptions();
|
||||
prepareExecutionIdentityContextAtAdmission(facts("schema-seed"), {
|
||||
...database,
|
||||
now: 1,
|
||||
runtimeInstanceId: "runtime-1",
|
||||
});
|
||||
const { db } = openOpenClawStateDatabase(database);
|
||||
db.exec("DELETE FROM execution_identity_contexts;");
|
||||
db.prepare(
|
||||
`WITH RECURSIVE rows(n) AS (
|
||||
VALUES (1)
|
||||
UNION ALL
|
||||
SELECT n + 1 FROM rows WHERE n < 1025
|
||||
)
|
||||
INSERT INTO execution_identity_contexts (
|
||||
context_id, execution_id, run_id, created_at, coverage_state, context_bytes, context_json
|
||||
)
|
||||
SELECT 'context-' || n, 'execution-' || n, 'run-' || n, 0, 'unattributed', 2, '{}'
|
||||
FROM rows`,
|
||||
).run();
|
||||
|
||||
expect(pruneExpiredExecutionIdentityContexts({ database, now: RETENTION_MS + 1 })).toBe(1_024);
|
||||
expect(db.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts").get()).toEqual({
|
||||
count: 1,
|
||||
});
|
||||
expect(pruneExpiredExecutionIdentityContexts({ database, now: RETENTION_MS + 1 })).toBe(1);
|
||||
expect(db.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts").get()).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("prunes retention and row-cap overflow in bounded batches", () => {
|
||||
const retentionDatabase = databaseOptions();
|
||||
for (const runId of ["old-1", "old-2", "old-3"]) {
|
||||
prepareExecutionIdentityContextAtAdmission(facts(runId), {
|
||||
...retentionDatabase,
|
||||
now: 0,
|
||||
runtimeInstanceId: "runtime-1",
|
||||
limits: { maxRows: 10, pruneBatchRows: 1 },
|
||||
});
|
||||
}
|
||||
prepareExecutionIdentityContextAtAdmission(facts("new-1"), {
|
||||
...retentionDatabase,
|
||||
now: RETENTION_MS + 1,
|
||||
runtimeInstanceId: "runtime-1",
|
||||
limits: { maxRows: 1, pruneBatchRows: 1 },
|
||||
});
|
||||
const retainedAfterOneBatch = openOpenClawStateDatabase(retentionDatabase)
|
||||
.db.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts")
|
||||
.get() as { count: number };
|
||||
expect(retainedAfterOneBatch.count).toBe(3);
|
||||
|
||||
const capDatabase = databaseOptions();
|
||||
for (const runId of ["cap-1", "cap-2", "cap-3"]) {
|
||||
prepareExecutionIdentityContextAtAdmission(facts(runId), {
|
||||
...capDatabase,
|
||||
now: 100,
|
||||
contextId: `context-${runId}`,
|
||||
runtimeInstanceId: "runtime-1",
|
||||
limits: { maxRows: 2, pruneBatchRows: 1 },
|
||||
});
|
||||
}
|
||||
const capped = openOpenClawStateDatabase(capDatabase)
|
||||
.db.prepare("SELECT run_id FROM execution_identity_contexts ORDER BY context_id")
|
||||
.all() as Array<{ run_id: string }>;
|
||||
expect(capped).toHaveLength(2);
|
||||
expect(capped.map((row) => row.run_id)).not.toContain("cap-1");
|
||||
});
|
||||
|
||||
it("enforces the row cap across independent database connections", () => {
|
||||
const database = databaseOptions();
|
||||
const path = openOpenClawStateDatabase(database).path;
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const first = openIndependentStateDatabase(path);
|
||||
const second = openIndependentStateDatabase(path);
|
||||
try {
|
||||
for (const [index, connection] of [first, second, first, second].entries()) {
|
||||
const runId = `shared-cap-${index + 1}`;
|
||||
prepareExecutionIdentityContextAtAdmission(facts(runId), {
|
||||
database: connection,
|
||||
now: 100 + index,
|
||||
contextId: `context-${runId}`,
|
||||
runtimeInstanceId: "runtime-1",
|
||||
limits: { maxRows: 2, pruneBatchRows: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
const retained = first.db
|
||||
.prepare("SELECT run_id FROM execution_identity_contexts ORDER BY created_at")
|
||||
.all() as Array<{ run_id: string }>;
|
||||
expect(retained.map((row) => row.run_id)).toEqual(["shared-cap-3", "shared-cap-4"]);
|
||||
} finally {
|
||||
first.db.close();
|
||||
second.db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("inspects through a read-only connection while another writer holds the database", () => {
|
||||
const database = databaseOptions();
|
||||
prepareExecutionIdentityContextAtAdmission(facts("held-lock-inspection"), {
|
||||
...database,
|
||||
now: 100,
|
||||
contextId: "context-held-lock-inspection",
|
||||
executionId: "execution-held-lock-inspection",
|
||||
runtimeInstanceId: "runtime-1",
|
||||
});
|
||||
const path = openOpenClawStateDatabase(database).path;
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const lockDatabase = openNodeSqliteDatabase(path);
|
||||
lockDatabase.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const startedAt = performance.now();
|
||||
expect(
|
||||
inspectExecutionIdentityRun(
|
||||
{ executionId: "execution-held-lock-inspection" },
|
||||
{ ...database, now: 100 },
|
||||
),
|
||||
).toMatchObject({
|
||||
identity: {
|
||||
state: "present",
|
||||
context: {
|
||||
contextId: "context-held-lock-inspection",
|
||||
executionId: "execution-held-lock-inspection",
|
||||
runId: "held-lock-inspection",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(performance.now() - startedAt).toBeLessThan(250);
|
||||
} finally {
|
||||
lockDatabase.exec("ROLLBACK");
|
||||
lockDatabase.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns typed corrupt, unknown, and unsupported projections", () => {
|
||||
const corruptDatabase = databaseOptions();
|
||||
prepareExecutionIdentityContextAtAdmission(facts("run-corrupt"), {
|
||||
...corruptDatabase,
|
||||
runtimeInstanceId: "runtime-1",
|
||||
});
|
||||
openOpenClawStateDatabase(corruptDatabase)
|
||||
.db.prepare("UPDATE execution_identity_contexts SET context_json = ? WHERE run_id = ?")
|
||||
.run("{", "run-corrupt");
|
||||
expect(inspectExecutionIdentityRun({ runId: "run-corrupt" }, corruptDatabase)).toMatchObject({
|
||||
run: { status: "known" },
|
||||
identity: { state: "unknown", reasonCode: "identity_context_corrupt" },
|
||||
coverage: { state: "unknown" },
|
||||
});
|
||||
|
||||
const unknownDatabase = databaseOptions();
|
||||
expect(inspectExecutionIdentityRun({ runId: "never-seen" }, unknownDatabase)).toMatchObject({
|
||||
run: { status: "unknown" },
|
||||
identity: {
|
||||
state: "unknown",
|
||||
reasonCode: "run_not_found",
|
||||
remediation: [expect.objectContaining({ code: "verify_run_id" })],
|
||||
},
|
||||
});
|
||||
|
||||
recordAuditEvent(
|
||||
{
|
||||
sourceId: "legacy-run:1",
|
||||
sourceSequence: 1,
|
||||
occurredAt: Date.now(),
|
||||
kind: "agent_run",
|
||||
action: "agent.run.started",
|
||||
status: "started",
|
||||
actorType: "agent",
|
||||
actorId: "main",
|
||||
agentId: "main",
|
||||
runId: "legacy-run",
|
||||
},
|
||||
unknownDatabase,
|
||||
);
|
||||
expect(inspectExecutionIdentityRun({ runId: "legacy-run" }, unknownDatabase)).toMatchObject({
|
||||
run: { status: "known" },
|
||||
identity: {
|
||||
state: "unsupported",
|
||||
reasonCode: "identity_context_unavailable",
|
||||
remediation: [expect.objectContaining({ code: "record_new_identity_context" })],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("projects one non-enforcement admission explanation", () => {
|
||||
const database = databaseOptions();
|
||||
prepareExecutionIdentityContextAtAdmission(facts("run-receipt"), {
|
||||
...database,
|
||||
now: 123,
|
||||
contextId: "context-receipt",
|
||||
runtimeInstanceId: "runtime-1",
|
||||
});
|
||||
const result = inspectExecutionIdentityRun({ runId: "run-receipt" }, { ...database, now: 123 });
|
||||
|
||||
expect(result.identity).toMatchObject({
|
||||
state: "present",
|
||||
context: { contextId: "context-receipt", coverageState: "unattributed" },
|
||||
});
|
||||
expect(result.decisions).toEqual([
|
||||
expect.objectContaining({
|
||||
decision: {
|
||||
outcome: "not-applicable",
|
||||
reasonCode: "run_admission_identity_not_evaluated",
|
||||
},
|
||||
enforcement: expect.objectContaining({
|
||||
coverageState: "unattributed",
|
||||
policyRefs: [],
|
||||
grantRefs: [],
|
||||
contextFieldsUsed: [],
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
inspectExecutionIdentityRun(
|
||||
{ runId: "run-receipt", decisionOffset: 1 },
|
||||
{ ...database, now: 123 },
|
||||
).decisions,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,726 @@
|
||||
/** Immutable execution identity context storage and run-admission projection. */
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { Selectable } from "kysely";
|
||||
import type {
|
||||
AuditRunInspectResult,
|
||||
DecisionReceiptV1,
|
||||
ExecutionIdentityContextV1,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import { validateExecutionIdentityContextV1 } from "../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
executeSqliteQuerySync,
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
getNodeSqliteKysely,
|
||||
} from "../infra/kysely-sync.js";
|
||||
import { normalizeSqliteNumber } from "../infra/sqlite-number.js";
|
||||
import { withExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js";
|
||||
import { tableExists } from "../state/openclaw-state-db-schema-helpers.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
type OpenClawStateDatabaseOptions,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { clearAuditIdentityKeyCacheForDatabase } from "./audit-identity.js";
|
||||
import {
|
||||
parseExecutionIdentityAdmissionEnvelope,
|
||||
parseExecutionIdentityAdmissionWork,
|
||||
type ExecutionIdentityAdmissionToken,
|
||||
} from "./execution-identity-admission.js";
|
||||
import {
|
||||
buildExecutionIdentityContext,
|
||||
ensureBoundedExecutionIdentityRef,
|
||||
freezeExecutionIdentityContext,
|
||||
} from "./execution-identity-context-build.js";
|
||||
|
||||
type ExecutionIdentityDatabase = Pick<
|
||||
OpenClawStateKyselyDatabase,
|
||||
"audit_events" | "execution_identity_contexts"
|
||||
>;
|
||||
type ExecutionIdentityRow = Selectable<OpenClawStateKyselyDatabase["execution_identity_contexts"]>;
|
||||
|
||||
const EXECUTION_IDENTITY_CONTEXT_MAX_BYTES = 16 * 1024;
|
||||
const EXECUTION_IDENTITY_CONTEXT_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
||||
const EXECUTION_IDENTITY_CONTEXT_MAX_ROWS = 100_000;
|
||||
const EXECUTION_IDENTITY_CONTEXT_PRUNE_BATCH_ROWS = 1_024;
|
||||
const EXECUTION_IDENTITY_HMAC_REF_RE = /^hmac-sha256:v1:[a-f0-9]{32}:[a-f0-9]{64}$/u;
|
||||
|
||||
const ensuredDatabases = new WeakSet<DatabaseSync>();
|
||||
|
||||
// Keep this feature-local DDL byte-for-byte aligned with the canonical schema.
|
||||
const EXECUTION_IDENTITY_CONTEXT_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS execution_identity_contexts (
|
||||
context_id TEXT NOT NULL PRIMARY KEY CHECK (length(context_id) BETWEEN 1 AND 256),
|
||||
execution_id TEXT NOT NULL UNIQUE CHECK (length(execution_id) BETWEEN 1 AND 256),
|
||||
run_id TEXT NOT NULL CHECK (length(run_id) BETWEEN 1 AND 256),
|
||||
created_at INTEGER NOT NULL CHECK (created_at >= 0),
|
||||
coverage_state TEXT NOT NULL CHECK (
|
||||
coverage_state IN ('attribution-only', 'unattributed', 'unknown', 'unsupported')
|
||||
),
|
||||
context_bytes INTEGER NOT NULL CHECK (context_bytes BETWEEN 1 AND 16384),
|
||||
context_json TEXT NOT NULL CHECK (length(context_json) > 0),
|
||||
UNIQUE (created_at, context_id)
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS execution_identity_contexts_run_created_idx
|
||||
ON execution_identity_contexts (run_id, created_at, execution_id);
|
||||
`;
|
||||
|
||||
type ExecutionIdentityStoreOptions = OpenClawStateDatabaseOptions & {
|
||||
now?: number;
|
||||
limits?: {
|
||||
maxRows: number;
|
||||
pruneBatchRows: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ExecutionIdentityReadOptions = OpenClawStateDatabaseOptions & {
|
||||
now?: number;
|
||||
};
|
||||
|
||||
type ExecutionIdentityContextReadResult =
|
||||
| { status: "found"; context: ExecutionIdentityContextV1 }
|
||||
| { status: "expired"; runId: string }
|
||||
| { status: "missing" }
|
||||
| { status: "corrupt"; runId: string; reasonCode: "identity_context_corrupt" };
|
||||
|
||||
function executionIdentityDb(db: DatabaseSync) {
|
||||
return getNodeSqliteKysely<ExecutionIdentityDatabase>(db);
|
||||
}
|
||||
|
||||
function ensureExecutionIdentityContextSchema(options: OpenClawStateDatabaseOptions = {}): void {
|
||||
const database = openOpenClawStateDatabase(options);
|
||||
if (ensuredDatabases.has(database.db)) {
|
||||
return;
|
||||
}
|
||||
runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
// sqlite-allow-raw -- feature-local additive schema DDL; context rows use Kysely.
|
||||
db.exec(EXECUTION_IDENTITY_CONTEXT_SCHEMA_SQL);
|
||||
},
|
||||
options,
|
||||
{ operationLabel: "audit.execution-identity.schema.ensure" },
|
||||
);
|
||||
ensuredDatabases.add(database.db);
|
||||
}
|
||||
|
||||
function parseExecutionIdentityRow(row: ExecutionIdentityRow): ExecutionIdentityContextV1 {
|
||||
if (
|
||||
typeof row.context_json !== "string" ||
|
||||
Buffer.byteLength(row.context_json, "utf8") !== normalizeSqliteNumber(row.context_bytes) ||
|
||||
Buffer.byteLength(row.context_json, "utf8") > EXECUTION_IDENTITY_CONTEXT_MAX_BYTES
|
||||
) {
|
||||
throw new Error("invalid context payload bounds");
|
||||
}
|
||||
const parsed = JSON.parse(row.context_json) as unknown;
|
||||
if (!validateExecutionIdentityContextV1(parsed)) {
|
||||
throw new Error("invalid context payload schema");
|
||||
}
|
||||
if (
|
||||
parsed.contextId !== row.context_id ||
|
||||
parsed.executionId !== row.execution_id ||
|
||||
parsed.runId !== row.run_id ||
|
||||
parsed.createdAt !== normalizeSqliteNumber(row.created_at) ||
|
||||
parsed.coverageState !== row.coverage_state ||
|
||||
JSON.stringify(parsed) !== row.context_json ||
|
||||
!EXECUTION_IDENTITY_HMAC_REF_RE.test(parsed.trustDomain.domainRef) ||
|
||||
!EXECUTION_IDENTITY_HMAC_REF_RE.test(parsed.runtimeInstance.runtimeRef)
|
||||
) {
|
||||
throw new Error("context payload disagrees with indexed columns");
|
||||
}
|
||||
return freezeExecutionIdentityContext(parsed);
|
||||
}
|
||||
|
||||
function readRowByExecutionId(
|
||||
db: DatabaseSync,
|
||||
executionId: string,
|
||||
): ExecutionIdentityRow | undefined {
|
||||
return executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
executionIdentityDb(db)
|
||||
.selectFrom("execution_identity_contexts")
|
||||
.selectAll()
|
||||
.where("execution_id", "=", executionId),
|
||||
);
|
||||
}
|
||||
|
||||
function readRowsByRunId(
|
||||
db: DatabaseSync,
|
||||
runId: string,
|
||||
now: number,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): ExecutionIdentityRow[] {
|
||||
return executeSqliteQuerySync(
|
||||
db,
|
||||
executionIdentityDb(db)
|
||||
.selectFrom("execution_identity_contexts")
|
||||
.selectAll()
|
||||
.where("run_id", "=", runId)
|
||||
.where("created_at", ">=", now - EXECUTION_IDENTITY_CONTEXT_RETENTION_MS)
|
||||
.orderBy("created_at", "asc")
|
||||
.orderBy("execution_id", "asc")
|
||||
.offset(offset)
|
||||
.limit(limit),
|
||||
).rows;
|
||||
}
|
||||
|
||||
function deleteExpiredExecutionIdentityContexts(db: DatabaseSync, now: number, limit: number) {
|
||||
const kysely = executionIdentityDb(db);
|
||||
const expiredIds = kysely
|
||||
.selectFrom("execution_identity_contexts")
|
||||
.select("context_id")
|
||||
.where("created_at", "<", now - EXECUTION_IDENTITY_CONTEXT_RETENTION_MS)
|
||||
.orderBy("created_at", "asc")
|
||||
.orderBy("context_id", "asc")
|
||||
.limit(limit);
|
||||
return executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.deleteFrom("execution_identity_contexts").where("context_id", "in", expiredIds),
|
||||
);
|
||||
}
|
||||
|
||||
function pruneExecutionIdentityContextsAfterInsert(
|
||||
db: DatabaseSync,
|
||||
now: number,
|
||||
limits: { maxRows: number; pruneBatchRows: number },
|
||||
): void {
|
||||
const kysely = executionIdentityDb(db);
|
||||
const expired = deleteExpiredExecutionIdentityContexts(db, now, limits.pruneBatchRows);
|
||||
const expiredCount = Number(expired.numAffectedRows ?? 0n);
|
||||
const remainingPruneBudget = Math.max(0, limits.pruneBatchRows - expiredCount);
|
||||
if (remainingPruneBudget > 0) {
|
||||
// Derive overflow from committed rows inside this transaction. A process-local
|
||||
// count misses writes from the Gateway worker or a concurrent direct CLI.
|
||||
const retainedIds = kysely
|
||||
.selectFrom("execution_identity_contexts")
|
||||
.select("context_id")
|
||||
.orderBy("created_at", "desc")
|
||||
.orderBy("context_id", "desc")
|
||||
.limit(limits.maxRows);
|
||||
const oldestOverflowIds = kysely
|
||||
.selectFrom("execution_identity_contexts")
|
||||
.select("context_id")
|
||||
.where("context_id", "not in", retainedIds)
|
||||
.orderBy("created_at", "asc")
|
||||
.orderBy("context_id", "asc")
|
||||
.limit(remainingPruneBudget);
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.deleteFrom("execution_identity_contexts").where("context_id", "in", oldestOverflowIds),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete one bounded batch during the existing audit startup/hourly maintenance tick. */
|
||||
export function pruneExpiredExecutionIdentityContexts(
|
||||
params: {
|
||||
now?: number;
|
||||
database?: OpenClawStateDatabaseOptions;
|
||||
} = {},
|
||||
): number {
|
||||
const databaseOptions = params.database ?? {};
|
||||
const database = openOpenClawStateDatabase(databaseOptions);
|
||||
// Maintenance must not create opt-in storage. First capture owns schema creation;
|
||||
// once the table exists, cleanup remains active even after collection is disabled.
|
||||
if (!tableExists(database.db, "execution_identity_contexts")) {
|
||||
return 0;
|
||||
}
|
||||
return runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const deleted = deleteExpiredExecutionIdentityContexts(
|
||||
db,
|
||||
params.now ?? Date.now(),
|
||||
EXECUTION_IDENTITY_CONTEXT_PRUNE_BATCH_ROWS,
|
||||
);
|
||||
return Number(deleted.numAffectedRows ?? 0n);
|
||||
},
|
||||
{ ...databaseOptions, database },
|
||||
{ operationLabel: "audit.execution-identity.context.maintenance" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Worker-owned canonicalization and persistence for one accepted admission envelope. */
|
||||
function persistExecutionIdentityAdmissionEnvelope(
|
||||
input: unknown,
|
||||
options: ExecutionIdentityStoreOptions = {},
|
||||
): ExecutionIdentityContextV1 {
|
||||
// Structured clone removes the admission-side freeze. Revalidate all bounds
|
||||
// before schema/key access so malformed messages never reach persistence.
|
||||
const envelope = parseExecutionIdentityAdmissionEnvelope(input);
|
||||
ensureExecutionIdentityContextSchema(options);
|
||||
const executionId = envelope.executionId;
|
||||
const opened = openOpenClawStateDatabase(options);
|
||||
// HMAC lookup/key creation and canonical serialization finish before BEGIN.
|
||||
// The transaction only rereads the authoritative row and synchronously commits.
|
||||
const plannedContext = buildExecutionIdentityContext(opened.db, envelope, {
|
||||
contextId: envelope.contextId,
|
||||
createdAt: envelope.createdAt,
|
||||
});
|
||||
const plannedContextJson = JSON.stringify(plannedContext);
|
||||
let transactionDatabase: DatabaseSync | undefined;
|
||||
try {
|
||||
return runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
transactionDatabase = db;
|
||||
const existing = readRowByExecutionId(db, executionId);
|
||||
if (existing) {
|
||||
const context = parseExecutionIdentityRow(existing);
|
||||
// Full canonical bytes, including the captured ID and timestamp, own replay identity.
|
||||
// Never rewrite a newly captured envelope to resemble the retained execution context.
|
||||
if (plannedContextJson !== existing.context_json) {
|
||||
throw new Error("execution identity context conflict for execution");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
executionIdentityDb(db)
|
||||
.insertInto("execution_identity_contexts")
|
||||
.values({
|
||||
context_id: plannedContext.contextId,
|
||||
execution_id: plannedContext.executionId,
|
||||
run_id: plannedContext.runId,
|
||||
created_at: plannedContext.createdAt,
|
||||
coverage_state: plannedContext.coverageState,
|
||||
context_bytes: Buffer.byteLength(plannedContextJson, "utf8"),
|
||||
context_json: plannedContextJson,
|
||||
}),
|
||||
);
|
||||
pruneExecutionIdentityContextsAfterInsert(
|
||||
db,
|
||||
options.now ?? Date.now(),
|
||||
options.limits ?? {
|
||||
maxRows: EXECUTION_IDENTITY_CONTEXT_MAX_ROWS,
|
||||
pruneBatchRows: EXECUTION_IDENTITY_CONTEXT_PRUNE_BATCH_ROWS,
|
||||
},
|
||||
);
|
||||
return plannedContext;
|
||||
},
|
||||
options,
|
||||
{ operationLabel: "audit.execution-identity.context.record" },
|
||||
);
|
||||
} catch (error) {
|
||||
if (transactionDatabase) {
|
||||
clearAuditIdentityKeyCacheForDatabase(transactionDatabase);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** A durable recovery retry may only confirm the originally captured execution. */
|
||||
function verifyExecutionIdentityAdmissionRetry(
|
||||
token: ExecutionIdentityAdmissionToken,
|
||||
options: ExecutionIdentityReadOptions = {},
|
||||
): ExecutionIdentityContextV1 {
|
||||
const { db } = openOpenClawStateDatabase(options);
|
||||
if (!tableExists(db, "execution_identity_contexts")) {
|
||||
throw new Error("execution identity recovery evidence unavailable");
|
||||
}
|
||||
const existing = readRowByExecutionId(db, token.executionId);
|
||||
if (!existing) {
|
||||
// Never reconstruct identity from the later runtime after an ambiguous restart.
|
||||
throw new Error("execution identity recovery evidence unavailable");
|
||||
}
|
||||
const context = parseExecutionIdentityRow(existing);
|
||||
if (
|
||||
context.contextId !== token.contextId ||
|
||||
context.executionId !== token.executionId ||
|
||||
context.runId !== token.runId ||
|
||||
context.createdAt !== token.createdAt
|
||||
) {
|
||||
throw new Error("execution identity context conflict for execution");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/** Worker-owned persistence/verification for one accepted bounded queue item. */
|
||||
export function processExecutionIdentityAdmissionWork(
|
||||
input: unknown,
|
||||
options: ExecutionIdentityStoreOptions = {},
|
||||
): ExecutionIdentityContextV1 {
|
||||
const work = parseExecutionIdentityAdmissionWork(input);
|
||||
return work.kind === "capture"
|
||||
? persistExecutionIdentityAdmissionEnvelope(work.envelope, options)
|
||||
: verifyExecutionIdentityAdmissionRetry(work.token, options);
|
||||
}
|
||||
|
||||
/** Read one exact execution while turning malformed rows into typed diagnostics. */
|
||||
function readExecutionIdentityContextByExecutionId(
|
||||
executionId: string,
|
||||
options: ExecutionIdentityReadOptions = {},
|
||||
): ExecutionIdentityContextReadResult {
|
||||
const normalizedExecutionId = ensureBoundedExecutionIdentityRef(executionId, "execution id");
|
||||
return (
|
||||
withExistingOpenClawStateDatabaseReadOnly(({ db }) => {
|
||||
if (!tableExists(db, "execution_identity_contexts")) {
|
||||
return { status: "missing" } as const;
|
||||
}
|
||||
const row = readRowByExecutionId(db, normalizedExecutionId);
|
||||
if (!row) {
|
||||
return { status: "missing" } as const;
|
||||
}
|
||||
const createdAt = normalizeSqliteNumber(row.created_at);
|
||||
if (
|
||||
createdAt !== undefined &&
|
||||
createdAt < (options.now ?? Date.now()) - EXECUTION_IDENTITY_CONTEXT_RETENTION_MS
|
||||
) {
|
||||
// The indexed timestamp may explain availability, but expired context JSON
|
||||
// must never be parsed or projected while bounded maintenance catches up.
|
||||
return { status: "expired", runId: row.run_id } as const;
|
||||
}
|
||||
try {
|
||||
return { status: "found", context: parseExecutionIdentityRow(row) } as const;
|
||||
} catch {
|
||||
return {
|
||||
status: "corrupt",
|
||||
runId: row.run_id,
|
||||
reasonCode: "identity_context_corrupt",
|
||||
} as const;
|
||||
}
|
||||
}, options) ?? { status: "missing" }
|
||||
);
|
||||
}
|
||||
|
||||
function admissionDecision(context: ExecutionIdentityContextV1): DecisionReceiptV1 {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
receiptId: `${context.contextId}:admission`,
|
||||
contextId: context.contextId,
|
||||
executionId: context.executionId,
|
||||
runId: context.runId,
|
||||
occurredAt: context.createdAt,
|
||||
action: {
|
||||
family: "run",
|
||||
operation: "admission",
|
||||
summary: "Run admission was recorded without an identity-aware policy or grant decision.",
|
||||
},
|
||||
decision: {
|
||||
outcome: "not-applicable",
|
||||
reasonCode: "run_admission_identity_not_evaluated",
|
||||
},
|
||||
enforcement: {
|
||||
coverageState: context.coverageState,
|
||||
policyRefs: [],
|
||||
grantRefs: [],
|
||||
contextFieldsUsed: [],
|
||||
},
|
||||
source: {
|
||||
owner: "agent-command",
|
||||
recordRef: context.contextId,
|
||||
decisionBoundary: "agent-command.run-admission",
|
||||
},
|
||||
missingEvidence: [...context.missingEvidence],
|
||||
remediation: [
|
||||
{
|
||||
code: "no_identity_enforcement_claimed",
|
||||
text: "Treat this receipt as attribution only; it does not prove authorization.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableResult(params: {
|
||||
selector: { runId: string } | { executionId: string };
|
||||
resolvedRunId?: string;
|
||||
runStatus: "known" | "unknown";
|
||||
state: "unknown" | "unsupported";
|
||||
reasonCode: string;
|
||||
missingEvidence: string[];
|
||||
remediation: Array<{ code: string; text: string }>;
|
||||
}): AuditRunInspectResult {
|
||||
const run: AuditRunInspectResult["run"] =
|
||||
"executionId" in params.selector
|
||||
? {
|
||||
executionId: params.selector.executionId,
|
||||
...(params.resolvedRunId ? { runId: params.resolvedRunId } : {}),
|
||||
status: params.runStatus,
|
||||
}
|
||||
: {
|
||||
runId: params.resolvedRunId ?? params.selector.runId,
|
||||
status: params.runStatus,
|
||||
};
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
run,
|
||||
identity: {
|
||||
state: params.state,
|
||||
reasonCode: params.reasonCode,
|
||||
missingEvidence: params.missingEvidence,
|
||||
remediation: params.remediation,
|
||||
},
|
||||
decisions: [],
|
||||
coverage: { state: params.state, missingEvidence: params.missingEvidence },
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableIdentityContext(
|
||||
selector: { runId: string } | { executionId: string },
|
||||
remediation: { code: string; text: string },
|
||||
resolvedRunId?: string,
|
||||
): AuditRunInspectResult {
|
||||
return unavailableResult({
|
||||
selector,
|
||||
resolvedRunId,
|
||||
runStatus: "known",
|
||||
state: "unsupported",
|
||||
reasonCode: "identity_context_unavailable",
|
||||
missingEvidence: ["identity.context"],
|
||||
remediation: [remediation],
|
||||
});
|
||||
}
|
||||
|
||||
function presentResult(params: {
|
||||
context: ExecutionIdentityContextV1;
|
||||
decisionOffset?: number;
|
||||
decisionLimit?: number;
|
||||
}): AuditRunInspectResult {
|
||||
const allDecisions = [admissionDecision(params.context)];
|
||||
const offset = params.decisionOffset ?? 0;
|
||||
const limit = params.decisionLimit ?? 50;
|
||||
const decisions = allDecisions.slice(offset, offset + limit);
|
||||
const nextOffset = offset + decisions.length;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
run: {
|
||||
runId: params.context.runId,
|
||||
executionId: params.context.executionId,
|
||||
status: "known",
|
||||
},
|
||||
identity: { state: "present", context: params.context },
|
||||
decisions,
|
||||
coverage: {
|
||||
state: params.context.coverageState,
|
||||
missingEvidence: [...params.context.missingEvidence],
|
||||
},
|
||||
...(nextOffset < allDecisions.length ? { nextDecisionCursor: String(nextOffset) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function inspectExactExecution(
|
||||
params: { executionId: string; decisionOffset?: number; decisionLimit?: number },
|
||||
options: ExecutionIdentityReadOptions,
|
||||
): AuditRunInspectResult {
|
||||
const executionId = ensureBoundedExecutionIdentityRef(params.executionId, "execution id");
|
||||
const selector = { executionId };
|
||||
const contextResult = readExecutionIdentityContextByExecutionId(executionId, options);
|
||||
if (contextResult.status === "found") {
|
||||
return presentResult({
|
||||
context: contextResult.context,
|
||||
decisionOffset: params.decisionOffset,
|
||||
decisionLimit: params.decisionLimit,
|
||||
});
|
||||
}
|
||||
if (contextResult.status === "corrupt") {
|
||||
return unavailableResult({
|
||||
selector,
|
||||
resolvedRunId: contextResult.runId,
|
||||
runStatus: "known",
|
||||
state: "unknown",
|
||||
reasonCode: contextResult.reasonCode,
|
||||
missingEvidence: ["identity.context.valid"],
|
||||
remediation: [
|
||||
{
|
||||
code: "inspect_state_integrity",
|
||||
text: "Run openclaw doctor and inspect the shared state database before trusting this execution.",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (contextResult.status === "expired") {
|
||||
return unavailableIdentityContext(
|
||||
selector,
|
||||
{
|
||||
code: "run_again_after_expiry",
|
||||
text: "This execution's identity context is outside the 30-day retention window; run the operation again to record a new context.",
|
||||
},
|
||||
contextResult.runId,
|
||||
);
|
||||
}
|
||||
return unavailableResult({
|
||||
selector,
|
||||
runStatus: "unknown",
|
||||
state: "unknown",
|
||||
reasonCode: "execution_not_found",
|
||||
missingEvidence: ["identity.context"],
|
||||
remediation: [
|
||||
{
|
||||
code: "verify_execution_id",
|
||||
text: "Verify the exact execution id; absence of best-effort identity evidence is not proof that no run occurred.",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function hasAnyRunContext(db: DatabaseSync, runId: string): boolean {
|
||||
return Boolean(
|
||||
executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
executionIdentityDb(db)
|
||||
.selectFrom("execution_identity_contexts")
|
||||
.select("context_id")
|
||||
.where("run_id", "=", runId)
|
||||
.limit(1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function hasRetainedAuditRun(db: DatabaseSync, runId: string, now: number): boolean {
|
||||
if (!tableExists(db, "audit_events")) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
executionIdentityDb(db)
|
||||
.selectFrom("audit_events")
|
||||
.select("sequence")
|
||||
.where("run_id", "=", runId)
|
||||
.where("occurred_at", ">=", now - EXECUTION_IDENTITY_CONTEXT_RETENTION_MS)
|
||||
.where("kind", "!=", "message")
|
||||
.limit(1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function inspectRunSelector(
|
||||
params: {
|
||||
runId: string;
|
||||
executionOffset?: number;
|
||||
executionLimit?: number;
|
||||
decisionOffset?: number;
|
||||
decisionLimit?: number;
|
||||
},
|
||||
options: ExecutionIdentityReadOptions,
|
||||
): AuditRunInspectResult {
|
||||
const runId = ensureBoundedExecutionIdentityRef(params.runId, "run id");
|
||||
const now = options.now ?? Date.now();
|
||||
const inspected = withExistingOpenClawStateDatabaseReadOnly<AuditRunInspectResult | undefined>(
|
||||
({ db }) => {
|
||||
const firstMatches = tableExists(db, "execution_identity_contexts")
|
||||
? readRowsByRunId(db, runId, now, 0, 2)
|
||||
: [];
|
||||
if (firstMatches.length === 1) {
|
||||
try {
|
||||
return presentResult({
|
||||
context: parseExecutionIdentityRow(firstMatches[0]!),
|
||||
decisionOffset: params.decisionOffset,
|
||||
decisionLimit: params.decisionLimit,
|
||||
});
|
||||
} catch {
|
||||
return unavailableResult({
|
||||
selector: { runId },
|
||||
runStatus: "known",
|
||||
state: "unknown",
|
||||
reasonCode: "identity_context_corrupt",
|
||||
missingEvidence: ["identity.context.valid"],
|
||||
remediation: [
|
||||
{
|
||||
code: "inspect_state_integrity",
|
||||
text: "Run openclaw doctor and inspect the shared state database before trusting this run.",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (firstMatches.length > 1) {
|
||||
const offset = params.executionOffset ?? 0;
|
||||
const limit = params.executionLimit ?? 50;
|
||||
const page = readRowsByRunId(db, runId, now, offset, limit + 1);
|
||||
const candidates = page.slice(0, limit).map((row) => ({
|
||||
executionId: row.execution_id,
|
||||
contextId: row.context_id,
|
||||
createdAt: normalizeSqliteNumber(row.created_at) ?? 0,
|
||||
}));
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
run: { runId, status: "known" },
|
||||
identity: {
|
||||
state: "ambiguous",
|
||||
reasonCode: "execution_selection_required",
|
||||
candidates,
|
||||
missingEvidence: ["execution.selection"],
|
||||
remediation: [
|
||||
{
|
||||
code: "select_execution_id",
|
||||
text: "Select one candidate with openclaw audit --execution <id> --explain.",
|
||||
},
|
||||
],
|
||||
},
|
||||
decisions: [],
|
||||
coverage: { state: "unknown", missingEvidence: ["execution.selection"] },
|
||||
...(page.length > limit ? { nextExecutionCursor: String(offset + limit) } : {}),
|
||||
};
|
||||
}
|
||||
if (tableExists(db, "execution_identity_contexts") && hasAnyRunContext(db, runId)) {
|
||||
return unavailableIdentityContext(
|
||||
{ runId },
|
||||
{
|
||||
code: "run_again_after_expiry",
|
||||
text: "This run's retained identity contexts are outside the 30-day window; run the operation again to record a new execution.",
|
||||
},
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (hasRetainedAuditRun(db, runId, now)) {
|
||||
return unavailableIdentityContext(
|
||||
{ runId },
|
||||
{
|
||||
code: "record_new_identity_context",
|
||||
text: "Confirm audit collection is enabled and the Gateway is current, then run the operation again to record a new execution context.",
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return unavailableResult({
|
||||
selector: { runId },
|
||||
runStatus: "unknown",
|
||||
state: "unknown",
|
||||
reasonCode: "run_evidence_unreadable",
|
||||
missingEvidence: ["run.record", "identity.context"],
|
||||
remediation: [
|
||||
{
|
||||
code: "inspect_state_integrity",
|
||||
text: "Run openclaw doctor and retry the run inspection.",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
options,
|
||||
);
|
||||
if (inspected) {
|
||||
return inspected;
|
||||
}
|
||||
return unavailableResult({
|
||||
selector: { runId },
|
||||
runStatus: "unknown",
|
||||
state: "unknown",
|
||||
reasonCode: "run_not_found",
|
||||
missingEvidence: ["run.record", "identity.context"],
|
||||
remediation: [
|
||||
{
|
||||
code: "verify_run_id",
|
||||
text: "Verify the run id; absence of best-effort audit activity is not proof of no run.",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Inspect one exact execution or discover bounded executions for a run correlation. */
|
||||
export function inspectExecutionIdentityRun(
|
||||
params:
|
||||
| {
|
||||
runId: string;
|
||||
executionOffset?: number;
|
||||
executionLimit?: number;
|
||||
decisionOffset?: number;
|
||||
decisionLimit?: number;
|
||||
}
|
||||
| { executionId: string; decisionOffset?: number; decisionLimit?: number },
|
||||
options: ExecutionIdentityReadOptions = {},
|
||||
): AuditRunInspectResult {
|
||||
return "executionId" in params
|
||||
? inspectExactExecution(params, options)
|
||||
: inspectRunSelector(params, options);
|
||||
}
|
||||
@@ -112,7 +112,7 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
|
||||
},
|
||||
{
|
||||
name: "audit",
|
||||
description: "Inspect metadata-only run, tool, and message lifecycle records",
|
||||
description: "Inspect activity records and exact-run identity context",
|
||||
hasSubcommands: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,10 +10,11 @@ import { runCommandWithRuntime } from "../cli-utils.js";
|
||||
export function registerAuditCommand(program: Command): void {
|
||||
program
|
||||
.command("audit")
|
||||
.description("Inspect metadata-only run, tool, and message lifecycle records")
|
||||
.description("Inspect activity records and exact-run identity context")
|
||||
.option("--agent <id>", "Filter by agent id")
|
||||
.option("--session <key>", "Filter by exact session key")
|
||||
.option("--run <id>", "Filter by run id")
|
||||
.option("--execution <id>", "Inspect one exact execution id")
|
||||
.option("--kind <kind>", "Filter by kind (agent_run, tool_action, or message)")
|
||||
.option(
|
||||
"--status <status>",
|
||||
@@ -24,7 +25,8 @@ export function registerAuditCommand(program: Command): void {
|
||||
.option("--after <timestamp>", "Include records at/after ISO time or Unix milliseconds")
|
||||
.option("--before <timestamp>", "Include records at/before ISO time or Unix milliseconds")
|
||||
.option("--cursor <sequence>", "Continue from a previous result cursor")
|
||||
.option("--limit <count>", "Maximum records (1-500)", "100")
|
||||
.option("--limit <count>", "Maximum records (1-500; decisions 1-100)")
|
||||
.option("--explain", "Inspect execution identity and run-admission reasoning", false)
|
||||
.option("--json", "Output a bounded JSON page", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
@@ -38,6 +40,7 @@ export function registerAuditCommand(program: Command): void {
|
||||
agentId: opts.agent as string | undefined,
|
||||
sessionKey: opts.session as string | undefined,
|
||||
runId: opts.run as string | undefined,
|
||||
executionId: opts.execution as string | undefined,
|
||||
kind: opts.kind as AuditListCommandOptions["kind"],
|
||||
status: opts.status as AuditListCommandOptions["status"],
|
||||
direction: opts.direction as AuditListCommandOptions["direction"],
|
||||
@@ -46,6 +49,7 @@ export function registerAuditCommand(program: Command): void {
|
||||
before: opts.before as string | undefined,
|
||||
cursor: opts.cursor as string | undefined,
|
||||
limit: opts.limit as string | undefined,
|
||||
explain: Boolean(opts.explain),
|
||||
json: Boolean(opts.json),
|
||||
},
|
||||
defaultRuntime,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { Readable } from "node:stream";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
loadAuthProfileStoreForRuntime,
|
||||
resolvePersistedAuthProfileOwnerAgentDir,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import { enqueueExecutionIdentityContextAtAdmission } from "../audit/execution-identity-admission.js";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSnapshot,
|
||||
@@ -333,6 +335,61 @@ describe("agent exec command composition", () => {
|
||||
await expect(fs.stat(observedStateDir)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("flushes opted-in identity evidence through its owned direct-local writer", async () => {
|
||||
const root = await makeTempRoot("openclaw-agent-exec-audit-");
|
||||
const admittedAt = Date.now();
|
||||
setRuntimeConfigSnapshot({ logging: { audit: { executionIdentity: true } } });
|
||||
try {
|
||||
const { runtime } = createRuntime();
|
||||
const result = await agentExecCommand("inspect", { stateDir: root }, runtime, {
|
||||
runAgent: vi.fn(async () => {
|
||||
expect(
|
||||
enqueueExecutionIdentityContextAtAdmission(
|
||||
{
|
||||
runId: "agent-exec-run",
|
||||
agentId: "main",
|
||||
ingress: {
|
||||
kind: "local-cli",
|
||||
boundary: "agent-command.local",
|
||||
state: "present",
|
||||
},
|
||||
runtime: { kind: "embedded" },
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
contextId: "agent-exec-context",
|
||||
executionId: "agent-exec-execution",
|
||||
now: admittedAt,
|
||||
runtimeInstanceId: "agent-exec-runtime",
|
||||
},
|
||||
),
|
||||
).toMatchObject({ accepted: true });
|
||||
return successResult();
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const database = new DatabaseSync(path.join(root, "state", "openclaw.sqlite"), {
|
||||
readOnly: true,
|
||||
});
|
||||
try {
|
||||
const row = database
|
||||
.prepare("SELECT context_json FROM execution_identity_contexts WHERE execution_id = ?")
|
||||
.get("agent-exec-execution") as { context_json: string };
|
||||
expect(JSON.parse(row.context_json)).toMatchObject({
|
||||
contextId: "agent-exec-context",
|
||||
executionId: "agent-exec-execution",
|
||||
runId: "agent-exec-run",
|
||||
ingress: { kind: "local-cli", state: "present" },
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
} finally {
|
||||
clearRuntimeConfigSnapshot();
|
||||
}
|
||||
});
|
||||
|
||||
it("discovers operator-installed plugins while run state stays ephemeral", async () => {
|
||||
const operatorStateDir = await makeTempRoot("openclaw-agent-exec-plugin-owner-");
|
||||
const pluginDir = path.join(operatorStateDir, "extensions", "exec-provider");
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TextDecoder } from "node:util";
|
||||
import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-with-limit";
|
||||
import { findAgentRunTerminalOutcome } from "../agents/agent-run-terminal-error.js";
|
||||
import type { EmbeddedAgentRunMeta } from "../agents/embedded-agent.js";
|
||||
import { isExecutionIdentityCollectionEnabled } from "../audit/audit-config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { mergeDeep } from "../infra/deep-merge.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
@@ -550,6 +551,7 @@ export async function agentExecCommand(
|
||||
let restoreRuntimeConfigSnapshot: (() => void) | undefined;
|
||||
let runtimePaths: typeof import("../config/paths.js") | undefined;
|
||||
let configIo: typeof import("../config/io.js") | undefined;
|
||||
let stopLocalAuditWriter: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
const prompt = await resolveAgentExecPrompt(
|
||||
positionalMessage,
|
||||
@@ -623,6 +625,15 @@ export async function agentExecCommand(
|
||||
// env-substituted provider keys to disk where the run's own exec tool
|
||||
// could read them.
|
||||
snapshotIo.setRuntimeConfigSnapshot(runConfig);
|
||||
if (isExecutionIdentityCollectionEnabled(runConfig)) {
|
||||
try {
|
||||
stopLocalAuditWriter = (await import("./agent-local-audit.js")).startAgentLocalAuditWriter({
|
||||
stateDir,
|
||||
});
|
||||
} catch {
|
||||
// Admission emits a bounded warning if the direct-process writer is unavailable.
|
||||
}
|
||||
}
|
||||
const [
|
||||
{ withAuthProfileStoreAgentDir, withEnvOnlyAuthProfileStore },
|
||||
{ withHostExecInheritedEnvOmitted },
|
||||
@@ -695,6 +706,7 @@ export async function agentExecCommand(
|
||||
}
|
||||
|
||||
let cleanupError: unknown;
|
||||
await stopLocalAuditWriter?.().catch(() => undefined);
|
||||
const runCleanupStep = (step: () => void) => {
|
||||
try {
|
||||
step();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Direct-local agent audit writer lifecycle shared by CLI entrypoints. */
|
||||
import { createAuditEventRecorder } from "../audit/audit-recorder.js";
|
||||
import {
|
||||
configureExecutionIdentityAdmissionSink,
|
||||
hasExecutionIdentityAdmissionSink,
|
||||
} from "../audit/execution-identity-admission.js";
|
||||
|
||||
/** Own one direct-process writer unless a surrounding runtime already owns it. */
|
||||
export function startAgentLocalAuditWriter(
|
||||
options: { stateDir?: string } = {},
|
||||
): (() => Promise<void>) | undefined {
|
||||
if (hasExecutionIdentityAdmissionSink()) {
|
||||
return undefined;
|
||||
}
|
||||
const recorder = createAuditEventRecorder({
|
||||
messageMode: "off",
|
||||
...(options.stateDir ? { stateDir: options.stateDir } : {}),
|
||||
});
|
||||
const clearSink = configureExecutionIdentityAdmissionSink(recorder.recordExecutionIdentity);
|
||||
return async () => {
|
||||
clearSink();
|
||||
await recorder.stop();
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
configureExecutionIdentityAdmissionSink,
|
||||
hasExecutionIdentityAdmissionSink,
|
||||
} from "../audit/execution-identity-admission.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loggingState } from "../logging/state.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -39,6 +43,11 @@ const agentCommand = vi.hoisted(() => vi.fn());
|
||||
const agentModuleLoadCount = vi.hoisted(() => vi.fn());
|
||||
const loadAgentSessionModuleMock = vi.hoisted(() => vi.fn());
|
||||
const startOneShotDiagnosticsExporters = vi.hoisted(() => vi.fn());
|
||||
const auditRecorderMocks = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
recordExecutionIdentity: vi.fn(() => true),
|
||||
stop: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
@@ -69,6 +78,7 @@ function mockConfig(storePath: string, overrides?: Partial<OpenClawConfig>) {
|
||||
...overrides?.session,
|
||||
},
|
||||
gateway: overrides?.gateway,
|
||||
logging: overrides?.logging,
|
||||
};
|
||||
loadConfig.mockReturnValue(config);
|
||||
loadConfigWithShellEnvFallback.mockResolvedValue(config);
|
||||
@@ -248,6 +258,15 @@ vi.mock("./agent.js", () => {
|
||||
vi.mock("../plugins/one-shot-diagnostics.js", () => ({
|
||||
startOneShotDiagnosticsExporters,
|
||||
}));
|
||||
vi.mock("../audit/audit-recorder.js", () => ({
|
||||
createAuditEventRecorder: (...args: unknown[]) => {
|
||||
auditRecorderMocks.create(...args);
|
||||
return {
|
||||
recordExecutionIdentity: auditRecorderMocks.recordExecutionIdentity,
|
||||
stop: auditRecorderMocks.stop,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
let originalForceConsoleToStderr = false;
|
||||
let zeroTimeoutGatewayRequestMs: number | undefined;
|
||||
@@ -275,6 +294,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
configureExecutionIdentityAdmissionSink(() => false)();
|
||||
agentViaGatewayTesting.setGatewayAbortRetryDelaysMsForTests();
|
||||
loggingState.forceConsoleToStderr = originalForceConsoleToStderr;
|
||||
});
|
||||
@@ -1820,6 +1840,7 @@ describe("agentCliCommand", () => {
|
||||
expect(loadRuntimeConfig).toHaveBeenCalledTimes(1);
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(auditRecorderMocks.create).not.toHaveBeenCalled();
|
||||
const startOrder = requireFirstCallOrder(startOneShotDiagnosticsExporters, "exporter start");
|
||||
const runOrder = requireFirstCallOrder(agentCommand, "embedded agent");
|
||||
const stopOrder = requireFirstCallOrder(stop, "exporter stop");
|
||||
@@ -1828,6 +1849,46 @@ describe("agentCliCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("owns and flushes the opt-in local audit writer without awaiting persistence", async () => {
|
||||
await withTempStore(
|
||||
async () => {
|
||||
agentCommand.mockImplementationOnce(async () => {
|
||||
expect(hasExecutionIdentityAdmissionSink()).toBe(true);
|
||||
return {
|
||||
payloads: [{ text: "local" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof AgentCommand>>;
|
||||
});
|
||||
|
||||
await agentCliCommand({ message: "hi", to: "+1555", local: true }, runtime);
|
||||
|
||||
expect(auditRecorderMocks.create).toHaveBeenCalledWith({ messageMode: "off" });
|
||||
expect(auditRecorderMocks.stop).toHaveBeenCalledOnce();
|
||||
expect(hasExecutionIdentityAdmissionSink()).toBe(false);
|
||||
},
|
||||
{ logging: { audit: { executionIdentity: true } } },
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses an existing lifecycle-owned identity writer for local dispatch", async () => {
|
||||
await withTempStore(
|
||||
async () => {
|
||||
const clearSink = configureExecutionIdentityAdmissionSink(() => true);
|
||||
mockLocalAgentReply();
|
||||
|
||||
await agentCliCommand({ message: "hi", to: "+1555", local: true }, runtime);
|
||||
|
||||
expect(auditRecorderMocks.create).not.toHaveBeenCalled();
|
||||
expect(hasExecutionIdentityAdmissionSink()).toBe(true);
|
||||
clearSink();
|
||||
},
|
||||
{ logging: { audit: { executionIdentity: true } } },
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses stdout diagnostic logs around JSON local embedded runs", async () => {
|
||||
await withTempStore(async () => {
|
||||
const stop = vi.fn(async () => {});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../../packages/gateway-protocol/src/client-info.js";
|
||||
import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope-config.js";
|
||||
import { measureAgentStartup } from "../agents/startup-timing.js";
|
||||
import { isExecutionIdentityCollectionEnabled } from "../audit/audit-config.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { CliDeps } from "../cli/deps.types.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
@@ -126,6 +127,9 @@ const embeddedAgentCommandLoader = createLazyPromiseLoader(
|
||||
() => import("./agent.js").then((module) => module.agentCommand),
|
||||
{ cacheRejections: true },
|
||||
);
|
||||
const localAuditModuleLoader = createLazyPromiseLoader(() => import("./agent-local-audit.js"), {
|
||||
cacheRejections: true,
|
||||
});
|
||||
const agentSessionModuleCache = createLazyPromiseLoader(() => agentSessionModuleLoader(), {
|
||||
cacheRejections: true,
|
||||
});
|
||||
@@ -154,10 +158,11 @@ type EmbeddedRunDiagnosticsOptions = {
|
||||
async function startEmbeddedRunDiagnosticsExporters(
|
||||
runtime: RuntimeEnv,
|
||||
options: EmbeddedRunDiagnosticsOptions,
|
||||
config: OpenClawConfig,
|
||||
): Promise<OneShotDiagnosticsHandle | null> {
|
||||
try {
|
||||
return await startOneShotDiagnosticsExporters({
|
||||
config: await loadRuntimeConfig(),
|
||||
config,
|
||||
suppressStdoutDiagnosticLogs: options.suppressStdoutDiagnosticLogs,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -182,11 +187,24 @@ async function runEmbeddedAgentCommand(
|
||||
const agentCommand = await measureAgentStartup("command-import", () =>
|
||||
embeddedAgentCommandLoader.load(),
|
||||
);
|
||||
const diagnostics = await startEmbeddedRunDiagnosticsExporters(runtime, diagnosticsOptions);
|
||||
const config = await loadRuntimeConfig();
|
||||
const diagnostics = await startEmbeddedRunDiagnosticsExporters(
|
||||
runtime,
|
||||
diagnosticsOptions,
|
||||
config,
|
||||
);
|
||||
let stopLocalAuditWriter: (() => Promise<void>) | undefined;
|
||||
if (isExecutionIdentityCollectionEnabled(config)) {
|
||||
try {
|
||||
stopLocalAuditWriter = (await localAuditModuleLoader.load()).startAgentLocalAuditWriter();
|
||||
} catch {
|
||||
// Admission emits one bounded warning if evidence cannot be queued.
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await agentCommand(opts, runtime, deps);
|
||||
} finally {
|
||||
await diagnostics?.stop();
|
||||
await Promise.all([diagnostics?.stop(), stopLocalAuditWriter?.().catch(() => undefined)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +219,7 @@ const loadReplyPayloadModule = replyPayloadModuleLoader.load;
|
||||
export const agentViaGatewayTesting = {
|
||||
resetLazyImportsForTests(): void {
|
||||
embeddedAgentCommandLoader.clear();
|
||||
localAuditModuleLoader.clear();
|
||||
agentSessionModuleCache.clear();
|
||||
runtimeConfigModuleLoader.clear();
|
||||
replyPayloadModuleLoader.clear();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"
|
||||
// Register shared mocks before imports bind their production exports.
|
||||
import "./agent-command.test-mocks.js";
|
||||
import { testing as acpManagerTesting } from "../acp/control-plane/manager.js";
|
||||
import { executionIdentity } from "../agents/agent-command-execution-identity.js";
|
||||
import * as authProfileStoreModule from "../agents/auth-profiles/store.js";
|
||||
import * as attemptExecutionRuntime from "../agents/command/attempt-execution.runtime.js";
|
||||
import { deliverAgentCommandResult } from "../agents/command/delivery.runtime.js";
|
||||
@@ -446,6 +447,67 @@ describe("agentCommand", () => {
|
||||
).rejects.toThrow("allowModelOverride must be explicitly set for ingress agent runs.");
|
||||
});
|
||||
|
||||
it("strips private recovery identity from runtime-shaped public ingress", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
const record = vi.spyOn(executionIdentity, "record").mockImplementation(() => undefined);
|
||||
const inheritedAdmission = {
|
||||
token: {
|
||||
tokenVersion: 1 as const,
|
||||
contextId: "inherited-context",
|
||||
executionId: "inherited-execution",
|
||||
runId: "public-ingress-run",
|
||||
createdAt: 1,
|
||||
},
|
||||
retryOnly: true,
|
||||
};
|
||||
const priorDescriptor = Object.getOwnPropertyDescriptor(
|
||||
Object.prototype,
|
||||
"executionIdentityAdmission",
|
||||
);
|
||||
// oxlint-disable-next-line no-extend-native -- Simulate a hostile JS plugin's prototype pollution.
|
||||
Object.defineProperty(Object.prototype, "executionIdentityAdmission", {
|
||||
configurable: true,
|
||||
value: inheritedAdmission,
|
||||
});
|
||||
|
||||
try {
|
||||
await agentCommandFromIngress(
|
||||
{
|
||||
message: "public plugin turn",
|
||||
agentId: "main",
|
||||
runId: "public-ingress-run",
|
||||
allowModelOverride: false,
|
||||
executionIdentityAdmission: {
|
||||
token: {
|
||||
tokenVersion: 1,
|
||||
contextId: "forged-context",
|
||||
executionId: "forged-execution",
|
||||
runId: "public-ingress-run",
|
||||
createdAt: 1,
|
||||
},
|
||||
retryOnly: true,
|
||||
},
|
||||
} as never,
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ admission: undefined, runId: "public-ingress-run" }),
|
||||
);
|
||||
} finally {
|
||||
record.mockRestore();
|
||||
if (priorDescriptor) {
|
||||
// oxlint-disable-next-line no-extend-native -- Restore the exact pre-test prototype descriptor.
|
||||
Object.defineProperty(Object.prototype, "executionIdentityAdmission", priorDescriptor);
|
||||
} else {
|
||||
delete (Object.prototype as Record<string, unknown>).executionIdentityAdmission;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a missing harness-owned session before local CLI dispatch", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./audit.js";
|
||||
import type { AuditRunInspectResult } from "../../packages/gateway-protocol/src/index.js";
|
||||
|
||||
type AuditTestEvent = {
|
||||
occurredAt: number;
|
||||
@@ -13,7 +14,11 @@ type AuditTestEvent = {
|
||||
};
|
||||
|
||||
type AuditCommandTestApi = {
|
||||
formatAuditRunInspection(result: AuditRunInspectResult): string[];
|
||||
formatAuditRows(events: readonly AuditTestEvent[]): string[];
|
||||
hasExplainIncompatibleFilters(options: Record<string, unknown>): boolean;
|
||||
parseAuditDecisionLimit(value: string | undefined): number;
|
||||
parseAuditExecutionLimit(value: string | undefined): number;
|
||||
parseAuditLimit(value: string | undefined): number;
|
||||
parseAuditTimestamp(value: string | undefined, flag: string): number | undefined;
|
||||
};
|
||||
@@ -25,9 +30,21 @@ function getTestApi(): AuditCommandTestApi {
|
||||
}
|
||||
|
||||
export const testApi: AuditCommandTestApi = {
|
||||
formatAuditRunInspection(result) {
|
||||
return getTestApi().formatAuditRunInspection(result);
|
||||
},
|
||||
formatAuditRows(events) {
|
||||
return getTestApi().formatAuditRows(events);
|
||||
},
|
||||
hasExplainIncompatibleFilters(options) {
|
||||
return getTestApi().hasExplainIncompatibleFilters(options);
|
||||
},
|
||||
parseAuditDecisionLimit(value) {
|
||||
return getTestApi().parseAuditDecisionLimit(value);
|
||||
},
|
||||
parseAuditExecutionLimit(value) {
|
||||
return getTestApi().parseAuditExecutionLimit(value);
|
||||
},
|
||||
parseAuditLimit(value) {
|
||||
return getTestApi().parseAuditLimit(value);
|
||||
},
|
||||
|
||||
@@ -26,6 +26,13 @@ function unknownActivityMethodError() {
|
||||
});
|
||||
}
|
||||
|
||||
function unknownRunInspectMethodError() {
|
||||
return Object.assign(new Error("unknown method: audit.run.inspect"), {
|
||||
name: "GatewayClientRequestError",
|
||||
gatewayCode: "INVALID_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
function oldGatewayUnknownMethodScopeError() {
|
||||
return Object.assign(new Error("missing scope: operator.admin"), {
|
||||
name: "GatewayClientRequestError",
|
||||
@@ -308,3 +315,218 @@ describe("audit command gateway compatibility", () => {
|
||||
expect(callGateway).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audit run explanation", () => {
|
||||
beforeEach(() => {
|
||||
callGateway.mockReset();
|
||||
vi.mocked(runtime.log).mockClear();
|
||||
});
|
||||
|
||||
it("rejects --execution without --explain before querying the Gateway", async () => {
|
||||
await expect(auditListCommand({ executionId: "execution-1" }, runtime)).rejects.toThrow(
|
||||
"--execution requires --explain",
|
||||
);
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires one exact run and keeps decision queries bounded", async () => {
|
||||
await expect(auditListCommand({ explain: true }, runtime)).rejects.toThrow(
|
||||
"exactly one of --run <id> or --execution <id>",
|
||||
);
|
||||
await expect(
|
||||
auditListCommand({ explain: true, runId: "run-1", executionId: "execution-1" }, runtime),
|
||||
).rejects.toThrow("exactly one");
|
||||
await expect(
|
||||
auditListCommand({ explain: true, runId: "run-1", agentId: "main" }, runtime),
|
||||
).rejects.toThrow("remove activity-list filters");
|
||||
expect(testApi.parseAuditDecisionLimit(undefined)).toBe(50);
|
||||
expect(testApi.parseAuditDecisionLimit("100")).toBe(100);
|
||||
expect(() => testApi.parseAuditDecisionLimit("101")).toThrow("with --explain");
|
||||
expect(testApi.parseAuditExecutionLimit("50")).toBe(50);
|
||||
expect(() => testApi.parseAuditExecutionLimit("51")).toThrow("run discovery");
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queries audit.run.inspect and renders all identity fields with explicit state", async () => {
|
||||
const hmacRef = `hmac-sha256:v1:${"a".repeat(32)}:${"b".repeat(64)}`;
|
||||
callGateway.mockResolvedValue({
|
||||
schemaVersion: 1,
|
||||
run: { runId: "run-1", executionId: "execution-1", status: "known" },
|
||||
identity: {
|
||||
state: "present",
|
||||
context: {
|
||||
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"],
|
||||
},
|
||||
},
|
||||
decisions: [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
receiptId: "context-1:admission",
|
||||
contextId: "context-1",
|
||||
executionId: "execution-1",
|
||||
runId: "run-1",
|
||||
occurredAt: 1,
|
||||
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: "context-1",
|
||||
decisionBoundary: "agent-command.run-admission",
|
||||
},
|
||||
missingEvidence: ["invoker.principal"],
|
||||
remediation: [{ code: "no_claim", text: "Treat this receipt as attribution only." }],
|
||||
},
|
||||
],
|
||||
coverage: { state: "unattributed", missingEvidence: ["invoker.principal"] },
|
||||
});
|
||||
|
||||
await auditListCommand({ explain: true, runId: "run-1", cursor: "1", limit: "25" }, runtime);
|
||||
|
||||
expect(callGateway).toHaveBeenCalledWith({
|
||||
method: "audit.run.inspect",
|
||||
params: {
|
||||
runId: "run-1",
|
||||
executionCursor: "1",
|
||||
executionLimit: 25,
|
||||
decisionCursor: "1",
|
||||
decisionLimit: 25,
|
||||
},
|
||||
});
|
||||
const output = vi.mocked(runtime.log).mock.calls.flat().join("\n");
|
||||
for (const label of [
|
||||
"Trust domain [present]",
|
||||
"Invoker [absent]",
|
||||
"Ingress [present]",
|
||||
"Agent principal [present]",
|
||||
"Agent definition [present]",
|
||||
"Runtime instance [present]",
|
||||
"Represented subject [absent]",
|
||||
"Sponsor [absent]",
|
||||
"Applicable grants [absent]",
|
||||
"Assurance [present]",
|
||||
"Parent [absent]",
|
||||
]) {
|
||||
expect(output).toContain(label);
|
||||
}
|
||||
expect(output).toContain("not-applicable");
|
||||
expect(output).toContain("run_admission_identity_not_evaluated");
|
||||
});
|
||||
|
||||
it("renders ambiguous run discovery and selects an exact execution", async () => {
|
||||
callGateway.mockResolvedValueOnce({
|
||||
schemaVersion: 1,
|
||||
run: { runId: "session-run", 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 candidate with openclaw audit --execution <id> --explain.",
|
||||
},
|
||||
],
|
||||
},
|
||||
decisions: [],
|
||||
coverage: { state: "unknown", missingEvidence: ["execution.selection"] },
|
||||
});
|
||||
|
||||
await auditListCommand({ explain: true, runId: "session-run" }, runtime);
|
||||
const output = vi.mocked(runtime.log).mock.calls.flat().join("\n");
|
||||
expect(output).toContain("Candidate: execution-1");
|
||||
expect(output).toContain("--execution <id> --explain");
|
||||
|
||||
callGateway.mockReset();
|
||||
callGateway.mockResolvedValue({
|
||||
schemaVersion: 1,
|
||||
run: { runId: "session-run", executionId: "execution-2", status: "unknown" },
|
||||
identity: {
|
||||
state: "unknown",
|
||||
reasonCode: "execution_not_found",
|
||||
missingEvidence: ["identity.context"],
|
||||
remediation: [{ code: "verify_execution_id", text: "Verify the exact execution id." }],
|
||||
},
|
||||
decisions: [],
|
||||
coverage: { state: "unknown", missingEvidence: ["identity.context"] },
|
||||
});
|
||||
await auditListCommand({ explain: true, executionId: "execution-2", json: true }, runtime);
|
||||
expect(callGateway).toHaveBeenCalledWith({
|
||||
method: "audit.run.inspect",
|
||||
params: { executionId: "execution-2", decisionLimit: 50 },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders expired identity as unsupported without context fields or decisions", async () => {
|
||||
callGateway.mockResolvedValue({
|
||||
schemaVersion: 1,
|
||||
run: { runId: "expired-run", status: "known" },
|
||||
identity: {
|
||||
state: "unsupported",
|
||||
reasonCode: "identity_context_unavailable",
|
||||
missingEvidence: ["identity.context"],
|
||||
remediation: [
|
||||
{
|
||||
code: "run_again_after_expiry",
|
||||
text: "This run's identity context is outside the 30-day retention window; run the operation again to record a new context.",
|
||||
},
|
||||
],
|
||||
},
|
||||
decisions: [],
|
||||
coverage: { state: "unsupported", missingEvidence: ["identity.context"] },
|
||||
});
|
||||
|
||||
await auditListCommand({ explain: true, runId: "expired-run" }, runtime);
|
||||
|
||||
const output = vi.mocked(runtime.log).mock.calls.flat().join("\n");
|
||||
expect(output).toContain("Ingress [unsupported]");
|
||||
expect(output).toContain("none [absent]");
|
||||
expect(output).toContain("outside the 30-day retention window");
|
||||
expect(output).not.toContain("Context:");
|
||||
expect(output).not.toContain("run_admission_identity_not_evaluated");
|
||||
});
|
||||
|
||||
it("returns an explicit upgrade state from an older Gateway", async () => {
|
||||
callGateway.mockRejectedValue(unknownRunInspectMethodError());
|
||||
|
||||
await auditListCommand({ explain: true, runId: "old-run", json: true }, runtime);
|
||||
|
||||
expect(callGateway).toHaveBeenCalledTimes(1);
|
||||
const output = vi.mocked(runtime.log).mock.calls.flat().join("\n");
|
||||
expect(output).toContain('"state": "unsupported"');
|
||||
expect(output).toContain("gateway_upgrade_required");
|
||||
expect(output).toContain("upgrade_gateway");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,11 @@ import type {
|
||||
AuditActivityListResult,
|
||||
AuditListParams,
|
||||
AuditListResult,
|
||||
AuditRunInspectParams,
|
||||
AuditRunInspectResult,
|
||||
DecisionReceiptV1,
|
||||
ExecutionIdentityContextV1,
|
||||
PrincipalRefV1,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
|
||||
import { parseAbsoluteTimeMs } from "../cron/parse.js";
|
||||
@@ -15,11 +20,15 @@ import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
|
||||
|
||||
const DEFAULT_AUDIT_LIMIT = 100;
|
||||
const MAX_AUDIT_LIMIT = 500;
|
||||
const DEFAULT_AUDIT_DECISION_LIMIT = 50;
|
||||
const MAX_AUDIT_DECISION_LIMIT = 100;
|
||||
const MAX_AUDIT_EXECUTION_LIMIT = 50;
|
||||
|
||||
export type AuditListCommandOptions = {
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
executionId?: string;
|
||||
kind?: AuditActivityListParams["kind"];
|
||||
status?: AuditActivityListParams["status"];
|
||||
direction?: AuditActivityListParams["direction"];
|
||||
@@ -28,6 +37,7 @@ export type AuditListCommandOptions = {
|
||||
before?: string;
|
||||
cursor?: string;
|
||||
limit?: string;
|
||||
explain?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
@@ -72,6 +82,29 @@ function parseAuditLimit(value: string | undefined): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseAuditDecisionLimit(value: string | undefined): number {
|
||||
if (!value) {
|
||||
return DEFAULT_AUDIT_DECISION_LIMIT;
|
||||
}
|
||||
const parsed = parseStrictPositiveInteger(value);
|
||||
if (parsed === undefined || parsed > MAX_AUDIT_DECISION_LIMIT) {
|
||||
throw new Error(
|
||||
`--limit must be between 1 and ${String(MAX_AUDIT_DECISION_LIMIT)} with --explain.`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseAuditExecutionLimit(value: string | undefined): number {
|
||||
const parsed = parseAuditDecisionLimit(value);
|
||||
if (parsed > MAX_AUDIT_EXECUTION_LIMIT) {
|
||||
throw new Error(
|
||||
`--limit must be between 1 and ${String(MAX_AUDIT_EXECUTION_LIMIT)} for run discovery.`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function short(value: string | undefined, maxChars: number): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -119,6 +152,16 @@ function isUnsupportedActivityMethodError(value: unknown): value is Error {
|
||||
);
|
||||
}
|
||||
|
||||
function isUnsupportedRunInspectMethodError(value: unknown): value is Error {
|
||||
return (
|
||||
value instanceof Error &&
|
||||
value.name === "GatewayClientRequestError" &&
|
||||
(value as Error & { gatewayCode?: unknown }).gatewayCode === "INVALID_REQUEST" &&
|
||||
(value.message === "unknown method: audit.run.inspect" ||
|
||||
value.message === "missing scope: operator.admin")
|
||||
);
|
||||
}
|
||||
|
||||
function hasMessageSpecificFilters(options: AuditListCommandOptions): boolean {
|
||||
return (
|
||||
options.kind === "message" || options.direction !== undefined || options.channel !== undefined
|
||||
@@ -171,11 +214,279 @@ async function queryAuditActivity(
|
||||
}
|
||||
}
|
||||
|
||||
function unsupportedRunInspection(
|
||||
selector: { runId: string } | { executionId: string },
|
||||
): AuditRunInspectResult {
|
||||
const missingEvidence = ["identity.context"];
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
run: { ...selector, status: "unknown" },
|
||||
identity: {
|
||||
state: "unsupported",
|
||||
reasonCode: "gateway_upgrade_required",
|
||||
missingEvidence,
|
||||
remediation: [
|
||||
{
|
||||
code: "upgrade_gateway",
|
||||
text: "Upgrade the Gateway, run the agent again, and repeat this exact-run inspection.",
|
||||
},
|
||||
],
|
||||
},
|
||||
decisions: [],
|
||||
coverage: { state: "unsupported", missingEvidence },
|
||||
};
|
||||
}
|
||||
|
||||
async function queryAuditRunInspection(
|
||||
params: AuditRunInspectParams,
|
||||
): Promise<AuditRunInspectResult> {
|
||||
try {
|
||||
return await callGateway<AuditRunInspectResult>({ method: "audit.run.inspect", params });
|
||||
} catch (error) {
|
||||
if (!isUnsupportedRunInspectMethodError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return unsupportedRunInspection(
|
||||
typeof params.runId === "string"
|
||||
? { runId: params.runId }
|
||||
: { executionId: params.executionId! },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function safe(value: string | undefined): string {
|
||||
return sanitizeTerminalText(value ?? "") || "-";
|
||||
}
|
||||
|
||||
function principalText(principal: PrincipalRefV1): string {
|
||||
return `${safe(principal.kind)} ${safe(principal.principalRef)} in ${safe(principal.domainRef)}`;
|
||||
}
|
||||
|
||||
function fieldLine(label: string, state: string, value?: string): string {
|
||||
return ` ${label} [${safe(state)}]${value ? `: ${safe(value)}` : ""}`;
|
||||
}
|
||||
|
||||
const IDENTITY_FIELD_LABELS = [
|
||||
"Trust domain",
|
||||
"Invoker",
|
||||
"Ingress",
|
||||
"Agent principal",
|
||||
"Agent definition",
|
||||
"Runtime instance",
|
||||
"Represented subject",
|
||||
"Sponsor",
|
||||
"Applicable grants",
|
||||
"Assurance",
|
||||
] as const;
|
||||
|
||||
function contextIdentityLines(context: ExecutionIdentityContextV1): string[] {
|
||||
const grants = context.applicableGrants.map((grant) => `${grant.grantRef} [${grant.state}]`);
|
||||
const assurance = context.assurance.map(
|
||||
(item) => `${item.kind} ${item.evidenceRef} [${item.strength}]`,
|
||||
);
|
||||
return [
|
||||
fieldLine(
|
||||
"Trust domain",
|
||||
context.trustDomain.state,
|
||||
`${context.trustDomain.kind} ${context.trustDomain.domainRef}`,
|
||||
),
|
||||
fieldLine(
|
||||
"Invoker",
|
||||
context.invoker.state,
|
||||
context.invoker.principal ? principalText(context.invoker.principal) : undefined,
|
||||
),
|
||||
fieldLine(
|
||||
"Ingress",
|
||||
context.ingress.state,
|
||||
`${context.ingress.kind} at ${context.ingress.boundary}${
|
||||
context.ingress.sourceRef ? ` (${context.ingress.sourceRef})` : ""
|
||||
}`,
|
||||
),
|
||||
fieldLine("Agent principal", "present", principalText(context.agentPrincipal)),
|
||||
fieldLine(
|
||||
"Agent definition",
|
||||
context.agentDefinition.state,
|
||||
`${context.agentDefinition.definitionRef}${
|
||||
context.agentDefinition.revisionRef ? ` @ ${context.agentDefinition.revisionRef}` : ""
|
||||
}`,
|
||||
),
|
||||
fieldLine(
|
||||
"Runtime instance",
|
||||
context.runtimeInstance.state,
|
||||
`${context.runtimeInstance.kind} ${context.runtimeInstance.runtimeRef}`,
|
||||
),
|
||||
fieldLine(
|
||||
"Represented subject",
|
||||
context.representedSubject?.state ?? "absent",
|
||||
context.representedSubject ? principalText(context.representedSubject.principal) : undefined,
|
||||
),
|
||||
fieldLine(
|
||||
"Sponsor",
|
||||
context.sponsor?.state ?? "absent",
|
||||
context.sponsor ? principalText(context.sponsor.principal) : undefined,
|
||||
),
|
||||
fieldLine("Applicable grants", grants.length > 0 ? "present" : "absent", grants.join(", ")),
|
||||
fieldLine("Assurance", assurance.length > 0 ? "present" : "absent", assurance.join(", ")),
|
||||
];
|
||||
}
|
||||
|
||||
function unavailableIdentityLines(state: "unknown" | "unsupported"): string[] {
|
||||
return IDENTITY_FIELD_LABELS.map((label) => fieldLine(label, state));
|
||||
}
|
||||
|
||||
function decisionLines(receipt: DecisionReceiptV1): string[] {
|
||||
return [
|
||||
` ${safe(receipt.action.family)}.${safe(receipt.action.operation)}: ${safe(receipt.decision.outcome)}`,
|
||||
` Coverage: ${safe(receipt.enforcement.coverageState)}`,
|
||||
` Reason: ${safe(receipt.decision.reasonCode)}`,
|
||||
` Source: ${safe(receipt.source.owner)} at ${safe(receipt.source.decisionBoundary)}`,
|
||||
...(receipt.action.summary ? [` Summary: ${safe(receipt.action.summary)}`] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function formatAuditRunInspection(result: AuditRunInspectResult): string[] {
|
||||
const selectorText = result.run.executionId
|
||||
? `Execution ${safe(result.run.executionId)}${result.run.runId ? ` (run ${safe(result.run.runId)})` : ""}`
|
||||
: `Run ${safe(result.run.runId)}`;
|
||||
const lines = [
|
||||
`${selectorText}: ${safe(result.run.status)} (${safe(result.coverage.state)})`,
|
||||
"",
|
||||
"Identity",
|
||||
];
|
||||
if (result.identity.state === "present") {
|
||||
const identityLines = contextIdentityLines(result.identity.context);
|
||||
lines.push(
|
||||
` Context: ${safe(result.identity.context.contextId)}`,
|
||||
` Created: ${timestampMsToIsoString(result.identity.context.createdAt) ?? String(result.identity.context.createdAt)}`,
|
||||
...identityLines.slice(0, 8),
|
||||
"",
|
||||
"Authority",
|
||||
...identityLines.slice(8),
|
||||
"",
|
||||
"Lineage",
|
||||
result.identity.context.lineage
|
||||
? fieldLine(
|
||||
"Parent",
|
||||
"present",
|
||||
result.identity.context.lineage.parentRunId ??
|
||||
result.identity.context.lineage.parentContextId ??
|
||||
`depth ${String(result.identity.context.lineage.depth)}`,
|
||||
)
|
||||
: fieldLine("Parent", "absent"),
|
||||
);
|
||||
} else if (result.identity.state === "ambiguous") {
|
||||
lines.push(
|
||||
` Reason: ${safe(result.identity.reasonCode)}`,
|
||||
...result.identity.candidates.map(
|
||||
(candidate) =>
|
||||
` Candidate: ${safe(candidate.executionId)} (context ${safe(candidate.contextId)}, ${timestampMsToIsoString(candidate.createdAt) ?? String(candidate.createdAt)})`,
|
||||
),
|
||||
"",
|
||||
"Authority",
|
||||
fieldLine("Selection", "unknown"),
|
||||
"",
|
||||
"Lineage",
|
||||
fieldLine("Parent", "unknown"),
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
` Reason: ${safe(result.identity.reasonCode)}`,
|
||||
...unavailableIdentityLines(result.identity.state).slice(0, 8),
|
||||
"",
|
||||
"Authority",
|
||||
...unavailableIdentityLines(result.identity.state).slice(8),
|
||||
"",
|
||||
"Lineage",
|
||||
fieldLine("Parent", result.identity.state),
|
||||
);
|
||||
}
|
||||
lines.push("", "Decisions");
|
||||
if (result.decisions.length === 0) {
|
||||
lines.push(" none [absent]");
|
||||
} else {
|
||||
for (const receipt of result.decisions) {
|
||||
lines.push(...decisionLines(receipt));
|
||||
}
|
||||
}
|
||||
lines.push("", "Missing evidence");
|
||||
lines.push(
|
||||
...(result.coverage.missingEvidence.length > 0
|
||||
? result.coverage.missingEvidence.map((item) => ` - ${safe(item)}`)
|
||||
: [" none"]),
|
||||
);
|
||||
const remediation = [
|
||||
...(result.identity.state === "present" ? [] : result.identity.remediation),
|
||||
...result.decisions.flatMap((decision) => decision.remediation),
|
||||
];
|
||||
lines.push("", "Next steps");
|
||||
lines.push(
|
||||
...(remediation.length > 0
|
||||
? [...new Map(remediation.map((item) => [item.code, item])).values()].map(
|
||||
(item) => ` - ${safe(item.text)}`,
|
||||
)
|
||||
: [" none"]),
|
||||
);
|
||||
if (result.nextDecisionCursor) {
|
||||
lines.push(` More decisions: --cursor ${safe(result.nextDecisionCursor)}`);
|
||||
}
|
||||
if (result.nextExecutionCursor) {
|
||||
lines.push(` More executions: --cursor ${safe(result.nextExecutionCursor)}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function hasExplainIncompatibleFilters(options: AuditListCommandOptions): boolean {
|
||||
return Boolean(
|
||||
options.agentId ||
|
||||
options.sessionKey ||
|
||||
options.kind ||
|
||||
options.status ||
|
||||
options.direction ||
|
||||
options.channel ||
|
||||
options.after ||
|
||||
options.before,
|
||||
);
|
||||
}
|
||||
|
||||
/** Query one stable page. JSON output is a bounded export with its next cursor. */
|
||||
export async function auditListCommand(
|
||||
options: AuditListCommandOptions,
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<void> {
|
||||
if (options.explain) {
|
||||
const runId = options.runId?.trim();
|
||||
const executionId = options.executionId?.trim();
|
||||
if (Boolean(runId) === Boolean(executionId)) {
|
||||
throw new Error("Pass exactly one of --run <id> or --execution <id> with --explain.");
|
||||
}
|
||||
if (hasExplainIncompatibleFilters(options)) {
|
||||
throw new Error(
|
||||
"--explain accepts only --run or --execution, plus --limit, --cursor, and --json; remove activity-list filters.",
|
||||
);
|
||||
}
|
||||
const result = await queryAuditRunInspection({
|
||||
...(executionId
|
||||
? { executionId }
|
||||
: {
|
||||
runId: runId!,
|
||||
executionLimit: parseAuditExecutionLimit(options.limit),
|
||||
...(options.cursor ? { executionCursor: options.cursor } : {}),
|
||||
}),
|
||||
decisionLimit: parseAuditDecisionLimit(options.limit),
|
||||
...(options.cursor ? { decisionCursor: options.cursor } : {}),
|
||||
});
|
||||
if (options.json) {
|
||||
writeRuntimeJson(runtime, result);
|
||||
return;
|
||||
}
|
||||
for (const line of formatAuditRunInspection(result)) {
|
||||
runtime.log(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (options.executionId) {
|
||||
throw new Error("--execution requires --explain.");
|
||||
}
|
||||
validateAuditKind(options.kind);
|
||||
const after = parseAuditTimestamp(options.after, "--after");
|
||||
const before = parseAuditTimestamp(options.before, "--before");
|
||||
@@ -212,6 +523,11 @@ const testApi = {
|
||||
formatAuditRows,
|
||||
hasMessageSpecificFilters,
|
||||
isUnsupportedActivityMethodError,
|
||||
isUnsupportedRunInspectMethodError,
|
||||
formatAuditRunInspection,
|
||||
hasExplainIncompatibleFilters,
|
||||
parseAuditDecisionLimit,
|
||||
parseAuditExecutionLimit,
|
||||
parseAuditLimit,
|
||||
parseAuditTimestamp,
|
||||
toLegacyAuditListParams,
|
||||
|
||||
@@ -42,6 +42,8 @@ export const CORE_FIELD_HELP: Record<string, string> = {
|
||||
"Bounded metadata-only audit history for operator review. Run and tool records are enabled by default; message lifecycle metadata is a separate privacy-sensitive opt-in. The background writer is best-effort rather than a lossless compliance archive.",
|
||||
"logging.audit.enabled":
|
||||
"Records new run, tool, and enabled message audit events. Default: true. Disabling event inserts does not immediately delete existing records; retained rows remain queryable until they expire.",
|
||||
"logging.audit.executionIdentity":
|
||||
"Retains bounded execution-identity attribution for exact-run inspection. Default: false. Requires logging.audit.enabled; restart the Gateway after changing it.",
|
||||
"logging.audit.messages":
|
||||
'Controls content-free message lifecycle records: "off" (default), "direct" for known direct conversations only, or "all" for direct, group, channel, and unknown conversation kinds. Both logging.audit.enabled and logging.audit.messages are startup-scoped; restart the Gateway after changing either setting.',
|
||||
diagnostics:
|
||||
|
||||
@@ -28,6 +28,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"wizard.securityAcknowledgedAt": "Wizard Security Acknowledgement Timestamp",
|
||||
"logging.audit": "Audit Ledger",
|
||||
"logging.audit.enabled": "Audit Ledger Enabled",
|
||||
"logging.audit.executionIdentity": "Execution Identity Audit",
|
||||
"logging.audit.messages": "Message Audit Scope",
|
||||
diagnostics: "Diagnostics",
|
||||
"diagnostics.otel": "OpenTelemetry",
|
||||
|
||||
@@ -5,6 +5,14 @@ export type MainRestartRecoveryState = {
|
||||
revision: number;
|
||||
/** Attempts charged when their reservation is persisted, before dispatch. */
|
||||
chargedAttempts: number;
|
||||
/** Private safe token for one recovered outer turn; raw identity refs never enter session state. */
|
||||
executionIdentity?: {
|
||||
tokenVersion: 1;
|
||||
contextId: string;
|
||||
executionId: string;
|
||||
runId: string;
|
||||
createdAt: number;
|
||||
};
|
||||
reservation?: {
|
||||
runId: string;
|
||||
attempt: number;
|
||||
|
||||
@@ -324,6 +324,11 @@ export type AuditConfig = {
|
||||
* records stay readable until they expire.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Retain bounded execution-identity attribution for exact-run inspection.
|
||||
* Default: false. Requires the audit ledger and takes effect after Gateway restart.
|
||||
*/
|
||||
executionIdentity?: boolean;
|
||||
/**
|
||||
* Record content-free message lifecycle metadata. `direct` records only
|
||||
* known direct conversations; `all` also records group, channel, and
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OpenClawSchema } from "./zod-schema.js";
|
||||
|
||||
describe("logging.audit.executionIdentity", () => {
|
||||
it("accepts only the explicit boolean config surface", () => {
|
||||
expect(
|
||||
OpenClawSchema.safeParse({
|
||||
logging: { audit: { executionIdentity: true } },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
OpenClawSchema.safeParse({
|
||||
logging: { audit: { executionIdentity: false } },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
OpenClawSchema.safeParse({
|
||||
logging: { audit: { executionIdentity: "true" } },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
OpenClawSchema.safeParse({
|
||||
logging: { audit: { execution_identity: true } },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -117,6 +117,7 @@ export const OpenClawSchemaShape = {
|
||||
audit: z
|
||||
.strictObject({
|
||||
enabled: z.boolean().optional(),
|
||||
executionIdentity: z.boolean().optional(),
|
||||
messages: z.union([z.literal("off"), z.literal("direct"), z.literal("all")]).optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -8,10 +8,11 @@ import type { SessionScope } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
|
||||
const agentCommand = vi.fn();
|
||||
const localAgentCommand = vi.fn();
|
||||
|
||||
vi.mock("../commands/agent.js", () => ({
|
||||
agentCommand,
|
||||
agentCommandFromIngress: agentCommand,
|
||||
agentCommand: localAgentCommand,
|
||||
agentCommandFromSystem: agentCommand,
|
||||
}));
|
||||
|
||||
const { runBootOnce } = await import("./boot.js");
|
||||
@@ -131,6 +132,7 @@ describe("runBootOnce", () => {
|
||||
}),
|
||||
).resolves.toEqual({ status: "ran" });
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
expect(localAgentCommand).not.toHaveBeenCalled();
|
||||
call = requireAgentCall();
|
||||
},
|
||||
);
|
||||
|
||||
+3
-2
@@ -11,7 +11,7 @@ import {
|
||||
} from "../agents/internal-runtime-context.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import type { CliDeps } from "../cli/deps.types.js";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import { agentCommandFromSystem } from "../commands/agent.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentMainSessionKey,
|
||||
@@ -150,7 +150,7 @@ export async function runBootOnce(params: {
|
||||
// same session key. Refs #53732.
|
||||
setBootEchoContextForSession(sessionKey, message);
|
||||
try {
|
||||
await agentCommand(
|
||||
await agentCommandFromSystem(
|
||||
{
|
||||
message,
|
||||
sessionKey,
|
||||
@@ -158,6 +158,7 @@ export async function runBootOnce(params: {
|
||||
deliver: false,
|
||||
suppressPromptPersistence: true,
|
||||
},
|
||||
{ boundary: "gateway.boot" },
|
||||
bootRuntime,
|
||||
params.deps,
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ describe("method scope resolution", () => {
|
||||
["sessions.resolve", ["operator.read"]],
|
||||
["tasks.list", ["operator.read"]],
|
||||
["audit.activity.list", ["operator.read"]],
|
||||
["audit.run.inspect", ["operator.read"]],
|
||||
["audit.list", ["operator.read"]],
|
||||
["users.list", ["operator.read"]],
|
||||
["users.self", ["operator.write"]],
|
||||
@@ -636,6 +637,16 @@ describe("operator scope authorization", () => {
|
||||
expect(authorizeOperatorScopesForMethod(method, scopes)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("authorizes execution identity inspection with operator.read and rejects unrelated scopes", () => {
|
||||
expect(authorizeOperatorScopesForMethod("audit.run.inspect", ["operator.read"])).toEqual({
|
||||
allowed: true,
|
||||
});
|
||||
expect(authorizeOperatorScopesForMethod("audit.run.inspect", ["operator.approvals"])).toEqual({
|
||||
allowed: false,
|
||||
missingScope: "operator.read",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires operator.write for write methods", () => {
|
||||
expect(authorizeOperatorScopesForMethod("send", ["operator.read"])).toEqual({
|
||||
allowed: false,
|
||||
|
||||
@@ -38,6 +38,7 @@ const CURRENT_TRAIN_METHODS = [
|
||||
"agents.workspace.get",
|
||||
"audit.list",
|
||||
"audit.activity.list",
|
||||
"audit.run.inspect",
|
||||
"board.widget.appView",
|
||||
"tts.speak",
|
||||
"environments.list",
|
||||
|
||||
@@ -484,6 +484,8 @@ const CORE_GATEWAY_METHOD_SPECS = [
|
||||
// Additive task recovery RPCs append so older advertised method indices stay stable.
|
||||
["tasks.retry", "tasks", "operator.write", "2026.7"],
|
||||
["tasks.dismiss", "tasks", "operator.write", "2026.7"],
|
||||
// Additive audit inspection appends so older advertised method indices stay stable.
|
||||
["audit.run.inspect", "audit", "operator.read", "2026.7"],
|
||||
] as const satisfies readonly CoreGatewayMethodSpecRow[];
|
||||
|
||||
export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>;
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("listGatewayMethods", () => {
|
||||
});
|
||||
|
||||
it("appends new methods after model probing without shifting older method indices", () => {
|
||||
expect(listGatewayMethods().slice(-30)).toEqual([
|
||||
expect(listGatewayMethods().slice(-31)).toEqual([
|
||||
"models.probe",
|
||||
"migrations.memory.plan",
|
||||
"migrations.memory.apply",
|
||||
@@ -97,6 +97,7 @@ describe("listGatewayMethods", () => {
|
||||
"hooks.status",
|
||||
"tasks.retry",
|
||||
"tasks.dismiss",
|
||||
"audit.run.inspect",
|
||||
]);
|
||||
const methods = listGatewayMethods();
|
||||
expect(methods.indexOf("node.pluginSurface.refresh")).toBe(
|
||||
@@ -134,6 +135,8 @@ describe("listGatewayMethods", () => {
|
||||
it("advertises the versioned activity audit method", () => {
|
||||
expect(listGatewayMethods()).toContain("audit.activity.list");
|
||||
expect(coreGatewayHandlers["audit.activity.list"]).toBeTypeOf("function");
|
||||
expect(listGatewayMethods()).toContain("audit.run.inspect");
|
||||
expect(coreGatewayHandlers["audit.run.inspect"]).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("does not advertise hidden core handlers", () => {
|
||||
@@ -162,7 +165,7 @@ describe("listGatewayMethods", () => {
|
||||
"exec.approval.get",
|
||||
]);
|
||||
expect(methods).toContain("tts.speak");
|
||||
expect(coreMethods.slice(-37)).toEqual([
|
||||
expect(coreMethods.slice(-38)).toEqual([
|
||||
"sessions.catalog.continue",
|
||||
"sessions.catalog.archive",
|
||||
"approval.get",
|
||||
@@ -200,9 +203,11 @@ describe("listGatewayMethods", () => {
|
||||
"hooks.status",
|
||||
"tasks.retry",
|
||||
"tasks.dismiss",
|
||||
"audit.run.inspect",
|
||||
]);
|
||||
expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak"));
|
||||
expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1);
|
||||
expect(methods.indexOf("audit.run.inspect")).toBe(methods.indexOf("tasks.dismiss") + 1);
|
||||
});
|
||||
|
||||
it("advertises the versioned Talk session RPCs", () => {
|
||||
|
||||
@@ -358,7 +358,11 @@ describe("agent request Swarm preflight", () => {
|
||||
});
|
||||
|
||||
describe("agent request restart recovery preflight", () => {
|
||||
function runRestartRecoveryPreflight(backend: boolean, sourceTool: string) {
|
||||
function runRestartRecoveryPreflight(
|
||||
backend: boolean,
|
||||
sourceTool: string,
|
||||
internalExecutionIdentityRetry?: boolean,
|
||||
) {
|
||||
const respond = vi.fn();
|
||||
const result = prepareAgentRequestPreflight({
|
||||
params: {
|
||||
@@ -366,6 +370,7 @@ describe("agent request restart recovery preflight", () => {
|
||||
idempotencyKey: "restart-recovery-run",
|
||||
forceRestartSafeTools: true,
|
||||
forceCodeModeTools: true,
|
||||
...(internalExecutionIdentityRetry !== undefined ? { internalExecutionIdentityRetry } : {}),
|
||||
inputProvenance: {
|
||||
kind: "internal_system",
|
||||
sourceSessionKey: "agent:main:main",
|
||||
@@ -385,12 +390,26 @@ describe("agent request restart recovery preflight", () => {
|
||||
}
|
||||
|
||||
it("accepts the Code Mode override only for backend restart recovery", () => {
|
||||
const accepted = runRestartRecoveryPreflight(true, "main_session_restart_recovery");
|
||||
const accepted = runRestartRecoveryPreflight(true, "main_session_restart_recovery", true);
|
||||
|
||||
expect(accepted.result).toBeDefined();
|
||||
expect(accepted.respond).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects private execution retry mode outside backend restart recovery", () => {
|
||||
const rejected = runRestartRecoveryPreflight(false, "main_session_restart_recovery", true);
|
||||
|
||||
expect(rejected.result).toBeUndefined();
|
||||
expect(rejected.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: "INVALID_REQUEST",
|
||||
message: expect.stringContaining("execution identity retry mode"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ backend: false, sourceTool: "main_session_restart_recovery" },
|
||||
{ backend: true, sourceTool: "other_internal_source" },
|
||||
|
||||
@@ -237,6 +237,17 @@ export function prepareAgentRequestPreflight(
|
||||
const inputProvenance = normalizeInputProvenance(request.inputProvenance);
|
||||
const isRestartRecoveryResumeRun =
|
||||
canUseInternalRuntimeHandoff && isMainSessionRestartRecoveryInputProvenance(inputProvenance);
|
||||
if (request.internalExecutionIdentityRetry !== undefined && !isRestartRecoveryResumeRun) {
|
||||
params.respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"internal execution identity retry mode is reserved for main-session restart recovery.",
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (request.forceCodeModeTools === true && !isRestartRecoveryResumeRun) {
|
||||
params.respond(
|
||||
false,
|
||||
|
||||
@@ -37,6 +37,7 @@ export type AgentRunRequest = {
|
||||
bootstrapContextRunKind?: "default" | "heartbeat" | "cron";
|
||||
acpTurnSource?: "manual_spawn";
|
||||
internalRuntimeHandoffId?: string;
|
||||
internalExecutionIdentityRetry?: boolean;
|
||||
execApprovalFollowupExpectedSessionId?: string;
|
||||
internalEvents?: AgentInternalEvent[];
|
||||
suppressPromptPersistence?: boolean;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAgentRestartRecoveryChannelContext } from "./agent-restart-recovery-context.js";
|
||||
import {
|
||||
resolveAgentRestartRecoveryChannelContext,
|
||||
resolveAgentRestartRecoveryExecutionIdentityAdmission,
|
||||
} from "./agent-restart-recovery-context.js";
|
||||
|
||||
const matchingParams = {
|
||||
canUseInternalRuntimeHandoff: true,
|
||||
@@ -83,3 +86,96 @@ describe("resolveAgentRestartRecoveryChannelContext", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentRestartRecoveryExecutionIdentityAdmission", () => {
|
||||
const token = {
|
||||
tokenVersion: 1 as const,
|
||||
contextId: "context-1",
|
||||
executionId: "execution-1",
|
||||
runId: "recovery-run-1",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
it("rehydrates the durable token for capture and exact retry without runtime reconstruction", () => {
|
||||
const sessionEntry = {
|
||||
...matchingParams.sessionEntry,
|
||||
mainRestartRecovery: {
|
||||
cycleId: "cycle-1",
|
||||
revision: 1,
|
||||
chargedAttempts: 1,
|
||||
executionIdentity: token,
|
||||
},
|
||||
};
|
||||
expect(
|
||||
resolveAgentRestartRecoveryExecutionIdentityAdmission({
|
||||
collectionEnabled: true,
|
||||
isRestartRecoveryResumeRun: true,
|
||||
retryOnly: false,
|
||||
runId: token.runId,
|
||||
sessionEntry,
|
||||
}),
|
||||
).toEqual({ token, retryOnly: false });
|
||||
expect(
|
||||
resolveAgentRestartRecoveryExecutionIdentityAdmission({
|
||||
collectionEnabled: true,
|
||||
isRestartRecoveryResumeRun: true,
|
||||
retryOnly: true,
|
||||
runId: token.runId,
|
||||
sessionEntry,
|
||||
}),
|
||||
).toEqual({ token, retryOnly: true });
|
||||
});
|
||||
|
||||
it("returns no token for ordinary runs and refuses lost recovery evidence", () => {
|
||||
expect(
|
||||
resolveAgentRestartRecoveryExecutionIdentityAdmission({
|
||||
collectionEnabled: true,
|
||||
isRestartRecoveryResumeRun: false,
|
||||
retryOnly: false,
|
||||
runId: token.runId,
|
||||
sessionEntry: matchingParams.sessionEntry,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(() =>
|
||||
resolveAgentRestartRecoveryExecutionIdentityAdmission({
|
||||
collectionEnabled: true,
|
||||
isRestartRecoveryResumeRun: true,
|
||||
retryOnly: true,
|
||||
runId: token.runId,
|
||||
sessionEntry: matchingParams.sessionEntry,
|
||||
}),
|
||||
).toThrow("token is unavailable");
|
||||
});
|
||||
|
||||
it("omits retained recovery identity while collection is disabled", () => {
|
||||
const sessionEntry = {
|
||||
...matchingParams.sessionEntry,
|
||||
mainRestartRecovery: {
|
||||
cycleId: "cycle-1",
|
||||
revision: 1,
|
||||
chargedAttempts: 1,
|
||||
executionIdentity: token,
|
||||
},
|
||||
};
|
||||
expect(
|
||||
resolveAgentRestartRecoveryExecutionIdentityAdmission({
|
||||
collectionEnabled: false,
|
||||
isRestartRecoveryResumeRun: true,
|
||||
retryOnly: true,
|
||||
runId: token.runId,
|
||||
sessionEntry,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses an enabled recovery without an explicit capture or retry mode", () => {
|
||||
expect(() =>
|
||||
resolveAgentRestartRecoveryExecutionIdentityAdmission({
|
||||
collectionEnabled: true,
|
||||
isRestartRecoveryResumeRun: true,
|
||||
runId: token.runId,
|
||||
sessionEntry: matchingParams.sessionEntry,
|
||||
}),
|
||||
).toThrow("admission mode is unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { parseExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js";
|
||||
import type { InternalSessionEntry, SessionEntry } from "../../config/sessions.js";
|
||||
import { resolveRestartRecoveryChannelAuthority } from "../../config/sessions/restart-recovery-state.js";
|
||||
|
||||
type AgentRestartRecoveryChannelContext = {
|
||||
@@ -12,6 +13,32 @@ type AgentRestartRecoveryChannelContext = {
|
||||
sourceTurnId: string;
|
||||
};
|
||||
|
||||
/** Resolve only the private token durably owned by the admitted recovery cycle. */
|
||||
export function resolveAgentRestartRecoveryExecutionIdentityAdmission(params: {
|
||||
collectionEnabled: boolean;
|
||||
isRestartRecoveryResumeRun: boolean;
|
||||
retryOnly?: boolean;
|
||||
runId: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
}) {
|
||||
if (!params.isRestartRecoveryResumeRun || !params.collectionEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
if (params.retryOnly === undefined) {
|
||||
throw new Error("restart recovery execution identity admission mode is unavailable");
|
||||
}
|
||||
const stored = (params.sessionEntry as InternalSessionEntry | undefined)?.mainRestartRecovery
|
||||
?.executionIdentity;
|
||||
if (!stored) {
|
||||
throw new Error("restart recovery execution identity token is unavailable");
|
||||
}
|
||||
const token = parseExecutionIdentityAdmissionToken(stored);
|
||||
if (!params.retryOnly && token.runId !== params.runId) {
|
||||
throw new Error("restart recovery execution identity token disagrees with the admitted run");
|
||||
}
|
||||
return { token, retryOnly: params.retryOnly };
|
||||
}
|
||||
|
||||
/** Rehydrates durable channel authority only for the exact host-owned recovery run. */
|
||||
export function resolveAgentRestartRecoveryChannelContext(params: {
|
||||
canUseInternalRuntimeHandoff: boolean;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "../../agents/main-session-recovery-store.js";
|
||||
import { resolveScheduledToolPolicyContext } from "../../agents/scheduled-tool-policy.js";
|
||||
import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js";
|
||||
import { isExecutionIdentityCollectionEnabled } from "../../audit/audit-config.js";
|
||||
import {
|
||||
setChannelSourceTurnId,
|
||||
setChannelSourceTurnSameThreadRequired,
|
||||
@@ -51,7 +52,10 @@ import {
|
||||
type RestoredCronContinuation,
|
||||
} from "./agent-handler-helpers.js";
|
||||
import type { AgentRunRequest } from "./agent-request-types.js";
|
||||
import { resolveAgentRestartRecoveryChannelContext } from "./agent-restart-recovery-context.js";
|
||||
import {
|
||||
resolveAgentRestartRecoveryChannelContext,
|
||||
resolveAgentRestartRecoveryExecutionIdentityAdmission,
|
||||
} from "./agent-restart-recovery-context.js";
|
||||
import type { PreparedAgentRunDispatch } from "./agent-run-admission-phase.js";
|
||||
import {
|
||||
resolveAbortedAgentStopReason,
|
||||
@@ -325,6 +329,13 @@ export function startAgentRunExecution(params: {
|
||||
params.client.internal.runtimePluginToolGrant?.pluginId
|
||||
? params.client.internal.runtimePluginToolGrant
|
||||
: undefined;
|
||||
const executionIdentityAdmission = resolveAgentRestartRecoveryExecutionIdentityAdmission({
|
||||
collectionEnabled: isExecutionIdentityCollectionEnabled(params.cfg),
|
||||
isRestartRecoveryResumeRun: params.isRestartRecoveryResumeRun,
|
||||
retryOnly: params.request.internalExecutionIdentityRetry,
|
||||
runId: params.runId,
|
||||
sessionEntry: params.sessionEntry,
|
||||
});
|
||||
const restartRecoveryChannelContext = resolveAgentRestartRecoveryChannelContext({
|
||||
canUseInternalRuntimeHandoff: params.canUseInternalRuntimeHandoff,
|
||||
expectedExistingSessionId: params.request.expectedExistingSessionId,
|
||||
@@ -421,6 +432,7 @@ export function startAgentRunExecution(params: {
|
||||
swarmOutputSchema: params.request.swarmOutputSchema,
|
||||
forceRestartSafeTools: params.request.forceRestartSafeTools,
|
||||
forceCodeModeTools: params.request.forceCodeModeTools,
|
||||
...(executionIdentityAdmission ? { executionIdentityAdmission } : {}),
|
||||
internalDeliveryMediaUrls: params.client?.internal?.internalDeliveryMediaUrls,
|
||||
internalDeliverySuppressText: params.client?.internal?.internalDeliverySuppressText,
|
||||
suppressPromptPersistence:
|
||||
|
||||
@@ -2,13 +2,20 @@ import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { auditHandlers, testApi } from "./audit.js";
|
||||
|
||||
const listAuditEvents = vi.hoisted(() => vi.fn());
|
||||
const { inspectExecutionIdentityRun, listAuditEvents } = vi.hoisted(() => ({
|
||||
inspectExecutionIdentityRun: vi.fn(),
|
||||
listAuditEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../audit/audit-event-store.js", () => ({ listAuditEvents }));
|
||||
vi.mock("../../audit/execution-identity-context.js", () => ({ inspectExecutionIdentityRun }));
|
||||
|
||||
const accountRef = `hmac-sha256:v1:${"a".repeat(32)}:${"b".repeat(64)}`;
|
||||
|
||||
async function runAuditHandler(method: "audit.activity.list" | "audit.list", params: object) {
|
||||
async function runAuditHandler(
|
||||
method: "audit.activity.list" | "audit.list" | "audit.run.inspect",
|
||||
params: object,
|
||||
) {
|
||||
const respond = vi.fn();
|
||||
await expectDefined(
|
||||
auditHandlers[method],
|
||||
@@ -40,6 +47,19 @@ describe("audit gateway methods", () => {
|
||||
],
|
||||
nextCursor: 10,
|
||||
});
|
||||
inspectExecutionIdentityRun.mockReset();
|
||||
inspectExecutionIdentityRun.mockReturnValue({
|
||||
schemaVersion: 1,
|
||||
run: { runId: "run-1", status: "unknown" },
|
||||
identity: {
|
||||
state: "unknown",
|
||||
reasonCode: "run_not_found",
|
||||
missingEvidence: ["run.record"],
|
||||
remediation: [{ code: "verify_run_id", text: "Verify the exact run id." }],
|
||||
},
|
||||
decisions: [],
|
||||
coverage: { state: "unknown", missingEvidence: ["run.record"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the exact shipped audit.list request and result shape", async () => {
|
||||
@@ -231,4 +251,46 @@ describe("audit gateway methods", () => {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("projects bounded run discovery and exact execution selection", async () => {
|
||||
await runAuditHandler("audit.run.inspect", {
|
||||
runId: "run-1",
|
||||
executionCursor: " 2 ",
|
||||
executionLimit: 10,
|
||||
decisionCursor: " 1 ",
|
||||
decisionLimit: 25,
|
||||
});
|
||||
expect(inspectExecutionIdentityRun).toHaveBeenLastCalledWith({
|
||||
runId: "run-1",
|
||||
executionOffset: 2,
|
||||
executionLimit: 10,
|
||||
decisionOffset: 1,
|
||||
decisionLimit: 25,
|
||||
});
|
||||
|
||||
await runAuditHandler("audit.run.inspect", {
|
||||
executionId: "execution-1",
|
||||
decisionLimit: 20,
|
||||
});
|
||||
expect(inspectExecutionIdentityRun).toHaveBeenLastCalledWith({
|
||||
executionId: "execution-1",
|
||||
decisionLimit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed run inspection before storage access", async () => {
|
||||
expect(
|
||||
await runAuditHandler("audit.run.inspect", { runId: "", extra: true }),
|
||||
).toHaveBeenCalledWith(false, undefined, expect.any(Object));
|
||||
expect(
|
||||
await runAuditHandler("audit.run.inspect", { runId: "run-1", decisionCursor: "0" }),
|
||||
).toHaveBeenCalledWith(false, undefined, expect.any(Object));
|
||||
expect(
|
||||
await runAuditHandler("audit.run.inspect", {
|
||||
runId: "run-1",
|
||||
executionId: "execution-1",
|
||||
}),
|
||||
).toHaveBeenCalledWith(false, undefined, expect.any(Object));
|
||||
expect(inspectExecutionIdentityRun).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type AuditEvent,
|
||||
validateAuditActivityListParams,
|
||||
validateAuditListParams,
|
||||
validateAuditRunInspectParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { listAuditEvents } from "../../audit/audit-event-store.js";
|
||||
import type {
|
||||
@@ -14,13 +15,14 @@ import type {
|
||||
AuditEventRecord,
|
||||
ToolActionAuditEventRecord,
|
||||
} from "../../audit/audit-event-types.js";
|
||||
import { inspectExecutionIdentityRun } from "../../audit/execution-identity-context.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
const DEFAULT_AUDIT_LIST_LIMIT = 100;
|
||||
const MAX_AUDIT_LIST_LIMIT = 500;
|
||||
|
||||
function parseAuditCursor(cursor: string | undefined): number | undefined | null {
|
||||
function parsePositiveCursor(cursor: string | undefined): number | undefined | null {
|
||||
if (cursor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -32,6 +34,10 @@ function parseAuditCursor(cursor: string | undefined): number | undefined | null
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
const parseAuditCursor = parsePositiveCursor;
|
||||
const parseDecisionCursor = parsePositiveCursor;
|
||||
const parseExecutionCursor = parsePositiveCursor;
|
||||
|
||||
/** Preserve the shipped audit.list result shape for run/tool-only clients. */
|
||||
function mapLegacyAuditEvent(
|
||||
event: AgentRunAuditEventRecord | ToolActionAuditEventRecord,
|
||||
@@ -156,6 +162,42 @@ export const auditHandlers: GatewayRequestHandlers = {
|
||||
...(page.nextCursor !== undefined ? { nextCursor: String(page.nextCursor) } : {}),
|
||||
});
|
||||
},
|
||||
"audit.run.inspect": ({ params, respond }) => {
|
||||
if (!assertValidParams(params, validateAuditRunInspectParams, "audit.run.inspect", respond)) {
|
||||
return;
|
||||
}
|
||||
const decisionOffset = parseDecisionCursor(params.decisionCursor);
|
||||
const executionOffset =
|
||||
typeof params.runId === "string" ? parseExecutionCursor(params.executionCursor) : undefined;
|
||||
if (decisionOffset === null || executionOffset === null) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "invalid audit.run.inspect cursor"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(
|
||||
true,
|
||||
inspectExecutionIdentityRun({
|
||||
...(typeof params.runId === "string"
|
||||
? {
|
||||
runId: params.runId,
|
||||
...(executionOffset !== undefined ? { executionOffset } : {}),
|
||||
executionLimit: params.executionLimit ?? 50,
|
||||
}
|
||||
: { executionId: params.executionId! }),
|
||||
...(decisionOffset !== undefined ? { decisionOffset } : {}),
|
||||
decisionLimit: params.decisionLimit ?? 50,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const testApi = { mapAuditActivityEvent, mapLegacyAuditEvent, parseAuditCursor };
|
||||
export const testApi = {
|
||||
mapAuditActivityEvent,
|
||||
mapLegacyAuditEvent,
|
||||
parseAuditCursor,
|
||||
parseDecisionCursor,
|
||||
parseExecutionCursor,
|
||||
};
|
||||
|
||||
@@ -1898,6 +1898,25 @@ describe("agent request events", () => {
|
||||
expect(optsRecord.runId).toBe(optsRecord.sessionId);
|
||||
});
|
||||
|
||||
it("preserves session-scoped routing across two distinct agent.request turns", async () => {
|
||||
loadSessionEntryMock.mockReturnValue(
|
||||
buildSessionLookup("agent:main:node-repeat", { sessionId: "node-session-1" }),
|
||||
);
|
||||
|
||||
for (const message of ["first turn", "second turn"]) {
|
||||
await handleNodeEvent(buildCtx(), "node-repeat", {
|
||||
event: "agent.request",
|
||||
payloadJSON: JSON.stringify({ message, sessionKey: "agent:main:node-repeat" }),
|
||||
});
|
||||
}
|
||||
|
||||
expect(agentCommandMock).toHaveBeenCalledTimes(2);
|
||||
const calls = agentCommandMock.mock.calls.map(([opts]) => opts as Record<string, unknown>);
|
||||
expect(calls.map((opts) => opts.message)).toEqual(["first turn", "second turn"]);
|
||||
expect(calls.map((opts) => opts.runId)).toEqual(["node-session-1", "node-session-1"]);
|
||||
expect(calls.map((opts) => opts.sessionId)).toEqual(["node-session-1", "node-session-1"]);
|
||||
});
|
||||
|
||||
it("passes supportsInlineImages false for text-only node-session models", async () => {
|
||||
const ctx = buildCtx();
|
||||
ctx.loadGatewayModelCatalog = async () => [
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Tests for gateway runtime subscription wiring.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
configureExecutionIdentityAdmissionSink,
|
||||
enqueueExecutionIdentityContextAtAdmission,
|
||||
hasExecutionIdentityAdmissionSink,
|
||||
} from "../audit/execution-identity-admission.js";
|
||||
import {
|
||||
emitAgentAuditEvent,
|
||||
emitAgentEvent,
|
||||
@@ -49,6 +54,7 @@ const auditTestState = vi.hoisted(() => ({
|
||||
messageMode: "off" as "off" | "direct" | "all",
|
||||
created: 0,
|
||||
recorded: 0,
|
||||
identityRecorded: 0,
|
||||
stopped: 0,
|
||||
}));
|
||||
const agentEventHandlerMocks = vi.hoisted(() => ({
|
||||
@@ -73,6 +79,10 @@ vi.mock("../audit/audit-recorder.js", () => ({
|
||||
}),
|
||||
recordTool: vi.fn(),
|
||||
recordMessage: vi.fn(),
|
||||
recordExecutionIdentity: vi.fn(() => {
|
||||
auditTestState.identityRecorded += 1;
|
||||
return true;
|
||||
}),
|
||||
stop: vi.fn(async () => {
|
||||
auditTestState.stopped += 1;
|
||||
}),
|
||||
@@ -144,6 +154,7 @@ describe("startGatewayEventSubscriptions", () => {
|
||||
auditTestState.messageMode = "off";
|
||||
auditTestState.created = 0;
|
||||
auditTestState.recorded = 0;
|
||||
auditTestState.identityRecorded = 0;
|
||||
auditTestState.stopped = 0;
|
||||
transcriptBroadcastMocks.useActualHandler = false;
|
||||
transcriptBroadcastMocks.readMessageCount.mockReset();
|
||||
@@ -161,6 +172,7 @@ describe("startGatewayEventSubscriptions", () => {
|
||||
void unsubs?.taskUnsub();
|
||||
resetAgentEventsForTest();
|
||||
resetTaskRegistryForTests({ persist: false });
|
||||
configureExecutionIdentityAdmissionSink(() => false)();
|
||||
});
|
||||
|
||||
it("records audit events by default and stops the recorder on unsubscribe", async () => {
|
||||
@@ -173,8 +185,22 @@ describe("startGatewayEventSubscriptions", () => {
|
||||
data: { phase: "start", startedAt: 1_000 },
|
||||
});
|
||||
expect(auditTestState.recorded).toBe(1);
|
||||
expect(hasExecutionIdentityAdmissionSink()).toBe(true);
|
||||
expect(
|
||||
enqueueExecutionIdentityContextAtAdmission(
|
||||
{
|
||||
runId: "gateway-admission",
|
||||
agentId: "main",
|
||||
ingress: { kind: "system", boundary: "gateway.boot", state: "present" },
|
||||
runtime: { kind: "embedded" },
|
||||
},
|
||||
{ enabled: true, runtimeInstanceId: "runtime-1" },
|
||||
)?.accepted,
|
||||
).toBe(true);
|
||||
expect(auditTestState.identityRecorded).toBe(1);
|
||||
await unsubs.agentUnsub();
|
||||
expect(auditTestState.stopped).toBe(1);
|
||||
expect(hasExecutionIdentityAdmissionSink()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps retention maintenance but creates no producers when audit.enabled is false", async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { isAuditLedgerEnabled, resolveAuditMessageMode } from "../audit/audit-config.js";
|
||||
import { createAuditEventRecorder } from "../audit/audit-recorder.js";
|
||||
import { configureExecutionIdentityAdmissionSink } from "../audit/execution-identity-admission.js";
|
||||
import { onTrustedMessageAuditEvent } from "../audit/message-audit-events.js";
|
||||
import { getRuntimeConfig } from "../config/io.js";
|
||||
import { onAgentAuditEvent, onAgentRuntimeEvent } from "../infra/agent-events.js";
|
||||
@@ -71,6 +72,9 @@ export function startGatewayEventSubscriptions(params: {
|
||||
const auditRecorder = createAuditEventRecorder({
|
||||
messageMode: auditEnabled ? auditMessageMode : "off",
|
||||
});
|
||||
const clearExecutionIdentityAdmissionSink = configureExecutionIdentityAdmissionSink(
|
||||
auditRecorder.recordExecutionIdentity,
|
||||
);
|
||||
const sessionObserver = createSessionObserver({
|
||||
getConfig: getRuntimeConfig,
|
||||
subscribers: params.sessionMessageSubscribers,
|
||||
@@ -322,6 +326,7 @@ export function startGatewayEventSubscriptions(params: {
|
||||
unsubscribePrivateAuditEvents?.();
|
||||
unsubscribeToolAuditEvents?.();
|
||||
unsubscribeMessageAuditEvents?.();
|
||||
clearExecutionIdentityAdmissionSink();
|
||||
await agentEventHandlerLoader
|
||||
.peek()
|
||||
?.then((handler) => handler.dispose())
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { agentCommandFromIngress } from "./agent-runtime.js";
|
||||
|
||||
type PublicIngressOptions = Parameters<typeof agentCommandFromIngress>[0];
|
||||
const optionalRunIdCaller: PublicIngressOptions = {
|
||||
message: "hello",
|
||||
sessionKey: "agent:main:plugin-session",
|
||||
allowModelOverride: false,
|
||||
};
|
||||
const privateRecoveryCorrelationIsHidden: "executionIdentityAdmission" extends keyof PublicIngressOptions
|
||||
? false
|
||||
: true = true;
|
||||
|
||||
describe("public agent ingress correlation contract", () => {
|
||||
it("keeps runId optional and private execution recovery state unavailable", () => {
|
||||
expect(optionalRunIdCaller).not.toHaveProperty("runId");
|
||||
expect(privateRecoveryCorrelationIsHidden).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,14 @@ import type { SqliteWalMaintenance } from "../infra/sqlite-wal.js";
|
||||
// v5 records durable cloud-worker result refs on pending workspace fences.
|
||||
export const OPENCLAW_STATE_SCHEMA_VERSION = 6;
|
||||
export const OPENCLAW_STATE_STRICT_SCHEMA_VERSION = 3;
|
||||
// Privacy-sensitive feature tables remain absent even in fresh databases until
|
||||
// their feature-local first write. The canonical SQL still owns their shape.
|
||||
export const FIRST_USE_STATE_TABLES = ["execution_identity_contexts"] as const;
|
||||
export const FIRST_USE_STATE_INDEXES = ["execution_identity_contexts_run_created_idx"] as const;
|
||||
// Added after v6 shipped. These tables stay optional until their feature-local
|
||||
// lazy ensures run; fold them into the next natural schema-version bump.
|
||||
export const LAZY_ADDITIVE_STATE_TABLES = [
|
||||
...FIRST_USE_STATE_TABLES,
|
||||
"model_catalog_remote",
|
||||
"sidebar_sections",
|
||||
"skill_workshop_proposal_events",
|
||||
@@ -15,6 +20,7 @@ export const LAZY_ADDITIVE_STATE_TABLES = [
|
||||
"skill_workshop_proposal_rollbacks",
|
||||
"skill_workshop_proposals",
|
||||
] as const;
|
||||
export const LAZY_ADDITIVE_STATE_INDEXES = [...FIRST_USE_STATE_INDEXES] as const;
|
||||
/** Maximum time one synchronous SQLite call may wait for a lock. */
|
||||
export const OPENCLAW_SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
||||
/** User-facing guide for schema refusals; lives here so error sites avoid import cycles. */
|
||||
|
||||
+11
@@ -640,6 +640,16 @@ export interface ExecApprovalsConfig {
|
||||
updated_at_ms: number;
|
||||
}
|
||||
|
||||
export interface ExecutionIdentityContexts {
|
||||
context_bytes: number;
|
||||
context_id: string;
|
||||
context_json: string;
|
||||
coverage_state: string;
|
||||
created_at: number;
|
||||
execution_id: string;
|
||||
run_id: string;
|
||||
}
|
||||
|
||||
export interface FleetCells {
|
||||
container_name: string;
|
||||
created_at_ms: number;
|
||||
@@ -1588,6 +1598,7 @@ export interface DB {
|
||||
diagnostic_events: DiagnosticEvents;
|
||||
diagnostic_stability_bundles: DiagnosticStabilityBundles;
|
||||
exec_approvals_config: ExecApprovalsConfig;
|
||||
execution_identity_contexts: ExecutionIdentityContexts;
|
||||
fleet_cells: FleetCells;
|
||||
flow_runs: FlowRuns;
|
||||
gateway_boot_lifecycle: GatewayBootLifecycle;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { readSqliteNumberPragma } from "../infra/sqlite-pragma.test-support.js";
|
||||
import { loadTaskRegistryStateFromSqlite } from "../tasks/task-registry.store.sqlite.js";
|
||||
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { FIRST_USE_STATE_TABLES } from "./openclaw-state-db-contract.js";
|
||||
import {
|
||||
findOpenClawStateDatabaseSchemaMigrationRequiredError,
|
||||
OpenClawStateDatabaseSchemaMigrationRequiredError,
|
||||
@@ -58,6 +59,16 @@ function createTempStateDir(): string {
|
||||
return makeTempDir(stateDbTempDirs, "openclaw-state-db-");
|
||||
}
|
||||
|
||||
function createInitialStateSchemaShape() {
|
||||
const shape = createSqliteSchemaShapeFromSql(
|
||||
new URL("./openclaw-state-schema.sql", import.meta.url),
|
||||
);
|
||||
for (const tableName of FIRST_USE_STATE_TABLES) {
|
||||
delete shape[tableName];
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
|
||||
function expectStateSchemaMigrationRequired(
|
||||
run: () => unknown,
|
||||
expected: {
|
||||
@@ -1076,9 +1087,12 @@ describe("openclaw state database", () => {
|
||||
env: { OPENCLAW_STATE_DIR: stateDir },
|
||||
});
|
||||
|
||||
expect(collectSqliteSchemaShape(database.db)).toEqual(
|
||||
createSqliteSchemaShapeFromSql(new URL("./openclaw-state-schema.sql", import.meta.url)),
|
||||
);
|
||||
expect(collectSqliteSchemaShape(database.db)).toEqual(createInitialStateSchemaShape());
|
||||
expect(
|
||||
database.db
|
||||
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
|
||||
.get("execution_identity_contexts"),
|
||||
).toBeUndefined();
|
||||
expect(database.path).toBe(path.join(stateDir, "state", "openclaw.sqlite"));
|
||||
expect(
|
||||
database.db
|
||||
@@ -1187,9 +1201,7 @@ INSERT INTO device_identities VALUES (
|
||||
updated_at_ms: 20,
|
||||
});
|
||||
expect(readSqliteNumberPragma(database.db, "user_version")).toBe(OPENCLAW_STATE_SCHEMA_VERSION);
|
||||
expect(collectSqliteSchemaShape(database.db)).toEqual(
|
||||
createSqliteSchemaShapeFromSql(new URL("./openclaw-state-schema.sql", import.meta.url)),
|
||||
);
|
||||
expect(collectSqliteSchemaShape(database.db)).toEqual(createInitialStateSchemaShape());
|
||||
});
|
||||
|
||||
it("adopts a canonical native PortGuardian seed without losing records", () => {
|
||||
@@ -1226,9 +1238,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
|
||||
timestamp: 42.5,
|
||||
});
|
||||
expect(readSqliteNumberPragma(database.db, "user_version")).toBe(OPENCLAW_STATE_SCHEMA_VERSION);
|
||||
expect(collectSqliteSchemaShape(database.db)).toEqual(
|
||||
createSqliteSchemaShapeFromSql(new URL("./openclaw-state-schema.sql", import.meta.url)),
|
||||
);
|
||||
expect(collectSqliteSchemaShape(database.db)).toEqual(createInitialStateSchemaShape());
|
||||
});
|
||||
|
||||
it("doctor migrates existing APNs tombstone tables to STRICT without losing rows", () => {
|
||||
@@ -3404,9 +3414,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
|
||||
const rootDir = createTempStateDir();
|
||||
const moduleUrl = new URL("./openclaw-state-db.ts", import.meta.url).href;
|
||||
const databasePaths = runConcurrentSchemaProbe({ mode: "upgrade", moduleUrl, rootDir });
|
||||
const expectedShape = createSqliteSchemaShapeFromSql(
|
||||
new URL("./openclaw-state-schema.sql", import.meta.url),
|
||||
);
|
||||
const expectedShape = createInitialStateSchemaShape();
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
|
||||
expect(databasePaths).toHaveLength(1);
|
||||
@@ -3438,9 +3446,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
|
||||
const rootDir = createTempStateDir();
|
||||
const moduleUrl = new URL("./openclaw-state-db.ts", import.meta.url).href;
|
||||
const databasePaths = runConcurrentSchemaProbe({ mode: "fresh", moduleUrl, rootDir });
|
||||
const expectedShape = createSqliteSchemaShapeFromSql(
|
||||
new URL("./openclaw-state-schema.sql", import.meta.url),
|
||||
);
|
||||
const expectedShape = createInitialStateSchemaShape();
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
|
||||
expect(databasePaths).toHaveLength(1);
|
||||
|
||||
@@ -45,7 +45,10 @@ import {
|
||||
} from "./openclaw-quarantine-store.js";
|
||||
import { repairAuditEventsSchema } from "./openclaw-state-db-audit-migration.js";
|
||||
import {
|
||||
FIRST_USE_STATE_INDEXES,
|
||||
FIRST_USE_STATE_TABLES,
|
||||
OPENCLAW_DATABASE_SCHEMA_DOCS_URL,
|
||||
LAZY_ADDITIVE_STATE_INDEXES,
|
||||
LAZY_ADDITIVE_STATE_TABLES,
|
||||
OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
|
||||
OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
@@ -196,19 +199,19 @@ export function assertOpenClawStateDatabaseFreshOpenAllowed(
|
||||
type OpenClawStateMetadataDatabase = Pick<OpenClawStateKyselyDatabase, "schema_meta">;
|
||||
const stateDbLog = createSubsystemLogger("state/db");
|
||||
|
||||
function executeCanonicalStateSchema(
|
||||
database: DatabaseSync,
|
||||
options: { includeLazyAdditiveTables: boolean },
|
||||
): void {
|
||||
if (options.includeLazyAdditiveTables) {
|
||||
database.exec(OPENCLAW_STATE_SCHEMA_SQL);
|
||||
return;
|
||||
}
|
||||
|
||||
// Current-version databases may lack lazy cache tables, but the remaining
|
||||
// canonical DDL must still run so doctor can restore indexes and triggers.
|
||||
function canonicalStateSchemaForRuntime(options: {
|
||||
includeVersionLazyAdditiveTables: boolean;
|
||||
}): string {
|
||||
// Current-version databases may lack lazy additive tables. First-use tables
|
||||
// remain absent on every schema path so only their feature owner can create them.
|
||||
let eagerSchema = OPENCLAW_STATE_SCHEMA_SQL;
|
||||
for (const tableName of LAZY_ADDITIVE_STATE_TABLES) {
|
||||
const omittedTables = options.includeVersionLazyAdditiveTables
|
||||
? FIRST_USE_STATE_TABLES
|
||||
: LAZY_ADDITIVE_STATE_TABLES;
|
||||
const omittedIndexes = options.includeVersionLazyAdditiveTables
|
||||
? FIRST_USE_STATE_INDEXES
|
||||
: LAZY_ADDITIVE_STATE_INDEXES;
|
||||
for (const tableName of omittedTables) {
|
||||
const startMarker = `CREATE TABLE IF NOT EXISTS ${tableName} (`;
|
||||
const start = eagerSchema.indexOf(startMarker);
|
||||
const endMarker = "\n) STRICT;";
|
||||
@@ -218,7 +221,23 @@ function executeCanonicalStateSchema(
|
||||
}
|
||||
eagerSchema = `${eagerSchema.slice(0, start)}${eagerSchema.slice(end + endMarker.length)}`;
|
||||
}
|
||||
database.exec(eagerSchema);
|
||||
for (const indexName of omittedIndexes) {
|
||||
const startMarker = `CREATE INDEX IF NOT EXISTS ${indexName}`;
|
||||
const start = eagerSchema.indexOf(startMarker);
|
||||
const end = start >= 0 ? eagerSchema.indexOf(";", start) : -1;
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error(`lazy additive state schema index is missing for ${indexName}`);
|
||||
}
|
||||
eagerSchema = `${eagerSchema.slice(0, start)}${eagerSchema.slice(end + 1)}`;
|
||||
}
|
||||
return eagerSchema;
|
||||
}
|
||||
|
||||
function executeCanonicalStateSchema(
|
||||
database: DatabaseSync,
|
||||
options: { includeVersionLazyAdditiveTables: boolean },
|
||||
): void {
|
||||
database.exec(canonicalStateSchemaForRuntime(options));
|
||||
}
|
||||
|
||||
export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabaseOptions = {}): {
|
||||
@@ -279,14 +298,16 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase
|
||||
if (tableExists(db, "audit_events")) {
|
||||
ensureAdditiveStateColumns(db);
|
||||
executeCanonicalStateSchema(db, {
|
||||
includeLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
includeVersionLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
});
|
||||
if (previousVersion < OPENCLAW_STATE_STRICT_SCHEMA_VERSION) {
|
||||
repairLegacyGatewayRestartHandoffsForStrictMigration(db);
|
||||
}
|
||||
const strictMigration = migrateSqliteSchemaToStrictInTransaction(
|
||||
db,
|
||||
OPENCLAW_STATE_SCHEMA_SQL,
|
||||
canonicalStateSchemaForRuntime({
|
||||
includeVersionLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
}),
|
||||
{ databaseLabel: pathname },
|
||||
);
|
||||
if (strictMigration.migratedTables.length > 0) {
|
||||
@@ -407,14 +428,18 @@ function ensureSchema(db: DatabaseSync, pathname: string): void {
|
||||
sessionWatchMigration.migrateSessionWatchCursorProvenance(db);
|
||||
assertCanonicalStateSchemaShape(db, pathname);
|
||||
executeCanonicalStateSchema(db, {
|
||||
includeLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
includeVersionLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
});
|
||||
migrateLegacyCronRunLogsToTaskRuns(db);
|
||||
if (previousVersion < OPENCLAW_STATE_STRICT_SCHEMA_VERSION) {
|
||||
repairLegacyGatewayRestartHandoffsForStrictMigration(db);
|
||||
migrateSqliteSchemaToStrictInTransaction(db, OPENCLAW_STATE_SCHEMA_SQL, {
|
||||
databaseLabel: pathname,
|
||||
});
|
||||
migrateSqliteSchemaToStrictInTransaction(
|
||||
db,
|
||||
canonicalStateSchemaForRuntime({
|
||||
includeVersionLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
}),
|
||||
{ databaseLabel: pathname },
|
||||
);
|
||||
}
|
||||
repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, {
|
||||
verifyPhysicalIntegrity: false,
|
||||
|
||||
@@ -196,6 +196,21 @@ CREATE TABLE IF NOT EXISTS audit_identity_keys (
|
||||
created_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS execution_identity_contexts (
|
||||
context_id TEXT NOT NULL PRIMARY KEY CHECK (length(context_id) BETWEEN 1 AND 256),
|
||||
execution_id TEXT NOT NULL UNIQUE CHECK (length(execution_id) BETWEEN 1 AND 256),
|
||||
run_id TEXT NOT NULL CHECK (length(run_id) BETWEEN 1 AND 256),
|
||||
created_at INTEGER NOT NULL CHECK (created_at >= 0),
|
||||
coverage_state TEXT NOT NULL CHECK (
|
||||
coverage_state IN ('attribution-only', 'unattributed', 'unknown', 'unsupported')
|
||||
),
|
||||
context_bytes INTEGER NOT NULL CHECK (context_bytes BETWEEN 1 AND 16384),
|
||||
context_json TEXT NOT NULL CHECK (length(context_json) > 0),
|
||||
UNIQUE (created_at, context_id)
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS execution_identity_contexts_run_created_idx
|
||||
ON execution_identity_contexts (run_id, created_at, execution_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_state_events (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dedupe_key TEXT UNIQUE,
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
// QA Lab producer proves exact-run identity inspection through a real local turn and Gateway.
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
QA_EVIDENCE_FILENAME,
|
||||
type QaEvidenceSummaryJson,
|
||||
} from "../../../../extensions/qa-lab/src/evidence-summary.js";
|
||||
import { startQaGatewayChild } from "../../../../extensions/qa-lab/src/gateway-child.js";
|
||||
import { startQaMockOpenAiServer } from "../../../../extensions/qa-lab/src/providers/mock-openai/server.js";
|
||||
import type { AuditRunInspectResult } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import { formatErrorMessage } from "../../../../src/infra/errors.js";
|
||||
import { createQaScriptEvidenceWriter, type QaScriptEvidenceStatus } from "./script-evidence.js";
|
||||
|
||||
const SCENARIO_ID = "agent-run-identity-inspection";
|
||||
const SNAPSHOT_FILE = `${SCENARIO_ID}-summary.json`;
|
||||
const TEXT_SECTIONS = [
|
||||
"Identity",
|
||||
"Authority",
|
||||
"Lineage",
|
||||
"Decisions",
|
||||
"Missing evidence",
|
||||
"Next steps",
|
||||
] as const;
|
||||
const IDENTITY_FIELDS = [
|
||||
"Trust domain",
|
||||
"Invoker",
|
||||
"Ingress",
|
||||
"Agent principal",
|
||||
"Agent definition",
|
||||
"Runtime instance",
|
||||
"Represented subject",
|
||||
"Sponsor",
|
||||
"Applicable grants",
|
||||
"Assurance",
|
||||
] as const;
|
||||
|
||||
type ProducerOptions = {
|
||||
artifactBase: string;
|
||||
repoRoot: string;
|
||||
};
|
||||
|
||||
type ProofResult = {
|
||||
artifacts?: Array<{ filePath: string; kind: string }>;
|
||||
details?: string;
|
||||
durationMs: number;
|
||||
status: QaScriptEvidenceStatus;
|
||||
};
|
||||
|
||||
async function updateExecutionIdentityConfig(
|
||||
configPath: string,
|
||||
values: { enabled?: boolean; executionIdentity: boolean },
|
||||
) {
|
||||
const raw = await fs.readFile(configPath, "utf8");
|
||||
const config = parseJson<Record<string, unknown>>(raw || "{}", "QA Gateway config");
|
||||
const logging =
|
||||
config.logging && typeof config.logging === "object"
|
||||
? (config.logging as Record<string, unknown>)
|
||||
: {};
|
||||
const audit =
|
||||
logging.audit && typeof logging.audit === "object"
|
||||
? (logging.audit as Record<string, unknown>)
|
||||
: {};
|
||||
config.logging = { ...logging, audit: { ...audit, ...values } };
|
||||
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function parseOptions(argv: readonly string[]): ProducerOptions {
|
||||
const readValue = (name: string) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 ? argv[index + 1] : undefined;
|
||||
};
|
||||
const artifactBase = readValue("--artifact-base");
|
||||
if (!artifactBase) {
|
||||
throw new Error("--artifact-base is required");
|
||||
}
|
||||
return {
|
||||
artifactBase: path.resolve(artifactBase),
|
||||
repoRoot: path.resolve(readValue("--repo-root") ?? process.cwd()),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJson<T>(raw: string, label: string): T {
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch (error) {
|
||||
throw new Error(`${label} was not JSON: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireIdentityContext(result: AuditRunInspectResult) {
|
||||
if (result.identity.state !== "present") {
|
||||
throw new Error(
|
||||
`identity inspection was ${result.identity.state}: ${result.identity.reasonCode}`,
|
||||
);
|
||||
}
|
||||
return result.identity.context;
|
||||
}
|
||||
|
||||
function normalizedContextJson(result: AuditRunInspectResult) {
|
||||
return JSON.stringify(requireIdentityContext(result));
|
||||
}
|
||||
|
||||
function sha256(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function assertTextProjection(text: string) {
|
||||
for (const label of [...TEXT_SECTIONS, ...IDENTITY_FIELDS]) {
|
||||
if (!text.includes(label)) {
|
||||
throw new Error(`audit text projection omitted ${label}`);
|
||||
}
|
||||
}
|
||||
if (!text.includes("run_admission_identity_not_evaluated") || !text.includes("not-applicable")) {
|
||||
throw new Error("audit text projection overstated or omitted the admission decision");
|
||||
}
|
||||
}
|
||||
|
||||
function assertJsonProjection(result: AuditRunInspectResult, runId: string) {
|
||||
const context = requireIdentityContext(result);
|
||||
if (result.run.runId !== runId || result.coverage.state !== context.coverageState) {
|
||||
throw new Error(`audit JSON projection did not preserve exact-run coverage: ${runId}`);
|
||||
}
|
||||
if (
|
||||
context.ingress.kind !== "local-cli" ||
|
||||
context.ingress.state !== "present" ||
|
||||
context.ingress.boundary !== "agent-command.local"
|
||||
) {
|
||||
throw new Error("local agent run did not retain authoritative local-CLI ingress");
|
||||
}
|
||||
const admission = result.decisions.find(
|
||||
(receipt) => receipt.action.family === "run" && receipt.action.operation === "admission",
|
||||
);
|
||||
if (
|
||||
!admission ||
|
||||
admission.decision.outcome !== "not-applicable" ||
|
||||
admission.decision.reasonCode !== "run_admission_identity_not_evaluated"
|
||||
) {
|
||||
throw new Error("audit JSON projection omitted the truthful admission receipt");
|
||||
}
|
||||
}
|
||||
|
||||
function findLocalRunId(gateway: Awaited<ReturnType<typeof startQaGatewayChild>>) {
|
||||
const stateDir = gateway.runtimeEnv.OPENCLAW_STATE_DIR;
|
||||
if (!stateDir) {
|
||||
throw new Error("QA Gateway did not expose its isolated state directory");
|
||||
}
|
||||
const database = new DatabaseSync(path.join(stateDir, "state", "openclaw.sqlite"), {
|
||||
readOnly: true,
|
||||
});
|
||||
try {
|
||||
const rows = database
|
||||
.prepare(
|
||||
"SELECT run_id, context_json FROM execution_identity_contexts ORDER BY created_at, context_id",
|
||||
)
|
||||
.all() as Array<{ run_id: string; context_json: string }>;
|
||||
const localRows = rows.filter((row) => {
|
||||
const context = parseJson<{ ingress?: { kind?: string } }>(
|
||||
row.context_json,
|
||||
"persisted local context",
|
||||
);
|
||||
return context.ingress?.kind === "local-cli";
|
||||
});
|
||||
if (localRows.length !== 1 || !localRows[0]?.run_id) {
|
||||
throw new Error(
|
||||
`local run recorded ${String(localRows.length)} local-CLI execution identity contexts`,
|
||||
);
|
||||
}
|
||||
return localRows[0].run_id;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
function inspectExecutionIdentityStorage(gateway: Awaited<ReturnType<typeof startQaGatewayChild>>) {
|
||||
const stateDir = gateway.runtimeEnv.OPENCLAW_STATE_DIR;
|
||||
if (!stateDir) {
|
||||
throw new Error("QA Gateway did not expose its isolated state directory");
|
||||
}
|
||||
const database = new DatabaseSync(path.join(stateDir, "state", "openclaw.sqlite"), {
|
||||
readOnly: true,
|
||||
});
|
||||
try {
|
||||
const table = database
|
||||
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
|
||||
.get("execution_identity_contexts");
|
||||
if (!table) {
|
||||
return { rowCount: 0, tablePresent: false };
|
||||
}
|
||||
const row = database
|
||||
.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts")
|
||||
.get() as { count: number };
|
||||
return { rowCount: row.count, tablePresent: true };
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runLocalTurn(
|
||||
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
|
||||
message: string,
|
||||
) {
|
||||
await gateway.runCli([
|
||||
"agent",
|
||||
"--local",
|
||||
"--agent",
|
||||
"qa",
|
||||
"--session-id",
|
||||
`identity-${randomUUID()}`,
|
||||
"--message",
|
||||
message,
|
||||
"--thinking",
|
||||
"off",
|
||||
"--timeout",
|
||||
"60",
|
||||
"--json",
|
||||
]);
|
||||
}
|
||||
|
||||
function findRunExecutions(
|
||||
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
|
||||
runId: string,
|
||||
) {
|
||||
const stateDir = gateway.runtimeEnv.OPENCLAW_STATE_DIR;
|
||||
if (!stateDir) {
|
||||
throw new Error("QA Gateway did not expose its isolated state directory");
|
||||
}
|
||||
const database = new DatabaseSync(path.join(stateDir, "state", "openclaw.sqlite"), {
|
||||
readOnly: true,
|
||||
});
|
||||
try {
|
||||
return database
|
||||
.prepare(
|
||||
"SELECT execution_id, context_id, created_at, context_json FROM execution_identity_contexts WHERE run_id = ? ORDER BY created_at, execution_id",
|
||||
)
|
||||
.all(runId) as Array<{
|
||||
execution_id: string;
|
||||
context_id: string;
|
||||
created_at: number;
|
||||
context_json: string;
|
||||
}>;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runRepeatedIngressTurns(
|
||||
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
|
||||
repoRoot: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const script = path.join(
|
||||
repoRoot,
|
||||
"test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts",
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ["--import", "tsx", script, sessionId], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, ...gateway.runtimeEnv },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let output = "";
|
||||
const collect = (chunk: Buffer) => {
|
||||
if (output.length < 8_192) {
|
||||
output += chunk.toString("utf8").slice(0, 8_192 - output.length);
|
||||
}
|
||||
};
|
||||
child.stdout?.on("data", collect);
|
||||
child.stderr?.on("data", collect);
|
||||
const timer = setTimeout(() => child.kill("SIGTERM"), 120_000);
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`repeated ingress child failed code=${String(code)} signal=${String(signal)}: ${output}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runProof(options: ProducerOptions): Promise<string> {
|
||||
const mock = await startQaMockOpenAiServer();
|
||||
let gateway: Awaited<ReturnType<typeof startQaGatewayChild>> | undefined;
|
||||
try {
|
||||
gateway = await startQaGatewayChild({
|
||||
repoRoot: options.repoRoot,
|
||||
useRepoCli: true,
|
||||
providerBaseUrl: `${mock.baseUrl}/v1`,
|
||||
providerMode: "mock-openai",
|
||||
transportBaseUrl: "http://127.0.0.1",
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
await runLocalTurn(gateway, "Reply exactly: IDENTITY-DISABLED-FRESH");
|
||||
if (inspectExecutionIdentityStorage(gateway).tablePresent) {
|
||||
throw new Error("fresh-install default unexpectedly created execution identity storage");
|
||||
}
|
||||
await gateway.restartAfterStateMutation(async () => {});
|
||||
await runLocalTurn(gateway, "Reply exactly: IDENTITY-DISABLED-UPGRADE");
|
||||
if (inspectExecutionIdentityStorage(gateway).tablePresent) {
|
||||
throw new Error("existing-install restart unexpectedly created execution identity storage");
|
||||
}
|
||||
await gateway.restartAfterStateMutation(async ({ configPath }) => {
|
||||
await updateExecutionIdentityConfig(configPath, { executionIdentity: true });
|
||||
});
|
||||
await runLocalTurn(gateway, "Reply exactly: IDENTITY-INSPECTION-OK");
|
||||
const runId = findLocalRunId(gateway);
|
||||
const beforeText = await gateway.runCli(["audit", "--run", runId, "--explain"]);
|
||||
assertTextProjection(beforeText);
|
||||
const before = parseJson<AuditRunInspectResult>(
|
||||
await gateway.runCli(["audit", "--run", runId, "--explain", "--json"]),
|
||||
"pre-restart audit inspection",
|
||||
);
|
||||
assertJsonProjection(before, runId);
|
||||
const beforeContext = normalizedContextJson(before);
|
||||
|
||||
const repeatedRunId = `identity-repeated-${randomUUID()}`;
|
||||
await runRepeatedIngressTurns(gateway, options.repoRoot, repeatedRunId);
|
||||
const repeatedRows = findRunExecutions(gateway, repeatedRunId);
|
||||
if (
|
||||
repeatedRows.length !== 2 ||
|
||||
new Set(repeatedRows.map((row) => row.execution_id)).size !== 2 ||
|
||||
new Set(repeatedRows.map((row) => row.context_id)).size !== 2
|
||||
) {
|
||||
throw new Error(
|
||||
`repeated same-session run recorded ${String(repeatedRows.length)} non-distinct executions`,
|
||||
);
|
||||
}
|
||||
const discoveryText = await gateway.runCli(["audit", "--run", repeatedRunId, "--explain"]);
|
||||
if (
|
||||
!discoveryText.includes("execution_selection_required") ||
|
||||
!discoveryText.includes("--execution <id> --explain")
|
||||
) {
|
||||
throw new Error("ambiguous run discovery omitted exact-execution selection guidance");
|
||||
}
|
||||
const discovery = parseJson<AuditRunInspectResult>(
|
||||
await gateway.runCli(["audit", "--run", repeatedRunId, "--explain", "--json"]),
|
||||
"repeated-run discovery",
|
||||
);
|
||||
if (discovery.identity.state !== "ambiguous" || discovery.identity.candidates.length !== 2) {
|
||||
throw new Error("repeated same-session run was not reported as two ambiguous executions");
|
||||
}
|
||||
const repeatedBeforeRestart = new Map<string, string>();
|
||||
for (const row of repeatedRows) {
|
||||
const text = await gateway.runCli(["audit", "--execution", row.execution_id, "--explain"]);
|
||||
assertTextProjection(text);
|
||||
const exact = parseJson<AuditRunInspectResult>(
|
||||
await gateway.runCli(["audit", "--execution", row.execution_id, "--explain", "--json"]),
|
||||
`execution ${row.execution_id}`,
|
||||
);
|
||||
const context = requireIdentityContext(exact);
|
||||
if (
|
||||
exact.run.executionId !== row.execution_id ||
|
||||
context.executionId !== row.execution_id ||
|
||||
context.contextId !== row.context_id ||
|
||||
context.runId !== repeatedRunId ||
|
||||
context.ingress.kind !== "api" ||
|
||||
context.ingress.state !== "unknown"
|
||||
) {
|
||||
throw new Error(`exact execution inspection selected the wrong turn: ${row.execution_id}`);
|
||||
}
|
||||
const exactContextJson = normalizedContextJson(exact);
|
||||
if (exactContextJson !== row.context_json) {
|
||||
throw new Error(`RPC context bytes differ from persisted bytes: ${row.execution_id}`);
|
||||
}
|
||||
repeatedBeforeRestart.set(row.execution_id, exactContextJson);
|
||||
}
|
||||
|
||||
await gateway.restartAfterStateMutation(async () => {});
|
||||
|
||||
const afterText = await gateway.runCli(["audit", "--run", runId, "--explain"]);
|
||||
assertTextProjection(afterText);
|
||||
const after = parseJson<AuditRunInspectResult>(
|
||||
await gateway.runCli(["audit", "--run", runId, "--explain", "--json"]),
|
||||
"post-restart audit inspection",
|
||||
);
|
||||
assertJsonProjection(after, runId);
|
||||
const afterContext = normalizedContextJson(after);
|
||||
if (afterContext !== beforeContext) {
|
||||
throw new Error("normalized execution identity context bytes changed across Gateway restart");
|
||||
}
|
||||
for (const [executionId, expectedContext] of repeatedBeforeRestart) {
|
||||
const afterExact = parseJson<AuditRunInspectResult>(
|
||||
await gateway.runCli(["audit", "--execution", executionId, "--explain", "--json"]),
|
||||
`post-restart execution ${executionId}`,
|
||||
);
|
||||
if (normalizedContextJson(afterExact) !== expectedContext) {
|
||||
throw new Error(`repeated execution changed across Gateway restart: ${executionId}`);
|
||||
}
|
||||
}
|
||||
const retainedBeforeGlobalDisable = inspectExecutionIdentityStorage(gateway).rowCount;
|
||||
await gateway.restartAfterStateMutation(async ({ configPath }) => {
|
||||
await updateExecutionIdentityConfig(configPath, {
|
||||
enabled: false,
|
||||
executionIdentity: true,
|
||||
});
|
||||
});
|
||||
await runLocalTurn(gateway, "Reply exactly: IDENTITY-DISABLED-GLOBAL");
|
||||
if (inspectExecutionIdentityStorage(gateway).rowCount !== retainedBeforeGlobalDisable) {
|
||||
throw new Error("global audit disable unexpectedly retained a new execution context");
|
||||
}
|
||||
const afterGlobalDisable = parseJson<AuditRunInspectResult>(
|
||||
await gateway.runCli(["audit", "--run", runId, "--explain", "--json"]),
|
||||
"global-disabled retained inspection",
|
||||
);
|
||||
if (normalizedContextJson(afterGlobalDisable) !== beforeContext) {
|
||||
throw new Error("global audit disable hid or changed retained identity evidence");
|
||||
}
|
||||
|
||||
const snapshotPath = path.join(options.artifactBase, SNAPSHOT_FILE);
|
||||
await fs.mkdir(options.artifactBase, { recursive: true });
|
||||
await fs.writeFile(
|
||||
snapshotPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
runId,
|
||||
repeatedRunId,
|
||||
repeatedExecutions: repeatedRows.map((row) => ({
|
||||
executionId: row.execution_id,
|
||||
contextId: row.context_id,
|
||||
})),
|
||||
coverage: before.coverage,
|
||||
decision: before.decisions[0]?.decision,
|
||||
contextSha256: sha256(beforeContext),
|
||||
byteEquivalentAfterRestart: true,
|
||||
byteEquivalentPersistedReadback: true,
|
||||
optIn: {
|
||||
explicitEnablement: true,
|
||||
freshInstallDisabled: true,
|
||||
freshInstallTableAbsent: true,
|
||||
globalAuditDisabled: true,
|
||||
upgradeStyleExistingInstallDisabled: true,
|
||||
upgradeStyleTableAbsent: true,
|
||||
},
|
||||
textSections: TEXT_SECTIONS,
|
||||
identityFields: IDENTITY_FIELDS,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return `local run=${runId}; repeated run=${repeatedRunId} executions=${repeatedRows.map((row) => row.execution_id).join(",")}; Gateway pid=${gateway.pid ?? "unknown"}; text+JSON exact selection passed before/after replacement; normalized context sha256=${sha256(beforeContext)}`;
|
||||
} finally {
|
||||
await gateway?.stop().catch(() => undefined);
|
||||
await mock.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function produceProof(options: ProducerOptions): Promise<ProofResult> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const details = await runProof(options);
|
||||
return {
|
||||
artifacts: [{ filePath: SNAPSHOT_FILE, kind: "summary" }],
|
||||
details,
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status: "pass",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
details: formatErrorMessage(error),
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status: "fail",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function runProducer(options: ProducerOptions): Promise<QaEvidenceSummaryJson> {
|
||||
const writer = createQaScriptEvidenceWriter({
|
||||
artifactBase: options.artifactBase,
|
||||
logFileName: `${SCENARIO_ID}.log`,
|
||||
primaryModel: "mock-openai/gpt-5.6-luna",
|
||||
providerMode: "mock-openai",
|
||||
repoRoot: options.repoRoot,
|
||||
target: {
|
||||
id: SCENARIO_ID,
|
||||
title: "Agent-run execution identity inspection",
|
||||
sourcePath: `qa/scenarios/runtime/${SCENARIO_ID}.yaml`,
|
||||
docsRefs: ["docs/gateway/audit.md", "docs/cli/audit.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",
|
||||
],
|
||||
},
|
||||
});
|
||||
const result = await produceProof(options);
|
||||
writer.appendLog(`${result.status}: ${result.details ?? "no details"}\n`);
|
||||
return await writer.write(result);
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]) {
|
||||
const evidence = await runProducer(parseOptions(argv));
|
||||
const status = evidence.entries[0]?.result.status;
|
||||
console.log(`Agent-run identity evidence: ${QA_EVIDENCE_FILENAME}`);
|
||||
console.log(`Agent-run identity status: ${status}`);
|
||||
return status === "pass" ? 0 : 1;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
main(process.argv.slice(2))
|
||||
.then((exitCode) => {
|
||||
process.exitCode = exitCode;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(formatErrorMessage(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Runs two real public-ingress turns in one session so QA can inspect their executions.
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createAuditEventRecorder } from "../../../../src/audit/audit-recorder.js";
|
||||
import { configureExecutionIdentityAdmissionSink } from "../../../../src/audit/execution-identity-admission.js";
|
||||
import { agentCommandFromIngress } from "../../../../src/plugin-sdk/agent-runtime.js";
|
||||
|
||||
async function main() {
|
||||
const sessionId = process.argv[2]?.trim();
|
||||
if (!sessionId) {
|
||||
throw new Error("session id is required");
|
||||
}
|
||||
const recorder = createAuditEventRecorder({ messageMode: "off" });
|
||||
const clearSink = configureExecutionIdentityAdmissionSink(recorder.recordExecutionIdentity);
|
||||
try {
|
||||
for (const message of [
|
||||
"Reply exactly: REPEATED-TURN-ONE",
|
||||
"Reply exactly: REPEATED-TURN-TWO",
|
||||
]) {
|
||||
const result = await agentCommandFromIngress(
|
||||
{
|
||||
message,
|
||||
sessionId,
|
||||
sessionKey: "agent:qa:identity-repeated-turns",
|
||||
agentId: "qa",
|
||||
allowModelOverride: false,
|
||||
deliver: false,
|
||||
},
|
||||
{ log: () => {}, error: () => {} } as never,
|
||||
);
|
||||
if (!result?.payloads?.some((payload) => payload.text?.trim())) {
|
||||
throw new Error("repeated public-ingress turn produced no text payload");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearSink();
|
||||
await recorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user