From a208a21f407ed5b4843b4ca5559b1d78f0eab393 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 25 Aug 2026 13:15:08 +1000 Subject: [PATCH] fix(ai): accept bounded opaque web-search results --- Cargo.lock | 2 +- crates/graphql-orm-ai/CHANGELOG.md | 22 ++ crates/graphql-orm-ai/Cargo.toml | 2 +- crates/graphql-orm-ai/MIGRATION.md | 14 ++ crates/graphql-orm-ai/README.md | 14 +- .../docs/implementation-status.md | 9 +- .../src/providers/codex_app_server.rs | 230 +++++++++++++----- docs/reference/workspace-packages.md | 2 +- 8 files changed, 219 insertions(+), 76 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34e52a4..94a5c79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.95.0" +version = "0.95.1" dependencies = [ "agql-auth", "async-graphql", diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 3021c08..921bb47 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -18,6 +18,28 @@ checkpoint facts. For the current workspace baseline and active gates, use the [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## [0.95.1] - 2026-08-25 + +Persistent schema module: **0.63.0** (unchanged from 0.95.0). + +### Fixed + +- The Codex app-server web-search lifecycle now follows the generated 0.148.0 + action contract: non-search completions may carry an empty query, bounded + extension fields no longer reject recognized text results, and opaque future + result types are accepted without being exposed as structured host metadata. + +### Security + +- The complete opaque result array remains count- and byte-bounded. Recognized + text results still require bounded fields, a valid reference, an HTTPS URL, + matching normalized host, and the active domain policy before exposure. + Strict frame correlation, lifecycle pairing, and per-turn call ceilings are + unchanged. + +There is no database, data, table, column, index, constraint, backfill, +protected-payload, GraphQL SDL, backup, or restore migration. + ## [0.95.0] - 2026-08-25 Persistent schema module: **0.63.0** (unchanged from 0.94.0). diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index 7e1eca2..7859ad2 100644 --- a/crates/graphql-orm-ai/Cargo.toml +++ b/crates/graphql-orm-ai/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-ai" -version = "0.95.0" +version = "0.95.1" edition = "2024" authors = ["Toby Martin "] description = "Project-agnostic AI agent runtime for graphql-orm applications" diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index b37af6c..09681a6 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,6 +19,20 @@ they describe. For the current workspace baseline and active delivery gates, use [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## 0.95.0 to 0.95.1: forward-compatible Codex web-search completion + +Adopt `graphql-orm-ai` 0.95.1 from one reviewed full monorepo revision. Hosts +do not need to change their API implementation. A completed non-search action +may now expose `Some("")` through the existing optional query field. The +structured result vector contains only recognized, fully validated text-result +metadata; opaque future result types and unexposed extension fields are +bounded but do not cross that typed boundary. + +Search authority, egress proof, domain policy, call ceilings, strict lifecycle +correlation, and recognized-result URL/domain validation are unchanged. The AI +schema module remains **0.63.0**; there is no database, data, GraphQL SDL, +protected-payload, backup, or restore migration. + ## 0.94.0 to 0.95.0: retained dynamic tools with governed built-ins Adopt `graphql-orm-ai` 0.95.0 from one reviewed full monorepo revision. Hosts diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index 5b2efa5..9ee9fc4 100644 --- a/crates/graphql-orm-ai/README.md +++ b/crates/graphql-orm-ai/README.md @@ -28,7 +28,7 @@ for AI, ORM, storage, backup, and tool-profile packages: ```toml [dependencies] -graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.95.0", default-features = false, features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.95.1", default-features = false, features = ["sqlite"] } ``` Exactly one persistence backend is required: `sqlite` (default), `postgres`, @@ -165,9 +165,15 @@ call ceiling. Its other sole process-level exception is `code_mode_host`: `dynamicToolCall` delivery on that Codex version, so the launch arguments omit only that flag while the per-thread configuration still sets the feature false. When native search is enabled, the actor admits only its exact bounded -item lifecycle and exposes structured result metadata for host accounting and -audit; results enter model context before that completion event. Reverify both -direct delivery and the negative native-item matrix before upgrading Codex. +item lifecycle and exposes recognized, validated structured result metadata +for host accounting and audit; results enter model context before that +completion event. Completion queries follow the action contract, so non-search +actions may report an empty query. The provider's opaque result array is +bounded as a whole; recognized text results retain strict HTTPS, +normalized-domain, allow-domain, reference, and field-size validation, while +unrecognized result types and unexposed extension fields do not become host +metadata. Reverify both direct delivery and the negative native-item matrix +before upgrading Codex. The Codex schema projector preserves bounded nullable scalar `type` arrays in the crate-authored FixedBroker definitions. It does not pass through arbitrary diff --git a/crates/graphql-orm-ai/docs/implementation-status.md b/crates/graphql-orm-ai/docs/implementation-status.md index 4862b7b..052e3fd 100644 --- a/crates/graphql-orm-ai/docs/implementation-status.md +++ b/crates/graphql-orm-ai/docs/implementation-status.md @@ -10,7 +10,7 @@ supersedes: [] # Implementation Status -`graphql-orm-ai` is at crate version `0.95.0` with AI schema module +`graphql-orm-ai` is at crate version `0.95.1` with AI schema module `0.63.0`. It uses workspace `graphql-orm` `0.26.0`, backend-neutral `graphql-orm-ai-tool-profiles` `0.10.0`, and external `agql-auth` `0.18.0` at `527d15d28e3c295a6f6b5e6d74559a7aecdc1322`. @@ -47,8 +47,11 @@ verification evidence belongs in the focused guides. dynamic-tools-only launch profile, a direct-tool model declaration, and a process-factory profile attestation; every exact call rechecks current rules and uses the ordinary registered GraphQL tool, - disclosure, egress, budget, and resolver authorization path. Generic - protocol bridging, shell, files, hosted web, MCP, and browser remain closed. + disclosure, egress, budget, and resolver authorization path. Native web + search is separately default-off and requires exact request capability, + egress, domain policy, and call ceilings; its opaque result envelope is + bounded while only validated structured metadata crosses the host boundary. + Generic protocol bridging, shell, files, MCP, and browser remain closed. Initialization uses one library-owned notification opt-out profile, while response-authoritative deletion, empty reasoning lifecycle, and retained cumulative-usage replay are admitted only through typed content-free diff --git a/crates/graphql-orm-ai/src/providers/codex_app_server.rs b/crates/graphql-orm-ai/src/providers/codex_app_server.rs index 4ae99a1..f34c1a8 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -3227,9 +3227,11 @@ pub enum AiCodexAppServerInbound { call_id: String, /// Provider-authored action, available on completion when reported. action: Option, - /// Bounded provider-authored query, absent for an empty start frame. + /// Bounded provider-authored query, absent on start and possibly empty + /// on completion for non-search actions. query: Option, - /// Validated structured search results, empty on start or no matches. + /// Validated structured search results, empty on start, no matches, or + /// when the provider reports only opaque forward-compatible results. results: Vec, /// Whether this is the terminal completion lifecycle. completed: bool, @@ -4536,11 +4538,7 @@ impl AiCodexAppServerProtocolActor { let query = item .get("query") .and_then(Value::as_str) - .filter(|query| { - !query.trim().is_empty() - && query.len() <= MAXIMUM_WEB_SEARCH_FIELD_BYTES - && !query.contains('\0') - }) + .filter(|query| query.len() <= MAXIMUM_WEB_SEARCH_FIELD_BYTES && !query.contains('\0')) .ok_or(ProviderError::Rejected)? .to_owned(); let domain_policy = self @@ -5113,10 +5111,11 @@ fn parse_web_search_action( return Err(ProviderError::Rejected); } let query = bounded_optional_web_search_string(object.get("query"))?; - if query - .as_deref() - .is_some_and(|query| query != completed_query) - { + if query.as_deref().is_some_and(|query| { + !query.trim().is_empty() + && !completed_query.trim().is_empty() + && query != completed_query + }) { return Err(ProviderError::Rejected); } let queries = match object.get("queries") { @@ -5131,6 +5130,12 @@ fn parse_web_search_action( .collect::, _>>()?, _ => return Err(ProviderError::Rejected), }; + if completed_query.trim().is_empty() + && query.as_deref().is_none_or(|query| query.trim().is_empty()) + && queries.is_empty() + { + return Err(ProviderError::Rejected); + } AiCodexAppServerWebSearchAction::Search { query, queries } } "openPage" => { @@ -5196,61 +5201,60 @@ fn parse_web_search_results( Some(Value::Array(values)) if values.len() <= MAXIMUM_WEB_SEARCH_RESULTS_PER_CALL => values, _ => return Err(ProviderError::Rejected), }; - let mut total_bytes = 0_usize; - values - .iter() - .map(|value| { - let object = value.as_object().ok_or(ProviderError::Rejected)?; - if object.keys().any(|key| { - !matches!( - key.as_str(), - "domain" | "ref_id" | "snippet" | "title" | "type" | "url" - ) - }) || object.get("type").and_then(Value::as_str) != Some("text_result") - { - return Err(ProviderError::Rejected); - } - let required = |key: &str| { - object - .get(key) - .and_then(Value::as_str) - .filter(|value| { - value.len() <= MAXIMUM_WEB_SEARCH_FIELD_BYTES && !value.contains('\0') - }) - .ok_or(ProviderError::Rejected) - }; - let domain = required("domain")?; - if domain != domain.to_ascii_lowercase() - || !crate::provider::valid_web_domain(domain) - || !web_search_domain_allowed(domain, domain_policy) - { - return Err(ProviderError::Rejected); - } - let url = required("url")?; - validate_web_search_url(url, Some(domain), domain_policy)?; - let title = required("title")?; - let snippet = required("snippet")?; - let reference_id = required("ref_id")?; - if !valid_reference(reference_id) { - return Err(ProviderError::Rejected); - } - for field in [domain, url, title, snippet, reference_id] { - total_bytes = total_bytes - .checked_add(field.len()) - .ok_or(ProviderError::Rejected)?; - } - if total_bytes > MAXIMUM_WEB_SEARCH_RESULT_BYTES_PER_CALL { - return Err(ProviderError::Rejected); - } - Ok(AiCodexAppServerWebSearchResult { - domain: domain.to_owned(), - url: url.to_owned(), - title: title.to_owned(), - snippet: snippet.to_owned(), - reference_id: reference_id.to_owned(), - }) - }) - .collect() + // Codex deliberately keeps this array opaque at the app-server boundary + // so result fields and result kinds can evolve independently. Bound the + // complete wire payload before selecting the stable metadata subset that + // this adapter is prepared to expose. + if serde_json::to_vec(values) + .map_err(|_| ProviderError::Rejected)? + .len() + > MAXIMUM_WEB_SEARCH_RESULT_BYTES_PER_CALL + { + return Err(ProviderError::Rejected); + } + let mut results = Vec::new(); + for value in values { + let Some(object) = value.as_object() else { + continue; + }; + // Unknown opaque entries remain available to the provider's own model + // context but never become trusted or typed host metadata. + if object.get("type").and_then(Value::as_str) != Some("text_result") { + continue; + } + let required = |key: &str| { + object + .get(key) + .and_then(Value::as_str) + .filter(|value| { + value.len() <= MAXIMUM_WEB_SEARCH_FIELD_BYTES && !value.contains('\0') + }) + .ok_or(ProviderError::Rejected) + }; + let domain = required("domain")?; + if domain != domain.to_ascii_lowercase() + || !crate::provider::valid_web_domain(domain) + || !web_search_domain_allowed(domain, domain_policy) + { + return Err(ProviderError::Rejected); + } + let url = required("url")?; + validate_web_search_url(url, Some(domain), domain_policy)?; + let title = required("title")?; + let snippet = required("snippet")?; + let reference_id = required("ref_id")?; + if !valid_reference(reference_id) { + return Err(ProviderError::Rejected); + } + results.push(AiCodexAppServerWebSearchResult { + domain: domain.to_owned(), + url: url.to_owned(), + title: title.to_owned(), + snippet: snippet.to_owned(), + reference_id: reference_id.to_owned(), + }); + } + Ok(results) } fn validate_web_search_url( @@ -11160,6 +11164,100 @@ pub(crate) mod tests { )); } + #[test] + fn native_web_search_admits_empty_other_and_opaque_result_extensions() { + let policy = ModelWebSearchDomainPolicy::allowed_domains(vec!["example.com".to_owned()]) + .expect("allow-domain policy should validate"); + let mut actor = active_web_search_protocol_actor(policy, 1); + actor + .accept(&lifecycle_notification( + "item/started", + json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "startedAtMs": 1, + "item": {"action": null, "id": "search-1", "query": "", "results": null, "type": "webSearch"} + }), + )) + .expect("web-search start should be admitted"); + let completed = actor + .accept(&lifecycle_notification( + "item/completed", + json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "completedAtMs": 2, + "item": { + "action": {"type": "other"}, + "id": "search-1", + "query": "", + "results": [ + { + "domain": "www.example.com", + "ref_id": "turn0search0", + "snippet": "Example result", + "thumbnail_url": "https://www.example.com/thumbnail.png", + "title": "Example Domain", + "type": "text_result", + "url": "https://www.example.com/" + }, + { + "payload": {"future": true}, + "type": "future_result" + }, + true + ], + "type": "webSearch" + } + }), + )) + .expect("schema-compatible opaque result extensions should be admitted"); + match completed { + AiCodexAppServerInbound::WebSearchLifecycle { + action, + query, + results, + completed, + .. + } => { + assert_eq!(action, Some(AiCodexAppServerWebSearchAction::Other)); + assert_eq!(query.as_deref(), Some("")); + assert!(completed); + assert_eq!(results.len(), 1); + assert_eq!(results[0].domain(), "www.example.com"); + } + other => panic!("unexpected inbound: {other:?}"), + } + } + + #[test] + fn native_web_search_bounds_opaque_results_and_requires_search_terms() { + let oversized = "x".repeat(MAXIMUM_WEB_SEARCH_RESULT_BYTES_PER_CALL); + assert!(matches!( + parse_web_search_results( + Some(&json!([{"payload": oversized, "type": "future_result"}])), + &ModelWebSearchDomainPolicy::PublicWeb, + ), + Err(ProviderError::Rejected) + )); + assert!(matches!( + parse_web_search_action( + Some(&json!({"query": "", "queries": [], "type": "search"})), + "", + &ModelWebSearchDomainPolicy::PublicWeb, + ), + Err(ProviderError::Rejected) + )); + assert!(matches!( + parse_web_search_action( + Some(&json!({"queries": ["example.com"], "type": "search"})), + "", + &ModelWebSearchDomainPolicy::PublicWeb, + ), + Ok(Some(AiCodexAppServerWebSearchAction::Search { .. })) + )); + } + #[test] fn native_web_search_rejects_results_outside_the_active_allow_domains() { let policy = ModelWebSearchDomainPolicy::allowed_domains(vec!["example.com".to_owned()]) diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 4294d21..0443be0 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -19,7 +19,7 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | | `graphql-orm` | `0.26.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.95.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai` | `0.95.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.10.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.1` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | | `graphql-orm-macros` | `0.26.0` | `crates/graphql-orm-macros` | `sqlite` | none |