mirror of
https://github.com/openai/codex.git
synced 2026-11-02 00:18:52 +00:00
codex: support hooks in config.toml and requirements.toml (#18893)
## Summary Support the existing hooks schema in inline TOML so hooks can be configured from both `config.toml` and enterprise-managed `requirements.toml` without requiring a separate `hooks.json` payload. This gives enterprise admins a way to ship managed hook policy through the existing requirements channel while still leaving script delivery to MDM or other device-management tooling, and it keeps `hooks.json` working unchanged for existing users. This also lays the groundwork for follow-on managed filtering work such as #15937, while continuing to respect project trust gating from #14718. It does **not** implement `allow_managed_hooks_only` itself. NOTE: yes, it's a bit unfortunate that the toml isn't formatted as closely as normal to our default styling. This is because we're trying to stay compatible with the spec for plugins/hooks that we'll need to support & the main usecase here is embedding into requirements.toml ## What changed - moved the shared hook serde model out of `codex-rs/hooks` into `codex-rs/config` so the same schema can power `hooks.json`, inline `config.toml` hooks, and managed `requirements.toml` hooks - added `hooks` support to both `ConfigToml` and `ConfigRequirementsToml`, including requirements-side `managed_dir` / `windows_managed_dir` - treated requirements-managed hooks as one constrained value via `Constrained`, so managed hook policy is merged atomically and cannot drift across requirement sources - updated hook discovery to load requirements-managed hooks first, then per-layer `hooks.json`, then per-layer inline TOML hooks, with a warning when a single layer defines both representations - threaded managed hook metadata through discovered handlers and exposed requirements hooks in app-server responses, generated schemas, and `/debug-config` - added hook/config coverage in `codex-rs/config`, `codex-rs/hooks`, `codex-rs/core/src/config_loader/tests.rs`, and `codex-rs/core/tests/suite/hooks.rs` ## Testing - `cargo test -p codex-config` - `cargo test -p codex-hooks` - `cargo test -p codex-app-server config_api` ## Documentation Companion updates are needed in the developers website repo for: - the hooks guide - the config reference, sample, basic, and advanced pages - the enterprise managed configuration guide --------- Co-authored-by: Michael Bolin <mbolin@openai.com>
This commit is contained in:
co-authored by
Michael Bolin
parent
9955eacd22
commit
2b2de3f38b
+155
@@ -7325,6 +7325,100 @@
|
|||||||
"title": "ConfigWriteResponse",
|
"title": "ConfigWriteResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ConfiguredHookHandler": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"async": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"statusMessage": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timeoutSec": {
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"command"
|
||||||
|
],
|
||||||
|
"title": "CommandConfiguredHookHandlerType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"async",
|
||||||
|
"command",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "CommandConfiguredHookHandler",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"prompt"
|
||||||
|
],
|
||||||
|
"title": "PromptConfiguredHookHandlerType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "PromptConfiguredHookHandler",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
|
"title": "AgentConfiguredHookHandlerType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "AgentConfiguredHookHandler",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ConfiguredHookMatcherGroup": {
|
||||||
|
"properties": {
|
||||||
|
"hooks": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/v2/ConfiguredHookHandler"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"matcher": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"hooks"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ContentItem": {
|
"ContentItem": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
@@ -10018,6 +10112,67 @@
|
|||||||
"title": "LogoutAccountResponse",
|
"title": "LogoutAccountResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ManagedHooksRequirements": {
|
||||||
|
"properties": {
|
||||||
|
"PermissionRequest": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"PostToolUse": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"PreToolUse": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"SessionStart": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"Stop": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"UserPromptSubmit": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"managedDir": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"windowsManagedDir": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"PermissionRequest",
|
||||||
|
"PostToolUse",
|
||||||
|
"PreToolUse",
|
||||||
|
"SessionStart",
|
||||||
|
"Stop",
|
||||||
|
"UserPromptSubmit"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"MarketplaceAddParams": {
|
"MarketplaceAddParams": {
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+155
@@ -3905,6 +3905,100 @@
|
|||||||
"title": "ConfigWriteResponse",
|
"title": "ConfigWriteResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ConfiguredHookHandler": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"async": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"statusMessage": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timeoutSec": {
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"command"
|
||||||
|
],
|
||||||
|
"title": "CommandConfiguredHookHandlerType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"async",
|
||||||
|
"command",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "CommandConfiguredHookHandler",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"prompt"
|
||||||
|
],
|
||||||
|
"title": "PromptConfiguredHookHandlerType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "PromptConfiguredHookHandler",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
|
"title": "AgentConfiguredHookHandlerType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "AgentConfiguredHookHandler",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ConfiguredHookMatcherGroup": {
|
||||||
|
"properties": {
|
||||||
|
"hooks": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ConfiguredHookHandler"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"matcher": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"hooks"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ContentItem": {
|
"ContentItem": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
@@ -6753,6 +6847,67 @@
|
|||||||
"title": "LogoutAccountResponse",
|
"title": "LogoutAccountResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ManagedHooksRequirements": {
|
||||||
|
"properties": {
|
||||||
|
"PermissionRequest": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"PostToolUse": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"PreToolUse": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"SessionStart": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"Stop": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"UserPromptSubmit": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"managedDir": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"windowsManagedDir": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"PermissionRequest",
|
||||||
|
"PostToolUse",
|
||||||
|
"PreToolUse",
|
||||||
|
"SessionStart",
|
||||||
|
"Stop",
|
||||||
|
"UserPromptSubmit"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"MarketplaceAddParams": {
|
"MarketplaceAddParams": {
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+155
@@ -111,6 +111,161 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ConfiguredHookHandler": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"async": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"statusMessage": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timeoutSec": {
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"command"
|
||||||
|
],
|
||||||
|
"title": "CommandConfiguredHookHandlerType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"async",
|
||||||
|
"command",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "CommandConfiguredHookHandler",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"prompt"
|
||||||
|
],
|
||||||
|
"title": "PromptConfiguredHookHandlerType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "PromptConfiguredHookHandler",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
|
"title": "AgentConfiguredHookHandlerType",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"title": "AgentConfiguredHookHandler",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ConfiguredHookMatcherGroup": {
|
||||||
|
"properties": {
|
||||||
|
"hooks": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ConfiguredHookHandler"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"matcher": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"hooks"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ManagedHooksRequirements": {
|
||||||
|
"properties": {
|
||||||
|
"PermissionRequest": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"PostToolUse": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"PreToolUse": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"SessionStart": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"Stop": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"UserPromptSubmit": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"managedDir": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"windowsManagedDir": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"PermissionRequest",
|
||||||
|
"PostToolUse",
|
||||||
|
"PreToolUse",
|
||||||
|
"SessionStart",
|
||||||
|
"Stop",
|
||||||
|
"UserPromptSubmit"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"NetworkDomainPermission": {
|
"NetworkDomainPermission": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"allow",
|
"allow",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||||
|
|
||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type ConfiguredHookHandler = { "type": "command", command: string, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, } | { "type": "prompt", } | { "type": "agent", };
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||||
|
|
||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { ConfiguredHookHandler } from "./ConfiguredHookHandler";
|
||||||
|
|
||||||
|
export type ConfiguredHookMatcherGroup = { matcher: string | null, hooks: Array<ConfiguredHookHandler>, };
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||||
|
|
||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup";
|
||||||
|
|
||||||
|
export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, };
|
||||||
@@ -71,6 +71,8 @@ export type { ConfigRequirementsReadResponse } from "./ConfigRequirementsReadRes
|
|||||||
export type { ConfigValueWriteParams } from "./ConfigValueWriteParams";
|
export type { ConfigValueWriteParams } from "./ConfigValueWriteParams";
|
||||||
export type { ConfigWarningNotification } from "./ConfigWarningNotification";
|
export type { ConfigWarningNotification } from "./ConfigWarningNotification";
|
||||||
export type { ConfigWriteResponse } from "./ConfigWriteResponse";
|
export type { ConfigWriteResponse } from "./ConfigWriteResponse";
|
||||||
|
export type { ConfiguredHookHandler } from "./ConfiguredHookHandler";
|
||||||
|
export type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup";
|
||||||
export type { ContextCompactedNotification } from "./ContextCompactedNotification";
|
export type { ContextCompactedNotification } from "./ContextCompactedNotification";
|
||||||
export type { CreditsSnapshot } from "./CreditsSnapshot";
|
export type { CreditsSnapshot } from "./CreditsSnapshot";
|
||||||
export type { DeprecationNoticeNotification } from "./DeprecationNoticeNotification";
|
export type { DeprecationNoticeNotification } from "./DeprecationNoticeNotification";
|
||||||
@@ -169,6 +171,7 @@ export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse"
|
|||||||
export type { LoginAccountParams } from "./LoginAccountParams";
|
export type { LoginAccountParams } from "./LoginAccountParams";
|
||||||
export type { LoginAccountResponse } from "./LoginAccountResponse";
|
export type { LoginAccountResponse } from "./LoginAccountResponse";
|
||||||
export type { LogoutAccountResponse } from "./LogoutAccountResponse";
|
export type { LogoutAccountResponse } from "./LogoutAccountResponse";
|
||||||
|
export type { ManagedHooksRequirements } from "./ManagedHooksRequirements";
|
||||||
export type { MarketplaceAddParams } from "./MarketplaceAddParams";
|
export type { MarketplaceAddParams } from "./MarketplaceAddParams";
|
||||||
export type { MarketplaceAddResponse } from "./MarketplaceAddResponse";
|
export type { MarketplaceAddResponse } from "./MarketplaceAddResponse";
|
||||||
export type { MarketplaceInterface } from "./MarketplaceInterface";
|
export type { MarketplaceInterface } from "./MarketplaceInterface";
|
||||||
|
|||||||
@@ -941,11 +941,71 @@ pub struct ConfigRequirements {
|
|||||||
pub allowed_sandbox_modes: Option<Vec<SandboxMode>>,
|
pub allowed_sandbox_modes: Option<Vec<SandboxMode>>,
|
||||||
pub allowed_web_search_modes: Option<Vec<WebSearchMode>>,
|
pub allowed_web_search_modes: Option<Vec<WebSearchMode>>,
|
||||||
pub feature_requirements: Option<BTreeMap<String, bool>>,
|
pub feature_requirements: Option<BTreeMap<String, bool>>,
|
||||||
|
#[experimental("configRequirements/read.hooks")]
|
||||||
|
pub hooks: Option<ManagedHooksRequirements>,
|
||||||
pub enforce_residency: Option<ResidencyRequirement>,
|
pub enforce_residency: Option<ResidencyRequirement>,
|
||||||
#[experimental("configRequirements/read.network")]
|
#[experimental("configRequirements/read.network")]
|
||||||
pub network: Option<NetworkRequirements>,
|
pub network: Option<NetworkRequirements>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export_to = "v2/")]
|
||||||
|
pub struct ManagedHooksRequirements {
|
||||||
|
pub managed_dir: Option<PathBuf>,
|
||||||
|
pub windows_managed_dir: Option<PathBuf>,
|
||||||
|
#[serde(rename = "PreToolUse")]
|
||||||
|
#[ts(rename = "PreToolUse")]
|
||||||
|
pub pre_tool_use: Vec<ConfiguredHookMatcherGroup>,
|
||||||
|
#[serde(rename = "PermissionRequest")]
|
||||||
|
#[ts(rename = "PermissionRequest")]
|
||||||
|
pub permission_request: Vec<ConfiguredHookMatcherGroup>,
|
||||||
|
#[serde(rename = "PostToolUse")]
|
||||||
|
#[ts(rename = "PostToolUse")]
|
||||||
|
pub post_tool_use: Vec<ConfiguredHookMatcherGroup>,
|
||||||
|
#[serde(rename = "SessionStart")]
|
||||||
|
#[ts(rename = "SessionStart")]
|
||||||
|
pub session_start: Vec<ConfiguredHookMatcherGroup>,
|
||||||
|
#[serde(rename = "UserPromptSubmit")]
|
||||||
|
#[ts(rename = "UserPromptSubmit")]
|
||||||
|
pub user_prompt_submit: Vec<ConfiguredHookMatcherGroup>,
|
||||||
|
#[serde(rename = "Stop")]
|
||||||
|
#[ts(rename = "Stop")]
|
||||||
|
pub stop: Vec<ConfiguredHookMatcherGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export_to = "v2/")]
|
||||||
|
pub struct ConfiguredHookMatcherGroup {
|
||||||
|
pub matcher: Option<String>,
|
||||||
|
pub hooks: Vec<ConfiguredHookHandler>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
#[ts(tag = "type", export_to = "v2/")]
|
||||||
|
pub enum ConfiguredHookHandler {
|
||||||
|
#[serde(rename = "command")]
|
||||||
|
#[ts(rename = "command")]
|
||||||
|
Command {
|
||||||
|
command: String,
|
||||||
|
#[serde(rename = "timeoutSec")]
|
||||||
|
#[ts(rename = "timeoutSec")]
|
||||||
|
timeout_sec: Option<u64>,
|
||||||
|
r#async: bool,
|
||||||
|
#[serde(rename = "statusMessage")]
|
||||||
|
#[ts(rename = "statusMessage")]
|
||||||
|
status_message: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "prompt")]
|
||||||
|
#[ts(rename = "prompt")]
|
||||||
|
Prompt {},
|
||||||
|
#[serde(rename = "agent")]
|
||||||
|
#[ts(rename = "agent")]
|
||||||
|
Agent {},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export_to = "v2/")]
|
#[ts(export_to = "v2/")]
|
||||||
@@ -8837,6 +8897,7 @@ mod tests {
|
|||||||
allowed_sandbox_modes: None,
|
allowed_sandbox_modes: None,
|
||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
enforce_residency: None,
|
enforce_residency: None,
|
||||||
network: None,
|
network: None,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ Example with notification opt-out:
|
|||||||
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin `details` returned by detect. When a request includes plugin imports, the server emits `externalAgentConfig/import/completed` after the full import finishes (immediately after the response when everything completed synchronously, or after background remote imports finish).
|
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin `details` returned by detect. When a request includes plugin imports, the server emits `externalAgentConfig/import/completed` after the full import finishes (immediately after the response when everything completed synchronously, or after background remote imports finish).
|
||||||
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
|
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
|
||||||
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads.
|
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads.
|
||||||
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly`.
|
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
|
||||||
|
|
||||||
### Example: Start or resume a thread
|
### Example: Start or resume a thread
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ use codex_app_server_protocol::ConfigRequirementsReadResponse;
|
|||||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||||
use codex_app_server_protocol::ConfigWriteErrorCode;
|
use codex_app_server_protocol::ConfigWriteErrorCode;
|
||||||
use codex_app_server_protocol::ConfigWriteResponse;
|
use codex_app_server_protocol::ConfigWriteResponse;
|
||||||
|
use codex_app_server_protocol::ConfiguredHookHandler;
|
||||||
|
use codex_app_server_protocol::ConfiguredHookMatcherGroup;
|
||||||
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
|
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
|
||||||
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
|
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
|
||||||
use codex_app_server_protocol::JSONRPCErrorError;
|
use codex_app_server_protocol::JSONRPCErrorError;
|
||||||
|
use codex_app_server_protocol::ManagedHooksRequirements;
|
||||||
use codex_app_server_protocol::NetworkDomainPermission;
|
use codex_app_server_protocol::NetworkDomainPermission;
|
||||||
use codex_app_server_protocol::NetworkRequirements;
|
use codex_app_server_protocol::NetworkRequirements;
|
||||||
use codex_app_server_protocol::NetworkUnixSocketPermission;
|
use codex_app_server_protocol::NetworkUnixSocketPermission;
|
||||||
@@ -22,6 +25,10 @@ use codex_app_server_protocol::SandboxMode;
|
|||||||
use codex_core::ThreadManager;
|
use codex_core::ThreadManager;
|
||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
use codex_core::config_loader::ConfigRequirementsToml;
|
use codex_core::config_loader::ConfigRequirementsToml;
|
||||||
|
use codex_core::config_loader::HookEventsToml;
|
||||||
|
use codex_core::config_loader::HookHandlerConfig as CoreHookHandlerConfig;
|
||||||
|
use codex_core::config_loader::ManagedHooksRequirementsToml;
|
||||||
|
use codex_core::config_loader::MatcherGroup as CoreMatcherGroup;
|
||||||
use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement;
|
use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement;
|
||||||
use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement;
|
use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement;
|
||||||
use codex_core::plugins::PluginId;
|
use codex_core::plugins::PluginId;
|
||||||
@@ -290,6 +297,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
|
|||||||
feature_requirements: requirements
|
feature_requirements: requirements
|
||||||
.feature_requirements
|
.feature_requirements
|
||||||
.map(|requirements| requirements.entries),
|
.map(|requirements| requirements.entries),
|
||||||
|
hooks: requirements.hooks.map(map_hooks_requirements_to_api),
|
||||||
enforce_residency: requirements
|
enforce_residency: requirements
|
||||||
.enforce_residency
|
.enforce_residency
|
||||||
.map(map_residency_requirement_to_api),
|
.map(map_residency_requirement_to_api),
|
||||||
@@ -297,6 +305,71 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements {
|
||||||
|
let ManagedHooksRequirementsToml {
|
||||||
|
managed_dir,
|
||||||
|
windows_managed_dir,
|
||||||
|
hooks,
|
||||||
|
} = hooks;
|
||||||
|
let HookEventsToml {
|
||||||
|
pre_tool_use,
|
||||||
|
permission_request,
|
||||||
|
post_tool_use,
|
||||||
|
session_start,
|
||||||
|
user_prompt_submit,
|
||||||
|
stop,
|
||||||
|
} = hooks;
|
||||||
|
|
||||||
|
ManagedHooksRequirements {
|
||||||
|
managed_dir,
|
||||||
|
windows_managed_dir,
|
||||||
|
pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use),
|
||||||
|
permission_request: map_hook_matcher_groups_to_api(permission_request),
|
||||||
|
post_tool_use: map_hook_matcher_groups_to_api(post_tool_use),
|
||||||
|
session_start: map_hook_matcher_groups_to_api(session_start),
|
||||||
|
user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit),
|
||||||
|
stop: map_hook_matcher_groups_to_api(stop),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_hook_matcher_groups_to_api(
|
||||||
|
groups: Vec<CoreMatcherGroup>,
|
||||||
|
) -> Vec<ConfiguredHookMatcherGroup> {
|
||||||
|
groups
|
||||||
|
.into_iter()
|
||||||
|
.map(map_hook_matcher_group_to_api)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_hook_matcher_group_to_api(group: CoreMatcherGroup) -> ConfiguredHookMatcherGroup {
|
||||||
|
ConfiguredHookMatcherGroup {
|
||||||
|
matcher: group.matcher,
|
||||||
|
hooks: group
|
||||||
|
.hooks
|
||||||
|
.into_iter()
|
||||||
|
.map(map_hook_handler_to_api)
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHandler {
|
||||||
|
match handler {
|
||||||
|
CoreHookHandlerConfig::Command {
|
||||||
|
command,
|
||||||
|
timeout_sec,
|
||||||
|
r#async,
|
||||||
|
status_message,
|
||||||
|
} => ConfiguredHookHandler::Command {
|
||||||
|
command,
|
||||||
|
timeout_sec,
|
||||||
|
r#async,
|
||||||
|
status_message,
|
||||||
|
},
|
||||||
|
CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {},
|
||||||
|
CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Option<SandboxMode> {
|
fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Option<SandboxMode> {
|
||||||
match mode {
|
match mode {
|
||||||
CoreSandboxModeRequirement::ReadOnly => Some(SandboxMode::ReadOnly),
|
CoreSandboxModeRequirement::ReadOnly => Some(SandboxMode::ReadOnly),
|
||||||
@@ -476,6 +549,22 @@ mod tests {
|
|||||||
("personality".to_string(), true),
|
("personality".to_string(), true),
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
|
hooks: Some(ManagedHooksRequirementsToml {
|
||||||
|
managed_dir: Some(PathBuf::from("/enterprise/hooks")),
|
||||||
|
windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")),
|
||||||
|
hooks: HookEventsToml {
|
||||||
|
pre_tool_use: vec![CoreMatcherGroup {
|
||||||
|
matcher: Some("^Bash$".to_string()),
|
||||||
|
hooks: vec![CoreHookHandlerConfig::Command {
|
||||||
|
command: "python3 /enterprise/hooks/pre.py".to_string(),
|
||||||
|
timeout_sec: Some(10),
|
||||||
|
r#async: false,
|
||||||
|
status_message: Some("checking".to_string()),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}),
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -542,6 +631,27 @@ mod tests {
|
|||||||
("personality".to_string(), true),
|
("personality".to_string(), true),
|
||||||
])),
|
])),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mapped.hooks,
|
||||||
|
Some(ManagedHooksRequirements {
|
||||||
|
managed_dir: Some(PathBuf::from("/enterprise/hooks")),
|
||||||
|
windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")),
|
||||||
|
pre_tool_use: vec![ConfiguredHookMatcherGroup {
|
||||||
|
matcher: Some("^Bash$".to_string()),
|
||||||
|
hooks: vec![ConfiguredHookHandler::Command {
|
||||||
|
command: "python3 /enterprise/hooks/pre.py".to_string(),
|
||||||
|
timeout_sec: Some(10),
|
||||||
|
r#async: false,
|
||||||
|
status_message: Some("checking".to_string()),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
permission_request: Vec::new(),
|
||||||
|
post_tool_use: Vec::new(),
|
||||||
|
session_start: Vec::new(),
|
||||||
|
user_prompt_submit: Vec::new(),
|
||||||
|
stop: Vec::new(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mapped.enforce_residency,
|
mapped.enforce_residency,
|
||||||
Some(codex_app_server_protocol::ResidencyRequirement::Us),
|
Some(codex_app_server_protocol::ResidencyRequirement::Us),
|
||||||
@@ -582,6 +692,7 @@ mod tests {
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -641,6 +752,7 @@ mod tests {
|
|||||||
allowed_web_search_modes: Some(Vec::new()),
|
allowed_web_search_modes: Some(Vec::new()),
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ use opentelemetry_sdk::trace::SpanData;
|
|||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::future::Future;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
@@ -291,6 +292,28 @@ fn build_test_processor(
|
|||||||
(processor, outgoing_rx)
|
(processor, outgoing_rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_current_thread_test_with_stack<F>(name: &str, future: F) -> Result<()>
|
||||||
|
where
|
||||||
|
F: Future<Output = Result<()>> + Send + 'static,
|
||||||
|
{
|
||||||
|
const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
let handle = std::thread::Builder::new()
|
||||||
|
.name(name.to_string())
|
||||||
|
.stack_size(TEST_STACK_SIZE_BYTES)
|
||||||
|
.spawn(move || -> Result<()> {
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
runtime.block_on(Box::pin(future))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match handle.join() {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => Err(anyhow::anyhow!("{name} thread panicked")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn span_attr<'a>(span: &'a SpanData, key: &str) -> Option<&'a str> {
|
fn span_attr<'a>(span: &'a SpanData, key: &str) -> Option<&'a str> {
|
||||||
span.attributes
|
span.attributes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -568,83 +591,96 @@ where
|
|||||||
spans.into_iter().skip(baseline_len).collect()
|
spans.into_iter().skip(baseline_len).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[test]
|
||||||
#[serial(app_server_tracing)]
|
#[serial(app_server_tracing)]
|
||||||
async fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> Result<()> {
|
fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> Result<()> {
|
||||||
let mut harness = TracingHarness::new().await?;
|
run_current_thread_test_with_stack(
|
||||||
|
"thread_start_jsonrpc_span_exports_server_span_and_parents_children",
|
||||||
|
async {
|
||||||
|
let mut harness = TracingHarness::new().await?;
|
||||||
|
|
||||||
let RemoteTrace {
|
let RemoteTrace {
|
||||||
trace_id: remote_trace_id,
|
trace_id: remote_trace_id,
|
||||||
parent_span_id: remote_parent_span_id,
|
parent_span_id: remote_parent_span_id,
|
||||||
context: remote_trace,
|
context: remote_trace,
|
||||||
..
|
..
|
||||||
} = RemoteTrace::new("00000000000000000000000000000011", "0000000000000022");
|
} = RemoteTrace::new("00000000000000000000000000000011", "0000000000000022");
|
||||||
|
|
||||||
let _: ThreadStartResponse = harness
|
let _: ThreadStartResponse = harness
|
||||||
.start_thread(/*request_id*/ 20_002, /*trace*/ None)
|
.start_thread(/*request_id*/ 20_002, /*trace*/ None)
|
||||||
.await;
|
.await;
|
||||||
let untraced_spans = wait_for_exported_spans(harness.tracing, |spans| {
|
let untraced_spans = wait_for_exported_spans(harness.tracing, |spans| {
|
||||||
spans.iter().any(|span| {
|
spans.iter().any(|span| {
|
||||||
span.span_kind == SpanKind::Server
|
span.span_kind == SpanKind::Server
|
||||||
&& span_attr(span, "rpc.method") == Some("thread/start")
|
&& span_attr(span, "rpc.method") == Some("thread/start")
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.await;
|
|
||||||
let untraced_server_span = find_rpc_span_with_trace(
|
|
||||||
&untraced_spans,
|
|
||||||
SpanKind::Server,
|
|
||||||
"thread/start",
|
|
||||||
untraced_spans
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|span| {
|
|
||||||
span.span_kind == SpanKind::Server
|
|
||||||
&& span_attr(span, "rpc.system") == Some("jsonrpc")
|
|
||||||
&& span_attr(span, "rpc.method") == Some("thread/start")
|
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| {
|
.await;
|
||||||
panic!(
|
let untraced_server_span = find_rpc_span_with_trace(
|
||||||
"missing latest thread/start server span; exported spans:\n{}",
|
&untraced_spans,
|
||||||
format_spans(&untraced_spans)
|
SpanKind::Server,
|
||||||
)
|
"thread/start",
|
||||||
|
untraced_spans
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|span| {
|
||||||
|
span.span_kind == SpanKind::Server
|
||||||
|
&& span_attr(span, "rpc.system") == Some("jsonrpc")
|
||||||
|
&& span_attr(span, "rpc.method") == Some("thread/start")
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"missing latest thread/start server span; exported spans:\n{}",
|
||||||
|
format_spans(&untraced_spans)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.span_context
|
||||||
|
.trace_id(),
|
||||||
|
);
|
||||||
|
assert_has_internal_descendant_at_min_depth(
|
||||||
|
&untraced_spans,
|
||||||
|
untraced_server_span,
|
||||||
|
/*min_depth*/ 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
let baseline_len = untraced_spans.len();
|
||||||
|
let _: ThreadStartResponse = harness
|
||||||
|
.start_thread(/*request_id*/ 20_003, Some(remote_trace))
|
||||||
|
.await;
|
||||||
|
let spans = wait_for_new_exported_spans(harness.tracing, baseline_len, |spans| {
|
||||||
|
spans.iter().any(|span| {
|
||||||
|
span.span_kind == SpanKind::Server
|
||||||
|
&& span_attr(span, "rpc.method") == Some("thread/start")
|
||||||
|
&& span.span_context.trace_id() == remote_trace_id
|
||||||
|
}) && spans.iter().any(|span| {
|
||||||
|
span.name.as_ref() == "app_server.thread_start.notify_started"
|
||||||
|
&& span.span_context.trace_id() == remote_trace_id
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.span_context
|
.await;
|
||||||
.trace_id(),
|
|
||||||
);
|
|
||||||
assert_has_internal_descendant_at_min_depth(
|
|
||||||
&untraced_spans,
|
|
||||||
untraced_server_span,
|
|
||||||
/*min_depth*/ 1,
|
|
||||||
);
|
|
||||||
|
|
||||||
let baseline_len = untraced_spans.len();
|
let server_request_span =
|
||||||
let _: ThreadStartResponse = harness
|
find_rpc_span_with_trace(&spans, SpanKind::Server, "thread/start", remote_trace_id);
|
||||||
.start_thread(/*request_id*/ 20_003, Some(remote_trace))
|
assert_eq!(server_request_span.name.as_ref(), "thread/start");
|
||||||
.await;
|
assert_eq!(server_request_span.parent_span_id, remote_parent_span_id);
|
||||||
let spans = wait_for_new_exported_spans(harness.tracing, baseline_len, |spans| {
|
assert!(server_request_span.parent_span_is_remote);
|
||||||
spans.iter().any(|span| {
|
assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id);
|
||||||
span.span_kind == SpanKind::Server
|
assert_ne!(server_request_span.span_context.span_id(), SpanId::INVALID);
|
||||||
&& span_attr(span, "rpc.method") == Some("thread/start")
|
assert_has_internal_descendant_at_min_depth(
|
||||||
&& span.span_context.trace_id() == remote_trace_id
|
&spans,
|
||||||
}) && spans.iter().any(|span| {
|
server_request_span,
|
||||||
span.name.as_ref() == "app_server.thread_start.notify_started"
|
/*min_depth*/ 1,
|
||||||
&& span.span_context.trace_id() == remote_trace_id
|
);
|
||||||
})
|
assert_has_internal_descendant_at_min_depth(
|
||||||
})
|
&spans,
|
||||||
.await;
|
server_request_span,
|
||||||
|
/*min_depth*/ 2,
|
||||||
|
);
|
||||||
|
harness.shutdown().await;
|
||||||
|
|
||||||
let server_request_span =
|
Ok(())
|
||||||
find_rpc_span_with_trace(&spans, SpanKind::Server, "thread/start", remote_trace_id);
|
},
|
||||||
assert_eq!(server_request_span.name.as_ref(), "thread/start");
|
)
|
||||||
assert_eq!(server_request_span.parent_span_id, remote_parent_span_id);
|
|
||||||
assert!(server_request_span.parent_span_is_remote);
|
|
||||||
assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id);
|
|
||||||
assert_ne!(server_request_span.span_context.span_id(), SpanId::INVALID);
|
|
||||||
assert_has_internal_descendant_at_min_depth(&spans, server_request_span, /*min_depth*/ 1);
|
|
||||||
assert_has_internal_descendant_at_min_depth(&spans, server_request_span, /*min_depth*/ 2);
|
|
||||||
harness.shutdown().await;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
|||||||
@@ -1172,6 +1172,7 @@ mod tests {
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1203,6 +1204,7 @@ mod tests {
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1234,6 +1236,7 @@ mod tests {
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1282,6 +1285,7 @@ mod tests {
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1366,6 +1370,7 @@ enabled = false
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1441,6 +1446,7 @@ enabled = false
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1514,6 +1520,7 @@ enabled = false
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1714,6 +1721,7 @@ enabled = false
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1751,6 +1759,7 @@ enabled = false
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1808,6 +1817,7 @@ enabled = false
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1860,6 +1870,7 @@ enabled = false
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1916,6 +1927,7 @@ enabled = false
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1973,6 +1985,7 @@ enabled = false
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -2030,6 +2043,7 @@ enabled = false
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -2120,6 +2134,7 @@ enabled = false
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -2149,6 +2164,7 @@ enabled = false
|
|||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use super::requirements_exec_policy::RequirementsExecPolicy;
|
|||||||
use super::requirements_exec_policy::RequirementsExecPolicyToml;
|
use super::requirements_exec_policy::RequirementsExecPolicyToml;
|
||||||
use crate::Constrained;
|
use crate::Constrained;
|
||||||
use crate::ConstraintError;
|
use crate::ConstraintError;
|
||||||
|
use crate::ManagedHooksRequirementsToml;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum RequirementSource {
|
pub enum RequirementSource {
|
||||||
@@ -86,6 +87,7 @@ pub struct ConfigRequirements {
|
|||||||
pub sandbox_policy: ConstrainedWithSource<SandboxPolicy>,
|
pub sandbox_policy: ConstrainedWithSource<SandboxPolicy>,
|
||||||
pub web_search_mode: ConstrainedWithSource<WebSearchMode>,
|
pub web_search_mode: ConstrainedWithSource<WebSearchMode>,
|
||||||
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
|
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
|
||||||
|
pub managed_hooks: Option<ConstrainedWithSource<ManagedHooksRequirementsToml>>,
|
||||||
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||||
pub exec_policy: Option<Sourced<RequirementsExecPolicy>>,
|
pub exec_policy: Option<Sourced<RequirementsExecPolicy>>,
|
||||||
pub enforce_residency: ConstrainedWithSource<Option<ResidencyRequirement>>,
|
pub enforce_residency: ConstrainedWithSource<Option<ResidencyRequirement>>,
|
||||||
@@ -117,6 +119,7 @@ impl Default for ConfigRequirements {
|
|||||||
/*source*/ None,
|
/*source*/ None,
|
||||||
),
|
),
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
managed_hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
exec_policy: None,
|
exec_policy: None,
|
||||||
enforce_residency: ConstrainedWithSource::new(
|
enforce_residency: ConstrainedWithSource::new(
|
||||||
@@ -628,6 +631,7 @@ pub struct ConfigRequirementsToml {
|
|||||||
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
|
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
|
||||||
#[serde(rename = "features", alias = "feature_requirements")]
|
#[serde(rename = "features", alias = "feature_requirements")]
|
||||||
pub feature_requirements: Option<FeatureRequirementsToml>,
|
pub feature_requirements: Option<FeatureRequirementsToml>,
|
||||||
|
pub hooks: Option<ManagedHooksRequirementsToml>,
|
||||||
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
|
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
|
||||||
pub apps: Option<AppsRequirementsToml>,
|
pub apps: Option<AppsRequirementsToml>,
|
||||||
pub rules: Option<RequirementsExecPolicyToml>,
|
pub rules: Option<RequirementsExecPolicyToml>,
|
||||||
@@ -673,6 +677,7 @@ pub struct ConfigRequirementsWithSources {
|
|||||||
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
|
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
|
||||||
pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
|
pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
|
||||||
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
|
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
|
||||||
|
pub hooks: Option<Sourced<ManagedHooksRequirementsToml>>,
|
||||||
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||||
pub apps: Option<Sourced<AppsRequirementsToml>>,
|
pub apps: Option<Sourced<AppsRequirementsToml>>,
|
||||||
pub rules: Option<Sourced<RequirementsExecPolicyToml>>,
|
pub rules: Option<Sourced<RequirementsExecPolicyToml>>,
|
||||||
@@ -707,6 +712,7 @@ impl ConfigRequirementsWithSources {
|
|||||||
remote_sandbox_config: _,
|
remote_sandbox_config: _,
|
||||||
allowed_web_search_modes: _,
|
allowed_web_search_modes: _,
|
||||||
feature_requirements: _,
|
feature_requirements: _,
|
||||||
|
hooks: _,
|
||||||
mcp_servers: _,
|
mcp_servers: _,
|
||||||
apps: _,
|
apps: _,
|
||||||
rules: _,
|
rules: _,
|
||||||
@@ -734,6 +740,7 @@ impl ConfigRequirementsWithSources {
|
|||||||
allowed_sandbox_modes,
|
allowed_sandbox_modes,
|
||||||
allowed_web_search_modes,
|
allowed_web_search_modes,
|
||||||
feature_requirements,
|
feature_requirements,
|
||||||
|
hooks,
|
||||||
mcp_servers,
|
mcp_servers,
|
||||||
rules,
|
rules,
|
||||||
enforce_residency,
|
enforce_residency,
|
||||||
@@ -759,6 +766,7 @@ impl ConfigRequirementsWithSources {
|
|||||||
allowed_sandbox_modes,
|
allowed_sandbox_modes,
|
||||||
allowed_web_search_modes,
|
allowed_web_search_modes,
|
||||||
feature_requirements,
|
feature_requirements,
|
||||||
|
hooks,
|
||||||
mcp_servers,
|
mcp_servers,
|
||||||
apps,
|
apps,
|
||||||
rules,
|
rules,
|
||||||
@@ -774,6 +782,7 @@ impl ConfigRequirementsWithSources {
|
|||||||
remote_sandbox_config: None,
|
remote_sandbox_config: None,
|
||||||
allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value),
|
allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value),
|
||||||
feature_requirements: feature_requirements.map(|sourced| sourced.value),
|
feature_requirements: feature_requirements.map(|sourced| sourced.value),
|
||||||
|
hooks: hooks.map(|sourced| sourced.value),
|
||||||
mcp_servers: mcp_servers.map(|sourced| sourced.value),
|
mcp_servers: mcp_servers.map(|sourced| sourced.value),
|
||||||
apps: apps.map(|sourced| sourced.value),
|
apps: apps.map(|sourced| sourced.value),
|
||||||
rules: rules.map(|sourced| sourced.value),
|
rules: rules.map(|sourced| sourced.value),
|
||||||
@@ -858,6 +867,10 @@ impl ConfigRequirementsToml {
|
|||||||
.feature_requirements
|
.feature_requirements
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_none_or(FeatureRequirementsToml::is_empty)
|
.is_none_or(FeatureRequirementsToml::is_empty)
|
||||||
|
&& self
|
||||||
|
.hooks
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(ManagedHooksRequirementsToml::is_empty)
|
||||||
&& self.mcp_servers.is_none()
|
&& self.mcp_servers.is_none()
|
||||||
&& self
|
&& self
|
||||||
.apps
|
.apps
|
||||||
@@ -884,6 +897,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
|||||||
allowed_sandbox_modes,
|
allowed_sandbox_modes,
|
||||||
allowed_web_search_modes,
|
allowed_web_search_modes,
|
||||||
feature_requirements,
|
feature_requirements,
|
||||||
|
hooks,
|
||||||
mcp_servers,
|
mcp_servers,
|
||||||
apps: _apps,
|
apps: _apps,
|
||||||
rules,
|
rules,
|
||||||
@@ -1064,6 +1078,34 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
|||||||
};
|
};
|
||||||
let feature_requirements =
|
let feature_requirements =
|
||||||
feature_requirements.filter(|requirements| !requirements.value.is_empty());
|
feature_requirements.filter(|requirements| !requirements.value.is_empty());
|
||||||
|
let managed_hooks = hooks
|
||||||
|
.filter(|managed_hooks| managed_hooks.value.handler_count() > 0)
|
||||||
|
.map(|sourced_hooks| {
|
||||||
|
let Sourced {
|
||||||
|
value,
|
||||||
|
source: requirement_source,
|
||||||
|
} = sourced_hooks;
|
||||||
|
let allowed = value;
|
||||||
|
let allowed_for_error = format!("{allowed:?}");
|
||||||
|
let requirement_source_for_error = requirement_source.clone();
|
||||||
|
let constrained = Constrained::new(allowed.clone(), move |candidate| {
|
||||||
|
if candidate == &allowed {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ConstraintError::InvalidValue {
|
||||||
|
field_name: "hooks",
|
||||||
|
candidate: format!("{candidate:?}"),
|
||||||
|
allowed: allowed_for_error.clone(),
|
||||||
|
requirement_source: requirement_source_for_error.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(ConstrainedWithSource::new(
|
||||||
|
constrained,
|
||||||
|
Some(requirement_source),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
let enforce_residency = match enforce_residency {
|
let enforce_residency = match enforce_residency {
|
||||||
Some(Sourced {
|
Some(Sourced {
|
||||||
@@ -1106,6 +1148,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
|||||||
sandbox_policy,
|
sandbox_policy,
|
||||||
web_search_mode,
|
web_search_mode,
|
||||||
feature_requirements,
|
feature_requirements,
|
||||||
|
managed_hooks,
|
||||||
mcp_servers,
|
mcp_servers,
|
||||||
exec_policy,
|
exec_policy,
|
||||||
enforce_residency,
|
enforce_residency,
|
||||||
@@ -1119,6 +1162,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::HookEventsToml;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use codex_execpolicy::Decision;
|
use codex_execpolicy::Decision;
|
||||||
use codex_execpolicy::Evaluation;
|
use codex_execpolicy::Evaluation;
|
||||||
@@ -1147,6 +1191,7 @@ mod tests {
|
|||||||
remote_sandbox_config: _,
|
remote_sandbox_config: _,
|
||||||
allowed_web_search_modes,
|
allowed_web_search_modes,
|
||||||
feature_requirements,
|
feature_requirements,
|
||||||
|
hooks,
|
||||||
mcp_servers,
|
mcp_servers,
|
||||||
apps,
|
apps,
|
||||||
rules,
|
rules,
|
||||||
@@ -1166,6 +1211,7 @@ mod tests {
|
|||||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||||
feature_requirements: feature_requirements
|
feature_requirements: feature_requirements
|
||||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||||
|
hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||||
mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||||
apps: apps.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
apps: apps.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||||
rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||||
@@ -1210,6 +1256,7 @@ mod tests {
|
|||||||
remote_sandbox_config: None,
|
remote_sandbox_config: None,
|
||||||
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
|
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
|
||||||
feature_requirements: Some(feature_requirements.clone()),
|
feature_requirements: Some(feature_requirements.clone()),
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1241,6 +1288,7 @@ mod tests {
|
|||||||
feature_requirements,
|
feature_requirements,
|
||||||
enforce_source.clone(),
|
enforce_source.clone(),
|
||||||
)),
|
)),
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1278,6 +1326,7 @@ mod tests {
|
|||||||
allowed_sandbox_modes: None,
|
allowed_sandbox_modes: None,
|
||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1323,6 +1372,7 @@ mod tests {
|
|||||||
allowed_sandbox_modes: None,
|
allowed_sandbox_modes: None,
|
||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -2232,6 +2282,124 @@ allowed_approvals_reviewers = ["user"]
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_managed_hooks_requirements() -> Result<()> {
|
||||||
|
let toml_str = r#"
|
||||||
|
managed_dir = "/enterprise/hooks"
|
||||||
|
windows_managed_dir = 'C:\enterprise\hooks'
|
||||||
|
|
||||||
|
[[PreToolUse]]
|
||||||
|
matcher = "^Bash$"
|
||||||
|
|
||||||
|
[[PreToolUse.hooks]]
|
||||||
|
type = "command"
|
||||||
|
command = "python3 /enterprise/hooks/pre.py"
|
||||||
|
timeout = 10
|
||||||
|
statusMessage = "checking"
|
||||||
|
"#;
|
||||||
|
let hooks: ManagedHooksRequirementsToml = from_str(toml_str)?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
hooks.managed_dir.as_deref(),
|
||||||
|
Some(std::path::Path::new("/enterprise/hooks"))
|
||||||
|
);
|
||||||
|
assert_eq!(hooks.handler_count(), 1);
|
||||||
|
assert_eq!(hooks.hooks.pre_tool_use.len(), 1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_unset_fields_does_not_overwrite_existing_hooks() -> Result<()> {
|
||||||
|
let mut target = ConfigRequirementsWithSources::default();
|
||||||
|
target.merge_unset_fields(
|
||||||
|
RequirementSource::CloudRequirements,
|
||||||
|
from_str::<ConfigRequirementsToml>(
|
||||||
|
r#"
|
||||||
|
[hooks]
|
||||||
|
managed_dir = "/cloud/hooks"
|
||||||
|
|
||||||
|
[[hooks.PreToolUse]]
|
||||||
|
matcher = "^Bash$"
|
||||||
|
|
||||||
|
[[hooks.PreToolUse.hooks]]
|
||||||
|
type = "command"
|
||||||
|
command = "python3 /cloud/hooks/pre.py"
|
||||||
|
"#,
|
||||||
|
)?,
|
||||||
|
);
|
||||||
|
target.merge_unset_fields(
|
||||||
|
RequirementSource::SystemRequirementsToml {
|
||||||
|
file: system_requirements_toml_file_for_test()?,
|
||||||
|
},
|
||||||
|
from_str::<ConfigRequirementsToml>(
|
||||||
|
r#"
|
||||||
|
[hooks]
|
||||||
|
managed_dir = "/system/hooks"
|
||||||
|
|
||||||
|
[[hooks.PreToolUse]]
|
||||||
|
matcher = "^Bash$"
|
||||||
|
|
||||||
|
[[hooks.PreToolUse.hooks]]
|
||||||
|
type = "command"
|
||||||
|
command = "python3 /system/hooks/pre.py"
|
||||||
|
"#,
|
||||||
|
)?,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
target
|
||||||
|
.hooks
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|hooks| hooks.value.managed_dir.as_ref())
|
||||||
|
.map(std::path::PathBuf::as_path),
|
||||||
|
Some(std::path::Path::new("/cloud/hooks"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target.hooks.as_ref().map(|hooks| hooks.source.clone()),
|
||||||
|
Some(RequirementSource::CloudRequirements)
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn managed_hooks_constraint_rejects_drift() -> Result<()> {
|
||||||
|
let config: ConfigRequirementsToml = from_str(
|
||||||
|
r#"
|
||||||
|
[hooks]
|
||||||
|
managed_dir = "/enterprise/hooks"
|
||||||
|
|
||||||
|
[[hooks.PreToolUse]]
|
||||||
|
matcher = "^Bash$"
|
||||||
|
|
||||||
|
[[hooks.PreToolUse.hooks]]
|
||||||
|
type = "command"
|
||||||
|
command = "python3 /enterprise/hooks/pre.py"
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
|
||||||
|
let mut managed_hooks = requirements
|
||||||
|
.managed_hooks
|
||||||
|
.expect("expected managed hooks requirements");
|
||||||
|
|
||||||
|
let err = managed_hooks
|
||||||
|
.set(ManagedHooksRequirementsToml {
|
||||||
|
managed_dir: Some(std::path::PathBuf::from("/other/hooks")),
|
||||||
|
windows_managed_dir: None,
|
||||||
|
hooks: HookEventsToml::default(),
|
||||||
|
})
|
||||||
|
.expect_err("managed hooks should reject drift");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
ConstraintError::InvalidValue {
|
||||||
|
field_name: "hooks",
|
||||||
|
requirement_source: RequirementSource::Unknown,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn network_requirements_are_preserved_as_constraints_with_source() -> Result<()> {
|
fn network_requirements_are_preserved_as_constraints_with_source() -> Result<()> {
|
||||||
let toml_str = r#"
|
let toml_str = r#"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::collections::BTreeMap;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::HookEventsToml;
|
||||||
use crate::permissions_toml::PermissionsToml;
|
use crate::permissions_toml::PermissionsToml;
|
||||||
use crate::profile_toml::ConfigProfile;
|
use crate::profile_toml::ConfigProfile;
|
||||||
use crate::types::AnalyticsConfigToml;
|
use crate::types::AnalyticsConfigToml;
|
||||||
@@ -332,6 +333,9 @@ pub struct ConfigToml {
|
|||||||
/// User-level skill config entries keyed by SKILL.md path.
|
/// User-level skill config entries keyed by SKILL.md path.
|
||||||
pub skills: Option<SkillsConfig>,
|
pub skills: Option<SkillsConfig>,
|
||||||
|
|
||||||
|
/// Lifecycle hooks configured inline in TOML.
|
||||||
|
pub hooks: Option<HookEventsToml>,
|
||||||
|
|
||||||
/// User-level plugin config entries keyed by plugin name.
|
/// User-level plugin config entries keyed by plugin name.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub plugins: HashMap<String, PluginConfig>,
|
pub plugins: HashMap<String, PluginConfig>,
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use codex_protocol::protocol::HookEventName;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct HooksFile {
|
||||||
|
#[serde(default)]
|
||||||
|
pub hooks: HookEventsToml,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct HookEventsToml {
|
||||||
|
#[serde(rename = "PreToolUse", default)]
|
||||||
|
pub pre_tool_use: Vec<MatcherGroup>,
|
||||||
|
#[serde(rename = "PermissionRequest", default)]
|
||||||
|
pub permission_request: Vec<MatcherGroup>,
|
||||||
|
#[serde(rename = "PostToolUse", default)]
|
||||||
|
pub post_tool_use: Vec<MatcherGroup>,
|
||||||
|
#[serde(rename = "SessionStart", default)]
|
||||||
|
pub session_start: Vec<MatcherGroup>,
|
||||||
|
#[serde(rename = "UserPromptSubmit", default)]
|
||||||
|
pub user_prompt_submit: Vec<MatcherGroup>,
|
||||||
|
#[serde(rename = "Stop", default)]
|
||||||
|
pub stop: Vec<MatcherGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookEventsToml {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
let Self {
|
||||||
|
pre_tool_use,
|
||||||
|
permission_request,
|
||||||
|
post_tool_use,
|
||||||
|
session_start,
|
||||||
|
user_prompt_submit,
|
||||||
|
stop,
|
||||||
|
} = self;
|
||||||
|
pre_tool_use.is_empty()
|
||||||
|
&& permission_request.is_empty()
|
||||||
|
&& post_tool_use.is_empty()
|
||||||
|
&& session_start.is_empty()
|
||||||
|
&& user_prompt_submit.is_empty()
|
||||||
|
&& stop.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handler_count(&self) -> usize {
|
||||||
|
let Self {
|
||||||
|
pre_tool_use,
|
||||||
|
permission_request,
|
||||||
|
post_tool_use,
|
||||||
|
session_start,
|
||||||
|
user_prompt_submit,
|
||||||
|
stop,
|
||||||
|
} = self;
|
||||||
|
[
|
||||||
|
pre_tool_use,
|
||||||
|
permission_request,
|
||||||
|
post_tool_use,
|
||||||
|
session_start,
|
||||||
|
user_prompt_submit,
|
||||||
|
stop,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.map(|group| group.hooks.len())
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 6] {
|
||||||
|
[
|
||||||
|
(HookEventName::PreToolUse, self.pre_tool_use),
|
||||||
|
(HookEventName::PermissionRequest, self.permission_request),
|
||||||
|
(HookEventName::PostToolUse, self.post_tool_use),
|
||||||
|
(HookEventName::SessionStart, self.session_start),
|
||||||
|
(HookEventName::UserPromptSubmit, self.user_prompt_submit),
|
||||||
|
(HookEventName::Stop, self.stop),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct MatcherGroup {
|
||||||
|
#[serde(default)]
|
||||||
|
pub matcher: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hooks: Vec<HookHandlerConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum HookHandlerConfig {
|
||||||
|
#[serde(rename = "command")]
|
||||||
|
Command {
|
||||||
|
command: String,
|
||||||
|
#[serde(default, rename = "timeout")]
|
||||||
|
timeout_sec: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
r#async: bool,
|
||||||
|
#[serde(default, rename = "statusMessage")]
|
||||||
|
status_message: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "prompt")]
|
||||||
|
Prompt {},
|
||||||
|
#[serde(rename = "agent")]
|
||||||
|
Agent {},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ManagedHooksRequirementsToml {
|
||||||
|
pub managed_dir: Option<PathBuf>,
|
||||||
|
pub windows_managed_dir: Option<PathBuf>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub hooks: HookEventsToml,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManagedHooksRequirementsToml {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
let Self {
|
||||||
|
managed_dir,
|
||||||
|
windows_managed_dir,
|
||||||
|
hooks,
|
||||||
|
} = self;
|
||||||
|
managed_dir.is_none() && windows_managed_dir.is_none() && hooks.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handler_count(&self) -> usize {
|
||||||
|
self.hooks.handler_count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn managed_dir_for_current_platform(&self) -> Option<&Path> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
self.windows_managed_dir.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
self.managed_dir.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "hooks_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
use super::HookEventsToml;
|
||||||
|
use super::HookHandlerConfig;
|
||||||
|
use super::HooksFile;
|
||||||
|
use super::ManagedHooksRequirementsToml;
|
||||||
|
use super::MatcherGroup;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hooks_file_deserializes_existing_json_shape() {
|
||||||
|
let parsed: HooksFile = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "^Bash$",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 /tmp/pre.py",
|
||||||
|
"timeout": 10,
|
||||||
|
"statusMessage": "checking"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("hooks.json should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parsed,
|
||||||
|
HooksFile {
|
||||||
|
hooks: HookEventsToml {
|
||||||
|
pre_tool_use: vec![MatcherGroup {
|
||||||
|
matcher: Some("^Bash$".to_string()),
|
||||||
|
hooks: vec![HookHandlerConfig::Command {
|
||||||
|
command: "python3 /tmp/pre.py".to_string(),
|
||||||
|
timeout_sec: Some(10),
|
||||||
|
r#async: false,
|
||||||
|
status_message: Some("checking".to_string()),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hook_events_deserialize_from_toml_arrays_of_tables() {
|
||||||
|
let parsed: HookEventsToml = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[[PreToolUse]]
|
||||||
|
matcher = "^Bash$"
|
||||||
|
|
||||||
|
[[PreToolUse.hooks]]
|
||||||
|
type = "command"
|
||||||
|
command = "python3 /tmp/pre.py"
|
||||||
|
timeout = 10
|
||||||
|
statusMessage = "checking"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("hook events TOML should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parsed,
|
||||||
|
HookEventsToml {
|
||||||
|
pre_tool_use: vec![MatcherGroup {
|
||||||
|
matcher: Some("^Bash$".to_string()),
|
||||||
|
hooks: vec![HookHandlerConfig::Command {
|
||||||
|
command: "python3 /tmp/pre.py".to_string(),
|
||||||
|
timeout_sec: Some(10),
|
||||||
|
r#async: false,
|
||||||
|
status_message: Some("checking".to_string()),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn managed_hooks_requirements_flatten_hook_events() {
|
||||||
|
let parsed: ManagedHooksRequirementsToml = toml::from_str(
|
||||||
|
r#"
|
||||||
|
managed_dir = "/enterprise/place"
|
||||||
|
|
||||||
|
[[PreToolUse]]
|
||||||
|
matcher = "^Bash$"
|
||||||
|
|
||||||
|
[[PreToolUse.hooks]]
|
||||||
|
type = "command"
|
||||||
|
command = "python3 /enterprise/place/pre.py"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("requirements hooks TOML should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parsed,
|
||||||
|
ManagedHooksRequirementsToml {
|
||||||
|
managed_dir: Some(std::path::PathBuf::from("/enterprise/place")),
|
||||||
|
windows_managed_dir: None,
|
||||||
|
hooks: HookEventsToml {
|
||||||
|
pre_tool_use: vec![MatcherGroup {
|
||||||
|
matcher: Some("^Bash$".to_string()),
|
||||||
|
hooks: vec![HookHandlerConfig::Command {
|
||||||
|
command: "python3 /enterprise/place/pre.py".to_string(),
|
||||||
|
timeout_sec: None,
|
||||||
|
r#async: false,
|
||||||
|
status_message: None,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ pub mod config_toml;
|
|||||||
mod constraint;
|
mod constraint;
|
||||||
mod diagnostics;
|
mod diagnostics;
|
||||||
mod fingerprint;
|
mod fingerprint;
|
||||||
|
mod hook_config;
|
||||||
mod host_name;
|
mod host_name;
|
||||||
mod key_aliases;
|
mod key_aliases;
|
||||||
mod marketplace_edit;
|
mod marketplace_edit;
|
||||||
@@ -27,6 +28,8 @@ pub const CONFIG_TOML_FILE: &str = "config.toml";
|
|||||||
pub use cloud_requirements::CloudRequirementsLoadError;
|
pub use cloud_requirements::CloudRequirementsLoadError;
|
||||||
pub use cloud_requirements::CloudRequirementsLoadErrorCode;
|
pub use cloud_requirements::CloudRequirementsLoadErrorCode;
|
||||||
pub use cloud_requirements::CloudRequirementsLoader;
|
pub use cloud_requirements::CloudRequirementsLoader;
|
||||||
|
pub use codex_app_server_protocol::ConfigLayerSource;
|
||||||
|
pub use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
pub use config_requirements::AppRequirementToml;
|
pub use config_requirements::AppRequirementToml;
|
||||||
pub use config_requirements::AppsRequirementsToml;
|
pub use config_requirements::AppsRequirementsToml;
|
||||||
pub use config_requirements::ConfigRequirements;
|
pub use config_requirements::ConfigRequirements;
|
||||||
@@ -65,6 +68,11 @@ pub use diagnostics::format_config_error;
|
|||||||
pub use diagnostics::format_config_error_with_source;
|
pub use diagnostics::format_config_error_with_source;
|
||||||
pub use diagnostics::io_error_from_config_error;
|
pub use diagnostics::io_error_from_config_error;
|
||||||
pub use fingerprint::version_for_toml;
|
pub use fingerprint::version_for_toml;
|
||||||
|
pub use hook_config::HookEventsToml;
|
||||||
|
pub use hook_config::HookHandlerConfig;
|
||||||
|
pub use hook_config::HooksFile;
|
||||||
|
pub use hook_config::ManagedHooksRequirementsToml;
|
||||||
|
pub use hook_config::MatcherGroup;
|
||||||
pub use host_name::host_name;
|
pub use host_name::host_name;
|
||||||
pub use marketplace_edit::MarketplaceConfigUpdate;
|
pub use marketplace_edit::MarketplaceConfigUpdate;
|
||||||
pub use marketplace_edit::RemoveMarketplaceConfigOutcome;
|
pub use marketplace_edit::RemoveMarketplaceConfigOutcome;
|
||||||
@@ -106,5 +114,4 @@ pub use thread_config::ThreadConfigLoadErrorCode;
|
|||||||
pub use thread_config::ThreadConfigLoader;
|
pub use thread_config::ThreadConfigLoader;
|
||||||
pub use thread_config::ThreadConfigSource;
|
pub use thread_config::ThreadConfigSource;
|
||||||
pub use thread_config::UserThreadConfig;
|
pub use thread_config::UserThreadConfig;
|
||||||
|
pub use toml::Value as TomlValue;
|
||||||
pub use codex_app_server_protocol::ConfigLayerSource;
|
|
||||||
|
|||||||
@@ -848,6 +848,117 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"HookEventsToml": {
|
||||||
|
"properties": {
|
||||||
|
"PermissionRequest": {
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/MatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"PostToolUse": {
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/MatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"PreToolUse": {
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/MatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"SessionStart": {
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/MatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"Stop": {
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/MatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"UserPromptSubmit": {
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/MatcherGroup"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"HookHandlerConfig": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"async": {
|
||||||
|
"default": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"statusMessage": {
|
||||||
|
"default": null,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"default": null,
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"command"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"command",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"prompt"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"MarketplaceConfig": {
|
"MarketplaceConfig": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -898,6 +1009,22 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"MatcherGroup": {
|
||||||
|
"properties": {
|
||||||
|
"hooks": {
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/HookHandlerConfig"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"matcher": {
|
||||||
|
"default": null,
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"McpServerEnvVar": {
|
"McpServerEnvVar": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -2634,6 +2761,14 @@
|
|||||||
"default": null,
|
"default": null,
|
||||||
"description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`."
|
"description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`."
|
||||||
},
|
},
|
||||||
|
"hooks": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/HookEventsToml"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Lifecycle hooks configured inline in TOML."
|
||||||
|
},
|
||||||
"include_apps_instructions": {
|
"include_apps_instructions": {
|
||||||
"description": "Whether to inject the `<apps_instructions>` developer block.",
|
"description": "Whether to inject the `<apps_instructions>` developer block.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
|||||||
@@ -5732,6 +5732,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
|
|||||||
crate::config_loader::WebSearchModeRequirement::Cached,
|
crate::config_loader::WebSearchModeRequirement::Cached,
|
||||||
]),
|
]),
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -6407,6 +6408,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
|
|||||||
remote_sandbox_config: None,
|
remote_sandbox_config: None,
|
||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
|
|||||||
@@ -1577,6 +1577,7 @@ impl Config {
|
|||||||
sandbox_policy: mut constrained_sandbox_policy,
|
sandbox_policy: mut constrained_sandbox_policy,
|
||||||
web_search_mode: mut constrained_web_search_mode,
|
web_search_mode: mut constrained_web_search_mode,
|
||||||
feature_requirements,
|
feature_requirements,
|
||||||
|
managed_hooks: _,
|
||||||
mcp_servers,
|
mcp_servers,
|
||||||
exec_policy: _,
|
exec_policy: _,
|
||||||
enforce_residency,
|
enforce_residency,
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ pub use codex_config::ConstrainedWithSource;
|
|||||||
pub use codex_config::FeatureRequirementsToml;
|
pub use codex_config::FeatureRequirementsToml;
|
||||||
pub use codex_config::FilesystemConstraints;
|
pub use codex_config::FilesystemConstraints;
|
||||||
pub use codex_config::FilesystemDenyReadPattern;
|
pub use codex_config::FilesystemDenyReadPattern;
|
||||||
|
pub use codex_config::HookEventsToml;
|
||||||
|
pub use codex_config::HookHandlerConfig;
|
||||||
pub use codex_config::LoaderOverrides;
|
pub use codex_config::LoaderOverrides;
|
||||||
|
pub use codex_config::ManagedHooksRequirementsToml;
|
||||||
|
pub use codex_config::MatcherGroup;
|
||||||
pub use codex_config::McpServerIdentity;
|
pub use codex_config::McpServerIdentity;
|
||||||
pub use codex_config::McpServerRequirement;
|
pub use codex_config::McpServerRequirement;
|
||||||
pub use codex_config::NetworkConstraints;
|
pub use codex_config::NetworkConstraints;
|
||||||
|
|||||||
@@ -777,6 +777,7 @@ allowed_approval_policies = ["on-request"]
|
|||||||
remote_sandbox_config: None,
|
remote_sandbox_config: None,
|
||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -833,6 +834,7 @@ allowed_approval_policies = ["on-request"]
|
|||||||
remote_sandbox_config: None,
|
remote_sandbox_config: None,
|
||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1041,6 +1043,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
|
|||||||
remote_sandbox_config: None,
|
remote_sandbox_config: None,
|
||||||
allowed_web_search_modes: None,
|
allowed_web_search_modes: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -1084,6 +1087,62 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result<()> {
|
||||||
|
let tmp = tempdir()?;
|
||||||
|
let codex_home = tmp.path().join("home");
|
||||||
|
tokio::fs::create_dir_all(&codex_home).await?;
|
||||||
|
let managed_dir = tmp.path().join("managed-hooks");
|
||||||
|
tokio::fs::create_dir_all(&managed_dir).await?;
|
||||||
|
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
|
||||||
|
|
||||||
|
let requirements = ConfigRequirementsToml {
|
||||||
|
hooks: Some(codex_config::ManagedHooksRequirementsToml {
|
||||||
|
managed_dir: Some(managed_dir.clone()),
|
||||||
|
windows_managed_dir: None,
|
||||||
|
hooks: codex_config::HookEventsToml {
|
||||||
|
pre_tool_use: vec![codex_config::MatcherGroup {
|
||||||
|
matcher: Some("^Bash$".to_string()),
|
||||||
|
hooks: vec![codex_config::HookHandlerConfig::Command {
|
||||||
|
command: format!("python3 {}/pre.py", managed_dir.display()),
|
||||||
|
timeout_sec: Some(10),
|
||||||
|
r#async: false,
|
||||||
|
status_message: Some("checking".to_string()),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
..ConfigRequirementsToml::default()
|
||||||
|
};
|
||||||
|
let expected = requirements.clone();
|
||||||
|
let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) });
|
||||||
|
|
||||||
|
let layers = load_config_layers_state(
|
||||||
|
LOCAL_FS.as_ref(),
|
||||||
|
&codex_home,
|
||||||
|
Some(cwd),
|
||||||
|
&[] as &[(String, TomlValue)],
|
||||||
|
LoaderOverrides::default(),
|
||||||
|
cloud_requirements,
|
||||||
|
&codex_config::NoopThreadConfigLoader,
|
||||||
|
/*host_name*/ None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
assert_eq!(layers.requirements_toml().hooks, expected.hooks);
|
||||||
|
assert_eq!(
|
||||||
|
layers
|
||||||
|
.requirements()
|
||||||
|
.managed_hooks
|
||||||
|
.as_ref()
|
||||||
|
.map(|hooks| hooks.source.clone()),
|
||||||
|
Some(Some(RequirementSource::CloudRequirements))
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::Result<()> {
|
async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::Result<()> {
|
||||||
let tmp = tempdir()?;
|
let tmp = tempdir()?;
|
||||||
|
|||||||
@@ -482,12 +482,12 @@ impl TestCodexBuilder {
|
|||||||
..built_in_model_providers(/*openai_base_url*/ None)["openai"].clone()
|
..built_in_model_providers(/*openai_base_url*/ None)["openai"].clone()
|
||||||
};
|
};
|
||||||
let cwd = Arc::new(TempDir::new()?);
|
let cwd = Arc::new(TempDir::new()?);
|
||||||
let mut config = load_default_config_for_test(home).await;
|
|
||||||
config.cwd = cwd_override;
|
|
||||||
config.model_provider = model_provider;
|
|
||||||
for hook in self.pre_build_hooks.drain(..) {
|
for hook in self.pre_build_hooks.drain(..) {
|
||||||
hook(home.path());
|
hook(home.path());
|
||||||
}
|
}
|
||||||
|
let mut config = load_default_config_for_test(home).await;
|
||||||
|
config.cwd = cwd_override;
|
||||||
|
config.model_provider = model_provider;
|
||||||
if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") {
|
if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") {
|
||||||
config.codex_self_exe = Some(path);
|
config.codex_self_exe = Some(path);
|
||||||
} else if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex-exec") {
|
} else if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex-exec") {
|
||||||
|
|||||||
@@ -254,6 +254,74 @@ elif mode == "exit_2":
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_pre_tool_use_hook_toml(
|
||||||
|
home: &Path,
|
||||||
|
script_name: &str,
|
||||||
|
log_name: &str,
|
||||||
|
matcher: Option<&str>,
|
||||||
|
mode: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let script_path = home.join(script_name);
|
||||||
|
let log_path = home.join(log_name);
|
||||||
|
let mode_json = serde_json::to_string(mode).context("serialize pre tool use mode")?;
|
||||||
|
let reason_json = serde_json::to_string(reason).context("serialize pre tool use reason")?;
|
||||||
|
let script = format!(
|
||||||
|
r#"import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
log_path = Path(r"{log_path}")
|
||||||
|
mode = {mode_json}
|
||||||
|
reason = {reason_json}
|
||||||
|
|
||||||
|
payload = json.load(sys.stdin)
|
||||||
|
|
||||||
|
with log_path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(payload) + "\n")
|
||||||
|
|
||||||
|
if mode == "json_deny":
|
||||||
|
print(json.dumps({{
|
||||||
|
"hookSpecificOutput": {{
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "deny",
|
||||||
|
"permissionDecisionReason": reason
|
||||||
|
}}
|
||||||
|
}}))
|
||||||
|
elif mode == "exit_2":
|
||||||
|
sys.stderr.write(reason + "\n")
|
||||||
|
raise SystemExit(2)
|
||||||
|
"#,
|
||||||
|
log_path = log_path.display(),
|
||||||
|
mode_json = mode_json,
|
||||||
|
reason_json = reason_json,
|
||||||
|
);
|
||||||
|
let matcher_line = matcher
|
||||||
|
.map(|matcher| format!("matcher = '{matcher}'\n"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let config_toml = format!(
|
||||||
|
r#"[features]
|
||||||
|
codex_hooks = true
|
||||||
|
|
||||||
|
[hooks]
|
||||||
|
|
||||||
|
[[hooks.PreToolUse]]
|
||||||
|
{matcher_line}
|
||||||
|
|
||||||
|
[[hooks.PreToolUse.hooks]]
|
||||||
|
type = "command"
|
||||||
|
command = 'python3 {script_path}'
|
||||||
|
statusMessage = "running pre tool use hook"
|
||||||
|
"#,
|
||||||
|
matcher_line = matcher_line,
|
||||||
|
script_path = script_path.display(),
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::write(&script_path, script).context("write TOML pre tool use hook script")?;
|
||||||
|
fs::write(home.join("config.toml"), config_toml).context("write config.toml hooks")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn write_permission_request_hook(
|
fn write_permission_request_hook(
|
||||||
home: &Path,
|
home: &Path,
|
||||||
matcher: Option<&str>,
|
matcher: Option<&str>,
|
||||||
@@ -546,12 +614,7 @@ fn read_stop_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn read_pre_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
fn read_pre_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
||||||
fs::read_to_string(home.join("pre_tool_use_hook_log.jsonl"))
|
read_hook_inputs_from_log(home.join("pre_tool_use_hook_log.jsonl").as_path())
|
||||||
.context("read pre tool use hook log")?
|
|
||||||
.lines()
|
|
||||||
.filter(|line| !line.trim().is_empty())
|
|
||||||
.map(|line| serde_json::from_str(line).context("parse pre tool use hook log line"))
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_permission_request_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
fn read_permission_request_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
||||||
@@ -605,11 +668,15 @@ fn assert_single_permission_request_hook_input_for_tool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn read_post_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
fn read_post_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
||||||
fs::read_to_string(home.join("post_tool_use_hook_log.jsonl"))
|
read_hook_inputs_from_log(home.join("post_tool_use_hook_log.jsonl").as_path())
|
||||||
.context("read post tool use hook log")?
|
}
|
||||||
|
|
||||||
|
fn read_hook_inputs_from_log(log_path: &Path) -> Result<Vec<serde_json::Value>> {
|
||||||
|
fs::read_to_string(log_path)
|
||||||
|
.with_context(|| format!("read hook log {}", log_path.display()))?
|
||||||
.lines()
|
.lines()
|
||||||
.filter(|line| !line.trim().is_empty())
|
.filter(|line| !line.trim().is_empty())
|
||||||
.map(|line| serde_json::from_str(line).context("parse post tool use hook log line"))
|
.map(|line| serde_json::from_str(line).context("parse hook log line"))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1785,6 +1852,190 @@ async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn pre_tool_use_blocks_shell_when_defined_in_config_toml() -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
let server = start_mock_server().await;
|
||||||
|
let call_id = "pretooluse-config-toml";
|
||||||
|
let marker = std::env::temp_dir().join("pretooluse-config-toml-marker");
|
||||||
|
let command = format!("printf blocked > {}", marker.display());
|
||||||
|
let args = serde_json::json!({ "command": command });
|
||||||
|
let responses = mount_sse_sequence(
|
||||||
|
&server,
|
||||||
|
vec![
|
||||||
|
sse(vec![
|
||||||
|
ev_response_created("resp-1"),
|
||||||
|
core_test_support::responses::ev_function_call(
|
||||||
|
call_id,
|
||||||
|
"shell_command",
|
||||||
|
&serde_json::to_string(&args)?,
|
||||||
|
),
|
||||||
|
ev_completed("resp-1"),
|
||||||
|
]),
|
||||||
|
sse(vec![
|
||||||
|
ev_response_created("resp-2"),
|
||||||
|
ev_assistant_message("msg-1", "config.toml hook blocked it"),
|
||||||
|
ev_completed("resp-2"),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut builder = test_codex().with_pre_build_hook(|home| {
|
||||||
|
if let Err(error) = write_pre_tool_use_hook_toml(
|
||||||
|
home,
|
||||||
|
"pre_tool_use_config_hook.py",
|
||||||
|
"pre_tool_use_config_hook_log.jsonl",
|
||||||
|
Some("^Bash$"),
|
||||||
|
"json_deny",
|
||||||
|
"blocked by config toml hook",
|
||||||
|
) {
|
||||||
|
panic!("failed to write config.toml hook test fixture: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let test = builder.build(&server).await?;
|
||||||
|
|
||||||
|
if marker.exists() {
|
||||||
|
fs::remove_file(&marker).context("remove leftover config.toml marker")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.submit_turn_with_policy(
|
||||||
|
"run the blocked shell command from config toml",
|
||||||
|
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let requests = responses.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
let output_item = requests[1].function_call_output(call_id);
|
||||||
|
let output = output_item
|
||||||
|
.get("output")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.expect("shell command output string");
|
||||||
|
assert!(
|
||||||
|
output.contains("Command blocked by PreToolUse hook: blocked by config toml hook"),
|
||||||
|
"blocked tool output should surface the config.toml hook reason",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!marker.exists(),
|
||||||
|
"config.toml hook should block command execution"
|
||||||
|
);
|
||||||
|
|
||||||
|
let hook_inputs = read_hook_inputs_from_log(
|
||||||
|
test.codex_home_path()
|
||||||
|
.join("pre_tool_use_config_hook_log.jsonl")
|
||||||
|
.as_path(),
|
||||||
|
)?;
|
||||||
|
assert_eq!(hook_inputs.len(), 1);
|
||||||
|
assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse");
|
||||||
|
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
||||||
|
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
let server = start_mock_server().await;
|
||||||
|
let call_id = "pretooluse-merged-sources";
|
||||||
|
let command = "printf merged-hooks".to_string();
|
||||||
|
let args = serde_json::json!({ "command": command });
|
||||||
|
let responses = mount_sse_sequence(
|
||||||
|
&server,
|
||||||
|
vec![
|
||||||
|
sse(vec![
|
||||||
|
ev_response_created("resp-1"),
|
||||||
|
core_test_support::responses::ev_function_call(
|
||||||
|
call_id,
|
||||||
|
"shell_command",
|
||||||
|
&serde_json::to_string(&args)?,
|
||||||
|
),
|
||||||
|
ev_completed("resp-1"),
|
||||||
|
]),
|
||||||
|
sse(vec![
|
||||||
|
ev_response_created("resp-2"),
|
||||||
|
ev_assistant_message("msg-1", "merged hook context observed"),
|
||||||
|
ev_completed("resp-2"),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut builder = test_codex().with_pre_build_hook(|home| {
|
||||||
|
if let Err(error) = write_pre_tool_use_hook(home, Some("^Bash$"), "allow", "unused") {
|
||||||
|
panic!("failed to write hooks.json hook fixture: {error}");
|
||||||
|
}
|
||||||
|
if let Err(error) = write_pre_tool_use_hook_toml(
|
||||||
|
home,
|
||||||
|
"pre_tool_use_toml_hook.py",
|
||||||
|
"pre_tool_use_toml_hook_log.jsonl",
|
||||||
|
Some("^Bash$"),
|
||||||
|
"allow",
|
||||||
|
"unused",
|
||||||
|
) {
|
||||||
|
panic!("failed to write config.toml hook fixture: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let test = builder.build(&server).await?;
|
||||||
|
|
||||||
|
test.submit_turn("run the shell command with merged hook sources")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let requests = responses.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
let output_item = requests[1].function_call_output(call_id);
|
||||||
|
let output = output_item
|
||||||
|
.get("output")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.expect("shell command output string");
|
||||||
|
assert!(
|
||||||
|
output.contains("merged-hooks"),
|
||||||
|
"shell command output should still reach the model",
|
||||||
|
);
|
||||||
|
|
||||||
|
let json_hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?
|
||||||
|
.into_iter()
|
||||||
|
.map(|hook_input| {
|
||||||
|
serde_json::json!({
|
||||||
|
"hook_event_name": hook_input["hook_event_name"],
|
||||||
|
"tool_name": hook_input["tool_name"],
|
||||||
|
"tool_use_id": hook_input["tool_use_id"],
|
||||||
|
"tool_input": hook_input["tool_input"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let toml_hook_inputs = read_hook_inputs_from_log(
|
||||||
|
test.codex_home_path()
|
||||||
|
.join("pre_tool_use_toml_hook_log.jsonl")
|
||||||
|
.as_path(),
|
||||||
|
)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|hook_input| {
|
||||||
|
serde_json::json!({
|
||||||
|
"hook_event_name": hook_input["hook_event_name"],
|
||||||
|
"tool_name": hook_input["tool_name"],
|
||||||
|
"tool_use_id": hook_input["tool_use_id"],
|
||||||
|
"tool_input": hook_input["tool_input"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let expected_hook_inputs = vec![serde_json::json!({
|
||||||
|
"hook_event_name": "PreToolUse",
|
||||||
|
"tool_name": "Bash",
|
||||||
|
"tool_use_id": call_id,
|
||||||
|
"tool_input": {
|
||||||
|
"command": command,
|
||||||
|
},
|
||||||
|
})];
|
||||||
|
assert_eq!(expected_hook_inputs, json_hook_inputs);
|
||||||
|
assert_eq!(expected_hook_inputs, toml_hook_inputs);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> {
|
async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> {
|
||||||
skip_if_no_network!(Ok(()));
|
skip_if_no_network!(Ok(()));
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
pub(crate) struct HooksFile {
|
|
||||||
#[serde(default)]
|
|
||||||
pub hooks: HookEvents,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
pub(crate) struct HookEvents {
|
|
||||||
#[serde(rename = "PreToolUse", default)]
|
|
||||||
pub pre_tool_use: Vec<MatcherGroup>,
|
|
||||||
#[serde(rename = "PermissionRequest", default)]
|
|
||||||
pub permission_request: Vec<MatcherGroup>,
|
|
||||||
#[serde(rename = "PostToolUse", default)]
|
|
||||||
pub post_tool_use: Vec<MatcherGroup>,
|
|
||||||
#[serde(rename = "SessionStart", default)]
|
|
||||||
pub session_start: Vec<MatcherGroup>,
|
|
||||||
#[serde(rename = "UserPromptSubmit", default)]
|
|
||||||
pub user_prompt_submit: Vec<MatcherGroup>,
|
|
||||||
#[serde(rename = "Stop", default)]
|
|
||||||
pub stop: Vec<MatcherGroup>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
pub(crate) struct MatcherGroup {
|
|
||||||
#[serde(default)]
|
|
||||||
pub matcher: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub hooks: Vec<HookHandlerConfig>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(tag = "type")]
|
|
||||||
pub(crate) enum HookHandlerConfig {
|
|
||||||
#[serde(rename = "command")]
|
|
||||||
Command {
|
|
||||||
command: String,
|
|
||||||
#[serde(default, rename = "timeout", alias = "timeoutSec")]
|
|
||||||
timeout_sec: Option<u64>,
|
|
||||||
#[serde(default)]
|
|
||||||
r#async: bool,
|
|
||||||
#[serde(default, rename = "statusMessage")]
|
|
||||||
status_message: Option<String>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "prompt")]
|
|
||||||
Prompt {},
|
|
||||||
#[serde(rename = "agent")]
|
|
||||||
Agent {},
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use codex_config::CONFIG_TOML_FILE;
|
||||||
|
use codex_config::ConfigLayerEntry;
|
||||||
|
use codex_config::ConfigLayerSource;
|
||||||
use codex_config::ConfigLayerStack;
|
use codex_config::ConfigLayerStack;
|
||||||
use codex_config::ConfigLayerStackOrdering;
|
use codex_config::ConfigLayerStackOrdering;
|
||||||
|
use codex_config::HookEventsToml;
|
||||||
|
use codex_config::HookHandlerConfig;
|
||||||
|
use codex_config::HooksFile;
|
||||||
|
use codex_config::ManagedHooksRequirementsToml;
|
||||||
|
use codex_config::MatcherGroup;
|
||||||
|
use codex_config::RequirementSource;
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use std::fs;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use super::ConfiguredHandler;
|
use super::ConfiguredHandler;
|
||||||
use super::config::HookHandlerConfig;
|
|
||||||
use super::config::HooksFile;
|
|
||||||
use super::config::MatcherGroup;
|
|
||||||
use crate::events::common::matcher_pattern_for_event;
|
use crate::events::common::matcher_pattern_for_event;
|
||||||
use crate::events::common::validate_matcher_pattern;
|
use crate::events::common::validate_matcher_pattern;
|
||||||
use codex_config::ConfigLayerSource;
|
|
||||||
use codex_protocol::protocol::HookSource;
|
use codex_protocol::protocol::HookSource;
|
||||||
|
|
||||||
pub(crate) struct DiscoveryResult {
|
pub(crate) struct DiscoveryResult {
|
||||||
@@ -17,6 +25,13 @@ pub(crate) struct DiscoveryResult {
|
|||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct HookHandlerSource<'a> {
|
||||||
|
path: &'a AbsolutePathBuf,
|
||||||
|
is_managed: bool,
|
||||||
|
source: HookSource,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -> DiscoveryResult {
|
pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -> DiscoveryResult {
|
||||||
let Some(config_layer_stack) = config_layer_stack else {
|
let Some(config_layer_stack) = config_layer_stack else {
|
||||||
return DiscoveryResult {
|
return DiscoveryResult {
|
||||||
@@ -29,80 +44,58 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
|||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
let mut display_order = 0_i64;
|
let mut display_order = 0_i64;
|
||||||
|
|
||||||
|
append_managed_requirement_handlers(
|
||||||
|
&mut handlers,
|
||||||
|
&mut warnings,
|
||||||
|
&mut display_order,
|
||||||
|
config_layer_stack,
|
||||||
|
);
|
||||||
|
|
||||||
for layer in config_layer_stack.get_layers(
|
for layer in config_layer_stack.get_layers(
|
||||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||||
/*include_disabled*/ false,
|
/*include_disabled*/ false,
|
||||||
) {
|
) {
|
||||||
let Some(folder) = layer.config_folder() else {
|
let hook_source = hook_source_for_config_layer_source(&layer.name);
|
||||||
continue;
|
let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings);
|
||||||
};
|
let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings);
|
||||||
let source_path = folder.join("hooks.json");
|
|
||||||
if !source_path.as_path().is_file() {
|
if let (Some((json_source_path, json_events)), Some((toml_source_path, toml_events))) =
|
||||||
continue;
|
(&json_hooks, &toml_hooks)
|
||||||
|
&& !json_events.is_empty()
|
||||||
|
&& !toml_events.is_empty()
|
||||||
|
{
|
||||||
|
warnings.push(format!(
|
||||||
|
"loading hooks from both {} and {}; prefer a single representation for this layer",
|
||||||
|
json_source_path.display(),
|
||||||
|
toml_source_path.display()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let contents = match fs::read_to_string(source_path.as_path()) {
|
if let Some((source_path, hook_events)) = json_hooks {
|
||||||
Ok(contents) => contents,
|
append_hook_events(
|
||||||
Err(err) => {
|
|
||||||
warnings.push(format!(
|
|
||||||
"failed to read hooks config {}: {err}",
|
|
||||||
source_path.display()
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let parsed: HooksFile = match serde_json::from_str(&contents) {
|
|
||||||
Ok(parsed) => parsed,
|
|
||||||
Err(err) => {
|
|
||||||
warnings.push(format!(
|
|
||||||
"failed to parse hooks config {}: {err}",
|
|
||||||
source_path.display()
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let super::config::HookEvents {
|
|
||||||
pre_tool_use,
|
|
||||||
permission_request,
|
|
||||||
post_tool_use,
|
|
||||||
session_start,
|
|
||||||
user_prompt_submit,
|
|
||||||
stop,
|
|
||||||
} = parsed.hooks;
|
|
||||||
|
|
||||||
for (event_name, groups) in [
|
|
||||||
(
|
|
||||||
codex_protocol::protocol::HookEventName::PreToolUse,
|
|
||||||
pre_tool_use,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
codex_protocol::protocol::HookEventName::PermissionRequest,
|
|
||||||
permission_request,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
codex_protocol::protocol::HookEventName::PostToolUse,
|
|
||||||
post_tool_use,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
codex_protocol::protocol::HookEventName::SessionStart,
|
|
||||||
session_start,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
codex_protocol::protocol::HookEventName::UserPromptSubmit,
|
|
||||||
user_prompt_submit,
|
|
||||||
),
|
|
||||||
(codex_protocol::protocol::HookEventName::Stop, stop),
|
|
||||||
] {
|
|
||||||
append_matcher_groups(
|
|
||||||
&mut handlers,
|
&mut handlers,
|
||||||
&mut warnings,
|
&mut warnings,
|
||||||
&mut display_order,
|
&mut display_order,
|
||||||
&source_path,
|
HookHandlerSource {
|
||||||
hook_source_for_config_layer_source(&layer.name),
|
path: &source_path,
|
||||||
event_name,
|
is_managed: false,
|
||||||
groups,
|
source: hook_source,
|
||||||
|
},
|
||||||
|
hook_events,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((source_path, hook_events)) = toml_hooks {
|
||||||
|
append_hook_events(
|
||||||
|
&mut handlers,
|
||||||
|
&mut warnings,
|
||||||
|
&mut display_order,
|
||||||
|
HookHandlerSource {
|
||||||
|
path: &source_path,
|
||||||
|
is_managed: false,
|
||||||
|
source: hook_source,
|
||||||
|
},
|
||||||
|
hook_events,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,71 +103,272 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
|||||||
DiscoveryResult { handlers, warnings }
|
DiscoveryResult { handlers, warnings }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn append_managed_requirement_handlers(
|
||||||
|
handlers: &mut Vec<ConfiguredHandler>,
|
||||||
|
warnings: &mut Vec<String>,
|
||||||
|
display_order: &mut i64,
|
||||||
|
config_layer_stack: &ConfigLayerStack,
|
||||||
|
) {
|
||||||
|
let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(source_path) =
|
||||||
|
managed_hooks_source_path(managed_hooks.get(), managed_hooks.source.as_ref(), warnings)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
append_hook_events(
|
||||||
|
handlers,
|
||||||
|
warnings,
|
||||||
|
display_order,
|
||||||
|
HookHandlerSource {
|
||||||
|
path: &source_path,
|
||||||
|
is_managed: true,
|
||||||
|
source: hook_source_for_requirement_source(managed_hooks.source.as_ref()),
|
||||||
|
},
|
||||||
|
managed_hooks.get().hooks.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn managed_hooks_source_path(
|
||||||
|
managed_hooks: &ManagedHooksRequirementsToml,
|
||||||
|
requirement_source: Option<&RequirementSource>,
|
||||||
|
warnings: &mut Vec<String>,
|
||||||
|
) -> Option<AbsolutePathBuf> {
|
||||||
|
let source = requirement_source
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.unwrap_or_else(|| "managed requirements".to_string());
|
||||||
|
let Some(source_path) = managed_hooks.managed_dir_for_current_platform() else {
|
||||||
|
warnings.push(format!(
|
||||||
|
"skipping managed hooks from {source}: no managed hook directory is configured for this platform"
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !source_path.is_absolute() {
|
||||||
|
warnings.push(format!(
|
||||||
|
"skipping managed hooks from {source}: managed hook directory {} is not absolute",
|
||||||
|
source_path.display()
|
||||||
|
));
|
||||||
|
None
|
||||||
|
} else if !source_path.exists() {
|
||||||
|
warnings.push(format!(
|
||||||
|
"skipping managed hooks from {source}: managed hook directory {} does not exist",
|
||||||
|
source_path.display()
|
||||||
|
));
|
||||||
|
None
|
||||||
|
} else if !source_path.is_dir() {
|
||||||
|
warnings.push(format!(
|
||||||
|
"skipping managed hooks from {source}: managed hook directory {} is not a directory",
|
||||||
|
source_path.display()
|
||||||
|
));
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
AbsolutePathBuf::from_absolute_path(source_path)
|
||||||
|
.inspect_err(|err| {
|
||||||
|
warnings.push(format!(
|
||||||
|
"skipping managed hooks from {source}: could not normalize managed hook directory {}: {err}",
|
||||||
|
source_path.display()
|
||||||
|
));
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_hooks_json(
|
||||||
|
config_folder: Option<&Path>,
|
||||||
|
warnings: &mut Vec<String>,
|
||||||
|
) -> Option<(AbsolutePathBuf, HookEventsToml)> {
|
||||||
|
let source_path = config_folder?.join("hooks.json");
|
||||||
|
if !source_path.as_path().is_file() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = match fs::read_to_string(source_path.as_path()) {
|
||||||
|
Ok(contents) => contents,
|
||||||
|
Err(err) => {
|
||||||
|
warnings.push(format!(
|
||||||
|
"failed to read hooks config {}: {err}",
|
||||||
|
source_path.display()
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let parsed: HooksFile = match serde_json::from_str(&contents) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(err) => {
|
||||||
|
warnings.push(format!(
|
||||||
|
"failed to parse hooks config {}: {err}",
|
||||||
|
source_path.display()
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let source_path = AbsolutePathBuf::from_absolute_path(&source_path)
|
||||||
|
.inspect_err(|err| {
|
||||||
|
warnings.push(format!(
|
||||||
|
"failed to normalize hooks config path {}: {err}",
|
||||||
|
source_path.display()
|
||||||
|
));
|
||||||
|
})
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
(!parsed.hooks.is_empty()).then_some((source_path, parsed.hooks))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_toml_hooks_from_layer(
|
||||||
|
layer: &ConfigLayerEntry,
|
||||||
|
warnings: &mut Vec<String>,
|
||||||
|
) -> Option<(AbsolutePathBuf, HookEventsToml)> {
|
||||||
|
let source_path = config_toml_source_path(layer);
|
||||||
|
let hook_value = layer.config.get("hooks")?.clone();
|
||||||
|
let parsed = match HookEventsToml::deserialize(hook_value) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(err) => {
|
||||||
|
warnings.push(format!(
|
||||||
|
"failed to parse TOML hooks in {}: {err}",
|
||||||
|
source_path.display()
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(!parsed.is_empty()).then_some((source_path, parsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf {
|
||||||
|
match &layer.name {
|
||||||
|
ConfigLayerSource::System { file }
|
||||||
|
| ConfigLayerSource::User { file }
|
||||||
|
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => file.clone(),
|
||||||
|
ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder.join(CONFIG_TOML_FILE),
|
||||||
|
ConfigLayerSource::Mdm { domain, key } => {
|
||||||
|
synthetic_layer_path(&format!("<mdm:{domain}:{key}>/{CONFIG_TOML_FILE}"))
|
||||||
|
}
|
||||||
|
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
|
||||||
|
synthetic_layer_path("<legacy-managed-config.toml-mdm>/managed_config.toml")
|
||||||
|
}
|
||||||
|
ConfigLayerSource::SessionFlags => synthetic_layer_path("<session-flags>/config.toml"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn synthetic_layer_path(path: &str) -> AbsolutePathBuf {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
AbsolutePathBuf::resolve_path_against_base(path, r"C:\")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
AbsolutePathBuf::resolve_path_against_base(path, "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_hook_events(
|
||||||
|
handlers: &mut Vec<ConfiguredHandler>,
|
||||||
|
warnings: &mut Vec<String>,
|
||||||
|
display_order: &mut i64,
|
||||||
|
source: HookHandlerSource<'_>,
|
||||||
|
hook_events: HookEventsToml,
|
||||||
|
) {
|
||||||
|
for (event_name, groups) in hook_events.into_matcher_groups() {
|
||||||
|
append_matcher_groups(
|
||||||
|
handlers,
|
||||||
|
warnings,
|
||||||
|
display_order,
|
||||||
|
source,
|
||||||
|
event_name,
|
||||||
|
groups,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn append_matcher_groups(
|
fn append_matcher_groups(
|
||||||
handlers: &mut Vec<ConfiguredHandler>,
|
handlers: &mut Vec<ConfiguredHandler>,
|
||||||
warnings: &mut Vec<String>,
|
warnings: &mut Vec<String>,
|
||||||
display_order: &mut i64,
|
display_order: &mut i64,
|
||||||
source_path: &AbsolutePathBuf,
|
source: HookHandlerSource<'_>,
|
||||||
source: HookSource,
|
|
||||||
event_name: codex_protocol::protocol::HookEventName,
|
event_name: codex_protocol::protocol::HookEventName,
|
||||||
groups: Vec<MatcherGroup>,
|
groups: Vec<MatcherGroup>,
|
||||||
) {
|
) {
|
||||||
for group in groups {
|
for group in groups {
|
||||||
let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref());
|
append_group_handlers(
|
||||||
if let Some(matcher) = matcher
|
handlers,
|
||||||
&& let Err(err) = validate_matcher_pattern(matcher)
|
warnings,
|
||||||
{
|
display_order,
|
||||||
warnings.push(format!(
|
source,
|
||||||
"invalid matcher {matcher:?} in {}: {err}",
|
event_name,
|
||||||
source_path.display()
|
matcher_pattern_for_event(event_name, group.matcher.as_deref()),
|
||||||
));
|
group.hooks,
|
||||||
continue;
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for handler in group.hooks {
|
fn append_group_handlers(
|
||||||
match handler {
|
handlers: &mut Vec<ConfiguredHandler>,
|
||||||
HookHandlerConfig::Command {
|
warnings: &mut Vec<String>,
|
||||||
|
display_order: &mut i64,
|
||||||
|
source: HookHandlerSource<'_>,
|
||||||
|
event_name: codex_protocol::protocol::HookEventName,
|
||||||
|
matcher: Option<&str>,
|
||||||
|
group_handlers: Vec<HookHandlerConfig>,
|
||||||
|
) {
|
||||||
|
if let Some(matcher) = matcher
|
||||||
|
&& let Err(err) = validate_matcher_pattern(matcher)
|
||||||
|
{
|
||||||
|
warnings.push(format!(
|
||||||
|
"invalid matcher {matcher:?} in {}: {err}",
|
||||||
|
source.path.display()
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for handler in group_handlers {
|
||||||
|
match handler {
|
||||||
|
HookHandlerConfig::Command {
|
||||||
|
command,
|
||||||
|
timeout_sec,
|
||||||
|
r#async,
|
||||||
|
status_message,
|
||||||
|
} => {
|
||||||
|
if r#async {
|
||||||
|
warnings.push(format!(
|
||||||
|
"skipping async hook in {}: async hooks are not supported yet",
|
||||||
|
source.path.display()
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if command.trim().is_empty() {
|
||||||
|
warnings.push(format!(
|
||||||
|
"skipping empty hook command in {}",
|
||||||
|
source.path.display()
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
|
||||||
|
handlers.push(ConfiguredHandler {
|
||||||
|
event_name,
|
||||||
|
is_managed: source.is_managed,
|
||||||
|
matcher: matcher.map(ToOwned::to_owned),
|
||||||
command,
|
command,
|
||||||
timeout_sec,
|
timeout_sec,
|
||||||
r#async,
|
|
||||||
status_message,
|
status_message,
|
||||||
} => {
|
source_path: source.path.clone(),
|
||||||
if r#async {
|
source: source.source,
|
||||||
warnings.push(format!(
|
display_order: *display_order,
|
||||||
"skipping async hook in {}: async hooks are not supported yet",
|
});
|
||||||
source_path.display()
|
*display_order += 1;
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if command.trim().is_empty() {
|
|
||||||
warnings.push(format!(
|
|
||||||
"skipping empty hook command in {}",
|
|
||||||
source_path.display()
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
|
|
||||||
handlers.push(ConfiguredHandler {
|
|
||||||
event_name,
|
|
||||||
matcher: matcher.map(ToOwned::to_owned),
|
|
||||||
command,
|
|
||||||
timeout_sec,
|
|
||||||
status_message,
|
|
||||||
source_path: source_path.clone(),
|
|
||||||
source,
|
|
||||||
display_order: *display_order,
|
|
||||||
});
|
|
||||||
*display_order += 1;
|
|
||||||
}
|
|
||||||
HookHandlerConfig::Prompt {} => warnings.push(format!(
|
|
||||||
"skipping prompt hook in {}: prompt hooks are not supported yet",
|
|
||||||
source_path.display()
|
|
||||||
)),
|
|
||||||
HookHandlerConfig::Agent {} => warnings.push(format!(
|
|
||||||
"skipping agent hook in {}: agent hooks are not supported yet",
|
|
||||||
source_path.display()
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
|
HookHandlerConfig::Prompt {} => warnings.push(format!(
|
||||||
|
"skipping prompt hook in {}: prompt hooks are not supported yet",
|
||||||
|
source.path.display()
|
||||||
|
)),
|
||||||
|
HookHandlerConfig::Agent {} => warnings.push(format!(
|
||||||
|
"skipping agent hook in {}: agent hooks are not supported yet",
|
||||||
|
source.path.display()
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,6 +387,22 @@ fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> HookSource {
|
||||||
|
match source {
|
||||||
|
Some(RequirementSource::MdmManagedPreferences { .. }) => HookSource::Mdm,
|
||||||
|
Some(RequirementSource::SystemRequirementsToml { .. }) => HookSource::System,
|
||||||
|
Some(RequirementSource::LegacyManagedConfigTomlFromFile { .. }) => {
|
||||||
|
HookSource::LegacyManagedConfigFile
|
||||||
|
}
|
||||||
|
Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => {
|
||||||
|
HookSource::LegacyManagedConfigMdm
|
||||||
|
}
|
||||||
|
Some(RequirementSource::CloudRequirements | RequirementSource::Unknown) | None => {
|
||||||
|
HookSource::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use codex_config::ConfigLayerSource;
|
use codex_config::ConfigLayerSource;
|
||||||
@@ -204,9 +414,9 @@ mod tests {
|
|||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
use super::ConfiguredHandler;
|
use super::ConfiguredHandler;
|
||||||
use super::HookHandlerConfig;
|
|
||||||
use super::MatcherGroup;
|
|
||||||
use super::append_matcher_groups;
|
use super::append_matcher_groups;
|
||||||
|
use codex_config::HookHandlerConfig;
|
||||||
|
use codex_config::MatcherGroup;
|
||||||
|
|
||||||
fn source_path() -> AbsolutePathBuf {
|
fn source_path() -> AbsolutePathBuf {
|
||||||
test_path_buf("/tmp/hooks.json").abs()
|
test_path_buf("/tmp/hooks.json").abs()
|
||||||
@@ -216,6 +426,14 @@ mod tests {
|
|||||||
HookSource::User
|
HookSource::User
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hook_handler_source(path: &AbsolutePathBuf) -> super::HookHandlerSource<'_> {
|
||||||
|
super::HookHandlerSource {
|
||||||
|
path,
|
||||||
|
is_managed: false,
|
||||||
|
source: hook_source(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn command_group(matcher: Option<&str>) -> MatcherGroup {
|
fn command_group(matcher: Option<&str>) -> MatcherGroup {
|
||||||
MatcherGroup {
|
MatcherGroup {
|
||||||
matcher: matcher.map(str::to_string),
|
matcher: matcher.map(str::to_string),
|
||||||
@@ -233,13 +451,13 @@ mod tests {
|
|||||||
let mut handlers = Vec::new();
|
let mut handlers = Vec::new();
|
||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
let mut display_order = 0;
|
let mut display_order = 0;
|
||||||
|
let source_path = source_path();
|
||||||
|
|
||||||
append_matcher_groups(
|
append_matcher_groups(
|
||||||
&mut handlers,
|
&mut handlers,
|
||||||
&mut warnings,
|
&mut warnings,
|
||||||
&mut display_order,
|
&mut display_order,
|
||||||
&source_path(),
|
hook_handler_source(&source_path),
|
||||||
hook_source(),
|
|
||||||
HookEventName::UserPromptSubmit,
|
HookEventName::UserPromptSubmit,
|
||||||
vec![command_group(Some("["))],
|
vec![command_group(Some("["))],
|
||||||
);
|
);
|
||||||
@@ -249,11 +467,12 @@ mod tests {
|
|||||||
handlers,
|
handlers,
|
||||||
vec![ConfiguredHandler {
|
vec![ConfiguredHandler {
|
||||||
event_name: HookEventName::UserPromptSubmit,
|
event_name: HookEventName::UserPromptSubmit,
|
||||||
|
is_managed: false,
|
||||||
matcher: None,
|
matcher: None,
|
||||||
command: "echo hello".to_string(),
|
command: "echo hello".to_string(),
|
||||||
timeout_sec: 600,
|
timeout_sec: 600,
|
||||||
status_message: None,
|
status_message: None,
|
||||||
source_path: source_path(),
|
source_path: source_path.clone(),
|
||||||
source: hook_source(),
|
source: hook_source(),
|
||||||
display_order: 0,
|
display_order: 0,
|
||||||
}]
|
}]
|
||||||
@@ -265,13 +484,13 @@ mod tests {
|
|||||||
let mut handlers = Vec::new();
|
let mut handlers = Vec::new();
|
||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
let mut display_order = 0;
|
let mut display_order = 0;
|
||||||
|
let source_path = source_path();
|
||||||
|
|
||||||
append_matcher_groups(
|
append_matcher_groups(
|
||||||
&mut handlers,
|
&mut handlers,
|
||||||
&mut warnings,
|
&mut warnings,
|
||||||
&mut display_order,
|
&mut display_order,
|
||||||
&source_path(),
|
hook_handler_source(&source_path),
|
||||||
hook_source(),
|
|
||||||
HookEventName::PreToolUse,
|
HookEventName::PreToolUse,
|
||||||
vec![command_group(Some("^Bash$"))],
|
vec![command_group(Some("^Bash$"))],
|
||||||
);
|
);
|
||||||
@@ -281,11 +500,12 @@ mod tests {
|
|||||||
handlers,
|
handlers,
|
||||||
vec![ConfiguredHandler {
|
vec![ConfiguredHandler {
|
||||||
event_name: HookEventName::PreToolUse,
|
event_name: HookEventName::PreToolUse,
|
||||||
|
is_managed: false,
|
||||||
matcher: Some("^Bash$".to_string()),
|
matcher: Some("^Bash$".to_string()),
|
||||||
command: "echo hello".to_string(),
|
command: "echo hello".to_string(),
|
||||||
timeout_sec: 600,
|
timeout_sec: 600,
|
||||||
status_message: None,
|
status_message: None,
|
||||||
source_path: source_path(),
|
source_path: source_path.clone(),
|
||||||
source: hook_source(),
|
source: hook_source(),
|
||||||
display_order: 0,
|
display_order: 0,
|
||||||
}]
|
}]
|
||||||
@@ -297,13 +517,13 @@ mod tests {
|
|||||||
let mut handlers = Vec::new();
|
let mut handlers = Vec::new();
|
||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
let mut display_order = 0;
|
let mut display_order = 0;
|
||||||
|
let source_path = source_path();
|
||||||
|
|
||||||
append_matcher_groups(
|
append_matcher_groups(
|
||||||
&mut handlers,
|
&mut handlers,
|
||||||
&mut warnings,
|
&mut warnings,
|
||||||
&mut display_order,
|
&mut display_order,
|
||||||
&source_path(),
|
hook_handler_source(&source_path),
|
||||||
hook_source(),
|
|
||||||
HookEventName::PreToolUse,
|
HookEventName::PreToolUse,
|
||||||
vec![command_group(Some("*"))],
|
vec![command_group(Some("*"))],
|
||||||
);
|
);
|
||||||
@@ -318,13 +538,13 @@ mod tests {
|
|||||||
let mut handlers = Vec::new();
|
let mut handlers = Vec::new();
|
||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
let mut display_order = 0;
|
let mut display_order = 0;
|
||||||
|
let source_path = source_path();
|
||||||
|
|
||||||
append_matcher_groups(
|
append_matcher_groups(
|
||||||
&mut handlers,
|
&mut handlers,
|
||||||
&mut warnings,
|
&mut warnings,
|
||||||
&mut display_order,
|
&mut display_order,
|
||||||
&source_path(),
|
hook_handler_source(&source_path),
|
||||||
hook_source(),
|
|
||||||
HookEventName::PostToolUse,
|
HookEventName::PostToolUse,
|
||||||
vec![command_group(Some("Edit|Write"))],
|
vec![command_group(Some("Edit|Write"))],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ mod tests {
|
|||||||
) -> ConfiguredHandler {
|
) -> ConfiguredHandler {
|
||||||
ConfiguredHandler {
|
ConfiguredHandler {
|
||||||
event_name,
|
event_name,
|
||||||
|
is_managed: false,
|
||||||
matcher: matcher.map(str::to_owned),
|
matcher: matcher.map(str::to_owned),
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
timeout_sec: 5,
|
timeout_sec: 5,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
pub(crate) mod command_runner;
|
pub(crate) mod command_runner;
|
||||||
pub(crate) mod config;
|
|
||||||
pub(crate) mod discovery;
|
pub(crate) mod discovery;
|
||||||
pub(crate) mod dispatcher;
|
pub(crate) mod dispatcher;
|
||||||
pub(crate) mod output_parser;
|
pub(crate) mod output_parser;
|
||||||
@@ -32,6 +31,7 @@ pub(crate) struct CommandShell {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) struct ConfiguredHandler {
|
pub(crate) struct ConfiguredHandler {
|
||||||
pub event_name: codex_protocol::protocol::HookEventName,
|
pub event_name: codex_protocol::protocol::HookEventName,
|
||||||
|
pub is_managed: bool,
|
||||||
pub matcher: Option<String>,
|
pub matcher: Option<String>,
|
||||||
pub command: String,
|
pub command: String,
|
||||||
pub timeout_sec: u64,
|
pub timeout_sec: u64,
|
||||||
@@ -170,3 +170,7 @@ impl ClaudeHooksEngine {
|
|||||||
crate::events::stop::run(&self.handlers, &self.shell, request).await
|
crate::events::stop::run(&self.handlers, &self.shell, request).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "mod_tests.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use codex_config::AbsolutePathBuf;
|
||||||
|
use codex_config::ConfigLayerEntry;
|
||||||
|
use codex_config::ConfigLayerSource;
|
||||||
|
use codex_config::ConfigLayerStack;
|
||||||
|
use codex_config::ConfigRequirements;
|
||||||
|
use codex_config::ConfigRequirementsToml;
|
||||||
|
use codex_config::Constrained;
|
||||||
|
use codex_config::ConstrainedWithSource;
|
||||||
|
use codex_config::HookEventsToml;
|
||||||
|
use codex_config::HookHandlerConfig;
|
||||||
|
use codex_config::ManagedHooksRequirementsToml;
|
||||||
|
use codex_config::MatcherGroup;
|
||||||
|
use codex_config::RequirementSource;
|
||||||
|
use codex_config::TomlValue;
|
||||||
|
use codex_protocol::ThreadId;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
use super::ClaudeHooksEngine;
|
||||||
|
use super::CommandShell;
|
||||||
|
use crate::events::pre_tool_use::PreToolUseRequest;
|
||||||
|
|
||||||
|
fn cwd() -> AbsolutePathBuf {
|
||||||
|
AbsolutePathBuf::current_dir().expect("current dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn managed_hooks_for_current_platform(
|
||||||
|
managed_dir: impl AsRef<Path>,
|
||||||
|
hooks: HookEventsToml,
|
||||||
|
) -> ManagedHooksRequirementsToml {
|
||||||
|
let managed_dir = managed_dir.as_ref().to_path_buf();
|
||||||
|
ManagedHooksRequirementsToml {
|
||||||
|
managed_dir: if cfg!(windows) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(managed_dir.clone())
|
||||||
|
},
|
||||||
|
windows_managed_dir: if cfg!(windows) {
|
||||||
|
Some(managed_dir)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
hooks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn requirements_managed_hooks_execute_from_managed_dir() {
|
||||||
|
let temp = tempdir().expect("create temp dir");
|
||||||
|
let managed_dir =
|
||||||
|
AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path");
|
||||||
|
fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir");
|
||||||
|
let script_path = managed_dir.join("pre_tool_use.py");
|
||||||
|
let log_path = managed_dir.join("pre_tool_use_log.jsonl");
|
||||||
|
fs::write(
|
||||||
|
script_path.as_path(),
|
||||||
|
format!(
|
||||||
|
r#"import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
payload = json.load(sys.stdin)
|
||||||
|
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(payload) + "\n")
|
||||||
|
"#,
|
||||||
|
log_path = log_path.display(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("write managed hook script");
|
||||||
|
|
||||||
|
let managed_hooks = managed_hooks_for_current_platform(
|
||||||
|
managed_dir.clone(),
|
||||||
|
HookEventsToml {
|
||||||
|
pre_tool_use: vec![MatcherGroup {
|
||||||
|
matcher: Some("^Bash$".to_string()),
|
||||||
|
hooks: vec![HookHandlerConfig::Command {
|
||||||
|
command: format!("python3 {}", script_path.display()),
|
||||||
|
timeout_sec: Some(10),
|
||||||
|
r#async: false,
|
||||||
|
status_message: Some("checking".to_string()),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let config_layer_stack = ConfigLayerStack::new(
|
||||||
|
Vec::new(),
|
||||||
|
ConfigRequirements {
|
||||||
|
managed_hooks: Some(ConstrainedWithSource::new(
|
||||||
|
Constrained::allow_any(managed_hooks.clone()),
|
||||||
|
Some(RequirementSource::CloudRequirements),
|
||||||
|
)),
|
||||||
|
..ConfigRequirements::default()
|
||||||
|
},
|
||||||
|
ConfigRequirementsToml {
|
||||||
|
hooks: Some(managed_hooks),
|
||||||
|
..ConfigRequirementsToml::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("config layer stack");
|
||||||
|
|
||||||
|
let engine = ClaudeHooksEngine::new(
|
||||||
|
/*enabled*/ true,
|
||||||
|
Some(&config_layer_stack),
|
||||||
|
CommandShell {
|
||||||
|
program: String::new(),
|
||||||
|
args: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(engine.warnings().is_empty());
|
||||||
|
assert_eq!(engine.handlers.len(), 1);
|
||||||
|
assert!(engine.handlers[0].is_managed);
|
||||||
|
let cwd = cwd();
|
||||||
|
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
|
||||||
|
session_id: ThreadId::new(),
|
||||||
|
turn_id: "turn-1".to_string(),
|
||||||
|
cwd: cwd.clone(),
|
||||||
|
transcript_path: None,
|
||||||
|
model: "gpt-test".to_string(),
|
||||||
|
permission_mode: "default".to_string(),
|
||||||
|
tool_name: "Bash".to_string(),
|
||||||
|
matcher_aliases: Vec::new(),
|
||||||
|
tool_use_id: "tool-1".to_string(),
|
||||||
|
command: "echo hello".to_string(),
|
||||||
|
});
|
||||||
|
assert_eq!(preview.len(), 1);
|
||||||
|
assert_eq!(preview[0].source_path, managed_dir);
|
||||||
|
|
||||||
|
let outcome = engine
|
||||||
|
.run_pre_tool_use(PreToolUseRequest {
|
||||||
|
session_id: ThreadId::new(),
|
||||||
|
turn_id: "turn-1".to_string(),
|
||||||
|
cwd,
|
||||||
|
transcript_path: None,
|
||||||
|
model: "gpt-test".to_string(),
|
||||||
|
permission_mode: "default".to_string(),
|
||||||
|
tool_name: "Bash".to_string(),
|
||||||
|
matcher_aliases: Vec::new(),
|
||||||
|
tool_use_id: "tool-1".to_string(),
|
||||||
|
command: "echo hello".to_string(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(!outcome.should_block);
|
||||||
|
let log_contents = fs::read_to_string(log_path).expect("read managed hook log");
|
||||||
|
assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requirements_managed_hooks_warn_when_managed_dir_is_missing() {
|
||||||
|
let temp = tempdir().expect("create temp dir");
|
||||||
|
let missing_dir = temp.path().join("missing-managed-hooks");
|
||||||
|
let managed_hooks = managed_hooks_for_current_platform(
|
||||||
|
missing_dir.clone(),
|
||||||
|
HookEventsToml {
|
||||||
|
pre_tool_use: vec![MatcherGroup {
|
||||||
|
matcher: Some("^Bash$".to_string()),
|
||||||
|
hooks: vec![HookHandlerConfig::Command {
|
||||||
|
command: format!("python3 {}", missing_dir.join("pre.py").display()),
|
||||||
|
timeout_sec: Some(10),
|
||||||
|
r#async: false,
|
||||||
|
status_message: Some("checking".to_string()),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let config_layer_stack = ConfigLayerStack::new(
|
||||||
|
Vec::new(),
|
||||||
|
ConfigRequirements {
|
||||||
|
managed_hooks: Some(ConstrainedWithSource::new(
|
||||||
|
Constrained::allow_any(managed_hooks.clone()),
|
||||||
|
Some(RequirementSource::CloudRequirements),
|
||||||
|
)),
|
||||||
|
..ConfigRequirements::default()
|
||||||
|
},
|
||||||
|
ConfigRequirementsToml {
|
||||||
|
hooks: Some(managed_hooks),
|
||||||
|
..ConfigRequirementsToml::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("config layer stack");
|
||||||
|
|
||||||
|
let engine = ClaudeHooksEngine::new(
|
||||||
|
/*enabled*/ true,
|
||||||
|
Some(&config_layer_stack),
|
||||||
|
CommandShell {
|
||||||
|
program: String::new(),
|
||||||
|
args: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(engine.warnings().iter().any(|warning| {
|
||||||
|
warning.contains("managed hook directory")
|
||||||
|
&& warning.contains("does not exist")
|
||||||
|
&& warning.contains(&missing_dir.display().to_string())
|
||||||
|
}));
|
||||||
|
let cwd = cwd();
|
||||||
|
assert!(
|
||||||
|
engine
|
||||||
|
.preview_pre_tool_use(&PreToolUseRequest {
|
||||||
|
session_id: ThreadId::new(),
|
||||||
|
turn_id: "turn-1".to_string(),
|
||||||
|
cwd,
|
||||||
|
transcript_path: None,
|
||||||
|
model: "gpt-test".to_string(),
|
||||||
|
permission_mode: "default".to_string(),
|
||||||
|
tool_name: "Bash".to_string(),
|
||||||
|
matcher_aliases: Vec::new(),
|
||||||
|
tool_use_id: "tool-1".to_string(),
|
||||||
|
command: "echo hello".to_string(),
|
||||||
|
})
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
|
||||||
|
let temp = tempdir().expect("create temp dir");
|
||||||
|
let config_path =
|
||||||
|
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
|
||||||
|
let hooks_json_path =
|
||||||
|
AbsolutePathBuf::try_from(temp.path().join("hooks.json")).expect("absolute hooks path");
|
||||||
|
fs::write(
|
||||||
|
hooks_json_path.as_path(),
|
||||||
|
r#"{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "^Bash$",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 /tmp/json-hook.py"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("write hooks.json");
|
||||||
|
let mut config_toml = TomlValue::Table(Default::default());
|
||||||
|
let TomlValue::Table(config_table) = &mut config_toml else {
|
||||||
|
unreachable!("config TOML root should be a table");
|
||||||
|
};
|
||||||
|
let mut hooks_table = TomlValue::Table(Default::default());
|
||||||
|
let TomlValue::Table(hooks_entries) = &mut hooks_table else {
|
||||||
|
unreachable!("hooks entry should be a table");
|
||||||
|
};
|
||||||
|
let mut pre_tool_use_group = TomlValue::Table(Default::default());
|
||||||
|
let TomlValue::Table(pre_tool_use_group_entries) = &mut pre_tool_use_group else {
|
||||||
|
unreachable!("PreToolUse group should be a table");
|
||||||
|
};
|
||||||
|
pre_tool_use_group_entries.insert(
|
||||||
|
"matcher".to_string(),
|
||||||
|
TomlValue::String("^Bash$".to_string()),
|
||||||
|
);
|
||||||
|
pre_tool_use_group_entries.insert(
|
||||||
|
"hooks".to_string(),
|
||||||
|
TomlValue::Array(vec![TomlValue::Table(Default::default())]),
|
||||||
|
);
|
||||||
|
let Some(TomlValue::Array(hooks_array)) = pre_tool_use_group_entries.get_mut("hooks") else {
|
||||||
|
unreachable!("PreToolUse hooks should be an array");
|
||||||
|
};
|
||||||
|
let Some(TomlValue::Table(handler_entries)) = hooks_array.first_mut() else {
|
||||||
|
unreachable!("PreToolUse handler should be a table");
|
||||||
|
};
|
||||||
|
handler_entries.insert("type".to_string(), TomlValue::String("command".to_string()));
|
||||||
|
handler_entries.insert(
|
||||||
|
"command".to_string(),
|
||||||
|
TomlValue::String("python3 /tmp/toml-hook.py".to_string()),
|
||||||
|
);
|
||||||
|
hooks_entries.insert(
|
||||||
|
"PreToolUse".to_string(),
|
||||||
|
TomlValue::Array(vec![pre_tool_use_group]),
|
||||||
|
);
|
||||||
|
config_table.insert("hooks".to_string(), hooks_table);
|
||||||
|
let config_layer_stack = ConfigLayerStack::new(
|
||||||
|
vec![ConfigLayerEntry::new(
|
||||||
|
ConfigLayerSource::User {
|
||||||
|
file: config_path.clone(),
|
||||||
|
},
|
||||||
|
config_toml,
|
||||||
|
)],
|
||||||
|
ConfigRequirements::default(),
|
||||||
|
ConfigRequirementsToml::default(),
|
||||||
|
)
|
||||||
|
.expect("config layer stack");
|
||||||
|
|
||||||
|
let engine = ClaudeHooksEngine::new(
|
||||||
|
/*enabled*/ true,
|
||||||
|
Some(&config_layer_stack),
|
||||||
|
CommandShell {
|
||||||
|
program: String::new(),
|
||||||
|
args: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(engine.warnings().iter().any(|warning| {
|
||||||
|
warning.contains("loading hooks from both")
|
||||||
|
&& warning.contains(&hooks_json_path.display().to_string())
|
||||||
|
&& warning.contains(&config_path.display().to_string())
|
||||||
|
}));
|
||||||
|
|
||||||
|
let cwd = cwd();
|
||||||
|
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
|
||||||
|
session_id: ThreadId::new(),
|
||||||
|
turn_id: "turn-1".to_string(),
|
||||||
|
cwd,
|
||||||
|
transcript_path: None,
|
||||||
|
model: "gpt-test".to_string(),
|
||||||
|
permission_mode: "default".to_string(),
|
||||||
|
tool_name: "Bash".to_string(),
|
||||||
|
matcher_aliases: Vec::new(),
|
||||||
|
tool_use_id: "tool-1".to_string(),
|
||||||
|
command: "echo hello".to_string(),
|
||||||
|
});
|
||||||
|
assert_eq!(preview.len(), 2);
|
||||||
|
assert!(engine.handlers.iter().all(|handler| !handler.is_managed));
|
||||||
|
assert_eq!(preview[0].source_path, hooks_json_path);
|
||||||
|
assert_eq!(preview[1].source_path, config_path);
|
||||||
|
}
|
||||||
@@ -545,6 +545,7 @@ mod tests {
|
|||||||
fn handler() -> ConfiguredHandler {
|
fn handler() -> ConfiguredHandler {
|
||||||
ConfiguredHandler {
|
ConfiguredHandler {
|
||||||
event_name: HookEventName::PostToolUse,
|
event_name: HookEventName::PostToolUse,
|
||||||
|
is_managed: false,
|
||||||
matcher: Some("^Bash$".to_string()),
|
matcher: Some("^Bash$".to_string()),
|
||||||
command: "python3 post_tool_use_hook.py".to_string(),
|
command: "python3 post_tool_use_hook.py".to_string(),
|
||||||
timeout_sec: 5,
|
timeout_sec: 5,
|
||||||
|
|||||||
@@ -534,6 +534,7 @@ mod tests {
|
|||||||
fn handler() -> ConfiguredHandler {
|
fn handler() -> ConfiguredHandler {
|
||||||
ConfiguredHandler {
|
ConfiguredHandler {
|
||||||
event_name: HookEventName::PreToolUse,
|
event_name: HookEventName::PreToolUse,
|
||||||
|
is_managed: false,
|
||||||
matcher: Some("^Bash$".to_string()),
|
matcher: Some("^Bash$".to_string()),
|
||||||
command: "echo hook".to_string(),
|
command: "echo hook".to_string(),
|
||||||
timeout_sec: 5,
|
timeout_sec: 5,
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ mod tests {
|
|||||||
fn handler() -> ConfiguredHandler {
|
fn handler() -> ConfiguredHandler {
|
||||||
ConfiguredHandler {
|
ConfiguredHandler {
|
||||||
event_name: HookEventName::SessionStart,
|
event_name: HookEventName::SessionStart,
|
||||||
|
is_managed: false,
|
||||||
matcher: None,
|
matcher: None,
|
||||||
command: "echo hook".to_string(),
|
command: "echo hook".to_string(),
|
||||||
timeout_sec: 600,
|
timeout_sec: 600,
|
||||||
|
|||||||
@@ -523,6 +523,7 @@ mod tests {
|
|||||||
fn handler() -> ConfiguredHandler {
|
fn handler() -> ConfiguredHandler {
|
||||||
ConfiguredHandler {
|
ConfiguredHandler {
|
||||||
event_name: HookEventName::Stop,
|
event_name: HookEventName::Stop,
|
||||||
|
is_managed: false,
|
||||||
matcher: None,
|
matcher: None,
|
||||||
command: "echo hook".to_string(),
|
command: "echo hook".to_string(),
|
||||||
timeout_sec: 600,
|
timeout_sec: 600,
|
||||||
|
|||||||
@@ -414,6 +414,7 @@ mod tests {
|
|||||||
fn handler() -> ConfiguredHandler {
|
fn handler() -> ConfiguredHandler {
|
||||||
ConfiguredHandler {
|
ConfiguredHandler {
|
||||||
event_name: HookEventName::UserPromptSubmit,
|
event_name: HookEventName::UserPromptSubmit,
|
||||||
|
is_managed: false,
|
||||||
matcher: None,
|
matcher: None,
|
||||||
command: "echo hook".to_string(),
|
command: "echo hook".to_string(),
|
||||||
timeout_sec: 5,
|
timeout_sec: 5,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use codex_app_server_protocol::ConfigLayerSource;
|
|||||||
use codex_config::ConfigLayerEntry;
|
use codex_config::ConfigLayerEntry;
|
||||||
use codex_config::ConfigLayerStack;
|
use codex_config::ConfigLayerStack;
|
||||||
use codex_config::ConfigLayerStackOrdering;
|
use codex_config::ConfigLayerStackOrdering;
|
||||||
|
use codex_config::ManagedHooksRequirementsToml;
|
||||||
use codex_config::NetworkConstraints;
|
use codex_config::NetworkConstraints;
|
||||||
use codex_config::NetworkDomainPermissionToml;
|
use codex_config::NetworkDomainPermissionToml;
|
||||||
use codex_config::NetworkUnixSocketPermissionToml;
|
use codex_config::NetworkUnixSocketPermissionToml;
|
||||||
@@ -168,6 +169,17 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec<Line<'static>> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(hooks) = requirements_toml.hooks.as_ref() {
|
||||||
|
requirement_lines.push(requirement_line(
|
||||||
|
"hooks",
|
||||||
|
format_managed_hooks_requirements(hooks),
|
||||||
|
requirements
|
||||||
|
.managed_hooks
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|managed_hooks| managed_hooks.source.as_ref()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(servers) = requirements_toml.mcp_servers.as_ref() {
|
if let Some(servers) = requirements_toml.mcp_servers.as_ref() {
|
||||||
let value = join_or_empty(servers.keys().cloned().collect::<Vec<_>>());
|
let value = join_or_empty(servers.keys().cloned().collect::<Vec<_>>());
|
||||||
requirement_lines.push(requirement_line(
|
requirement_lines.push(requirement_line(
|
||||||
@@ -257,6 +269,23 @@ fn render_session_flag_details(config: &TomlValue) -> Vec<Line<'static>> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_managed_hooks_requirements(hooks: &ManagedHooksRequirementsToml) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
|
||||||
|
if let Some(managed_dir) = hooks.managed_dir.as_ref() {
|
||||||
|
parts.push(format!("managed_dir={}", managed_dir.display()));
|
||||||
|
}
|
||||||
|
if let Some(windows_managed_dir) = hooks.windows_managed_dir.as_ref() {
|
||||||
|
parts.push(format!(
|
||||||
|
"windows_managed_dir={}",
|
||||||
|
windows_managed_dir.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
parts.push(format!("handlers={}", hooks.handler_count()));
|
||||||
|
|
||||||
|
join_or_empty(parts)
|
||||||
|
}
|
||||||
|
|
||||||
fn render_mdm_layer_details(layer: &ConfigLayerEntry) -> Vec<Line<'static>> {
|
fn render_mdm_layer_details(layer: &ConfigLayerEntry) -> Vec<Line<'static>> {
|
||||||
let value = layer
|
let value = layer
|
||||||
.raw_toml()
|
.raw_toml()
|
||||||
@@ -484,6 +513,10 @@ mod tests {
|
|||||||
use codex_config::ConstrainedWithSource;
|
use codex_config::ConstrainedWithSource;
|
||||||
use codex_config::FeatureRequirementsToml;
|
use codex_config::FeatureRequirementsToml;
|
||||||
use codex_config::FilesystemConstraints;
|
use codex_config::FilesystemConstraints;
|
||||||
|
use codex_config::HookEventsToml;
|
||||||
|
use codex_config::HookHandlerConfig;
|
||||||
|
use codex_config::ManagedHooksRequirementsToml;
|
||||||
|
use codex_config::MatcherGroup;
|
||||||
use codex_config::McpServerIdentity;
|
use codex_config::McpServerIdentity;
|
||||||
use codex_config::McpServerRequirement;
|
use codex_config::McpServerRequirement;
|
||||||
use codex_config::NetworkConstraints;
|
use codex_config::NetworkConstraints;
|
||||||
@@ -655,6 +688,7 @@ mod tests {
|
|||||||
feature_requirements: Some(FeatureRequirementsToml {
|
feature_requirements: Some(FeatureRequirementsToml {
|
||||||
entries: BTreeMap::from([("guardian_approval".to_string(), true)]),
|
entries: BTreeMap::from([("guardian_approval".to_string(), true)]),
|
||||||
}),
|
}),
|
||||||
|
hooks: None,
|
||||||
mcp_servers: Some(BTreeMap::from([(
|
mcp_servers: Some(BTreeMap::from([(
|
||||||
"docs".to_string(),
|
"docs".to_string(),
|
||||||
McpServerRequirement {
|
McpServerRequirement {
|
||||||
@@ -860,6 +894,7 @@ approval_policy = "never"
|
|||||||
allowed_web_search_modes: Some(Vec::new()),
|
allowed_web_search_modes: Some(Vec::new()),
|
||||||
guardian_policy_config: None,
|
guardian_policy_config: None,
|
||||||
feature_requirements: None,
|
feature_requirements: None,
|
||||||
|
hooks: None,
|
||||||
mcp_servers: None,
|
mcp_servers: None,
|
||||||
apps: None,
|
apps: None,
|
||||||
rules: None,
|
rules: None,
|
||||||
@@ -877,6 +912,50 @@ approval_policy = "never"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_config_output_lists_managed_hooks_requirement() {
|
||||||
|
let requirements = ConfigRequirements {
|
||||||
|
managed_hooks: Some(ConstrainedWithSource::new(
|
||||||
|
Constrained::allow_any(ManagedHooksRequirementsToml {
|
||||||
|
managed_dir: Some(if cfg!(windows) {
|
||||||
|
std::path::PathBuf::from(r"C:\enterprise\hooks")
|
||||||
|
} else {
|
||||||
|
std::path::PathBuf::from("/enterprise/hooks")
|
||||||
|
}),
|
||||||
|
windows_managed_dir: Some(std::path::PathBuf::from(r"C:\enterprise\hooks")),
|
||||||
|
hooks: HookEventsToml {
|
||||||
|
pre_tool_use: vec![MatcherGroup {
|
||||||
|
matcher: Some("^Bash$".to_string()),
|
||||||
|
hooks: vec![HookHandlerConfig::Command {
|
||||||
|
command: "python3 /enterprise/hooks/pre.py".to_string(),
|
||||||
|
timeout_sec: Some(10),
|
||||||
|
r#async: false,
|
||||||
|
status_message: Some("checking".to_string()),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Some(RequirementSource::CloudRequirements),
|
||||||
|
)),
|
||||||
|
..ConfigRequirements::default()
|
||||||
|
};
|
||||||
|
let requirements_toml = ConfigRequirementsToml {
|
||||||
|
hooks: requirements
|
||||||
|
.managed_hooks
|
||||||
|
.as_ref()
|
||||||
|
.map(|hooks| hooks.get().clone()),
|
||||||
|
..ConfigRequirementsToml::default()
|
||||||
|
};
|
||||||
|
let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)
|
||||||
|
.expect("config layer stack");
|
||||||
|
|
||||||
|
let rendered = render_to_text(&render_debug_config_lines(&stack));
|
||||||
|
assert!(rendered.contains("hooks:"));
|
||||||
|
assert!(rendered.contains("handlers=1"));
|
||||||
|
assert!(rendered.contains("(source: cloud requirements)"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_all_proxy_url_uses_socks_when_enabled() {
|
fn session_all_proxy_url_uses_socks_when_enabled() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
Reference in New Issue
Block a user