Add per-account approval settings for apps (#42047)

## What changed

- Add `apps.<app_id>.links.<link_id>` configuration for overriding
  `approvals_reviewer` and `default_tools_approval_mode` per connected account.
- Expose link settings through the app-server protocol and generated JSON and
  TypeScript schemas.

## Testing

- Extend `config/read` coverage for populated, empty, and absent `links`
  sections.

GitOrigin-RevId: 43004d2722439060fdadc5e5cd9ed3108774ecaf
This commit is contained in:
Alex Zamoshchin
2026-09-01 13:07:56 +00:00
committed by copyberry
parent 90ae0c4ef9
commit 0ec375eb70
15 changed files with 316 additions and 14 deletions
@@ -6999,6 +6999,17 @@
"default": true,
"type": "boolean"
},
"links": {
"anyOf": [
{
"$ref": "#/definitions/v2/AppLinksConfig"
},
{
"type": "null"
}
],
"description": "Per-account approval settings keyed by link ID."
},
"open_world_enabled": {
"type": [
"boolean",
@@ -7127,6 +7138,36 @@
],
"type": "object"
},
"AppLinkConfig": {
"description": "Approval settings for a connected account within an app.",
"properties": {
"approvals_reviewer": {
"anyOf": [
{
"$ref": "#/definitions/v2/ApprovalsReviewer"
},
{
"type": "null"
}
]
},
"default_tools_approval_mode": {
"anyOf": [
{
"$ref": "#/definitions/v2/AppToolApproval"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"AppLinksConfig": {
"description": "Account settings for a single app.",
"type": "object"
},
"AppListUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "EXPERIMENTAL - notification emitted when the app list changes.",
@@ -490,6 +490,17 @@
"default": true,
"type": "boolean"
},
"links": {
"anyOf": [
{
"$ref": "#/definitions/AppLinksConfig"
},
{
"type": "null"
}
],
"description": "Per-account approval settings keyed by link ID."
},
"open_world_enabled": {
"type": [
"boolean",
@@ -618,6 +629,36 @@
],
"type": "object"
},
"AppLinkConfig": {
"description": "Approval settings for a connected account within an app.",
"properties": {
"approvals_reviewer": {
"anyOf": [
{
"$ref": "#/definitions/ApprovalsReviewer"
},
{
"type": "null"
}
]
},
"default_tools_approval_mode": {
"anyOf": [
{
"$ref": "#/definitions/AppToolApproval"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"AppLinksConfig": {
"description": "Account settings for a single app.",
"type": "object"
},
"AppListUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "EXPERIMENTAL - notification emitted when the app list changes.",
@@ -62,6 +62,17 @@
"default": true,
"type": "boolean"
},
"links": {
"anyOf": [
{
"$ref": "#/definitions/AppLinksConfig"
},
{
"type": "null"
}
],
"description": "Per-account approval settings keyed by link ID."
},
"open_world_enabled": {
"type": [
"boolean",
@@ -81,6 +92,36 @@
},
"type": "object"
},
"AppLinkConfig": {
"description": "Approval settings for a connected account within an app.",
"properties": {
"approvals_reviewer": {
"anyOf": [
{
"$ref": "#/definitions/ApprovalsReviewer"
},
{
"type": "null"
}
]
},
"default_tools_approval_mode": {
"anyOf": [
{
"$ref": "#/definitions/AppToolApproval"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"AppLinksConfig": {
"description": "Account settings for a single app.",
"type": "object"
},
"AppToolApproval": {
"enum": [
"auto",
@@ -0,0 +1,10 @@
// 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 { AppToolApproval } from "./AppToolApproval";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
/**
* Account settings for a single app.
*/
export type AppLinksConfig = { [key in string]?: { approvals_reviewer: ApprovalsReviewer | null, default_tools_approval_mode: AppToolApproval | null, } };
@@ -1,9 +1,14 @@
// 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 { AppLinksConfig } from "./AppLinksConfig";
import type { AppToolApproval } from "./AppToolApproval";
import type { AppToolsConfig } from "./AppToolsConfig";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AppsDefaultConfig } from "./AppsDefaultConfig";
export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, approvals_reviewer: ApprovalsReviewer | null, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null, } });
export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, approvals_reviewer: ApprovalsReviewer | null, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null,
/**
* Per-account approval settings keyed by link ID.
*/
links: AppLinksConfig | null, } });
@@ -20,6 +20,7 @@ export type { AllowDenyRequirement } from "./AllowDenyRequirement";
export type { AnalyticsConfig } from "./AnalyticsConfig";
export type { AppBranding } from "./AppBranding";
export type { AppInfo } from "./AppInfo";
export type { AppLinksConfig } from "./AppLinksConfig";
export type { AppListUpdatedNotification } from "./AppListUpdatedNotification";
export type { AppMetadata } from "./AppMetadata";
export type { AppReview } from "./AppReview";
@@ -209,6 +209,24 @@ pub struct AppToolsConfig {
pub tools: HashMap<String, AppToolConfig>,
}
/// Approval settings for a connected account within an app.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub struct AppLinkConfig {
pub approvals_reviewer: Option<ApprovalsReviewer>,
pub default_tools_approval_mode: Option<AppToolApproval>,
}
/// Account settings for a single app.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub struct AppLinksConfig {
#[serde(default, flatten)]
pub links: HashMap<String, AppLinkConfig>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
@@ -221,6 +239,8 @@ pub struct AppConfig {
pub default_tools_approval_mode: Option<AppToolApproval>,
pub default_tools_enabled: Option<bool>,
pub tools: Option<AppToolsConfig>,
/// Per-account approval settings keyed by link ID.
pub links: Option<AppLinksConfig>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
+5
View File
@@ -2416,6 +2416,11 @@ Setting the app value to `"user"` routes its approval prompts to the user
instead of Guardian; setting it to `"auto_review"` opts that app into Guardian
review when allowed by configuration requirements.
Per-account approval configuration uses `apps.<app_id>.links.<link_id>` with
`approvals_reviewer` and `default_tools_approval_mode`. Like `tools`, `links` is
an optional section: `config/read` returns `null` when it is absent, `{}` when
it is explicitly empty, and a map keyed by link ID when accounts are configured.
Use `apps._default.default_tools_approval_mode` to set the approval mode for
tools without a per-app or per-tool override. Supported values are `"auto"`,
`"prompt"`, `"writes"`, and `"approve"`. The `"writes"` mode prompts for tools
@@ -357,6 +357,7 @@ async fn write_value_supports_nested_app_paths() -> Result<()> {
default_tools_approval_mode: Some(AppToolApproval::Prompt),
default_tools_enabled: None,
tools: None,
links: None,
},
)]),
})
@@ -4,6 +4,8 @@ use app_test_support::test_path_buf_with_windows;
use app_test_support::test_tmp_path_buf;
use codex_app_server_protocol::AllowDenyRequirement;
use codex_app_server_protocol::AppConfig;
use codex_app_server_protocol::AppLinkConfig;
use codex_app_server_protocol::AppLinksConfig;
use codex_app_server_protocol::AppToolApproval;
use codex_app_server_protocol::ApprovalsReviewer;
use codex_app_server_protocol::AppsConfig;
@@ -1020,6 +1022,18 @@ enabled = false
approvals_reviewer = "user"
destructive_enabled = false
default_tools_approval_mode = "prompt"
[apps.app1.links.link_work]
approvals_reviewer = "auto_review"
default_tools_approval_mode = "approve"
[apps.app1.links.link_personal]
default_tools_approval_mode = "writes"
[apps.app_without_links]
enabled = true
[apps.app_with_empty_links.links]
"#,
)?;
let codex_home_path = codex_home.path().canonicalize()?;
@@ -1037,11 +1051,21 @@ default_tools_approval_mode = "prompt"
cwd: None,
})
.await?;
let response: serde_json::Value =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
response["config"]["apps"]["app_without_links"].get("links"),
Some(&json!(null)),
);
assert_eq!(
response["config"]["apps"]["app_with_empty_links"].get("links"),
Some(&json!({})),
);
let ConfigReadResponse {
config,
origins,
layers,
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
} = serde_json::from_value(response)?;
assert_eq!(
config.apps,
@@ -1053,18 +1077,66 @@ default_tools_approval_mode = "prompt"
open_world_enabled: true,
default_tools_approval_mode: Some(AppToolApproval::Writes),
}),
apps: std::collections::HashMap::from([(
"app1".to_string(),
AppConfig {
enabled: false,
approvals_reviewer: Some(ApprovalsReviewer::User),
destructive_enabled: Some(false),
open_world_enabled: None,
default_tools_approval_mode: Some(AppToolApproval::Prompt),
default_tools_enabled: None,
tools: None,
},
)]),
apps: std::collections::HashMap::from([
(
"app1".to_string(),
AppConfig {
enabled: false,
approvals_reviewer: Some(ApprovalsReviewer::User),
destructive_enabled: Some(false),
open_world_enabled: None,
default_tools_approval_mode: Some(AppToolApproval::Prompt),
default_tools_enabled: None,
tools: None,
links: Some(AppLinksConfig {
links: std::collections::HashMap::from([
(
"link_work".to_string(),
AppLinkConfig {
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
default_tools_approval_mode: Some(AppToolApproval::Approve),
},
),
(
"link_personal".to_string(),
AppLinkConfig {
approvals_reviewer: None,
default_tools_approval_mode: Some(AppToolApproval::Writes),
},
),
]),
}),
},
),
(
"app_without_links".to_string(),
AppConfig {
enabled: true,
approvals_reviewer: None,
destructive_enabled: None,
open_world_enabled: None,
default_tools_approval_mode: None,
default_tools_enabled: None,
tools: None,
links: None,
},
),
(
"app_with_empty_links".to_string(),
AppConfig {
enabled: true,
approvals_reviewer: None,
destructive_enabled: None,
open_world_enabled: None,
default_tools_approval_mode: None,
default_tools_enabled: None,
tools: None,
links: Some(AppLinksConfig {
links: std::collections::HashMap::new(),
}),
},
),
]),
})
);
assert_eq!(
+26
View File
@@ -455,6 +455,28 @@ pub struct AppToolsConfig {
pub tools: HashMap<String, AppToolConfig>,
}
/// Approval settings for a connected account within an app.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AppLinkConfig {
/// Reviewer for approval prompts from this account, overriding the app default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approvals_reviewer: Option<ApprovalsReviewer>,
/// Approval mode for this account unless a tool override exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_tools_approval_mode: Option<AppToolApproval>,
}
/// Account settings for a single app.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AppLinksConfig {
/// Per-account approval settings keyed by link ID.
#[serde(default, flatten)]
pub links: HashMap<String, AppLinkConfig>,
}
/// Config values for a single app/connector.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
@@ -486,6 +508,10 @@ pub struct AppConfig {
/// Per-tool settings for this app.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<AppToolsConfig>,
/// Per-account approval settings keyed by link ID.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub links: Option<AppLinksConfig>,
}
/// App/connector settings loaded from `config.toml`.
+38
View File
@@ -147,6 +147,14 @@
"description": "When `false`, Codex does not surface this app.",
"type": "boolean"
},
"links": {
"allOf": [
{
"$ref": "#/definitions/AppLinksConfig"
}
],
"description": "Per-account approval settings keyed by link ID."
},
"open_world_enabled": {
"description": "Whether tools with `open_world_hint = true` are allowed for this app.",
"type": "boolean"
@@ -162,6 +170,36 @@
},
"type": "object"
},
"AppLinkConfig": {
"additionalProperties": false,
"description": "Approval settings for a connected account within an app.",
"properties": {
"approvals_reviewer": {
"allOf": [
{
"$ref": "#/definitions/ApprovalsReviewer"
}
],
"description": "Reviewer for approval prompts from this account, overriding the app default."
},
"default_tools_approval_mode": {
"allOf": [
{
"$ref": "#/definitions/AppToolApproval"
}
],
"description": "Approval mode for this account unless a tool override exists."
}
},
"type": "object"
},
"AppLinksConfig": {
"additionalProperties": {
"$ref": "#/definitions/AppLinkConfig"
},
"description": "Account settings for a single app.",
"type": "object"
},
"AppToolApproval": {
"enum": [
"auto",
+1
View File
@@ -2065,6 +2065,7 @@ async fn persist_codex_app_tool_approval_writes_tool_override() {
},
)]),
}),
links: None,
},
)]),
})