Add browser extension request headers to config requirements (#51194)

## What changed

Support `browser_use.extension.request_headers` as a list of `name`/`value` pairs and expose it through `config/requirements/read` as `browserUse.extension.requestHeaders`. Update the generated JSON schemas, TypeScript types, and Python models.

Preserve header values, including template strings, verbatim. Higher-priority requirement layers replace the entire header list, including with an explicit empty list. Redact header values in Rust debug output while retaining them in API serialization.

## Testing

Add coverage for parsing and malformed entries, extension scoping, empty-list overrides, API mapping and read responses, and debug redaction without changing serialized values.

GitOrigin-RevId: 02299e82a91aa863966d0277091bc5e8cc1ebe07
This commit is contained in:
John Nastos
2026-10-05 22:52:14 +00:00
committed by copyberry
parent b4e3726d73
commit a6c5f3a9fa
22 changed files with 407 additions and 19 deletions
@@ -8192,6 +8192,20 @@
},
"type": "object"
},
"BrowserUseExtensionRequirements": {
"properties": {
"requestHeaders": {
"items": {
"$ref": "#/definitions/v2/RequestHeader"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
},
"BrowserUseOriginPolicy": {
"properties": {
"access": {
@@ -8344,6 +8358,16 @@
"null"
]
},
"extension": {
"anyOf": [
{
"$ref": "#/definitions/v2/BrowserUseExtensionRequirements"
},
{
"type": "null"
}
]
},
"origins": {
"additionalProperties": {
"$ref": "#/definitions/v2/BrowserUseOriginPolicy"
@@ -18521,6 +18545,21 @@
"title": "RemoteControlStatusChangedNotification",
"type": "object"
},
"RequestHeader": {
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": [
"name",
"value"
],
"type": "object"
},
"RequestId": {
"anyOf": [
{
@@ -1471,6 +1471,20 @@
},
"type": "object"
},
"BrowserUseExtensionRequirements": {
"properties": {
"requestHeaders": {
"items": {
"$ref": "#/definitions/RequestHeader"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
},
"BrowserUseOriginPolicy": {
"properties": {
"access": {
@@ -1623,6 +1637,16 @@
"null"
]
},
"extension": {
"anyOf": [
{
"$ref": "#/definitions/BrowserUseExtensionRequirements"
},
{
"type": "null"
}
]
},
"origins": {
"additionalProperties": {
"$ref": "#/definitions/BrowserUseOriginPolicy"
@@ -14533,6 +14557,21 @@
"title": "RemoteControlStatusChangedNotification",
"type": "object"
},
"RequestHeader": {
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": [
"name",
"value"
],
"type": "object"
},
"RequestId": {
"anyOf": [
{
@@ -130,6 +130,20 @@
],
"type": "string"
},
"BrowserUseExtensionRequirements": {
"properties": {
"requestHeaders": {
"items": {
"$ref": "#/definitions/RequestHeader"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
},
"BrowserUseOriginPolicy": {
"properties": {
"access": {
@@ -237,6 +251,16 @@
"null"
]
},
"extension": {
"anyOf": [
{
"$ref": "#/definitions/BrowserUseExtensionRequirements"
},
{
"type": "null"
}
]
},
"origins": {
"additionalProperties": {
"$ref": "#/definitions/BrowserUseOriginPolicy"
@@ -1056,6 +1080,21 @@
"minLength": 1,
"type": "string"
},
"RequestHeader": {
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": [
"name",
"value"
],
"type": "object"
},
"ResidencyRequirement": {
"enum": [
"us"
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RequestHeader } from "./RequestHeader";
export type BrowserUseExtensionRequirements = { requestHeaders: Array<RequestHeader> | null, };
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BrowserUseExtensionRequirements } from "./BrowserUseExtensionRequirements";
import type { BrowserUseOriginPolicy } from "./BrowserUseOriginPolicy";
export type BrowserUseRequirements = { allowWebmcp: boolean | null, allowHistoryAccess: boolean | null, disableAutoReview: boolean | null, allowGlobalPersistentApproval: boolean | null, defaultOriginPolicy: BrowserUseOriginPolicy | null, origins: { [key in string]?: BrowserUseOriginPolicy } | null, };
export type BrowserUseRequirements = { extension: BrowserUseExtensionRequirements | null, allowWebmcp: boolean | null, allowHistoryAccess: boolean | null, disableAutoReview: boolean | null, allowGlobalPersistentApproval: boolean | null, defaultOriginPolicy: BrowserUseOriginPolicy | null, origins: { [key in string]?: BrowserUseOriginPolicy } | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RequestHeader = { name: string, value: string, };
@@ -52,6 +52,7 @@ export type { AutoReviewDecisionSource } from "./AutoReviewDecisionSource";
export type { AutoReviewRequirements } from "./AutoReviewRequirements";
export type { BrowserUseAccessApprovalLifetime } from "./BrowserUseAccessApprovalLifetime";
export type { BrowserUseConfig } from "./BrowserUseConfig";
export type { BrowserUseExtensionRequirements } from "./BrowserUseExtensionRequirements";
export type { BrowserUseOriginPolicy } from "./BrowserUseOriginPolicy";
export type { BrowserUseOriginPolicyConfig } from "./BrowserUseOriginPolicyConfig";
export type { BrowserUseRequirements } from "./BrowserUseRequirements";
@@ -427,6 +428,7 @@ export type { RemoteControlConnectionStatus } from "./RemoteControlConnectionSta
export type { RemoteControlDisableParams } from "./RemoteControlDisableParams";
export type { RemoteControlEnableParams } from "./RemoteControlEnableParams";
export type { RemoteControlStatusChangedNotification } from "./RemoteControlStatusChangedNotification";
export type { RequestHeader } from "./RequestHeader";
export type { RequestPermissionProfile } from "./RequestPermissionProfile";
export type { ResidencyRequirement } from "./ResidencyRequirement";
export type { ResponseUsageMetadata } from "./ResponseUsageMetadata";
@@ -514,6 +514,7 @@ pub struct ComputerUseRequirements {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct BrowserUseRequirements {
pub extension: Option<BrowserUseExtensionRequirements>,
pub allow_webmcp: Option<bool>,
pub allow_history_access: Option<bool>,
pub disable_auto_review: Option<bool>,
@@ -522,6 +523,30 @@ pub struct BrowserUseRequirements {
pub origins: Option<BTreeMap<String, BrowserUseOriginPolicy>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct BrowserUseExtensionRequirements {
pub request_headers: Option<Vec<RequestHeader>>,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RequestHeader {
pub name: String,
pub value: String,
}
impl std::fmt::Debug for RequestHeader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RequestHeader")
.field("name", &self.name)
.field("value", &"[REDACTED]")
.finish()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1141,3 +1166,7 @@ pub struct ConfigWarningNotification {
#[ts(optional)]
pub range: Option<TextRange>,
}
#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;
@@ -0,0 +1,28 @@
use super::BrowserUseRequirements;
use super::RequestHeader;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn request_header_debug_redacts_values_without_changing_serialization() {
let wire = json!({ "name": "x-company-token", "value": "private-header-token" });
let header: RequestHeader = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(serde_json::to_value(&header).unwrap(), wire);
assert_eq!(
format!("{header:?}"),
r#"RequestHeader { name: "x-company-token", value: "[REDACTED]" }"#
);
let requirements: BrowserUseRequirements = serde_json::from_value(json!({
"extension": { "requestHeaders": [wire] }
}))
.unwrap();
for debug in [format!("{requirements:?}"), format!("{requirements:#?}")] {
assert!(!debug.contains("private-header-token"));
assert!(debug.contains("x-company-token"));
assert!(debug.contains("[REDACTED]"));
}
assert_eq!(
serde_json::to_value(&requirements).unwrap()["extension"]["requestHeaders"],
json!([wire])
);
}
@@ -626,6 +626,19 @@ fn map_browser_use_requirements_to_api(
browser_use: codex_config::BrowserUseRequirementsToml,
) -> BrowserUseRequirements {
BrowserUseRequirements {
extension: browser_use.extension.map(|extension| {
codex_app_server_protocol::BrowserUseExtensionRequirements {
request_headers: extension.request_headers.map(|headers| {
headers
.into_iter()
.map(|header| codex_app_server_protocol::RequestHeader {
name: header.name,
value: header.value,
})
.collect()
}),
}
}),
allow_webmcp: browser_use.allow_webmcp,
allow_history_access: browser_use.allow_history_access,
disable_auto_review: browser_use.disable_auto_review,
@@ -1096,6 +1109,12 @@ client_id = "mcp-client"
let mapped = map_test_requirements(ConfigRequirementsToml {
allow_browser_and_computer_use: Some(false),
browser_use: Some(BrowserUseRequirementsToml {
extension: Some(codex_config::BrowserUseExtensionRequirementsToml {
request_headers: Some(vec![codex_config::RequestHeaderToml {
name: "x-browser-agent".into(),
value: "ChatGPT/{{session_id}}".into(),
}]),
}),
allow_webmcp: Some(true),
allow_history_access: Some(false),
disable_auto_review: Some(true),
@@ -1154,6 +1173,12 @@ client_id = "mcp-client"
assert_eq!(
mapped.browser_use,
Some(BrowserUseRequirements {
extension: Some(codex_app_server_protocol::BrowserUseExtensionRequirements {
request_headers: Some(vec![codex_app_server_protocol::RequestHeader {
name: "x-browser-agent".into(),
value: "ChatGPT/{{session_id}}".into()
}]),
}),
allow_webmcp: Some(true),
allow_history_access: Some(false),
disable_auto_review: Some(true),
@@ -57,6 +57,7 @@ async fn config_requirements_read_preserves_webmcp_policy() -> Result<()> {
json!({
"allowWebmcp": allow_webmcp,
"allowHistoryAccess": allow_history_access,
"extension": null,
"disableAutoReview": null,
"allowGlobalPersistentApproval": null,
"defaultOriginPolicy": null,
@@ -106,6 +106,7 @@ async fn import_policy_is_separate_from_browser_feature_and_agent_policy() -> Re
allow_external_browser_settings_import: Some(false),
}),
Some(BrowserUseRequirements {
extension: None,
allow_webmcp: None,
allow_history_access: None,
disable_auto_review: Some(true),
@@ -245,6 +245,9 @@ allow_history_access = false
disable_auto_review = true
allow_global_persistent_approval = false
[browser_use.extension]
request_headers = [{ name = "x-browser-agent", value = "ChatGPT/{{session_id}}" }]
[browser_use.default_origin_policy]
access = "deny"
downloads = "allow"
@@ -298,6 +301,12 @@ access = "deny"
assert_eq!(
requirements.browser_use,
Some(BrowserUseRequirements {
extension: Some(codex_app_server_protocol::BrowserUseExtensionRequirements {
request_headers: Some(vec![codex_app_server_protocol::RequestHeader {
name: "x-browser-agent".to_string(),
value: "ChatGPT/{{session_id}}".to_string(),
}]),
}),
allow_webmcp: None,
allow_history_access: Some(false),
disable_auto_review: Some(true),
@@ -824,6 +833,7 @@ access = "deny"
assert_eq!(
requirements.browser_use,
Some(BrowserUseRequirements {
extension: None,
allow_webmcp: None,
allow_history_access: Some(false),
disable_auto_review: None,
+5 -1
View File
@@ -424,7 +424,11 @@ impl ManagedClientStartup {
startup_complete.store(true, Ordering::Release);
outcome
};
originator.scope(startup).in_current_span().boxed().shared()
originator
.scope(Box::pin(startup))
.in_current_span()
.boxed()
.shared()
}
}
@@ -42,6 +42,7 @@ pub enum BrowserUseAccessApprovalLifetimeToml {
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct BrowserUseRequirementsToml {
pub extension: Option<BrowserUseExtensionRequirementsToml>,
pub allow_webmcp: Option<bool>,
pub allow_history_access: Option<bool>,
pub disable_auto_review: Option<bool>,
@@ -52,7 +53,10 @@ pub struct BrowserUseRequirementsToml {
impl BrowserUseRequirementsToml {
pub fn is_empty(&self) -> bool {
self.allow_webmcp.is_none()
self.extension
.as_ref()
.is_none_or(|extension| extension.request_headers.is_none())
&& self.allow_webmcp.is_none()
&& self.allow_history_access.is_none()
&& self.disable_auto_review.is_none()
&& self.allow_global_persistent_approval.is_none()
@@ -67,6 +71,27 @@ impl BrowserUseRequirementsToml {
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct BrowserUseExtensionRequirementsToml {
pub request_headers: Option<Vec<RequestHeaderToml>>,
}
#[derive(Deserialize, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RequestHeaderToml {
pub name: String,
pub value: String,
}
impl std::fmt::Debug for RequestHeaderToml {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RequestHeaderToml")
.field("name", &self.name)
.field("value", &"[REDACTED]")
.finish()
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct ComputerUseMacosRequirementsToml {
pub bundle_ids: Option<BTreeMap<String, AllowDenyRequirementToml>>,
@@ -43,3 +43,94 @@ fn webmcp_requirements_reject_non_booleans() {
assert!(error.to_string().contains("allow_webmcp"));
}
}
#[test]
fn extension_headers_preserve_templates_and_explicit_disablement() {
for (value, expected) in [
("[]", vec![]),
(
r#"[{name = "x-browser-agent", value = "ChatGPT/{{session_id}}"}]"#,
vec![crate::RequestHeaderToml {
name: "x-browser-agent".into(),
value: "ChatGPT/{{session_id}}".into(),
}],
),
] {
let requirements: ConfigRequirementsToml = toml::from_str(&format!(
"[browser_use.extension]\nrequest_headers = {value}"
))
.unwrap();
assert!(!requirements.is_empty());
assert_eq!(
requirements
.browser_use
.unwrap()
.extension
.unwrap()
.request_headers,
Some(expected)
);
}
}
#[test]
fn extension_headers_reject_malformed_entries() {
for value in [
"true",
r#"[{name = "x-test"}]"#,
r#"[{name = "x-test", value = 1}]"#,
r#"[{name = "x-test", value = "ok", typo = true}]"#,
] {
assert!(
toml::from_str::<ConfigRequirementsToml>(&format!(
"[browser_use.extension]\nrequest_headers = {value}"
))
.is_err()
);
}
}
#[test]
fn extension_headers_remain_scoped_to_extension() {
let extension = r#"[browser_use.extension]
request_headers = [{ name = "x-extension", value = "{{session_id}}" }]"#;
let other = r#"[browser_use.iab]
request_headers = [{ name = "x-iab", value = "future" }]"#;
let expected: ConfigRequirementsToml = toml::from_str(extension).unwrap();
let combined: ConfigRequirementsToml =
toml::from_str(&format!("{extension}\n{other}")).unwrap();
assert_eq!(combined, expected);
for contents in [other, "[browser_use.extension]"] {
let requirements: ConfigRequirementsToml = toml::from_str(contents).unwrap();
assert!(requirements.is_empty());
}
}
#[test]
fn request_header_debug_redacts_values_in_parent_requirements() {
let requirements: ConfigRequirementsToml = toml::from_str(
r#"[browser_use.extension]
request_headers = [{ name = "x-company-token", value = "private-header-token" }]"#,
)
.unwrap();
let header = &requirements
.browser_use
.as_ref()
.unwrap()
.extension
.as_ref()
.unwrap()
.request_headers
.as_ref()
.unwrap()[0];
assert_eq!(header.value, "private-header-token");
assert_eq!(
format!("{header:?}"),
r#"RequestHeaderToml { name: "x-company-token", value: "[REDACTED]" }"#
);
for debug in [format!("{requirements:?}"), format!("{requirements:#?}")] {
assert!(!debug.contains("private-header-token"));
assert!(debug.contains("x-company-token"));
assert!(debug.contains("[REDACTED]"));
}
}
@@ -2577,6 +2577,7 @@ mod tests {
assert_eq!(
requirements.browser_use,
Some(BrowserUseRequirementsToml {
extension: None,
allow_webmcp: None,
allow_history_access: Some(false),
disable_auto_review: Some(true),
@@ -2810,6 +2811,7 @@ mod tests {
entries: BTreeMap::from([("personality".to_string(), true)]),
};
let browser_use = BrowserUseRequirementsToml {
extension: None,
allow_webmcp: None,
allow_history_access: Some(false),
disable_auto_review: Some(true),
+2
View File
@@ -56,12 +56,14 @@ pub use application_requirements::ApplicationRequirementsToml;
pub use auth_policy::ManagedAuthPolicy;
pub use browser_computer_use_requirements::AllowDenyRequirementToml;
pub use browser_computer_use_requirements::BrowserUseAccessApprovalLifetimeToml;
pub use browser_computer_use_requirements::BrowserUseExtensionRequirementsToml;
pub use browser_computer_use_requirements::BrowserUseOriginPolicyToml;
pub use browser_computer_use_requirements::BrowserUseRequirementsToml;
pub use browser_computer_use_requirements::ComputerUseMacosRequirementsToml;
pub use browser_computer_use_requirements::ComputerUseRequirementsToml;
pub use browser_computer_use_requirements::ComputerUseWindowsExeRequirementToml;
pub use browser_computer_use_requirements::ComputerUseWindowsRequirementsToml;
pub use browser_computer_use_requirements::RequestHeaderToml;
pub use browser_use::BrowserUseConfigToml;
pub use browser_use::BrowserUseOriginPolicyConfigToml;
pub use cloud_config_bundle::CloudConfigBundle;
@@ -1588,3 +1588,26 @@ path = "../plugins"
)
);
}
#[test]
fn extension_headers_replace_lower_priority_lists_including_explicit_empty() {
for higher in [
r#"[browser_use.extension]
request_headers = []"#,
r#"[browser_use.extension]
request_headers = [{ name = "x-managed", value = "{{session_id}}" }]"#,
] {
let composed = compose(vec![
layer(
"low",
"Lower priority",
r#"[browser_use.extension]
request_headers = [{ name = "x-old", value = "old" }]"#,
),
layer("high", "Higher priority", higher),
])
.expect("compose requirements")
.expect("requirements present");
assert_eq!(composed, expected_requirements(higher));
}
}
+32 -16
View File
@@ -465,22 +465,6 @@ class BrowserUseOriginPolicyConfig(BaseModel):
uploads: AllowDenyRequirement | None = None
class BrowserUseRequirements(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
allow_global_persistent_approval: Annotated[
bool | None, Field(alias="allowGlobalPersistentApproval")
] = None
allow_history_access: Annotated[bool | None, Field(alias="allowHistoryAccess")] = None
allow_webmcp: Annotated[bool | None, Field(alias="allowWebmcp")] = None
default_origin_policy: Annotated[
BrowserUseOriginPolicy | None, Field(alias="defaultOriginPolicy")
] = None
disable_auto_review: Annotated[bool | None, Field(alias="disableAutoReview")] = None
origins: dict[str, Any] | None = None
class ByteRange(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -3872,6 +3856,14 @@ class RemoteControlStatusChangedNotification(BaseModel):
status: RemoteControlConnectionStatus
class RequestHeader(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
name: str
value: str
class RequestId(RootModel[str | int]):
model_config = ConfigDict(
populate_by_name=True,
@@ -6860,6 +6852,30 @@ class BrowserUseConfig(BaseModel):
origins: dict[str, Any] | None = None
class BrowserUseExtensionRequirements(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
request_headers: Annotated[list[RequestHeader] | None, Field(alias="requestHeaders")] = None
class BrowserUseRequirements(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
allow_global_persistent_approval: Annotated[
bool | None, Field(alias="allowGlobalPersistentApproval")
] = None
allow_history_access: Annotated[bool | None, Field(alias="allowHistoryAccess")] = None
allow_webmcp: Annotated[bool | None, Field(alias="allowWebmcp")] = None
default_origin_policy: Annotated[
BrowserUseOriginPolicy | None, Field(alias="defaultOriginPolicy")
] = None
disable_auto_review: Annotated[bool | None, Field(alias="disableAutoReview")] = None
extension: BrowserUseExtensionRequirements | None = None
origins: dict[str, Any] | None = None
class CancelLoginAccountResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,