Files
agent-pet-companion/crates/petcore/tests/event_envelope_security.rs
T
2026-08-09 17:18:27 +08:00

1297 lines
43 KiB
Rust

use petcore::db::{Database, EventRetentionPolicy, InsertEventOutcome};
use petcore::event_envelope::{NormalizedAgentEvent, MAX_RECENT_EVENTS};
use petcore_types::{
AgentEvent, AgentEventType, AgentSource, PetOrigin, PetSummary, QualityLevel, RenderSize,
};
use rusqlite::{params, Connection};
use serde_json::{json, Value};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
fn received_at() -> &'static str {
// Ordinary fixtures stay inside the retention window without embedding a
// calendar deadline. Pruning tests provide their own explicit timestamps.
static RECEIVED_AT: OnceLock<String> = OnceLock::new();
RECEIVED_AT
.get_or_init(|| {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.expect("format current fixture timestamp")
})
.as_str()
}
const FORBIDDEN_VALUES: &[&str] = &[
"FORBIDDEN_PROMPT_VALUE_7f16",
"FORBIDDEN_COMMAND_VALUE_b8c2",
"FORBIDDEN_API_KEY_VALUE_4a91",
"FORBIDDEN_TOOL_RESPONSE_VALUE_19df",
"FORBIDDEN_TRANSCRIPT_PATH_82e1",
"FORBIDDEN_ARBITRARY_OUTPUT_VALUE_d611",
];
fn strict_event(
id: &str,
source: AgentSource,
session_id: Option<&str>,
created_at: &str,
) -> AgentEvent {
AgentEvent {
id: id.to_string(),
source,
project_path: None,
session_id: session_id.map(ToOwned::to_owned),
event_type: AgentEventType::Tool,
title: "Working".to_string(),
detail: Some("shell".to_string()),
payload_json: json!({
"schema_version": "apc.agent-event.v1",
"external_event_id": id,
"source_event": "PostToolUse",
"tool_name": "shell",
"outcome": "completed",
"diagnostic": false
}),
created_at: created_at.to_string(),
}
}
fn external_event(id: &str, session_id: Option<&str>) -> Value {
json!({
"id": id,
"session_id": session_id,
"event_type": "tool",
"title": "Working",
"detail": "shell",
"payload": {
"source_event": "PostToolUse",
"tool_name": "shell",
"outcome": "completed",
"diagnostic": false
}
})
}
#[test]
fn external_display_and_metadata_aliases_never_reach_visible_records() {
let event = NormalizedAgentEvent::from_external(
AgentSource::Codex,
json!({
"id": "alias-sentinels",
"session_id": "session-security",
"event_type": "tool",
"title": "RAW_TITLE_ALIAS_SENTINEL",
"detail": "RAW_DETAIL_ALIAS_SENTINEL",
"payload": {
"source_event": "RAW_SOURCE_EVENT_ALIAS_SENTINEL",
"tool_name": "RAW_TOOL_NAME_ALIAS_SENTINEL",
"outcome": "RAW_OUTCOME_ALIAS_SENTINEL",
"diagnostic": false
}
}),
received_at(),
)
.unwrap();
assert_eq!(event.title, AgentEventType::Tool.zh_label());
assert_eq!(event.detail, None);
assert_eq!(event.payload_json["source_event"], "unclassified");
assert_eq!(event.payload_json["tool_name"], "other");
assert_eq!(event.payload_json["outcome"], "unknown");
let visible_record = serde_json::to_string(&event).unwrap();
for sentinel in [
"RAW_TITLE_ALIAS_SENTINEL",
"RAW_DETAIL_ALIAS_SENTINEL",
"RAW_SOURCE_EVENT_ALIAS_SENTINEL",
"RAW_TOOL_NAME_ALIAS_SENTINEL",
"RAW_OUTCOME_ALIAS_SENTINEL",
] {
assert!(!visible_record.contains(sentinel), "leaked {sentinel}");
}
}
#[test]
fn pi_input_is_a_first_class_allowlisted_lifecycle_event() {
let event = NormalizedAgentEvent::from_external(
AgentSource::Pi,
json!({
"id": "pi-input-allowlisted",
"session_id": "pi-input-session",
"event_type": "start",
"payload": {
"source_event": "input",
"session_active": true,
"message_role": "user",
"message_content": "下一条用户消息",
"diagnostic": false
}
}),
received_at(),
)
.unwrap();
assert_eq!(event.payload_json["source_event"], "input");
assert_eq!(event.payload_json["message_role"], "user");
assert_eq!(event.payload_json["message_content"], "下一条用户消息");
}
#[test]
fn activity_ingress_never_recurses_through_credential_containers() {
let sources = [
(AgentSource::Codex, "PostToolUse"),
(AgentSource::ClaudeCode, "PostToolUse"),
(AgentSource::Pi, "tool_execution_end"),
(AgentSource::Opencode, "tool.execute.after"),
];
for (source_index, (source, source_event)) in sources.into_iter().enumerate() {
for (container_index, container) in [
json!({"headers": {"content": "secret header"}}),
json!({"environment": {"message": "secret environment"}}),
json!({"tokens": {"output": "secret token"}}),
]
.into_iter()
.enumerate()
{
let event = NormalizedAgentEvent::from_external(
source,
json!({
"id": format!("sensitive-activity-{source_index}-{container_index}"),
"session_id": format!("sensitive-session-{source_index}"),
"event_type": "tool",
"payload": {
"source_event": source_event,
"session_active": true,
"activity_kind": "thinking",
"activity_content": serde_json::to_string(&container).unwrap(),
"diagnostic": false
}
}),
received_at(),
)
.unwrap();
assert!(event.payload_json["activity_content"].is_null());
}
}
let readable = NormalizedAgentEvent::from_external(
AgentSource::Codex,
json!({
"id": "readable-structured-activity",
"session_id": "readable-structured-session",
"event_type": "tool",
"payload": {
"source_event": "PostToolUse",
"session_active": true,
"activity_kind": "thinking",
"activity_content": "{\"file_path\":\"Sources/App.swift\"}",
"diagnostic": false
}
}),
received_at(),
)
.unwrap();
assert_eq!(
readable.payload_json["activity_content"],
"Sources/App.swift"
);
}
#[test]
fn session_display_fields_enforce_exact_utf8_byte_boundaries() {
let session_title = format!("{}a", "界".repeat(53));
let message_content = format!("{}a", "界".repeat(1365));
assert_eq!(session_title.len(), 160);
assert_eq!(message_content.len(), 4096);
let accepted = NormalizedAgentEvent::from_external(
AgentSource::Codex,
json!({
"id": "display-boundary-accepted",
"session_id": "display-boundary-session",
"event_type": "start",
"payload": {
"source_event": "UserPromptSubmit",
"session_active": true,
"message_role": "user",
"message_content": message_content,
"session_title": session_title,
"diagnostic": false
}
}),
received_at(),
)
.unwrap();
assert_eq!(
accepted.payload_json["session_title"]
.as_str()
.unwrap()
.len(),
160
);
assert_eq!(
accepted.payload_json["message_content"]
.as_str()
.unwrap()
.len(),
4096
);
for (index, key, maximum, oversized) in [
(0, "session_title", 160, format!("{}ab", "界".repeat(53))),
(
1,
"message_content",
4096,
format!("{}ab", "界".repeat(1365)),
),
] {
assert_eq!(oversized.len(), maximum + 1);
let mut payload = json!({
"source_event": "UserPromptSubmit",
"session_active": true,
"diagnostic": false
});
payload[key] = Value::String(oversized);
let error = NormalizedAgentEvent::from_external(
AgentSource::Codex,
json!({
"id": format!("display-boundary-rejected-{index}"),
"session_id": "display-boundary-session",
"event_type": "start",
"payload": payload
}),
received_at(),
)
.unwrap_err();
let error = error.to_string();
assert!(
error.contains(&format!(
"agent event payload {key} exceeds {maximum} UTF-8 bytes"
)),
"{error}"
);
}
}
#[test]
fn opencode_v2_activity_events_are_first_class_allowlisted_lifecycle_names() {
for (index, (source_event, event_type)) in [
("permission.v2.asked", "waiting"),
("permission.v2.replied", "start"),
("question.v2.asked", "waiting"),
("question.v2.replied", "start"),
("question.v2.rejected", "start"),
("session.next.prompt.admitted", "start"),
("session.next.reasoning.ended", "thinking"),
("session.plan.updated", "plan"),
]
.into_iter()
.enumerate()
{
let event = NormalizedAgentEvent::from_external(
AgentSource::Opencode,
json!({
"id": format!("opencode-v2-source-event-{index}"),
"session_id": "opencode-v2-source-event-session",
"event_type": event_type,
"payload": {
"source_event": source_event,
"session_active": true,
"diagnostic": false
}
}),
received_at(),
)
.unwrap();
assert_eq!(event.payload_json["source_event"], source_event);
}
}
#[test]
fn opencode_prompt_admitted_outcome_survives_the_envelope_vocabularies() {
let event = NormalizedAgentEvent::from_external(
AgentSource::Opencode,
json!({
"id": "opencode-prompt-admitted-outcome",
"session_id": "opencode-prompt-admitted-session",
"event_type": "start",
"payload": {
"source_event": "session.next.prompt.admitted",
"outcome": "prompt_admitted",
"session_active": true,
"diagnostic": false
}
}),
received_at(),
)
.unwrap();
assert_eq!(event.payload_json["outcome"], "prompt_admitted");
}
#[test]
fn payload_json_alias_is_normalized_through_the_same_closed_vocabularies() {
let event = NormalizedAgentEvent::from_external(
AgentSource::ClaudeCode,
json!({
"id": "payload-json-alias",
"source": "claude_code",
"event_type": "failed",
"title": "RAW_TITLE_ALIAS_SENTINEL",
"detail": "RAW_DETAIL_ALIAS_SENTINEL",
"payload_json": {
"schema_version": "apc.agent-event.v1",
"external_event_id": "payload-json-alias",
"source_event": "StopFailure",
"tool_name": "Bash",
"outcome": "api_failure",
"diagnostic": false
},
"created_at": received_at()
}),
received_at(),
)
.unwrap();
assert_eq!(event.title, AgentEventType::Failed.zh_label());
assert_eq!(event.detail, None);
assert_eq!(event.payload_json["source_event"], "StopFailure");
assert_eq!(event.payload_json["tool_name"], "shell");
assert_eq!(event.payload_json["outcome"], "api_failure");
let record = serde_json::to_string(&event).unwrap();
assert!(!record.contains("RAW_TITLE_ALIAS_SENTINEL"));
assert!(!record.contains("RAW_DETAIL_ALIAS_SENTINEL"));
}
#[test]
fn diagnostic_events_always_normalize_to_non_activity_metadata() {
for supplied in [None, Some(true), Some(false)] {
let mut payload = json!({
"source_event": "connection.test",
"diagnostic": true
});
if let Some(value) = supplied {
payload["affects_activity"] = json!(value);
}
let event = NormalizedAgentEvent::from_external(
AgentSource::Codex,
json!({
"id": format!("diagnostic-{supplied:?}"),
"event_type": "tool",
"payload": payload
}),
received_at(),
)
.unwrap();
assert_eq!(event.payload_json["diagnostic"], true);
assert_eq!(event.payload_json["affects_activity"], false);
}
}
#[test]
fn session_navigation_accepts_only_allowlisted_warp_focus_urls() {
let event = NormalizedAgentEvent::from_external(
AgentSource::Codex,
json!({
"id": "warp-session-target",
"event_type": "start",
"payload": {
"source_event": "UserPromptSubmit",
"session_active": true,
"session_open": true,
"session_surface": "cli_terminal",
"terminal_app": "warp",
"session_open_url": "warppreview://session/A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
}
}),
received_at(),
)
.unwrap();
assert_eq!(event.payload_json["session_open"], true);
assert_eq!(event.payload_json["terminal_app"], "warp");
assert_eq!(
event.payload_json["session_open_url"],
"warppreview://session/A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
);
for (index, invalid_url) in [
"https://example.com/session/not-allowed",
" warppreview://session/A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
"warppreview://session/A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4 ",
]
.into_iter()
.enumerate()
{
let rejected = NormalizedAgentEvent::from_external(
AgentSource::Codex,
json!({
"id": format!("unsafe-session-target-{index}"),
"event_type": "start",
"payload": {
"source_event": "UserPromptSubmit",
"session_open_url": invalid_url
}
}),
received_at(),
)
.unwrap_err();
assert!(
rejected.to_string().contains("not a supported session URL"),
"{rejected}"
);
}
}
#[test]
fn strict_ingest_rejects_unknown_top_level_and_envelope_fields() {
let top_level = NormalizedAgentEvent::from_external(
AgentSource::Codex,
json!({
"id": "unknown-top-level",
"event_type": "start",
"raw_prompt": "must not be ignored"
}),
received_at(),
)
.unwrap_err();
assert!(top_level.to_string().contains("field is not supported"));
let nested = NormalizedAgentEvent::from_external(
AgentSource::Codex,
json!({
"id": "unknown-envelope-field",
"event_type": "tool",
"payload": {
"source_event": "PreToolUse",
"tool_name": "Bash",
"outcome": "started",
"raw_command": "must not be ignored"
}
}),
received_at(),
)
.unwrap_err();
assert!(nested
.to_string()
.contains("payload field is not supported"));
}
#[test]
fn strict_ingest_rejects_ambiguous_payload_aliases_and_source_mismatch() {
let ambiguous = NormalizedAgentEvent::from_external(
AgentSource::Pi,
json!({
"event_type": "tool",
"payload": {},
"payload_json": {}
}),
received_at(),
)
.unwrap_err();
assert!(ambiguous.to_string().contains("only one of payload"));
let mismatch = NormalizedAgentEvent::from_external(
AgentSource::Pi,
json!({
"source": "codex",
"event_type": "start"
}),
received_at(),
)
.unwrap_err();
assert!(mismatch.to_string().contains("source does not match"));
}
fn security_fixture() -> Value {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/security/sensitive-agent-event.json");
serde_json::from_slice(&fs::read(path).unwrap()).unwrap()
}
#[test]
fn raw_hook_payload_cannot_cross_the_strict_ingest_boundary() {
let encoded = serde_json::to_string(&security_fixture()).unwrap();
let error =
NormalizedAgentEvent::from_external(AgentSource::Codex, security_fixture(), received_at())
.unwrap_err();
assert!(error.to_string().contains("payload field is not supported"));
for forbidden in FORBIDDEN_VALUES {
assert!(encoded.contains(forbidden));
assert!(!error.to_string().contains(forbidden));
}
}
#[test]
fn external_title_and_detail_are_discarded_even_when_multibyte() {
let mut value = external_event("utf8-bounds", Some("session"));
value["title"] = Value::String("宠".repeat(100));
value["detail"] = Value::String("🙂".repeat(200));
let event = NormalizedAgentEvent::from_external(AgentSource::Pi, value, received_at()).unwrap();
assert_eq!(event.title, AgentEventType::Tool.zh_label());
assert_eq!(event.detail, None);
}
#[test]
fn missing_external_id_is_stable_for_the_same_canonical_input() {
let mut value = external_event("ignored", Some("session"));
value.as_object_mut().unwrap().remove("id");
let first =
NormalizedAgentEvent::from_external(AgentSource::ClaudeCode, value.clone(), received_at())
.unwrap();
let second =
NormalizedAgentEvent::from_external(AgentSource::ClaudeCode, value, received_at()).unwrap();
assert_eq!(first.id, second.id);
assert!(first.id.starts_with("evt_external_"));
}
#[test]
fn same_external_id_from_two_sources_is_not_deduplicated() {
let temp = tempfile::tempdir().unwrap();
let database = Database::new(temp.path().join("events.sqlite"));
database.init().unwrap();
let value = external_event("shared-id", Some("session"));
let codex =
NormalizedAgentEvent::from_external(AgentSource::Codex, value.clone(), received_at())
.unwrap();
let pi = NormalizedAgentEvent::from_external(AgentSource::Pi, value, received_at()).unwrap();
assert_eq!(
database.insert_event(&codex).unwrap(),
InsertEventOutcome::Inserted
);
assert_eq!(
database.insert_event(&pi).unwrap(),
InsertEventOutcome::Inserted
);
assert_eq!(database.recent_events(10).unwrap().len(), 2);
}
#[test]
fn same_id_in_two_sessions_is_not_deduplicated() {
let temp = tempfile::tempdir().unwrap();
let database = Database::new(temp.path().join("events.sqlite"));
database.init().unwrap();
let first = NormalizedAgentEvent::from_external(
AgentSource::Codex,
external_event("shared-id", Some("session-a")),
received_at(),
)
.unwrap();
let second = NormalizedAgentEvent::from_external(
AgentSource::Codex,
external_event("shared-id", Some("session-b")),
received_at(),
)
.unwrap();
assert_eq!(
database.insert_event(&first).unwrap(),
InsertEventOutcome::Inserted
);
assert_eq!(
database.insert_event(&second).unwrap(),
InsertEventOutcome::Inserted
);
assert_eq!(database.recent_events(10).unwrap().len(), 2);
}
#[test]
fn null_and_empty_sessions_share_a_stable_deduplication_namespace() {
let temp = tempfile::tempdir().unwrap();
let database = Database::new(temp.path().join("events.sqlite"));
database.init().unwrap();
let missing = NormalizedAgentEvent::from_external(
AgentSource::Opencode,
external_event("same-no-session", None),
received_at(),
)
.unwrap();
let empty = NormalizedAgentEvent::from_external(
AgentSource::Opencode,
external_event("same-no-session", Some(" ")),
received_at(),
)
.unwrap();
assert_eq!(
database.insert_event(&missing).unwrap(),
InsertEventOutcome::Inserted
);
assert_eq!(
database.insert_event(&empty).unwrap(),
InsertEventOutcome::Duplicate
);
}
#[test]
fn internal_codex_suggestion_session_is_deleted_and_future_events_are_suppressed() {
let temp = tempfile::tempdir().unwrap();
let database = Database::new(temp.path().join("events.sqlite"));
database.init().unwrap();
let session_id = "019f6ed7-de50-7623-8462-6a857e367a96";
let session_start = AgentEvent {
id: "internal-session-start".to_string(),
source: AgentSource::Codex,
project_path: None,
session_id: Some(session_id.to_string()),
event_type: AgentEventType::Start,
title: AgentEventType::Start.zh_label().to_string(),
detail: None,
payload_json: json!({
"source_event": "SessionStart",
"session_active": false,
"diagnostic": false
}),
created_at: received_at().to_string(),
};
assert_eq!(
database.insert_event(&session_start).unwrap(),
InsertEventOutcome::Inserted
);
let internal_prompt = AgentEvent {
id: "internal-user-prompt".to_string(),
source: AgentSource::Codex,
project_path: None,
session_id: Some(session_id.to_string()),
event_type: AgentEventType::Start,
title: AgentEventType::Start.zh_label().to_string(),
detail: None,
payload_json: json!({
"source_event": "UserPromptSubmit",
"session_active": true,
"message_role": "user",
"message_content": "# Overview\n\nGenerate 0 to 3 hyperpersonalized suggestions for what this user can do with Codex in this Projectless task\n\nGet an understanding of the user's intent",
"diagnostic": false
}),
created_at: received_at().to_string(),
};
assert_eq!(
database.insert_event(&internal_prompt).unwrap(),
InsertEventOutcome::Suppressed
);
assert!(database.recent_events(10).unwrap().is_empty());
let later_tool = strict_event(
"internal-later-tool",
AgentSource::Codex,
Some(session_id),
received_at(),
);
assert_eq!(
database.insert_event(&later_tool).unwrap(),
InsertEventOutcome::Suppressed
);
let marker_count: i64 = Connection::open(database.path())
.unwrap()
.query_row(
"SELECT COUNT(*) FROM suppressed_agent_sessions WHERE source = 'codex' AND session_key = ?1",
params![format!("1:{session_id}")],
|row| row.get(0),
)
.unwrap();
assert_eq!(marker_count, 1);
}
#[test]
fn child_session_marker_deletes_existing_projection_and_blocks_future_events() {
let temp = tempfile::tempdir().unwrap();
let database = Database::new(temp.path().join("events.sqlite"));
database.init().unwrap();
let session_id = "opencode-child-session";
let ordinary = strict_event(
"child-before-lineage",
AgentSource::Opencode,
Some(session_id),
received_at(),
);
assert_eq!(
database.insert_event(&ordinary).unwrap(),
InsertEventOutcome::Inserted
);
let marker = AgentEvent {
id: "child-lineage-marker".to_string(),
source: AgentSource::Opencode,
project_path: None,
session_id: Some(session_id.to_string()),
event_type: AgentEventType::Start,
title: AgentEventType::Start.zh_label().to_string(),
detail: None,
payload_json: json!({
"source_event": "session.child",
"session_active": false,
"diagnostic": false
}),
created_at: received_at().to_string(),
};
assert_eq!(
database.insert_event(&marker).unwrap(),
InsertEventOutcome::Suppressed
);
assert!(database.recent_events(10).unwrap().is_empty());
let later = strict_event(
"child-after-lineage",
AgentSource::Opencode,
Some(session_id),
received_at(),
);
assert_eq!(
database.insert_event(&later).unwrap(),
InsertEventOutcome::Suppressed
);
}
#[test]
fn title_only_app_server_activity_removes_an_existing_projected_session() {
let temp = tempfile::tempdir().unwrap();
let database = Database::new(temp.path().join("events.sqlite"));
database.init().unwrap();
let session_id = "019f6ed7-de50-7623-8462-6a857e367a96";
let ordinary_activity = AgentEvent {
id: "app-server-title-only-status".to_string(),
source: AgentSource::Codex,
project_path: None,
session_id: Some(session_id.to_string()),
event_type: AgentEventType::Done,
title: AgentEventType::Done.zh_label().to_string(),
detail: None,
payload_json: json!({
"source_event": "app_server_activity",
"session_active": false,
"session_title": "Visible user task",
"message_role": "assistant",
"message_content": "Done",
"diagnostic": false
}),
created_at: received_at().to_string(),
};
assert!(database
.upsert_codex_activity_event(&ordinary_activity)
.unwrap());
assert_eq!(database.recent_events(10).unwrap().len(), 1);
let internal_activity = AgentEvent {
payload_json: json!({
"source_event": "app_server_activity",
"session_active": false,
"session_title": "# Overview Generate 0 to 3 hyperpersonalized suggestions for what this user can do with Codex in this local project: /tmp/project",
"message_role": "assistant",
"message_content": "{\"suggestions\":[]}",
"diagnostic": false
}),
..ordinary_activity
};
assert!(!database
.upsert_codex_activity_event(&internal_activity)
.unwrap());
assert!(database.recent_events(10).unwrap().is_empty());
let marker_count: i64 = Connection::open(database.path())
.unwrap()
.query_row(
"SELECT COUNT(*) FROM suppressed_agent_sessions WHERE source = 'codex' AND session_key = ?1",
params![format!("1:{session_id}")],
|row| row.get(0),
)
.unwrap();
assert_eq!(marker_count, 1);
}
#[test]
fn schema_upgrade_scrubs_historical_internal_codex_suggestion_sessions() {
let temp = tempfile::tempdir().unwrap();
let database = Database::new(temp.path().join("events.sqlite"));
database.init().unwrap();
let session_id = "019f6ed2-390b-7fd2-b6a7-d9ffaa5acc23";
{
let connection = Connection::open(database.path()).unwrap();
connection
.execute(
r#"
INSERT INTO agent_events
(external_event_id, source, project_path, session_id, session_key,
event_type, title, detail, payload_json, created_at)
VALUES (?1, 'codex', NULL, ?2, ?2, 'start', '开始处理', NULL, ?3, ?4)
"#,
params![
"historical-internal-prompt",
session_id,
serde_json::to_string(&json!({
"source_event": "app_server_activity",
"session_active": false,
"session_title": "# Overview Generate 0 to 3 hyperpersonalized suggestions for what this user can do with Codex in this Projectless task",
"message_role": "assistant",
"message_content": "{\"suggestions\":[]}",
"diagnostic": false
}))
.unwrap(),
received_at()
],
)
.unwrap();
connection.pragma_update(None, "user_version", 4).unwrap();
}
database.init().unwrap();
assert!(database.recent_events(10).unwrap().is_empty());
let connection = Connection::open(database.path()).unwrap();
let marker_count: i64 = connection
.query_row(
"SELECT COUNT(*) FROM suppressed_agent_sessions WHERE source = 'codex' AND session_key = ?1",
params![session_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(marker_count, 1);
let schema_version: u32 = connection
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(schema_version, petcore::db::DATABASE_SCHEMA_VERSION);
}
#[test]
fn recent_events_clamps_limit() {
let temp = tempfile::tempdir().unwrap();
let database = Database::new(temp.path().join("events.sqlite"));
database.init().unwrap();
for index in 0..(MAX_RECENT_EVENTS + 5) {
let event = strict_event(
&format!("event-{index:03}"),
AgentSource::Codex,
Some("session"),
received_at(),
);
assert_eq!(
database.insert_event(&event).unwrap(),
InsertEventOutcome::Inserted
);
}
assert!(database.recent_events(0).unwrap().is_empty());
assert_eq!(
database.recent_events(usize::MAX).unwrap().len(),
MAX_RECENT_EVENTS
);
}
#[test]
fn event_retention_prunes_oldest_rows() {
let temp = tempfile::tempdir().unwrap();
let database = Database::new(temp.path().join("events.sqlite"));
database.init().unwrap();
for (id, created_at) in [
("oldest", "2099-07-01T00:00:00Z"),
("middle", "2099-07-02T00:00:00Z"),
("newest", "2099-07-03T00:00:00Z"),
] {
database
.insert_event(&strict_event(
id,
AgentSource::ClaudeCode,
Some("session"),
created_at,
))
.unwrap();
}
let defaults = EventRetentionPolicy::default();
assert_eq!(defaults.max_rows, 10_000);
assert_eq!(defaults.max_age_days, 30);
let pruned = database
.prune_events(EventRetentionPolicy {
max_rows: 2,
max_age_days: 36_500,
})
.unwrap();
assert_eq!(pruned, 1);
let recent = database.recent_events(10).unwrap();
assert_eq!(recent.len(), 2);
assert!(recent.iter().all(|event| event.id != "oldest"));
let summarized: i64 = Connection::open(database.path())
.unwrap()
.query_row(
r#"
SELECT event_count
FROM agent_event_daily_counts
WHERE event_day = '2099-07-01'
AND source = 'claude_code'
AND event_type = 'tool'
"#,
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(summarized, 1);
}
#[test]
fn session_alias_authority_survives_restart_migration_and_tracks_event_retention() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("events.sqlite");
let database = Database::new(path.clone());
database.init().unwrap();
for (id, session_id, created_at) in [
("alias-a-first", "raw-session-a", "2026-07-20T00:00:00Z"),
("alias-b-first", "raw-session-b", "2026-07-20T00:00:01Z"),
] {
database
.insert_event(&strict_event(
id,
AgentSource::ClaudeCode,
Some(session_id),
created_at,
))
.unwrap();
}
let read_aliases = || {
let connection = Connection::open(&path).unwrap();
let mut statement = connection
.prepare(
r#"
SELECT session_key, alias_sequence
FROM agent_session_aliases
WHERE source = 'claude_code'
ORDER BY alias_sequence
"#,
)
.unwrap();
statement
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap()
};
let initial = read_aliases();
assert_eq!(
initial,
vec![
("1:raw-session-a".to_string(), 1),
("1:raw-session-b".to_string(), 2)
]
);
// A normal daemon restart preserves the assigned authority exactly.
Database::new(path.clone()).init().unwrap();
assert_eq!(read_aliases(), initial);
// Simulate a schema-5 database that already has retained events. Schema 6
// deterministically backfills by first-seen event order.
Connection::open(&path)
.unwrap()
.execute_batch(
r#"
DROP TABLE agent_session_aliases;
PRAGMA user_version = 5;
"#,
)
.unwrap();
Database::new(path.clone()).init().unwrap();
assert_eq!(read_aliases(), initial);
// New activity changes display recency but never reallocates the alias.
database
.insert_event(&strict_event(
"alias-a-newest",
AgentSource::ClaudeCode,
Some("raw-session-a"),
"2026-07-20T00:00:02Z",
))
.unwrap();
assert_eq!(read_aliases(), initial);
database
.prune_events(EventRetentionPolicy {
max_rows: 1,
max_age_days: 36_500,
})
.unwrap();
assert_eq!(read_aliases(), vec![("1:raw-session-a".to_string(), 1)]);
database
.insert_event(&strict_event(
"alias-c-after-retention",
AgentSource::ClaudeCode,
Some("raw-session-c"),
"2026-07-20T00:00:03Z",
))
.unwrap();
assert_eq!(
read_aliases(),
vec![
("1:raw-session-a".to_string(), 1),
("1:raw-session-c".to_string(), 3)
],
"pruned alias sequences must never be reused for a new session"
);
}
#[test]
fn legacy_payload_migration_removes_plaintext_and_rebuilds_rows() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("legacy.sqlite");
let connection = Connection::open(&path).unwrap();
connection
.execute_batch(
r#"
PRAGMA journal_mode = WAL;
PRAGMA user_version = 0;
CREATE TABLE agent_events (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
project_path TEXT,
session_id TEXT,
event_type TEXT NOT NULL,
title TEXT,
detail TEXT,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
"#,
)
.unwrap();
connection
.execute(
r#"
INSERT INTO agent_events
(id, source, project_path, session_id, event_type, title, detail, payload_json, created_at)
VALUES (?1, 'codex', NULL, NULL, 'tool',
'FORBIDDEN_LEGACY_TITLE_ALIAS_0f31',
'FORBIDDEN_LEGACY_DETAIL_ALIAS_4b72', ?2, ?3)
"#,
params![
"legacy-event",
r#"{"prompt":"FORBIDDEN_LEGACY_PLAINTEXT_63bb","command":"cat secret"}"#,
received_at(),
],
)
.unwrap();
drop(connection);
let database = Database::new(path.clone());
database.init().unwrap();
let connection = Connection::open(&path).unwrap();
let columns = connection
.prepare("PRAGMA table_info(agent_events)")
.unwrap()
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
assert!(columns.iter().any(|column| column == "row_id"));
assert!(columns.iter().any(|column| column == "external_event_id"));
assert!(columns.iter().any(|column| column == "session_key"));
let payload_text: String = connection
.query_row("SELECT payload_json FROM agent_events", [], |row| {
row.get(0)
})
.unwrap();
let payload: Value = serde_json::from_str(&payload_text).unwrap();
assert_eq!(payload["external_event_id"], "legacy-event");
assert_eq!(payload["source_event"], "legacy");
assert!(!payload_text.contains("FORBIDDEN_LEGACY_PLAINTEXT_63bb"));
let (title, detail): (String, Option<String>) = connection
.query_row("SELECT title, detail FROM agent_events", [], |row| {
Ok((row.get(0)?, row.get(1)?))
})
.unwrap();
assert_eq!(title, AgentEventType::Tool.zh_label());
assert_eq!(detail, None);
let user_version: u32 = connection
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert!(user_version >= 1);
drop(connection);
for candidate in [
path.clone(),
PathBuf::from(format!("{}-wal", path.display())),
PathBuf::from(format!("{}-shm", path.display())),
] {
if candidate.exists() {
let bytes = fs::read(&candidate).unwrap();
for forbidden in [
b"FORBIDDEN_LEGACY_PLAINTEXT_63bb".as_slice(),
b"FORBIDDEN_LEGACY_TITLE_ALIAS_0f31".as_slice(),
b"FORBIDDEN_LEGACY_DETAIL_ALIAS_4b72".as_slice(),
] {
assert!(
!bytes
.windows(forbidden.len())
.any(|window| window == forbidden),
"legacy plaintext remained in {}",
candidate.display()
);
}
}
}
}
#[test]
fn pending_privacy_scrub_is_retried_before_schema_version_completes() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("interrupted-privacy.sqlite");
let connection = Connection::open(&path).unwrap();
connection
.execute_batch(
r#"
PRAGMA journal_mode = WAL;
PRAGMA user_version = 2;
CREATE TABLE agent_events (
row_id INTEGER PRIMARY KEY AUTOINCREMENT,
external_event_id TEXT NOT NULL,
source TEXT NOT NULL,
project_path TEXT,
session_id TEXT,
session_key TEXT NOT NULL,
event_type TEXT NOT NULL,
title TEXT,
detail TEXT,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(source, session_key, external_event_id)
);
CREATE TABLE privacy_migrations (
migration_key TEXT PRIMARY KEY,
phase TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO privacy_migrations (migration_key, phase, updated_at)
VALUES ('event-envelope-v4-secure-vacuum', 'pending_secure_vacuum',
'2026-07-10T00:00:00Z');
CREATE TABLE discarded_sensitive_pages (value TEXT NOT NULL);
INSERT INTO discarded_sensitive_pages VALUES (
'FORBIDDEN_INTERRUPTED_MIGRATION_PLAINTEXT_93ac'
);
DROP TABLE discarded_sensitive_pages;
"#,
)
.unwrap();
drop(connection);
let database = Database::new(path.clone());
database.init().unwrap();
let connection = Connection::open(&path).unwrap();
let pending: i64 = connection
.query_row(
"SELECT COUNT(*) FROM privacy_migrations WHERE migration_key = 'event-envelope-v4-secure-vacuum'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(pending, 0);
let user_version: u32 = connection
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert!(user_version >= 4);
drop(connection);
for candidate in [
path.clone(),
PathBuf::from(format!("{}-wal", path.display())),
PathBuf::from(format!("{}-shm", path.display())),
] {
if candidate.exists() {
let bytes = fs::read(&candidate).unwrap();
let forbidden = b"FORBIDDEN_INTERRUPTED_MIGRATION_PLAINTEXT_93ac";
assert!(
!bytes
.windows(forbidden.len())
.any(|window| window == forbidden),
"interrupted migration plaintext remained in {}",
candidate.display()
);
}
}
}
#[test]
fn state_revision_changes_for_every_client_visible_pet_field() {
let temp = tempfile::tempdir().unwrap();
let database = Database::new(temp.path().join("events.sqlite"));
database.init().unwrap();
let mut pet = PetSummary {
id: "pet-revision".to_string(),
name: "Pet".to_string(),
style: "pixel".to_string(),
quality: QualityLevel::Standard,
render_size: RenderSize {
width: 384,
height: 416,
},
states: petcore_types::default_pet_states(),
petpack_path: "/tmp/pet.petpack".to_string(),
cover_path: "/tmp/cover.png".to_string(),
origin: PetOrigin::ExternalImport,
generator: None,
provenance: None,
revision_id: None,
revision_count: 0,
active: false,
created_at: "2026-07-01T00:00:00Z".to_string(),
};
let mut previous = database.state_revision().unwrap();
macro_rules! mutate_and_assert_revision {
($mutation:expr) => {{
$mutation;
database.upsert_pet(&pet).unwrap();
let current = database.state_revision().unwrap();
assert!(current > previous, "revision did not advance");
previous = current;
}};
}
mutate_and_assert_revision!(pet.name = "Renamed".to_string());
mutate_and_assert_revision!(pet.style = "watercolor".to_string());
mutate_and_assert_revision!(pet.quality = QualityLevel::Low);
mutate_and_assert_revision!(pet.render_size.width = 192);
mutate_and_assert_revision!(pet.render_size.height = 208);
mutate_and_assert_revision!(pet.petpack_path = "/tmp/revision.petpack".to_string());
mutate_and_assert_revision!(pet.cover_path = "/tmp/revision-cover.png".to_string());
mutate_and_assert_revision!(pet.origin = PetOrigin::GeneratedByPetcoreJob);
mutate_and_assert_revision!(pet.generator = Some("codex".to_string()));
mutate_and_assert_revision!(pet.provenance = Some("verified".to_string()));
mutate_and_assert_revision!(pet.active = true);
mutate_and_assert_revision!(pet.created_at = "2026-07-02T00:00:00Z".to_string());
database
.set_pet_asset_validation("pet-revision", "fingerprint", false, Some("repair needed"))
.unwrap();
let validation_revision = database.state_revision().unwrap();
assert!(validation_revision > previous);
let connection = Connection::open(database.path()).unwrap();
let row_count: i64 = connection
.query_row("SELECT COUNT(*) FROM state_revision", [], |row| row.get(0))
.unwrap();
let stored: u64 = connection
.query_row(
"SELECT revision FROM state_revision WHERE singleton = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(row_count, 1);
assert_eq!(stored, validation_revision);
}