diff --git a/.changeset/codex-hook-contract.md b/.changeset/codex-hook-contract.md new file mode 100644 index 000000000..c7ffca33a --- /dev/null +++ b/.changeset/codex-hook-contract.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': minor +--- + +Pin the Codex 0.147.0 hook contract: close `hooks/hooks.json` to the eleven release events, admit every documented `command` and `mcp_tool` handler field while rejecting parsed-but-skipped `prompt`/`agent` handlers and per-event rules the host would ignore (`codex.hooks.*` and `codex.native-hooks.*` diagnostics), byte-pin all 21 generated hook wire schemas and validate Codex lifecycle-replay envelopes and codec outputs against them, accept a null `last_assistant_message` on Codex `Stop`, and publish dated four-state hook-contract capability rows (handler types, async, `additionalContextLimit`, `commandWindows`, `statusMessage`, timeouts, matcher semantics, trust review, generated-schema validation). diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 681fd7801..0ec8fa3a6 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -45,6 +45,19 @@ plus explicit host-native selectors such as `claude:WebSearch` or `codex:view_im contribute only to that host's native matcher. A hook that selects tools must leave every selected target with at least one applicable selector, otherwise the build fails. +Codex hooks follow the release contract pinned in `codex-0.147.0.json` (`hooks.contract`). The +emitted and authored (`codex.nativeHooks`) `hooks/hooks.json` is closed to the eleven +release-documented events; `Interrupt` stays deferred until the pin moves to a release that ships +its generated schema. Native documents may use every documented `command` handler field +(`commandWindows`, `timeout`, `statusMessage`, `additionalContextLimit`, `async`) and `mcp_tool` +handlers (`server`, `tool`, `input`, `timeout`, `statusMessage`); `prompt` and `agent` handlers, +which Codex parses but skips, fail with `codex.native-hooks.handler.skipped`. Rules the host would +otherwise apply silently fail the build instead: `mcp_tool`, `async`, or a timeout above three +seconds on `SessionEnd`, `additionalContextLimit` on an event that returns no `additionalContext`, +a `matcher` on `UserPromptSubmit` or `Stop`, and a `codex:WebSearch`-style hosted-tool selector. +Hook trust (review by current hash in `/hooks`, plugin hooks skipped until trusted, managed hooks +immutable) is host-owned and is recorded as unavailable rather than claimed. + agent-bundle also owns the npm-facing package build: `bin` entries become self-executing `dist/bin/.js` bundles (shebang, executable bit, generated `main(argv)` envelope) and the optional `lib` entry becomes `dist/.js` with declarations (resolving `typescript` from the diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index 261fb83c7..5527c4730 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -14,7 +14,7 @@ }, "deferredNativeEvents": { "Interrupt": { - "reason": "retrieved 2026-09-02: generated Interrupt schema exists in the codex repository, but release documentation and a pinned CLI revision do not yet agree on its contract (#258 defer list).", + "reason": "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks now documents Interrupt (main-thread turn interruption; matcher ignored; one-second default and three-second maximum timeout; systemMessage-only output), but the pinned rust-v0.147.0 generated hook schema directory has no interrupt.command.{input,output}.schema.json (those files exist only on main), and Agent Bundle has no canonical interrupt event route (#97 owns route expansion). Deferred until the Codex pin moves to a release that ships the generated Interrupt schemas; authored native Interrupt hooks fail closed with codex.native-hooks.event.deferred.", "state": "unavailable" } }, @@ -81,6 +81,154 @@ "file.write": "^(?:apply_patch|Edit|Write)$", "mcp": "^mcp__", "shell": "^Bash$" + }, + "releaseEvents": [ + "PermissionRequest", + "PostCompact", + "PostToolUse", + "PreCompact", + "PreToolUse", + "SessionEnd", + "SessionStart", + "Stop", + "SubagentStart", + "SubagentStop", + "UserPromptSubmit" + ], + "contract": { + "releaseEvents": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks lists SessionStart, SessionEnd, PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, UserPromptSubmit, SubagentStart, SubagentStop, and Stop as the release events; Interrupt is documented on the same page but has no generated schema at rust-v0.147.0 and stays in deferredNativeEvents.", + "retrieved 2026-09-02: the pinned hooks.schema.json closes the hooks object to exactly these eleven event keys, so an authored native document naming any other event fails before publication instead of being emitted for the host to ignore.", + "retrieved 2026-09-02: the SubagentStart and SubagentStop mappings, wrapper codec rules, and pinned subagent schemas were corrected by PR #194 (fix/codex-subagent-events) and the agent/start and agent/stop routes by the #258 events lanes (PRs #351 and #360); this pass credits those lanes and does not reimplement the mapping." + ], + "state": "supported" + }, + "handlerCommand": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks documents command handlers with command, commandWindows (Windows-only override; TOML also accepts command_windows), timeout in seconds, statusMessage, additionalContextLimit, and async; commands run with the session cwd as their working directory.", + "retrieved 2026-09-02: generated Agent Bundle hooks compile to command handlers whose command is node \"${PLUGIN_ROOT}/hooks/.mjs\"; the pinned schema admits every documented command-handler field for authored native documents." + ], + "fields": ["command", "commandWindows", "timeout", "statusMessage", "additionalContextLimit", "async"], + "state": "supported" + }, + "handlerMcpTool": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks documents mcp_tool handlers with required server and tool, optional input (argument templates, default {}), timeout (default 600 seconds), and statusMessage; ${field.nested} placeholders expand from the hook event, keep their JSON type when they fill a whole value, render as text inside larger strings, and expand recursively through objects and arrays.", + "retrieved 2026-09-02: the pinned hooks.schema.json admits mcp_tool handlers in authored native documents and the adapter rejects them under SessionEnd, which the hooks page documents as unsupported for MCP tool hooks." + ], + "fields": ["server", "tool", "input", "timeout", "statusMessage"], + "reason": "mcp_tool handlers are admitted and validated in authored Codex native hook documents, but canonical Agent Bundle hooks always compile to command handlers and the compiler cannot verify that the named server is connected or that the tool exists.", + "state": "degraded" + }, + "handlerPromptAgent": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks states that command and mcp_tool handlers are supported while prompt and agent handlers are parsed but skipped." + ], + "reason": "Codex 0.147.0 parses prompt and agent handlers but never runs them, so the compiler rejects them with codex.native-hooks.handler.skipped instead of emitting handlers the host would silently drop.", + "state": "unavailable" + }, + "mcpToolExecution": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks states MCP tool hooks use an existing connection and never start or reconnect servers; errors, missing servers, and unavailable tools do not block the operation; they run synchronously, request no tool approval, and trigger no other hooks; the shorter of the hook or server timeout applies and elicitation waits are excluded; SessionStart hooks may run before a server is ready without blocking the session; SessionEnd does not support MCP tool hooks." + ], + "reason": "Connected-server requirement, synchronous non-recursive execution, and timeout arbitration are Codex runtime behavior that the compiler cannot enforce; it validates handler shape and rejects SessionEnd mcp_tool handlers only.", + "state": "unavailable", + "unsupportedEvents": ["SessionEnd"] + }, + "asyncCommandHooks": { + "concurrencyLimit": 8, + "synchronousEvents": ["SessionEnd"], + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks documents async: true command hooks that share input, matcher, trust review, timeout, and large-output handling with synchronous hooks; output is delivered at the next safe point (next model request in an active turn, otherwise the next user turn) and cannot block, approve, rewrite, or otherwise control the triggering operation.", + "retrieved 2026-09-02: the same page limits a session to eight concurrent background hooks, lets them finish out of order, cancels unfinished background hooks and discards undelivered output at session end, and states SessionEnd hooks always run synchronously." + ], + "reason": "Authored native documents may set async on command handlers and the adapter rejects async on SessionEnd, but canonical Agent Bundle hooks have no async authoring surface and the eight-per-session limit, ordering, cancellation, and non-controlling output rules are host runtime behavior.", + "state": "degraded" + }, + "additionalContextLimit": { + "additionalContextEvents": ["SessionStart", "SubagentStart", "PreToolUse", "PostToolUse", "UserPromptSubmit"], + "defaultTokens": 2500, + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks caps each model-visible hook output message at roughly 2,500 tokens, spills larger additionalContext to /hook_outputs// with a head-and-tail preview, and falls back to a truncated preview when the file cannot be written.", + "retrieved 2026-09-02: additionalContextLimit accepts a positive integer threshold or 0 for unlimited pass-through, applies only to additionalContext, is evaluated per matching handler, and is ignored with a configuration warning on events that cannot produce additional context; the hooks page documents additionalContext output for SessionStart, SubagentStart, PreToolUse, PostToolUse, and UserPromptSubmit only." + ], + "reason": "Authored native documents may set additionalContextLimit and the adapter rejects it on events that cannot return additionalContext, but canonical Agent Bundle hooks cannot author the limit and the spill-to-disk behavior is host runtime behavior.", + "state": "degraded" + }, + "commandWindows": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks documents commandWindows as an optional Windows-only command override; TOML accepts command_windows or commandWindows, and hooks.json uses commandWindows." + ], + "reason": "Authored native documents may set commandWindows, but generated Agent Bundle wrappers emit one node command for every platform and expose no per-platform override.", + "state": "degraded" + }, + "statusMessage": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks documents statusMessage as an optional message shown while a command or mcp_tool hook runs." + ], + "reason": "Authored native documents may set statusMessage, but canonical Agent Bundle hooks have no status-message authoring surface.", + "state": "degraded" + }, + "timeoutRules": { + "defaultSeconds": 600, + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks states timeout is in seconds and defaults to 600 for most hooks, while SessionEnd and Interrupt default to 1 second and support at most 3 seconds; SessionEnd timeouts or errors are reported as hook failures.", + "retrieved 2026-09-02: generated hooks project hook.timeoutMs to whole seconds and the adapter rejects SessionEnd handlers whose timeout exceeds the documented three-second maximum." + ], + "shortTimeoutEvents": { "SessionEnd": { "defaultSeconds": 1, "maximumSeconds": 3 } }, + "state": "supported" + }, + "matcherSemantics": { + "applyPatchAliases": ["Edit", "Write"], + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks documents matcher as a regex applied to tool name for PreToolUse, PostToolUse, and PermissionRequest (including apply_patch aliases Edit and Write while tool_name still reports apply_patch), to trigger (manual|auto) for PreCompact and PostCompact, to reason (other) for SessionEnd, to source (startup|resume|clear|compact) for SessionStart, and to agent_type for SubagentStart and SubagentStop; UserPromptSubmit, Stop, and Interrupt ignore any configured matcher.", + "retrieved 2026-09-02: the tool-coverage table routes shell and unified exec as Bash, apply_patch as apply_patch|Edit|Write, MCP tools as mcp____, and other local function tools such as update_plan (spawn_agent also matches Agent) through PreToolUse and PostToolUse, while hosted tools such as WebSearch never reach the local function-tool hook path.", + "retrieved 2026-09-02: canonical shell, file.write, and mcp selectors compile to ^Bash$, ^(?:apply_patch|Edit|Write)$, and ^mcp__; codex-scoped native selectors compile to anchored exact-name matchers for local function tools; matchers on UserPromptSubmit and Stop and native selectors naming the documented hosted tool fail before publication." + ], + "hostedToolExclusions": ["WebSearch"], + "ignoredMatcherEvents": ["UserPromptSubmit", "Stop"], + "matcherSubjects": { + "PermissionRequest": "tool_name", + "PostCompact": "trigger", + "PostToolUse": "tool_name", + "PreCompact": "trigger", + "PreToolUse": "tool_name", + "SessionEnd": "reason", + "SessionStart": "source", + "SubagentStart": "agent_type", + "SubagentStop": "agent_type" + }, + "state": "supported" + }, + "trustReview": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks records trust against each non-managed hook's current hash, marks new or changed hooks for review and skips them until trusted, exposes /hooks to inspect sources, review, trust, or disable individual non-managed hooks, and prints a startup warning when review is pending.", + "retrieved 2026-09-02: installing or enabling a plugin does not trust its hooks; Codex skips plugin-bundled hooks until the user reviews and trusts the current definition; managed hooks from system, MDM, cloud, or requirements.toml sources are trusted by policy and cannot be disabled from the user hook browser; --dangerously-bypass-hook-trust runs enabled hooks without persisted trust for one invocation." + ], + "reason": "Hook trust is a host-owned per-user review decision keyed to the emitted hook hash; the compiler cannot pre-trust generated or native plugin hooks, and emitted hooks stay skipped until the user trusts them in /hooks.", + "state": "unavailable" + }, + "generatedSchemaValidation": { + "evidence": [ + "retrieved 2026-09-02: https://github.com/openai/codex/tree/rust-v0.147.0/codex-rs/hooks/schema/generated publishes 21 generated command schemas (input and output for PermissionRequest, PostCompact, PostToolUse, PreCompact, PreToolUse, SessionStart, Stop, SubagentStart, SubagentStop, and UserPromptSubmit, plus SessionEnd input only); every file is byte-pinned under schemas/codex/generated with its upstream SHA-256 recorded in validation.pinnedGeneratedComparison.", + "retrieved 2026-09-02: the Codex lifecycle-replay starter envelope for every supported semantic event route validates against the matching pinned input schema, and the native output codec's encoded results validate against the matching pinned output schema, so event I/O is checked against the generated wire contract rather than the open-ended event map." + ], + "schemas": { + "PermissionRequest": { "input": "permission-request.command.input.schema.json", "output": "permission-request.command.output.schema.json" }, + "PostCompact": { "input": "post-compact.command.input.schema.json", "output": "post-compact.command.output.schema.json" }, + "PostToolUse": { "input": "post-tool-use.command.input.schema.json", "output": "post-tool-use.command.output.schema.json" }, + "PreCompact": { "input": "pre-compact.command.input.schema.json", "output": "pre-compact.command.output.schema.json" }, + "PreToolUse": { "input": "pre-tool-use.command.input.schema.json", "output": "pre-tool-use.command.output.schema.json" }, + "SessionEnd": { "input": "session-end.command.input.schema.json" }, + "SessionStart": { "input": "session-start.command.input.schema.json", "output": "session-start.command.output.schema.json" }, + "Stop": { "input": "stop.command.input.schema.json", "output": "stop.command.output.schema.json" }, + "SubagentStart": { "input": "subagent-start.command.input.schema.json", "output": "subagent-start.command.output.schema.json" }, + "SubagentStop": { "input": "subagent-stop.command.input.schema.json", "output": "subagent-stop.command.output.schema.json" }, + "UserPromptSubmit": { "input": "user-prompt-submit.command.input.schema.json", "output": "user-prompt-submit.command.output.schema.json" } + }, + "state": "supported" + } } }, "mcp": { @@ -324,9 +472,17 @@ "permission-request.command.output.schema.json", "post-compact.command.input.schema.json", "post-compact.command.output.schema.json", + "post-tool-use.command.input.schema.json", + "post-tool-use.command.output.schema.json", "pre-compact.command.input.schema.json", "pre-compact.command.output.schema.json", + "pre-tool-use.command.input.schema.json", + "pre-tool-use.command.output.schema.json", "session-end.command.input.schema.json", + "session-start.command.input.schema.json", + "session-start.command.output.schema.json", + "stop.command.input.schema.json", + "stop.command.output.schema.json", "subagent-start.command.input.schema.json", "subagent-start.command.output.schema.json", "subagent-stop.command.input.schema.json", @@ -335,13 +491,21 @@ "user-prompt-submit.command.output.schema.json" ], "pinnedRepositorySha256": { - "permission-request.command.input.schema.json": "75c73d7a38cfc0e73ef06bd1fc506a44d25874522069ec4fb85e0bf1e7d6b8fb", - "permission-request.command.output.schema.json": "749c73245b4b6d43537c3049f76720ab1c2bd48d7e4752b744b376925b9d57a1", + "permission-request.command.input.schema.json": "ffe7200494f1113307efc1c958816fbaa3c16297ce4bb358bc5b22172a437a70", + "permission-request.command.output.schema.json": "69c0a581b3f02ec44b22d4f8a7f2f3c15d37dc2df4bb106b3e4395f5e0c70972", "post-compact.command.input.schema.json": "d5cecd14bd2ca18605ba8209108f76291f886ffe3cb4762d70e712c148836f31", "post-compact.command.output.schema.json": "811b7ae2a4b277cd51c9df989f347e494fa981d01e346bc2a757506e97734882", + "post-tool-use.command.input.schema.json": "d569956a287ec0864b514d8ff5dbdf0b70bfbb370d89e61a1207bbdf9208ba7d", + "post-tool-use.command.output.schema.json": "fe7271d436266554150b2be783ca10da08dded8784eb8af2ffd00fb81668988a", "pre-compact.command.input.schema.json": "5728b5da4c63e1e07f2d53ac8b2adc18306cd3dc8b01b556fc422e75b32a8734", "pre-compact.command.output.schema.json": "98ba0dbf0848d8283cfe48a85122f4d9122baae1b457f50e239504864900247e", + "pre-tool-use.command.input.schema.json": "d5cb38c98a7d931907aa96614c4c52a5bc0c37d61b3f897837e32331842d3100", + "pre-tool-use.command.output.schema.json": "1ba5f66f888f5a8362ff8c46883b4cb1297de53d3d802b92d25535460cfdd0fc", "session-end.command.input.schema.json": "99bf6e75091525b96926dd85a0adf6589dea93fe0d181394027fabda8139402e", + "session-start.command.input.schema.json": "9a2fe5c4541eb733c1b9a402b28b3c50939bd3d3bb76cf6d7ec7e56cc1441f5d", + "session-start.command.output.schema.json": "72c8cded2efc7024abfd62692b84934b1b1999cb595665f4a520dc8ebb5eb690", + "stop.command.input.schema.json": "b162ebcae8c3b872aee3725128b436769557fc1fe0c83336c4e76eed62b22626", + "stop.command.output.schema.json": "37679d1a933fdc3dc8cea5ff12d7d0e2dacfc036208d134aefc09247e1222d88", "subagent-start.command.input.schema.json": "e1cacc5cd92217e96e327cf182038fa93099d194c3107439b4dad4b806d414cc", "subagent-start.command.output.schema.json": "531f7a457ad8430de82388319ff2bf030fd3a1dc0e9a0d4078447bc30948448b", "subagent-stop.command.input.schema.json": "27842578768e74fb8bcd86b30156b207011829a81dc01b00cfd55340df8b079f", @@ -379,7 +543,12 @@ "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory and hooks/src/lib.rs inventory contain no tool-failure event, so tool/failure is unavailable rather than inferred from PostToolUse.", "retrieved 2026-09-02: rust-v0.147.0 pre-compact.command.input.schema.json pins PreCompact trigger, model, turn_id, session, cwd, and nullable transcript input (sha256 065f0ae3cd628ac9af8c0cf9bd1d5a673bcbd5ea1d7dcdc0c6437f34dd0189d9); its output schema accepts only common continue, stopReason, suppressOutput, and systemMessage fields (sha256 c392f3054ae6750f427d4dec07380fd67e8c58a7939a35d5c69bfa070c7ca032).", "retrieved 2026-09-02: rust-v0.147.0 post-compact.command.input.schema.json pins PostCompact with the same input fields and no compact summary field (sha256 4a4b3f3022c939a15ab12e95f5c5c17b18bb20f74fe962ae0a51b2a3e76e63f9); its output schema accepts only the same common fields (sha256 48355bfcb568259cf396beb6ade2ac32827f50bf6a3c20b395c337dce184cbed).", - "2026-09-02: the generated compact output schemas do not document event-level runtime effects for continue:false or the other common fields, so Agent Bundle exposes no canonical compact result channel until runtime evidence pins those semantics." + "2026-09-02: the generated compact output schemas do not document event-level runtime effects for continue:false or the other common fields, so Agent Bundle exposes no canonical compact result channel until runtime evidence pins those semantics.", + "retrieved 2026-09-02: rust-v0.147.0 pre-tool-use.command.input.schema.json requires cwd, hook_event_name, model, permission_mode, session_id, tool_input (any JSON), tool_name, tool_use_id, nullable transcript_path, and turn_id (upstream sha256 fabed428f0fe75767c5700208b166da5faef4e031d601dfc8bff2f96d340c682); its output schema admits continue, decision approve|block, reason, stopReason, suppressOutput, systemMessage, and hookSpecificOutput with permissionDecision allow|deny|ask, permissionDecisionReason, updatedInput, and additionalContext (upstream sha256 e684f81c63fbb5972892f6a848b49fec68c8ce137931651093d2dd1da56a1dd6).", + "retrieved 2026-09-02: rust-v0.147.0 post-tool-use.command.input.schema.json adds tool_response (any JSON) to the PreToolUse input fields (upstream sha256 8ea1e4bccb262fad05b85c300d562d2653c5a64118d6a2c5704468fc4ea836a9); its output schema admits decision block, reason, the common fields, and hookSpecificOutput with additionalContext and updatedMCPToolOutput (upstream sha256 a823d0e2c941e98d7d3af825dfdb0b1dfa6a935696ff8b8529e8e83232a1b0c8).", + "retrieved 2026-09-02: rust-v0.147.0 session-start.command.input.schema.json requires cwd, hook_event_name, model, permission_mode, session_id, source startup|resume|clear|compact, and nullable transcript_path with no turn_id (upstream sha256 690c0eef7c9f3ddcd41e24207b81b362101a300b4abec076b990a1cd79a66e20); its output schema admits the common fields plus hookSpecificOutput.additionalContext (upstream sha256 f375e6de1c59ecbabd8c1aff05a67976d0f3aa2ef061808838de4c7c20be1c71).", + "retrieved 2026-09-02: rust-v0.147.0 stop.command.input.schema.json requires cwd, hook_event_name, nullable last_assistant_message, model, permission_mode, session_id, stop_hook_active, nullable transcript_path, and turn_id (upstream sha256 7db4793c404b5c46b230c27b9507eb1a558fd958689d8715221c5dd81351a06a); its output schema admits decision block, reason, and the common fields with no hookSpecificOutput (upstream sha256 dc2b30e84c97beca5825aa64ca46e1337e402781dc5a9142b67111d10523f15c). The Codex wrapper and envelope validator accept a null last_assistant_message for Stop because this pinned schema allows it.", + "retrieved 2026-09-02: the GitHub contents API for codex-rs/hooks/schema/generated at rust-v0.147.0 lists exactly 21 files and no interrupt schema; the same directory on main adds interrupt.command.{input,output}.schema.json, and the latest release at retrieval time was rust-v0.153.0, so Interrupt remains deferred until the Codex pin moves." ] } } diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 4e4b20e11..396efbb0a 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -3,6 +3,7 @@ import { posix } from 'node:path'; import type { ValidateFunction } from 'ajv/dist/2020.js'; import { createTargetDiagnostics } from './diagnostics.ts'; +import type { CapabilityState } from '../core/capabilities.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { readMcpTransport, unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts'; import { dataArrayValues } from '../core/strict-json.ts'; @@ -30,6 +31,7 @@ import { encodeNativeHookPlaygroundInput, encodeNativeHookPlaygroundOutput, nativeHookWrapperSource, + nativeHooksFor, planHooks, readStandardNativeHookCommands, validatedNativeHookDocument, @@ -157,12 +159,39 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.6.0', + adapterRevision: '1.7.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); const evidence = capabilityEvidence(codexName, metadata); +/** Lifts a pinned four-state capability-table row into the shared capability namespace. */ +const tableCapability = (row: { readonly reason?: string; readonly state: string }): CapabilityState => { + switch (row.state) { + case 'supported': + return supportedCapability(evidence); + case 'degraded': + return Object.freeze({ evidence, reason: row.reason ?? '', state: 'degraded' }); + case 'unavailable': + return unavailableCapability(row.reason ?? ''); + default: + throw new TypeError(`Unsupported Codex capability-table state ${JSON.stringify(row.state)}.`); + } +}; + +const hookContractTable = capabilityTable.hooks.contract; +const codexReleaseHookEvents: readonly string[] = capabilityTable.hooks.releaseEvents; +const codexHookRules = Object.freeze({ + additionalContextEvents: hookContractTable.additionalContextLimit.additionalContextEvents as readonly string[], + hostedToolExclusions: hookContractTable.matcherSemantics.hostedToolExclusions as readonly string[], + ignoredMatcherEvents: hookContractTable.matcherSemantics.ignoredMatcherEvents as readonly string[], + mcpToolUnsupportedEvents: hookContractTable.mcpToolExecution.unsupportedEvents as readonly string[], + shortTimeoutEvents: hookContractTable.timeoutRules.shortTimeoutEvents as Readonly< + Record + >, + synchronousEvents: hookContractTable.asyncCommandHooks.synchronousEvents as readonly string[], +}); + const artifactValidation = deepFreeze({ documents: [ Object.freeze({ path: '.app.json', required: false, schema: 'app' }), @@ -234,6 +263,155 @@ const codexInterfaceFields = Object.freeze([ const isEmail = (value: unknown): value is string => isNonemptyString(value) && /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value); +const hookHandlersOf = ( + hooks: Readonly>, +): readonly { + readonly event: string; + readonly group: Readonly>; + readonly groupIndex: number; + readonly handler: Readonly>; + readonly handlerIndex: number; +}[] => { + const handlers = []; + for (const [event, groups] of Object.entries(hooks)) { + for (const [groupIndex, group] of (dataArrayValues(groups) ?? []).entries()) { + if (!isPlainDataRecord(group)) continue; + for (const [handlerIndex, handler] of (dataArrayValues(group['hooks']) ?? []).entries()) { + if (!isPlainDataRecord(handler)) continue; + handlers.push({ event, group, groupIndex, handler, handlerIndex }); + } + } + } + return handlers; +}; + +/** + * Names the documented-but-unsupported surfaces in an authored native hook + * document before schema validation, so the closed schema's generic rejection + * does not hide why Codex would skip the entry. + */ +const scanCodexNativeHookDocument = (document: unknown, source: string): readonly Diagnostic[] => { + if (!isPlainDataRecord(document) || !isPlainDataRecord(document['hooks'])) return []; + const diagnostics: Diagnostic[] = []; + for (const event of Object.keys(document['hooks'])) { + if (codexReleaseHookEvents.includes(event)) continue; + diagnostics.push(event === 'Interrupt' + ? { + ...errorDiagnostic( + 'codex.native-hooks.event.deferred', + `Codex native hooks file ${JSON.stringify(source)} declares the Interrupt event, which is deferred: the pinned rust-v0.147.0 generated hook schemas publish no Interrupt contract.`, + ), + recovery: 'Remove the Interrupt group until the Codex pin moves to a release that ships the generated Interrupt schemas.', + } + : { + ...errorDiagnostic( + 'codex.native-hooks.event.unknown', + `Codex native hooks file ${JSON.stringify(source)} declares ${JSON.stringify(event)}, which is not one of the eleven release-documented Codex hook events.`, + ), + recovery: `Use one of ${codexReleaseHookEvents.join(', ')}.`, + }); + } + for (const { event, groupIndex, handler, handlerIndex } of hookHandlersOf(document['hooks'])) { + const type = handler['type']; + if (type !== 'prompt' && type !== 'agent') continue; + diagnostics.push({ + ...errorDiagnostic( + 'codex.native-hooks.handler.skipped', + `Codex native hooks file ${JSON.stringify(source)} ${event}[${groupIndex}].hooks[${handlerIndex}] uses handler type ${JSON.stringify(type)}, which Codex 0.147.0 parses but skips.`, + ), + recovery: 'Use a command or mcp_tool handler; Codex runs no prompt or agent handlers.', + }); + } + return diagnostics; +}; + +/** + * Applies the per-event rules from https://learn.chatgpt.com/docs/hooks that + * the closed hooks schema cannot express, to generated and native handlers + * alike, so no handler field the host would ignore or reject is published. + */ +const codexHookDocumentDiagnostics = (document: Readonly>): readonly Diagnostic[] => { + const hooks = document['hooks']; + if (!isPlainDataRecord(hooks)) return []; + const diagnostics: Diagnostic[] = []; + for (const [event, groups] of Object.entries(hooks)) { + if (!codexHookRules.ignoredMatcherEvents.includes(event)) continue; + for (const [groupIndex, group] of (dataArrayValues(groups) ?? []).entries()) { + if (!isPlainDataRecord(group) || typeof group['matcher'] !== 'string') continue; + diagnostics.push({ + ...errorDiagnostic( + 'codex.hooks.matcher.ignored', + `Codex ignores any matcher on ${event}; ${event}[${groupIndex}] declares matcher ${JSON.stringify(group['matcher'])}.`, + ), + recovery: `Remove the matcher from the ${event} group; the hook already runs on every ${event} event.`, + }); + } + } + for (const { event, groupIndex, handler, handlerIndex } of hookHandlersOf(hooks)) { + const location = `${event}[${groupIndex}].hooks[${handlerIndex}]`; + if (handler['type'] === 'mcp_tool' && codexHookRules.mcpToolUnsupportedEvents.includes(event)) { + diagnostics.push({ + ...errorDiagnostic( + 'codex.hooks.session-end.mcp-tool', + `Codex ${event} does not support mcp_tool handlers; ${location} declares one.`, + ), + recovery: `Use a command handler for ${event}, or move the mcp_tool handler to a supported event.`, + }); + } + if (handler['async'] === true && codexHookRules.synchronousEvents.includes(event)) { + diagnostics.push({ + ...errorDiagnostic( + 'codex.hooks.session-end.async', + `Codex always runs ${event} hooks synchronously; ${location} sets async to true, which the host would ignore.`, + ), + recovery: `Remove async from the ${event} handler.`, + }); + } + const shortTimeout = codexHookRules.shortTimeoutEvents[event]; + if (shortTimeout !== undefined && typeof handler['timeout'] === 'number' && handler['timeout'] > shortTimeout.maximumSeconds) { + diagnostics.push({ + ...errorDiagnostic( + 'codex.hooks.session-end.timeout', + `Codex ${event} hooks support at most ${shortTimeout.maximumSeconds} seconds (default ${shortTimeout.defaultSeconds}); ${location} declares ${handler['timeout']}.`, + ), + recovery: `Set the ${event} handler timeout to ${shortTimeout.maximumSeconds} seconds or less.`, + }); + } + if (handler['additionalContextLimit'] !== undefined && !codexHookRules.additionalContextEvents.includes(event)) { + diagnostics.push({ + ...errorDiagnostic( + 'codex.hooks.additional-context-limit.event', + `Codex ${event} hooks cannot return additionalContext, so ${location} additionalContextLimit would be ignored with a host configuration warning.`, + ), + recovery: `Remove additionalContextLimit from the ${event} handler; it applies only to ${codexHookRules.additionalContextEvents.join(', ')}.`, + }); + } + } + return diagnostics; +}; + +/** Rejects codex-scoped native selectors that name hosted tools outside the local function-tool hook path. */ +const codexHostedToolDiagnostics = ( + model: NormalizedPlugin, + isSelected: (targets: readonly string[]) => boolean, +): readonly Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + for (const hook of model.hooks) { + if (!isSelected(hook.targets)) continue; + for (const nativeTool of hook.nativeTools ?? []) { + if (nativeTool.target !== codexName || !codexHookRules.hostedToolExclusions.includes(nativeTool.name)) continue; + diagnostics.push({ + ...errorDiagnostic( + 'codex.hook.tool.hosted', + `Codex hosted tool ${JSON.stringify(nativeTool.name)} never reaches the local function-tool hook path, so hook ${JSON.stringify(hook.name)} cannot select it.`, + ), + recovery: 'Select a shell, apply_patch, MCP, or local function tool instead; hosted tools such as WebSearch are not hookable in Codex.', + }); + } + } + return diagnostics; +}; + interface CodexManifestMetadataPlan { readonly diagnostics: readonly Diagnostic[]; readonly document?: Readonly>; @@ -806,15 +984,27 @@ export const planCodexArtifacts = ( const mcp = Object.keys(servers).length === 0 ? undefined : { mcpServers: servers }; const mcpValid = mcp !== undefined && validateMcp(mcp); if (mcp !== undefined) diagnostics.push(...schemaDiagnostics('mcp', mcpValid, validateMcp.errors)); + diagnostics.push(...codexHostedToolDiagnostics(model, isSelected)); const generatedHooks = planHooks(model, targetName, hookContract); diagnostics.push(...generatedHooks.diagnostics); if (generatedHooks.document !== undefined) { diagnostics.push(...schemaDiagnostics('hooks', validateHooks(generatedHooks.document), validateHooks.errors)); } - const nativeHooks = validatedNativeHookDocument(model, codexName, 'Codex', validateHooks, errorDiagnostic); + const declaredNativeHooks = nativeHooksFor(model, codexName); + const nativeScan = declaredNativeHooks?.document === undefined + ? [] + : scanCodexNativeHookDocument(declaredNativeHooks.document, declaredNativeHooks.source); + diagnostics.push(...nativeScan); + // A scan finding already names the exact unsupported surface; the closed + // schema would only add a generic rejection of the same entry. + const nativeHooks = nativeScan.length > 0 + ? { diagnostics: [] } + : validatedNativeHookDocument(model, codexName, 'Codex', validateHooks, errorDiagnostic); diagnostics.push(...nativeHooks.diagnostics); const hookDocument = mergeHookDocuments(generatedHooks.document, nativeHooks.document); - const hookDocumentValid = hookDocument !== undefined && validateHooks(hookDocument); + const hookSemantics = hookDocument === undefined ? [] : codexHookDocumentDiagnostics(hookDocument); + diagnostics.push(...hookSemantics); + const hookDocumentValid = hookDocument !== undefined && hookSemantics.length === 0 && validateHooks(hookDocument); const manifestMetadata = planCodexManifestMetadata(model); diagnostics.push(...manifestMetadata.diagnostics); const appsPlan = planCodexApps(model); @@ -969,6 +1159,19 @@ export const codexAdapter: TargetAdapter = Object.freeze({ install: supportedCapability(evidence), marketplace: supportedCapability(evidence), hooks: supportedCapability(evidence), + hookAdditionalContextLimit: tableCapability(hookContractTable.additionalContextLimit), + hookAsyncCommands: tableCapability(hookContractTable.asyncCommandHooks), + hookCommandWindows: tableCapability(hookContractTable.commandWindows), + hookGeneratedSchemas: tableCapability(hookContractTable.generatedSchemaValidation), + hookHandlerCommand: tableCapability(hookContractTable.handlerCommand), + hookHandlerMcpTool: tableCapability(hookContractTable.handlerMcpTool), + hookHandlerPromptAgent: tableCapability(hookContractTable.handlerPromptAgent), + hookMatcherSemantics: tableCapability(hookContractTable.matcherSemantics), + hookMcpToolExecution: tableCapability(hookContractTable.mcpToolExecution), + hookReleaseEvents: tableCapability(hookContractTable.releaseEvents), + hookStatusMessage: tableCapability(hookContractTable.statusMessage), + hookTimeoutRules: tableCapability(hookContractTable.timeoutRules), + hookTrustReview: tableCapability(hookContractTable.trustReview), // The pinned Codex plugin contract documents no LSP surface at all, so // this is an absent host capability rather than a degraded one: nothing // of Claude's `.lsp.json` is copied to the Codex manifest. diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 8f66cfe7a..b299da98c 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -108,9 +108,13 @@ export const createNativeEventStarter = ( ? { command: '*** Begin Patch\n*** Add File: lifecycle-replay.txt\n+Lifecycle replay\n*** End Patch' } : { file_path: 'lifecycle-replay.txt' }; const toolName = target === 'codex' ? 'apply_patch' : 'Write'; + // The pinned rust-v0.147.0 generated input schemas require model and + // permission_mode on every Codex event below, and turn_id on turn-scoped ones. + const codexSession = target === 'codex' ? { model: 'default', permission_mode: 'default' } : {}; + const codexTurn = target === 'codex' ? { ...codexSession, turn_id: 'lifecycle-replay-turn' } : {}; switch (canonicalEvent) { case 'session/start': - return deepFreeze(target === 'cursor' ? base : { ...base, source: 'startup' }); + return deepFreeze(target === 'cursor' ? base : { ...base, ...codexSession, source: 'startup' }); case 'session/end': return deepFreeze(target === 'cursor' ? { @@ -184,6 +188,7 @@ export const createNativeEventStarter = ( case 'tool/before': return deepFreeze({ ...base, + ...codexTurn, tool_input: toolInput, tool_name: toolName, tool_use_id: 'lifecycle-replay-tool', @@ -191,6 +196,7 @@ export const createNativeEventStarter = ( case 'tool/after': return deepFreeze({ ...base, + ...codexTurn, tool_input: toolInput, tool_name: toolName, ...(target === 'cursor' ? { tool_output: '{}' } : { tool_response: {} }), @@ -199,7 +205,7 @@ export const createNativeEventStarter = ( case 'stop': return deepFreeze(target === 'cursor' ? { ...base, loop_count: 0 } - : { ...base, last_assistant_message: 'Lifecycle replay stopped.', stop_hook_active: false }); + : { ...base, ...codexTurn, last_assistant_message: 'Lifecycle replay stopped.', stop_hook_active: false }); case 'agent/start': return deepFreeze(target === 'cursor' ? base @@ -1171,7 +1177,7 @@ export const nativeHookWrapperSource = ( ' return;', ' }', ' if (typeof input.stop_hook_active !== "boolean") fail("native Stop stop_hook_active must be a boolean");', - ' requireString(input, "last_assistant_message");', + ' if (target === "codex") requireNullableString(input, "last_assistant_message"); else requireString(input, "last_assistant_message");', '};', 'const run = async () => {', ' const handler = Reflect.get(handlerModule, "default");', diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 080895741..064354cde 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -110,6 +110,23 @@ const interfaceUnifiedReason = 'The unified bundle emits the Codex-only interface install surface, but the pinned Claude and Cursor plugin contracts declare no shared interface metadata field.'; const mcpPolicyUnifiedReason = 'The MCP approval policy is enforced by the Codex host at install time; the pinned Claude and Cursor contracts publish no shared per-plugin MCP policy surface.'; +const hookContractUnifiedReason = + 'The unified bundle emits the Codex-only hook handler contract, but the pinned Claude and Cursor hook contracts declare no shared handler-type, timeout, matcher, or trust surface.'; +const codexHookContractCapabilities = [ + 'hookAdditionalContextLimit', + 'hookAsyncCommands', + 'hookCommandWindows', + 'hookGeneratedSchemas', + 'hookHandlerCommand', + 'hookHandlerMcpTool', + 'hookHandlerPromptAgent', + 'hookMatcherSemantics', + 'hookMcpToolExecution', + 'hookReleaseEvents', + 'hookStatusMessage', + 'hookTimeoutRules', + 'hookTrustReview', +] as const; const reconciledMatcherKeys = new Set(['file.read', 'file.write']); const claudeMatchers: Readonly> = claudeCapabilityTable.hooks.matchers; const codexMatchers: Readonly> = codexCapabilityTable.hooks.matchers; @@ -191,7 +208,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.21.0', + adapterRevision: '1.22.0', observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`, // Metadata schemas must exactly match the validation contract: each host's // documents, with one shared Claude-format hook schema (the pinned Codex @@ -584,6 +601,16 @@ const componentCapabilities = Object.freeze(Object.fromEntries( ]), )); +const codexHookContractUnifiedCapabilities = Object.freeze(Object.fromEntries( + codexHookContractCapabilities.map((capability) => [ + capability, + intersectCapabilityStates( + codexAdapter.capabilities[capability]!, + unavailableCapability(hookContractUnifiedReason), + ), + ]), +)); + const agentCapabilities = Object.freeze(Object.fromEntries( Object.keys(claudeCapabilityTable.plugin.agents).map((rowName) => { const capability = rowName === 'component' ? 'agents' : `agents.${rowName}`; @@ -604,6 +631,7 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ artifactLayout, capabilities: Object.freeze({ ...agentCapabilities, + ...codexHookContractUnifiedCapabilities, ...compositeEventCapabilities, bin: unavailableCapability( 'The unified bundle emits the Claude-only bin directory, but the pinned Codex and Cursor contracts declare no shared plugin executable surface.', diff --git a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json index bf719c0a0..9bd27d70c 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json @@ -2,16 +2,21 @@ "observedCliVersion": "0.147.0", "retrievedAt": "2026-09-02", "schemaSource": "https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md", - "notes": "plugin.schema.json transcribes the 2026-09-02 documented package manifest rather than a host-exported schema because Codex 0.147.0 publishes no plugin validate command. Skills stays optional so MCP-only plugins admitted by the documented packaging and submission flows validate. The generated subagent-{start,stop}.command.{input,output}.schema.json, user-prompt-submit.command.{input,output}.schema.json, session-end.command.input.schema.json, {pre,post}-compact.command.{input,output}.schema.json, and permission-request.command.{input,output}.schema.json evidence snapshots are byte-pinned from the rust-v0.147.0 tag at https://github.com/openai/codex/tree/rust-v0.147.0/codex-rs/hooks/schema/generated. That tag has no session-end.command.output.schema.json and no tool-failure, permission-denied, or stop-failure schema. Repository text files add one POSIX trailing newline; adapter-metadata.test.ts removes only that byte before comparing the authoritative upstream SHA-256 digests recorded in codex-0.147.0.json.", + "notes": "plugin.schema.json transcribes the 2026-09-02 documented package manifest rather than a host-exported schema because Codex 0.147.0 publishes no plugin validate command. Skills stays optional so MCP-only plugins admitted by the documented packaging and submission flows validate. All 21 generated command schemas published at the rust-v0.147.0 tag (https://github.com/openai/codex/tree/rust-v0.147.0/codex-rs/hooks/schema/generated) are byte-pinned under generated/: input and output for permission-request, post-compact, post-tool-use, pre-compact, pre-tool-use, session-start, stop, subagent-start, subagent-stop, and user-prompt-submit, plus session-end input. That tag has no session-end.command.output.schema.json, no interrupt schema (interrupt.command.{input,output}.schema.json exist only on main as of 2026-09-02), and no tool-failure, permission-denied, or stop-failure schema. hooks.schema.json transcribes the 2026-09-02 release hook contract from https://learn.chatgpt.com/docs/hooks: the eleven release events, command handlers (command, commandWindows, timeout, statusMessage, additionalContextLimit, async) and mcp_tool handlers (server, tool, input, timeout, statusMessage); prompt and agent handlers are documented as parsed-but-skipped and are excluded so they fail before publication. Repository text files add one POSIX trailing newline; adapter-metadata.test.ts removes only that byte before comparing the authoritative upstream SHA-256 digests recorded in codex-0.147.0.json.", "schemaTightenings": { "plugin.schema.json": [ "Top-level and author objects are closed; author admits only the documented name, email, and url fields.", "Documented URL and email fields use JSON Schema uri and email formats, and authored discovery strings must be nonempty.", "Component paths must begin with ./ and must not contain a parent-directory segment so they remain inside the plugin root.", - "Inline mcpServers values must be objects; inline hook documents use the same closed command-hook shape as hooks.schema.json.", + "Inline mcpServers values must be objects; inline hook documents use the same closed eleven-event, command-or-mcp_tool handler shape as hooks.schema.json.", "The closed interface object admits every documented install-surface field; brandColor requires a six-digit hexadecimal value, external links require http(s), asset paths must stay inside the plugin root, and screenshots must be ./assets/-relative PNG paths.", "The apps pointer is const-locked to ./.app.json, and app.schema.json requires a nonempty apps map whose entries carry exactly one nonempty registered-connection id.", "Component and interface-asset path patterns treat backslashes as separators and reject them outright so Windows-form parent traversal cannot escape the plugin root; HTTP(S) URL scheme patterns are case-insensitive to match WHATWG URL protocol normalization." + ], + "hooks.schema.json": [ + "The hooks object is closed to the eleven release-documented events; Interrupt is excluded because rust-v0.147.0 ships no generated Interrupt schema.", + "Handlers are a closed oneOf of command and mcp_tool shapes; prompt and agent handlers are excluded because Codex parses but skips them.", + "Per-event rules the schema cannot express (SessionEnd rejects mcp_tool, async, and timeouts above 3 seconds; additionalContextLimit only on events that return additionalContext; matchers ignored on UserPromptSubmit and Stop) are enforced by codex.hooks.* diagnostics in the adapter." ] }, "schemas": { @@ -21,8 +26,8 @@ "url": "https://developers.openai.com/plugins/build/plugins" }, "hooks.schema.json": { - "bytes": 1107, - "sha256": "e42eef736997b9abb8f28b2ee9262f5c7b1f7f11d8289e9c25da8cc94a504eff", + "bytes": 2596, + "sha256": "175b859eb8e85bd287d85ee840d97c3f5c2d0dda3223507a796d158e3770eeba", "url": "https://learn.chatgpt.com/docs/hooks" }, "marketplace.schema.json": { @@ -36,8 +41,8 @@ "url": "https://github.com/openai/codex/blob/main/codex-rs/core/config.schema.json" }, "plugin.schema.json": { - "bytes": 4962, - "sha256": "decee14ec76a602701f3c312aee135a0983c1ce95fa89dafc588ebb5c968843b", + "bytes": 6454, + "sha256": "986bcafa6ef46f9dc4558f05781f53400b3d75533a075068184ba8d43670d4ec", "url": "https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md" } }, diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.input.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.input.schema.json index 9ee8996db..28e08187f 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.input.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.input.schema.json @@ -64,4 +64,4 @@ ], "title": "permission-request.command.input", "type": "object" -} \ No newline at end of file +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.output.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.output.schema.json index 347820f7e..a82a5ba1f 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.output.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.output.schema.json @@ -88,4 +88,4 @@ }, "title": "permission-request.command.output", "type": "object" -} \ No newline at end of file +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/post-tool-use.command.input.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/post-tool-use.command.input.schema.json new file mode 100644 index 000000000..ba860d6a8 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/post-tool-use.command.input.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "PostToolUse", + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "tool_input": true, + "tool_name": { + "type": "string" + }, + "tool_response": true, + "tool_use_id": { + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "cwd", + "hook_event_name", + "model", + "permission_mode", + "session_id", + "tool_input", + "tool_name", + "tool_response", + "tool_use_id", + "transcript_path", + "turn_id" + ], + "title": "post-tool-use.command.input", + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/post-tool-use.command.output.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/post-tool-use.command.output.schema.json new file mode 100644 index 000000000..5236ed54f --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/post-tool-use.command.output.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "BlockDecisionWire": { + "enum": [ + "block" + ], + "type": "string" + }, + "PostToolUseHookSpecificOutputWire": { + "additionalProperties": false, + "properties": { + "additionalContext": { + "default": null, + "type": "string" + }, + "hookEventName": { + "const": "PostToolUse", + "type": "string" + }, + "updatedMCPToolOutput": { + "default": null + } + }, + "required": [ + "hookEventName" + ], + "type": "object" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "decision": { + "allOf": [ + { + "$ref": "#/definitions/BlockDecisionWire" + } + ], + "default": null + }, + "hookSpecificOutput": { + "allOf": [ + { + "$ref": "#/definitions/PostToolUseHookSpecificOutputWire" + } + ], + "default": null + }, + "reason": { + "default": null, + "type": "string" + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "post-tool-use.command.output", + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/pre-tool-use.command.input.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/pre-tool-use.command.input.schema.json new file mode 100644 index 000000000..bb3f6cde4 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/pre-tool-use.command.input.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "PreToolUse", + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "tool_input": true, + "tool_name": { + "type": "string" + }, + "tool_use_id": { + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "cwd", + "hook_event_name", + "model", + "permission_mode", + "session_id", + "tool_input", + "tool_name", + "tool_use_id", + "transcript_path", + "turn_id" + ], + "title": "pre-tool-use.command.input", + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/pre-tool-use.command.output.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/pre-tool-use.command.output.schema.json new file mode 100644 index 000000000..0e483a70a --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/pre-tool-use.command.output.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "PreToolUseDecisionWire": { + "enum": [ + "approve", + "block" + ], + "type": "string" + }, + "PreToolUseHookSpecificOutputWire": { + "additionalProperties": false, + "properties": { + "additionalContext": { + "default": null, + "type": "string" + }, + "hookEventName": { + "const": "PreToolUse", + "type": "string" + }, + "permissionDecision": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUsePermissionDecisionWire" + } + ], + "default": null + }, + "permissionDecisionReason": { + "default": null, + "type": "string" + }, + "updatedInput": { + "default": null + } + }, + "required": [ + "hookEventName" + ], + "type": "object" + }, + "PreToolUsePermissionDecisionWire": { + "enum": [ + "allow", + "deny", + "ask" + ], + "type": "string" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "decision": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUseDecisionWire" + } + ], + "default": null + }, + "hookSpecificOutput": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUseHookSpecificOutputWire" + } + ], + "default": null + }, + "reason": { + "default": null, + "type": "string" + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "pre-tool-use.command.output", + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/session-start.command.input.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/session-start.command.input.schema.json new file mode 100644 index 000000000..21b184630 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/session-start.command.input.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "SessionStart", + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "source": { + "enum": [ + "startup", + "resume", + "clear", + "compact" + ], + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + } + }, + "required": [ + "cwd", + "hook_event_name", + "model", + "permission_mode", + "session_id", + "source", + "transcript_path" + ], + "title": "session-start.command.input", + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/session-start.command.output.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/session-start.command.output.schema.json new file mode 100644 index 000000000..10e612a41 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/session-start.command.output.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "SessionStartHookSpecificOutputWire": { + "additionalProperties": false, + "properties": { + "additionalContext": { + "default": null, + "type": "string" + }, + "hookEventName": { + "const": "SessionStart", + "type": "string" + } + }, + "required": [ + "hookEventName" + ], + "type": "object" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "hookSpecificOutput": { + "allOf": [ + { + "$ref": "#/definitions/SessionStartHookSpecificOutputWire" + } + ], + "default": null + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "session-start.command.output", + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/stop.command.input.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/stop.command.input.schema.json new file mode 100644 index 000000000..90f63d255 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/stop.command.input.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "Stop", + "type": "string" + }, + "last_assistant_message": { + "$ref": "#/definitions/NullableString" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "stop_hook_active": { + "type": "boolean" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "cwd", + "hook_event_name", + "last_assistant_message", + "model", + "permission_mode", + "session_id", + "stop_hook_active", + "transcript_path", + "turn_id" + ], + "title": "stop.command.input", + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/stop.command.output.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/stop.command.output.schema.json new file mode 100644 index 000000000..086892683 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/stop.command.output.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "BlockDecisionWire": { + "enum": [ + "block" + ], + "type": "string" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "decision": { + "allOf": [ + { + "$ref": "#/definitions/BlockDecisionWire" + } + ], + "default": null + }, + "reason": { + "default": null, + "description": "Claude requires `reason` when `decision` is `block`; we enforce that semantic rule during output parsing rather than in the JSON schema.", + "type": "string" + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "stop.command.output", + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/hooks.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/hooks.schema.json index d28390016..d729d350f 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/hooks.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/hooks.schema.json @@ -1,36 +1,75 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://agent-bundle.dev/schemas/codex/0.147.0/hooks.schema.json", + "$defs": { + "commandHandler": { + "type": "object", + "additionalProperties": false, + "properties": { + "additionalContextLimit": { "type": "integer", "minimum": 0 }, + "async": { "type": "boolean" }, + "command": { "type": "string", "minLength": 1 }, + "commandWindows": { "type": "string", "minLength": 1 }, + "statusMessage": { "type": "string" }, + "timeout": { "type": "integer", "minimum": 1 }, + "type": { "const": "command" } + }, + "required": ["type", "command"] + }, + "mcpToolHandler": { + "type": "object", + "additionalProperties": false, + "properties": { + "input": { "type": "object" }, + "server": { "type": "string", "minLength": 1 }, + "statusMessage": { "type": "string" }, + "timeout": { "type": "integer", "minimum": 1 }, + "tool": { "type": "string", "minLength": 1 }, + "type": { "const": "mcp_tool" } + }, + "required": ["type", "server", "tool"] + }, + "matcherGroups": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "matcher": { "type": "string" }, + "hooks": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { "$ref": "#/$defs/commandHandler" }, + { "$ref": "#/$defs/mcpToolHandler" } + ] + } + } + }, + "required": ["hooks"] + } + } + }, "type": "object", "additionalProperties": false, "properties": { "description": { "type": "string" }, "hooks": { "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "matcher": { "type": "string" }, - "hooks": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "command": { "type": "string", "minLength": 1 }, - "timeout": { "type": "integer", "minimum": 1 }, - "type": { "const": "command" } - }, - "required": ["type", "command"] - } - } - }, - "required": ["hooks"] - } + "additionalProperties": false, + "properties": { + "PermissionRequest": { "$ref": "#/$defs/matcherGroups" }, + "PostCompact": { "$ref": "#/$defs/matcherGroups" }, + "PostToolUse": { "$ref": "#/$defs/matcherGroups" }, + "PreCompact": { "$ref": "#/$defs/matcherGroups" }, + "PreToolUse": { "$ref": "#/$defs/matcherGroups" }, + "SessionEnd": { "$ref": "#/$defs/matcherGroups" }, + "SessionStart": { "$ref": "#/$defs/matcherGroups" }, + "Stop": { "$ref": "#/$defs/matcherGroups" }, + "SubagentStart": { "$ref": "#/$defs/matcherGroups" }, + "SubagentStop": { "$ref": "#/$defs/matcherGroups" }, + "UserPromptSubmit": { "$ref": "#/$defs/matcherGroups" } } } }, diff --git a/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json index fc36741fc..78e47aae4 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json @@ -6,36 +6,73 @@ "pattern": "^\\./(?!(?:.*[\\/\\\\])?\\.\\.(?:[\\/\\\\]|$))[^\\\\]+$", "type": "string" }, - "hookDocument": { + "hookCommandHandler": { "additionalProperties": false, "properties": { - "description": { "type": "string" }, - "hooks": { - "additionalProperties": { + "additionalContextLimit": { "minimum": 0, "type": "integer" }, + "async": { "type": "boolean" }, + "command": { "minLength": 1, "type": "string" }, + "commandWindows": { "minLength": 1, "type": "string" }, + "statusMessage": { "type": "string" }, + "timeout": { "minimum": 1, "type": "integer" }, + "type": { "const": "command" } + }, + "required": ["type", "command"], + "type": "object" + }, + "hookMcpToolHandler": { + "additionalProperties": false, + "properties": { + "input": { "type": "object" }, + "server": { "minLength": 1, "type": "string" }, + "statusMessage": { "type": "string" }, + "timeout": { "minimum": 1, "type": "integer" }, + "tool": { "minLength": 1, "type": "string" }, + "type": { "const": "mcp_tool" } + }, + "required": ["type", "server", "tool"], + "type": "object" + }, + "hookMatcherGroups": { + "items": { + "additionalProperties": false, + "properties": { + "hooks": { "items": { - "additionalProperties": false, - "properties": { - "hooks": { - "items": { - "additionalProperties": false, - "properties": { - "command": { "minLength": 1, "type": "string" }, - "timeout": { "minimum": 1, "type": "integer" }, - "type": { "const": "command" } - }, - "required": ["type", "command"], - "type": "object" - }, - "minItems": 1, - "type": "array" - }, - "matcher": { "type": "string" } - }, - "required": ["hooks"], - "type": "object" + "oneOf": [ + { "$ref": "#/$defs/hookCommandHandler" }, + { "$ref": "#/$defs/hookMcpToolHandler" } + ] }, + "minItems": 1, "type": "array" }, + "matcher": { "type": "string" } + }, + "required": ["hooks"], + "type": "object" + }, + "type": "array" + }, + "hookDocument": { + "additionalProperties": false, + "properties": { + "description": { "type": "string" }, + "hooks": { + "additionalProperties": false, + "properties": { + "PermissionRequest": { "$ref": "#/$defs/hookMatcherGroups" }, + "PostCompact": { "$ref": "#/$defs/hookMatcherGroups" }, + "PostToolUse": { "$ref": "#/$defs/hookMatcherGroups" }, + "PreCompact": { "$ref": "#/$defs/hookMatcherGroups" }, + "PreToolUse": { "$ref": "#/$defs/hookMatcherGroups" }, + "SessionEnd": { "$ref": "#/$defs/hookMatcherGroups" }, + "SessionStart": { "$ref": "#/$defs/hookMatcherGroups" }, + "Stop": { "$ref": "#/$defs/hookMatcherGroups" }, + "SubagentStart": { "$ref": "#/$defs/hookMatcherGroups" }, + "SubagentStop": { "$ref": "#/$defs/hookMatcherGroups" }, + "UserPromptSubmit": { "$ref": "#/$defs/hookMatcherGroups" } + }, "type": "object" } }, diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index e658e153f..74d555ae6 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -317,7 +317,15 @@ export const validateNativeEventEnvelope = ( if (typeof native.stop_hook_active !== 'boolean') { return nativeEventError('native stop_hook_active must be a boolean'); } - requireNativeString(native, 'last_assistant_message'); + // The pinned rust-v0.147.0 stop.command.input schema types + // last_assistant_message as string | null; Claude documents a string. + if (target === 'codex') { + if (native.last_assistant_message !== null && typeof native.last_assistant_message !== 'string') { + return nativeEventError('native last_assistant_message must be a string or null'); + } + } else { + requireNativeString(native, 'last_assistant_message'); + } } return native; }; diff --git a/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts index a22ac4981..6879ff66d 100644 --- a/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts @@ -27,12 +27,10 @@ const schemaGenerationTimeoutMs = 15_000; const versionTimeoutMs = 5_000; const pinnedRevision = capabilityTable.observedCliVersion; -const generatedSchemaNames = Object.freeze([ - 'subagent-start.command.input.schema.json', - 'subagent-start.command.output.schema.json', - 'subagent-stop.command.input.schema.json', - 'subagent-stop.command.output.schema.json', -]); +/** Every generated hook command schema pinned from the rust-v0.147.0 tag. */ +const generatedSchemaNames = Object.freeze( + Object.keys(capabilityTable.validation.pinnedGeneratedComparison.pinnedRepositorySha256).sort(), +); type CodexPluginTermination = 'output-limit' | 'timed-out'; type CodexPluginDiagnosticCode = 'AB6030' | 'AB6031' | 'AB6032' | 'AB6033'; diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 7df65381b..d205236af 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import { expect, it } from '@rstest/core'; +import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; import { TargetRegistry, createDefaultRegistry } from '../src/adapters/registry.ts'; import { createDraft7AdapterValidator } from '../src/adapters/types.ts'; import { sha256Hex } from '../src/core/digest.ts'; @@ -67,7 +68,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'codex')).toEqual({ - adapterRevision: '1.6.0', + adapterRevision: '1.7.0', observedVersion: '0.147.0', schemas: [ { @@ -78,7 +79,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'hooks', revision: '0.147.0', - sha256: 'e42eef736997b9abb8f28b2ee9262f5c7b1f7f11d8289e9c25da8cc94a504eff', + sha256: '175b859eb8e85bd287d85ee840d97c3f5c2d0dda3223507a796d158e3770eeba', }, { name: 'marketplace', @@ -93,7 +94,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'plugin', revision: '0.147.0', - sha256: 'decee14ec76a602701f3c312aee135a0983c1ce95fa89dafc588ebb5c968843b', + sha256: '986bcafa6ef46f9dc4558f05781f53400b3d75533a075068184ba8d43670d4ec', }, ], }); @@ -169,7 +170,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.21.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.22.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { @@ -325,7 +326,134 @@ it('pins and validates the Codex 0.147.0 event wire schemas', async () => { output: {}, sha256: '48355bfcb568259cf396beb6ade2ac32827f50bf6a3c20b395c337dce184cbed', }, + { + input: { + cwd: '/workspace', + hook_event_name: 'PermissionRequest', + model: 'gpt-5.6-sol', + permission_mode: 'default', + session_id: 'session-codex-1', + tool_input: { command: 'rm -rf build', description: null }, + tool_name: 'Bash', + transcript_path: null, + turn_id: 'turn-codex-1', + }, + name: 'permission-request.command.input.schema.json', + sha256: '75c73d7a38cfc0e73ef06bd1fc506a44d25874522069ec4fb85e0bf1e7d6b8fb', + }, + { + name: 'permission-request.command.output.schema.json', + output: { + hookSpecificOutput: { + decision: { behavior: 'deny', message: 'Blocked by repository policy.' }, + hookEventName: 'PermissionRequest', + }, + }, + sha256: '749c73245b4b6d43537c3049f76720ab1c2bd48d7e4752b744b376925b9d57a1', + }, + { + input: { + cwd: '/workspace', + hook_event_name: 'SessionStart', + model: 'gpt-5.6-sol', + permission_mode: 'default', + session_id: 'session-codex-1', + source: 'startup', + transcript_path: null, + }, + name: 'session-start.command.input.schema.json', + sha256: '690c0eef7c9f3ddcd41e24207b81b362101a300b4abec076b990a1cd79a66e20', + }, + { + name: 'session-start.command.output.schema.json', + output: { + hookSpecificOutput: { + additionalContext: 'Load the workspace conventions before editing.', + hookEventName: 'SessionStart', + }, + }, + sha256: 'f375e6de1c59ecbabd8c1aff05a67976d0f3aa2ef061808838de4c7c20be1c71', + }, + { + input: { + cwd: '/workspace', + hook_event_name: 'PreToolUse', + model: 'gpt-5.6-sol', + permission_mode: 'default', + session_id: 'session-codex-1', + tool_input: { command: 'git status' }, + tool_name: 'Bash', + tool_use_id: 'call-codex-1', + transcript_path: null, + turn_id: 'turn-codex-1', + }, + name: 'pre-tool-use.command.input.schema.json', + sha256: 'fabed428f0fe75767c5700208b166da5faef4e031d601dfc8bff2f96d340c682', + }, + { + name: 'pre-tool-use.command.output.schema.json', + output: { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'allow', + updatedInput: { command: 'echo rewritten' }, + }, + }, + sha256: 'e684f81c63fbb5972892f6a848b49fec68c8ce137931651093d2dd1da56a1dd6', + }, + { + input: { + cwd: '/workspace', + hook_event_name: 'PostToolUse', + model: 'gpt-5.6-sol', + permission_mode: 'default', + session_id: 'session-codex-1', + tool_input: { command: 'git status' }, + tool_name: 'Bash', + tool_response: 'On branch main', + tool_use_id: 'call-codex-1', + transcript_path: null, + turn_id: 'turn-codex-1', + }, + name: 'post-tool-use.command.input.schema.json', + sha256: '8ea1e4bccb262fad05b85c300d562d2653c5a64118d6a2c5704468fc4ea836a9', + }, + { + name: 'post-tool-use.command.output.schema.json', + output: { + decision: 'block', + hookSpecificOutput: { + additionalContext: 'The command updated generated files.', + hookEventName: 'PostToolUse', + }, + reason: 'The Bash output needs review before continuing.', + }, + sha256: 'a823d0e2c941e98d7d3af825dfdb0b1dfa6a935696ff8b8529e8e83232a1b0c8', + }, + { + input: { + cwd: '/workspace', + hook_event_name: 'Stop', + last_assistant_message: null, + model: 'gpt-5.6-sol', + permission_mode: 'default', + session_id: 'session-codex-1', + stop_hook_active: false, + transcript_path: null, + turn_id: 'turn-codex-1', + }, + name: 'stop.command.input.schema.json', + sha256: '7db4793c404b5c46b230c27b9507eb1a558fd958689d8715221c5dd81351a06a', + }, + { + name: 'stop.command.output.schema.json', + output: { decision: 'block', reason: 'Run one more pass over the failing tests.' }, + sha256: 'dc2b30e84c97beca5825aa64ca46e1337e402781dc5a9142b67111d10523f15c', + }, ] as const; + expect(schemas.map((schema) => schema.name).sort()).toEqual( + Object.keys(codexCapabilityTable.validation.pinnedGeneratedComparison.pinnedRepositorySha256).sort(), + ); const validator = createDraft7AdapterValidator(); for (const schema of schemas) { diff --git a/packages/agent-bundle/tests/codex-hook-contract.test.ts b/packages/agent-bundle/tests/codex-hook-contract.test.ts new file mode 100644 index 000000000..ce450d5e3 --- /dev/null +++ b/packages/agent-bundle/tests/codex-hook-contract.test.ts @@ -0,0 +1,401 @@ +import { readFile } from 'node:fs/promises'; + +import { expect, it } from '@rstest/core'; + +import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; +import { codexAdapter } from '../src/adapters/codex.ts'; +import { encodeNativeHookPlaygroundOutput } from '../src/adapters/hook-contract.ts'; +import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import hooksSchema from '../src/adapters/schemas/codex/hooks.schema.json' with { type: 'json' }; +import { createAdapterValidator, createDraft7AdapterValidator } from '../src/adapters/types.ts'; +import { validateNativeEventEnvelope } from '../src/events/projection.ts'; +import type { CanonicalAgentEvent } from '../src/routes/public.ts'; +import type { NormalizedPlugin } from '../src/core/types.ts'; + +const contractRows = { + additionalContextLimit: 'hookAdditionalContextLimit', + asyncCommandHooks: 'hookAsyncCommands', + commandWindows: 'hookCommandWindows', + generatedSchemaValidation: 'hookGeneratedSchemas', + handlerCommand: 'hookHandlerCommand', + handlerMcpTool: 'hookHandlerMcpTool', + handlerPromptAgent: 'hookHandlerPromptAgent', + matcherSemantics: 'hookMatcherSemantics', + mcpToolExecution: 'hookMcpToolExecution', + releaseEvents: 'hookReleaseEvents', + statusMessage: 'hookStatusMessage', + timeoutRules: 'hookTimeoutRules', + trustReview: 'hookTrustReview', +} as const; + +const expectedStates: Readonly> = { + additionalContextLimit: 'degraded', + asyncCommandHooks: 'degraded', + commandWindows: 'degraded', + generatedSchemaValidation: 'supported', + handlerCommand: 'supported', + handlerMcpTool: 'degraded', + handlerPromptAgent: 'unavailable', + matcherSemantics: 'supported', + mcpToolExecution: 'unavailable', + releaseEvents: 'supported', + statusMessage: 'degraded', + timeoutRules: 'supported', + trustReview: 'unavailable', +}; + +const releaseEvents = [ + 'PermissionRequest', + 'PostCompact', + 'PostToolUse', + 'PreCompact', + 'PreToolUse', + 'SessionEnd', + 'SessionStart', + 'Stop', + 'SubagentStart', + 'SubagentStop', + 'UserPromptSubmit', +]; + +const plugin: NormalizedPlugin = Object.freeze({ + extensions: Object.freeze({}), + hooks: Object.freeze([]), + marketplace: true as const, + mcpServers: Object.freeze([]), + metadata: Object.freeze({ + description: 'Review code and explain findings.', + id: 'plugin:review-tools', + name: 'review-tools', + provenance: Object.freeze({ kind: 'config' as const, sourcePath: '/workspace/agent-bundle.config.ts' }), + version: '1.2.3', + }), + runtime: Object.freeze({ node: '22.12.0' }), + scripts: Object.freeze([]), + skills: Object.freeze([]), + targets: Object.freeze([ + Object.freeze({ id: 'target:codex', name: 'codex', provenance: Object.freeze({ kind: 'config' as const, sourcePath: '/workspace/agent-bundle.config.ts' }) }), + ]), +}); + +const nativeSource = '/workspace/codex-hooks.json'; + +const withNativeHooks = (document: unknown): NormalizedPlugin => ({ + ...plugin, + nativeHooks: [{ + document: document as Record, + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + source: nativeSource, + target: 'codex', + }], +}); + +const commandGroup = (handler: Readonly> = {}, matcher?: string) => ({ + hooks: [{ command: 'echo codex', type: 'command', ...handler }], + ...(matcher === undefined ? {} : { matcher }), +}); + +const planCodes = (model: NormalizedPlugin): readonly string[] => + codexAdapter.plan(model).diagnostics.map((diagnostic) => diagnostic.code); + +const emittedHooks = (model: NormalizedPlugin): unknown => { + const entry = codexAdapter.plan(model).entries.find((candidate) => candidate.relativePath === 'hooks/hooks.json'); + if (entry?.kind !== 'write') return undefined; + return JSON.parse(entry.content); +}; + +it('records dated four-state Codex hook-contract rows mirrored by the adapter and intersected by the unified bundle', () => { + const registry = createDefaultRegistry(); + const unified = registry.get('plugin'); + const contract = codexCapabilityTable.hooks.contract as Readonly>; + + expect(Object.keys(contract).sort()).toEqual(Object.keys(contractRows).sort()); + for (const [rowName, capability] of Object.entries(contractRows)) { + const row = contract[rowName]!; + const expectedState = expectedStates[rowName as keyof typeof contractRows]; + expect(row.state, rowName).toBe(expectedState); + expect(row.evidence.length, rowName).toBeGreaterThan(0); + expect(row.evidence.every((line) => line.startsWith('retrieved 2026-09-02:')), rowName).toBe(true); + if (expectedState === 'supported') { + expect(row.reason).toBeUndefined(); + expect(codexAdapter.capabilities[capability]).toEqual({ + evidence: { observedVersion: '0.147.0', target: 'codex' }, + state: 'supported', + }); + } else { + expect(row.reason, rowName).toMatch(/\S/u); + expect(codexAdapter.capabilities[capability]).toMatchObject({ + reason: row.reason, + state: expectedState, + ...(expectedState === 'degraded' ? { evidence: { observedVersion: '0.147.0', target: 'codex' } } : {}), + }); + } + expect(registry.supports('codex', capability)).toBe(expectedState === 'supported'); + expect(unified.capabilities[capability]).toMatchObject({ state: 'unavailable' }); + expect(registry.supports('plugin', capability)).toBe(false); + } +}); + +it('pins the eleven release events, keeps Interrupt deferred, and closes the hooks schema to those events', () => { + expect(codexCapabilityTable.hooks.releaseEvents).toEqual(releaseEvents); + expect(Object.keys(hooksSchema.properties.hooks.properties).sort()).toEqual([...releaseEvents].sort()); + expect(hooksSchema.properties.hooks.additionalProperties).toBe(false); + for (const route of Object.values(codexCapabilityTable.hooks.eventRoutes)) { + if (route.state !== 'supported') continue; + expect(releaseEvents).toContain((route as { readonly nativeEvent: string }).nativeEvent); + } + for (const nativeEvent of Object.values(codexCapabilityTable.hooks.events)) { + expect(releaseEvents).toContain(nativeEvent); + } + expect(codexCapabilityTable.deferredNativeEvents.Interrupt).toMatchObject({ + reason: expect.stringMatching(/2026-09-02.*rust-v0\.147\.0.*no interrupt/su), + state: 'unavailable', + }); + expect(releaseEvents).not.toContain('Interrupt'); + expect(Object.keys(codexCapabilityTable.hooks.contract.generatedSchemaValidation.schemas).sort()).toEqual([...releaseEvents].sort()); +}); + +it('admits every documented command and mcp_tool handler field and rejects skipped handler types in the pinned hooks schema', () => { + const validate = createAdapterValidator().compile(hooksSchema); + const full = { + description: 'Documented handler surface.', + hooks: { + PostToolUse: [{ + hooks: [ + { + additionalContextLimit: 5000, + async: true, + command: 'python3 ${PLUGIN_ROOT}/hooks/post.py', + commandWindows: 'py -3 %PLUGIN_ROOT%\\hooks\\post.py', + statusMessage: 'Reviewing Bash output', + timeout: 120, + type: 'command', + }, + { + input: { patch: '${tool_input.command}' }, + server: 'scanner', + statusMessage: 'Scanning edited files', + timeout: 30, + tool: 'scan_patch', + type: 'mcp_tool', + }, + ], + matcher: 'Write|Edit', + }], + SessionStart: [{ hooks: [{ additionalContextLimit: 0, command: 'echo start', type: 'command' }], matcher: 'startup|resume' }], + }, + }; + expect(validate(full), JSON.stringify(validate.errors)).toBe(true); + + const rejected = [ + { hooks: { Stop: [{ hooks: [{ prompt: 'Summarize.', type: 'prompt' }] }] } }, + { hooks: { Stop: [{ hooks: [{ agent: 'reviewer', type: 'agent' }] }] } }, + { hooks: { Interrupt: [{ hooks: [{ command: 'echo interrupted', type: 'command' }] }] } }, + { hooks: { Notification: [{ hooks: [{ command: 'echo notify', type: 'command' }] }] } }, + { hooks: { Stop: [{ hooks: [{ server: 'scanner', type: 'mcp_tool' }] }] } }, + { hooks: { Stop: [{ hooks: [{ command: 'echo', type: 'command', unknown: true }] }] } }, + { hooks: { Stop: [{ hooks: [{ command: 'echo', type: 'command', additionalContextLimit: -1 }] }] } }, + { hooks: { Stop: [{ hooks: [{ async: true, server: 'scanner', tool: 'scan', type: 'mcp_tool' }] }] } }, + ]; + for (const document of rejected) { + expect(validate(document), JSON.stringify(document)).toBe(false); + } +}); + +it('names deferred, unknown, and skipped native hook surfaces before schema validation', () => { + expect(planCodes(withNativeHooks({ + hooks: { Interrupt: [commandGroup()] }, + }))).toEqual(['codex.native-hooks.event.deferred']); + expect(planCodes(withNativeHooks({ + hooks: { Notification: [commandGroup()] }, + }))).toEqual(['codex.native-hooks.event.unknown']); + expect(planCodes(withNativeHooks({ + hooks: { + Stop: [{ hooks: [{ prompt: 'Summarize the turn.', type: 'prompt' }, { agent: 'reviewer', type: 'agent' }] }], + }, + }))).toEqual(['codex.native-hooks.handler.skipped', 'codex.native-hooks.handler.skipped']); + const plan = codexAdapter.plan(withNativeHooks({ hooks: { Interrupt: [commandGroup()] } })); + expect(plan.diagnostics[0]).toMatchObject({ + message: expect.stringContaining('rust-v0.147.0'), + recovery: expect.stringContaining('Remove the Interrupt group'), + severity: 'error', + target: 'codex', + }); + expect(plan.entries.some((entry) => entry.relativePath === 'hooks/hooks.json')).toBe(false); +}); + +it('rejects handler fields the Codex host would ignore or refuse for the event', () => { + expect(planCodes(withNativeHooks({ + hooks: { SessionEnd: [{ hooks: [{ server: 'scanner', tool: 'flush', type: 'mcp_tool' }] }] }, + }))).toEqual(['codex.hooks.session-end.mcp-tool']); + expect(planCodes(withNativeHooks({ + hooks: { SessionEnd: [commandGroup({ async: true })] }, + }))).toEqual(['codex.hooks.session-end.async']); + expect(planCodes(withNativeHooks({ + hooks: { SessionEnd: [commandGroup({ timeout: 4 })] }, + }))).toEqual(['codex.hooks.session-end.timeout']); + expect(planCodes(withNativeHooks({ + hooks: { SessionEnd: [commandGroup({ timeout: 3 })] }, + }))).toEqual([]); + expect(planCodes(withNativeHooks({ + hooks: { + PreCompact: [commandGroup({ additionalContextLimit: 5000 })], + Stop: [commandGroup({ additionalContextLimit: 0 })], + }, + }))).toEqual(['codex.hooks.additional-context-limit.event', 'codex.hooks.additional-context-limit.event']); + expect(planCodes(withNativeHooks({ + hooks: { + Stop: [commandGroup({}, 'Bash')], + UserPromptSubmit: [commandGroup({}, '.*')], + }, + }))).toEqual(['codex.hooks.matcher.ignored', 'codex.hooks.matcher.ignored']); + const rejected = codexAdapter.plan(withNativeHooks({ hooks: { SessionEnd: [commandGroup({ timeout: 30 })] } })); + expect(rejected.diagnostics[0]).toMatchObject({ + message: expect.stringContaining('at most 3 seconds'), + recovery: expect.stringContaining('3 seconds or less'), + }); + expect(rejected.entries.some((entry) => entry.relativePath === 'hooks/hooks.json')).toBe(false); +}); + +it('emits a native document that uses every documented handler field unchanged', () => { + const document = { + description: 'Documented handler surface.', + hooks: { + PostToolUse: [{ + hooks: [ + { + additionalContextLimit: 5000, + async: true, + command: 'python3 ${PLUGIN_ROOT}/hooks/post.py', + commandWindows: 'py -3 %PLUGIN_ROOT%\\hooks\\post.py', + statusMessage: 'Reviewing Bash output', + timeout: 120, + type: 'command', + }, + { + input: { patch: '${tool_input.command}' }, + server: 'scanner', + statusMessage: 'Scanning edited files', + timeout: 30, + tool: 'scan_patch', + type: 'mcp_tool', + }, + ], + matcher: 'Write|Edit', + }], + SessionEnd: [commandGroup({ timeout: 3 }, 'other')], + SessionStart: [commandGroup({ additionalContextLimit: 0 }, 'startup|resume|clear|compact')], + UserPromptSubmit: [commandGroup()], + }, + }; + const model = withNativeHooks(document); + expect(planCodes(model)).toEqual([]); + expect(emittedHooks(model)).toEqual(document); +}); + +it('rejects codex-scoped native selectors that name the documented hosted tool', () => { + const hook = { + event: 'beforeTool' as const, + id: 'hook:before-tool:search', + name: 'search', + nativeTools: [{ name: 'WebSearch', target: 'codex' }], + provenance: { kind: 'config' as const, sourcePath: '/workspace/agent-bundle.config.ts' }, + source: '/workspace/src/hooks/search.ts', + targets: ['codex'], + tools: [], + }; + const codes = planCodes({ ...plugin, hooks: [hook] }); + expect(codes).toContain('codex.hook.tool.hosted'); + expect(planCodes({ + ...plugin, + hooks: [{ ...hook, nativeTools: [{ name: 'update_plan', target: 'codex' }] }], + })).toEqual([]); + expect(codexCapabilityTable.hooks.contract.matcherSemantics).toMatchObject({ + applyPatchAliases: ['Edit', 'Write'], + hostedToolExclusions: ['WebSearch'], + ignoredMatcherEvents: ['UserPromptSubmit', 'Stop'], + }); + expect(codexCapabilityTable.hooks.matchers['file.write']).toBe('^(?:apply_patch|Edit|Write)$'); +}); + +const outputSamples: Readonly> | undefined>> = { + PermissionRequest: undefined, + PostCompact: undefined, + PostToolUse: { additionalContext: 'The command updated generated files.', outcome: 'continue' }, + PreCompact: undefined, + PreToolUse: { outcome: 'deny', reason: 'Destructive command blocked by hook.' }, + SessionStart: { additionalContext: 'Load the workspace conventions before editing.', outcome: 'continue' }, + Stop: { outcome: 'deny', reason: 'Run one more pass over the failing tests.' }, + SubagentStart: { additionalContext: 'Review the repository test conventions first.', outcome: 'continue' }, + SubagentStop: { outcome: 'deny', reason: 'Run one more focused pass inside the subagent.' }, + UserPromptSubmit: { additionalContext: 'Ask for a clearer reproduction before editing files.', outcome: 'continue' }, +}; + +const canonicalHookEventFor: Readonly> = { + PostToolUse: 'afterTool', + PreToolUse: 'beforeTool', + SessionStart: 'sessionStart', + Stop: 'stop', + SubagentStart: 'agentStart', + SubagentStop: 'agentStop', + UserPromptSubmit: 'promptSubmit', +}; + +it('validates every Codex lifecycle-replay starter and codec output against the pinned generated wire schemas', async () => { + const validator = createDraft7AdapterValidator(); + const schemasFor = codexCapabilityTable.hooks.contract.generatedSchemaValidation.schemas as Readonly< + Record + >; + const schemaRoot = new URL('../src/adapters/schemas/codex/generated/', import.meta.url); + const compile = async (name: string) => validator.compile(JSON.parse(await readFile(new URL(name, schemaRoot), 'utf8'))); + const routes = codexCapabilityTable.hooks.eventRoutes as Readonly>; + const covered = new Set(); + + for (const [event, route] of Object.entries(routes)) { + if (route.state !== 'supported' || route.nativeEvent === undefined) continue; + const canonicalEvent = event as CanonicalAgentEvent; + const starter = codexAdapter.hookContract!.nativeEventStarter!(canonicalEvent); + expect(starter, event).toBeDefined(); + const schemas = schemasFor[route.nativeEvent]!; + const validateInput = await compile(schemas.input); + expect(validateInput(starter), `${event} ${JSON.stringify(validateInput.errors)}`).toBe(true); + expect(() => validateNativeEventEnvelope(starter, { + canonicalEvent, + nativeEvent: route.nativeEvent!, + target: 'codex', + })).not.toThrow(); + covered.add(route.nativeEvent); + + const canonicalHookEvent = canonicalHookEventFor[route.nativeEvent]; + const sample = outputSamples[route.nativeEvent]; + if (schemas.output === undefined || canonicalHookEvent === undefined || sample === undefined) continue; + const validateOutput = await compile(schemas.output); + const encoded = encodeNativeHookPlaygroundOutput(sample, canonicalHookEvent as never, route.nativeEvent, 'codex'); + expect(encoded, event).toBeDefined(); + expect(validateOutput(encoded), `${event} ${JSON.stringify(validateOutput.errors)}`).toBe(true); + } + expect([...covered].sort()).toEqual([...releaseEvents].sort()); +}); + +it('accepts a null last_assistant_message on Codex Stop as the pinned stop input schema does', () => { + const stop = { + cwd: '/workspace', + hook_event_name: 'Stop', + last_assistant_message: null, + model: 'gpt-5.6-sol', + permission_mode: 'default', + session_id: 'session-codex-1', + stop_hook_active: false, + transcript_path: null, + turn_id: 'turn-codex-1', + }; + expect(validateNativeEventEnvelope(stop, { canonicalEvent: 'stop', nativeEvent: 'Stop', target: 'codex' })).toBe(stop); + expect(() => validateNativeEventEnvelope( + { ...stop, transcript_path: '/tmp/transcript.jsonl' }, + { canonicalEvent: 'stop', nativeEvent: 'Stop', target: 'claude' }, + )).toThrow(/last_assistant_message must be a (?:nonempty )?string/u); +}); diff --git a/packages/agent-bundle/tests/codex-plugin-validation.test.ts b/packages/agent-bundle/tests/codex-plugin-validation.test.ts index 8364fdffe..4a18a6b36 100644 --- a/packages/agent-bundle/tests/codex-plugin-validation.test.ts +++ b/packages/agent-bundle/tests/codex-plugin-validation.test.ts @@ -4,17 +4,15 @@ import { dirname, join } from 'node:path'; import { expect, it } from '@rstest/core'; +import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; import { validateCodexPlugin, type CodexPluginCommandRunner, } from '../src/host-contracts/codex-plugin-validation.ts'; -const generatedSchemaNames = Object.freeze([ - 'subagent-start.command.input.schema.json', - 'subagent-start.command.output.schema.json', - 'subagent-stop.command.input.schema.json', - 'subagent-stop.command.output.schema.json', -]); +const generatedSchemaNames = Object.freeze( + Object.keys(codexCapabilityTable.validation.pinnedGeneratedComparison.pinnedRepositorySha256).sort(), +); const validDocuments = Object.freeze({ '.agents/plugins/marketplace.json': {