hooks: add rust-owned schema fixtures

This commit is contained in:
Andrei Eternal
2026-03-03 13:58:04 -08:00
parent d7eb195b62
commit 69fe65f4c7
13 changed files with 596 additions and 2 deletions
+1
View File
@@ -2040,6 +2040,7 @@ dependencies = [
"codex-protocol",
"futures",
"pretty_assertions",
"schemars 0.8.22",
"serde",
"serde_json",
"tempfile",
+6
View File
@@ -322,6 +322,9 @@
"codex_git_commit": {
"type": "boolean"
},
"codex_hooks": {
"type": "boolean"
},
"collab": {
"type": "boolean"
},
@@ -1712,6 +1715,9 @@
"codex_git_commit": {
"type": "boolean"
},
"codex_hooks": {
"type": "boolean"
},
"collab": {
"type": "boolean"
},
+8
View File
@@ -90,6 +90,8 @@ pub enum Feature {
ApplyPatchFreeform,
/// Allow requesting additional filesystem permissions while staying sandboxed.
RequestPermissions,
/// Enable Claude-style lifecycle hooks loaded from hooks.json files.
CodexHooks,
/// Allow the model to request web searches that fetch live content.
WebSearchRequest,
/// Allow the model to request web searches that fetch cached content.
@@ -541,6 +543,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::CodexHooks,
key: "codex_hooks",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::UseLinuxSandboxBwrap,
key: "use_linux_sandbox_bwrap",
+2 -1
View File
@@ -13,15 +13,16 @@ path = "src/lib.rs"
workspace = true
[dependencies]
anyhow = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
codex-protocol = { workspace = true }
futures = { workspace = true, features = ["alloc"] }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["process"] }
[dev-dependencies]
anyhow = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
@@ -0,0 +1,59 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"NullableString": {
"type": [
"string",
"null"
]
}
},
"properties": {
"cwd": {
"type": "string"
},
"hook_event_name": {
"const": "SessionStart",
"type": "string"
},
"model": {
"type": "string"
},
"permission_mode": {
"enum": [
"default",
"acceptEdits",
"plan",
"dontAsk",
"bypassPermissions"
],
"type": "string"
},
"session_id": {
"type": "string"
},
"source": {
"enum": [
"startup",
"resume",
"clear"
],
"type": "string"
},
"transcript_path": {
"$ref": "#/definitions/NullableString"
}
},
"required": [
"cwd",
"hook_event_name",
"model",
"permission_mode",
"session_id",
"source",
"transcript_path"
],
"title": "session-start.command.input",
"type": "object"
}
@@ -0,0 +1,57 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"HookEventNameWire": {
"enum": [
"SessionStart",
"Stop"
],
"type": "string"
},
"SessionStartHookSpecificOutputWire": {
"additionalProperties": false,
"properties": {
"additionalContext": {
"default": null,
"type": "string"
},
"hookEventName": {
"$ref": "#/definitions/HookEventNameWire"
}
},
"required": [
"hookEventName"
],
"type": "object"
}
},
"properties": {
"continue": {
"default": true,
"type": "boolean"
},
"hookSpecificOutput": {
"allOf": [
{
"$ref": "#/definitions/SessionStartHookSpecificOutputWire"
}
],
"default": null
},
"stopReason": {
"default": null,
"type": "string"
},
"suppressOutput": {
"default": false,
"type": "boolean"
},
"systemMessage": {
"default": null,
"type": "string"
}
},
"title": "session-start.command.output",
"type": "object"
}
@@ -0,0 +1,58 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"NullableString": {
"type": [
"string",
"null"
]
}
},
"properties": {
"cwd": {
"type": "string"
},
"hook_event_name": {
"const": "Stop",
"type": "string"
},
"last_assistant_message": {
"$ref": "#/definitions/NullableString"
},
"model": {
"type": "string"
},
"permission_mode": {
"enum": [
"default",
"acceptEdits",
"plan",
"dontAsk",
"bypassPermissions"
],
"type": "string"
},
"session_id": {
"type": "string"
},
"stop_hook_active": {
"type": "boolean"
},
"transcript_path": {
"$ref": "#/definitions/NullableString"
}
},
"required": [
"cwd",
"hook_event_name",
"last_assistant_message",
"model",
"permission_mode",
"session_id",
"stop_hook_active",
"transcript_path"
],
"title": "stop.command.input",
"type": "object"
}
@@ -0,0 +1,44 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"StopDecisionWire": {
"enum": [
"block"
],
"type": "string"
}
},
"properties": {
"continue": {
"default": true,
"type": "boolean"
},
"decision": {
"allOf": [
{
"$ref": "#/definitions/StopDecisionWire"
}
],
"default": null
},
"reason": {
"default": null,
"type": "string"
},
"stopReason": {
"default": null,
"type": "string"
},
"suppressOutput": {
"default": false,
"type": "boolean"
},
"systemMessage": {
"default": null,
"type": "string"
}
},
"title": "stop.command.output",
"type": "object"
}
@@ -0,0 +1,9 @@
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
let schema_root = std::env::args_os()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("schema"));
codex_hooks::write_schema_fixtures(&schema_root)
}
+2
View File
@@ -1,10 +1,12 @@
mod registry;
mod schema;
mod types;
mod user_notification;
pub use registry::Hooks;
pub use registry::HooksConfig;
pub use registry::command_from_argv;
pub use schema::write_schema_fixtures;
pub use types::Hook;
pub use types::HookEvent;
pub use types::HookEventAfterAgent;
+344
View File
@@ -0,0 +1,344 @@
use schemars::JsonSchema;
use schemars::r#gen::SchemaGenerator;
use schemars::r#gen::SchemaSettings;
use schemars::schema::InstanceType;
use schemars::schema::RootSchema;
use schemars::schema::Schema;
use schemars::schema::SchemaObject;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Map;
use serde_json::Value;
use std::path::Path;
use std::path::PathBuf;
const GENERATED_DIR: &str = "generated";
const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json";
const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json";
const STOP_INPUT_FIXTURE: &str = "stop.command.input.schema.json";
const STOP_OUTPUT_FIXTURE: &str = "stop.command.output.schema.json";
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub(crate) struct NullableString(Option<String>);
impl NullableString {
fn from_path(path: Option<PathBuf>) -> Self {
Self(path.map(|path| path.display().to_string()))
}
fn from_string(value: Option<String>) -> Self {
Self(value)
}
}
impl JsonSchema for NullableString {
fn schema_name() -> String {
"NullableString".to_string()
}
fn json_schema(_gen: &mut SchemaGenerator) -> Schema {
Schema::Object(SchemaObject {
instance_type: Some(vec![InstanceType::String, InstanceType::Null].into()),
..Default::default()
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct HookUniversalOutputWire {
#[serde(default = "default_continue")]
pub r#continue: bool,
#[serde(default)]
pub stop_reason: Option<String>,
#[serde(default)]
pub suppress_output: bool,
#[serde(default)]
pub system_message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum HookEventNameWire {
#[serde(rename = "SessionStart")]
SessionStart,
#[serde(rename = "Stop")]
Stop,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "session-start.command.output")]
pub(crate) struct SessionStartCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub hook_specific_output: Option<SessionStartHookSpecificOutputWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct SessionStartHookSpecificOutputWire {
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub additional_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "stop.command.output")]
pub(crate) struct StopCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub decision: Option<StopDecisionWire>,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum StopDecisionWire {
#[serde(rename = "block")]
Block,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "session-start.command.input")]
pub(crate) struct SessionStartCommandInput {
pub session_id: String,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "session_start_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
#[schemars(schema_with = "session_start_source_schema")]
pub source: String,
}
impl SessionStartCommandInput {
pub(crate) fn new(
session_id: impl Into<String>,
transcript_path: Option<PathBuf>,
cwd: impl Into<String>,
model: impl Into<String>,
permission_mode: impl Into<String>,
source: impl Into<String>,
) -> Self {
Self {
session_id: session_id.into(),
transcript_path: NullableString::from_path(transcript_path),
cwd: cwd.into(),
hook_event_name: "SessionStart".to_string(),
model: model.into(),
permission_mode: permission_mode.into(),
source: source.into(),
}
}
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "stop.command.input")]
pub(crate) struct StopCommandInput {
pub session_id: String,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "stop_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
pub stop_hook_active: bool,
pub last_assistant_message: NullableString,
}
impl StopCommandInput {
pub(crate) fn new(
session_id: impl Into<String>,
transcript_path: Option<PathBuf>,
cwd: impl Into<String>,
model: impl Into<String>,
permission_mode: impl Into<String>,
stop_hook_active: bool,
last_assistant_message: Option<String>,
) -> Self {
Self {
session_id: session_id.into(),
transcript_path: NullableString::from_path(transcript_path),
cwd: cwd.into(),
hook_event_name: "Stop".to_string(),
model: model.into(),
permission_mode: permission_mode.into(),
stop_hook_active,
last_assistant_message: NullableString::from_string(last_assistant_message),
}
}
}
pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
let generated_dir = schema_root.join(GENERATED_DIR);
ensure_empty_dir(&generated_dir)?;
write_schema(
&generated_dir.join(SESSION_START_INPUT_FIXTURE),
schema_json::<SessionStartCommandInput>()?,
)?;
write_schema(
&generated_dir.join(SESSION_START_OUTPUT_FIXTURE),
schema_json::<SessionStartCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(STOP_INPUT_FIXTURE),
schema_json::<StopCommandInput>()?,
)?;
write_schema(
&generated_dir.join(STOP_OUTPUT_FIXTURE),
schema_json::<StopCommandOutputWire>()?,
)?;
Ok(())
}
fn write_schema(path: &Path, json: Vec<u8>) -> anyhow::Result<()> {
std::fs::write(path, json)?;
Ok(())
}
fn ensure_empty_dir(dir: &Path) -> anyhow::Result<()> {
if dir.exists() {
std::fs::remove_dir_all(dir)?;
}
std::fs::create_dir_all(dir)?;
Ok(())
}
fn schema_json<T>() -> anyhow::Result<Vec<u8>>
where
T: JsonSchema,
{
let schema = schema_for_type::<T>();
let value = serde_json::to_value(schema)?;
let value = canonicalize_json(&value);
Ok(serde_json::to_vec_pretty(&value)?)
}
fn schema_for_type<T>() -> RootSchema
where
T: JsonSchema,
{
SchemaSettings::draft07()
.with(|settings| {
settings.option_add_null_type = false;
})
.into_generator()
.into_root_schema_for::<T>()
}
fn canonicalize_json(value: &Value) -> Value {
match value {
Value::Array(items) => Value::Array(items.iter().map(canonicalize_json).collect()),
Value::Object(map) => {
let mut entries: Vec<_> = map.iter().collect();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let mut sorted = Map::with_capacity(map.len());
for (key, child) in entries {
sorted.insert(key.clone(), canonicalize_json(child));
}
Value::Object(sorted)
}
_ => value.clone(),
}
}
fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("SessionStart")
}
fn stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("Stop")
}
fn permission_mode_schema(_gen: &mut SchemaGenerator) -> Schema {
string_enum_schema(&[
"default",
"acceptEdits",
"plan",
"dontAsk",
"bypassPermissions",
])
}
fn session_start_source_schema(_gen: &mut SchemaGenerator) -> Schema {
string_enum_schema(&["startup", "resume", "clear"])
}
fn string_const_schema(value: &str) -> Schema {
let mut schema = SchemaObject {
instance_type: Some(InstanceType::String.into()),
..Default::default()
};
schema.const_value = Some(Value::String(value.to_string()));
Schema::Object(schema)
}
fn string_enum_schema(values: &[&str]) -> Schema {
let mut schema = SchemaObject {
instance_type: Some(InstanceType::String.into()),
..Default::default()
};
schema.enum_values = Some(
values
.iter()
.map(|value| Value::String((*value).to_string()))
.collect(),
);
Schema::Object(schema)
}
fn default_continue() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::SESSION_START_INPUT_FIXTURE;
use super::SESSION_START_OUTPUT_FIXTURE;
use super::STOP_INPUT_FIXTURE;
use super::STOP_OUTPUT_FIXTURE;
use super::write_schema_fixtures;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn generated_hook_schemas_match_fixtures() {
let temp_dir = TempDir::new().expect("create temp dir");
let schema_root = temp_dir.path().join("schema");
write_schema_fixtures(&schema_root).expect("write generated hook schemas");
for fixture in [
SESSION_START_INPUT_FIXTURE,
SESSION_START_OUTPUT_FIXTURE,
STOP_INPUT_FIXTURE,
STOP_OUTPUT_FIXTURE,
] {
let expected = std::fs::read_to_string(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("schema")
.join("generated")
.join(fixture),
)
.unwrap_or_else(|err| panic!("read fixture {fixture}: {err}"));
let actual = std::fs::read_to_string(schema_root.join("generated").join(fixture))
.unwrap_or_else(|err| panic!("read generated schema {fixture}: {err}"));
assert_eq!(expected, actual, "fixture should match generated schema");
}
}
}
+4
View File
@@ -82,6 +82,10 @@ write-config-schema:
write-app-server-schema *args:
cargo run -p codex-app-server-protocol --bin write_schema_fixtures -- "$@"
[no-cd]
write-hooks-schema:
cargo run --manifest-path ./codex-rs/Cargo.toml -p codex-hooks --bin write_schema_fixtures
# Tail logs from the state SQLite database
log *args:
if [ "${1:-}" = "--" ]; then shift; fi; cargo run -p codex-state --bin logs_client -- "$@"
+2 -1
View File
@@ -4,7 +4,8 @@
"description": "Tools for repo-wide maintenance.",
"scripts": {
"format": "prettier --check *.json *.md docs/*.md .github/workflows/*.yml **/*.js",
"format:fix": "prettier --write *.json *.md docs/*.md .github/workflows/*.yml **/*.js"
"format:fix": "prettier --write *.json *.md docs/*.md .github/workflows/*.yml **/*.js",
"write-hooks-schema": "cargo run --manifest-path ./codex-rs/Cargo.toml -p codex-hooks --bin write_schema_fixtures"
},
"devDependencies": {
"prettier": "^3.5.3"