app-server: add in-memory config layer

git-stack-id: aaron/app-server-in-memory-config---4ivokd7wtu-snl
git-stack-title: app-server: add in-memory config layer
This commit is contained in:
Aaron Levine
2026-06-03 20:37:14 -07:00
parent 6bcccb0ee6
commit 9a5113ad73
45 changed files with 747 additions and 41 deletions
@@ -435,6 +435,19 @@
],
"type": "object"
},
"ConfigInMemorySetParams": {
"properties": {
"config": {
"additionalProperties": true,
"description": "Full replacement in-memory config layer, shaped like config.toml. An empty object clears the layer.",
"type": "object"
}
},
"required": [
"config"
],
"type": "object"
},
"ConfigReadParams": {
"properties": {
"cwd": {
@@ -6093,6 +6106,30 @@
"title": "Config/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"config/inMemory/set"
],
"title": "Config/inMemory/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ConfigInMemorySetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Config/inMemory/setRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -288,8 +288,8 @@
"environmentId": {
"default": null,
"type": [
"null",
"string"
"string",
"null"
]
},
"itemId": {
@@ -326,4 +326,4 @@
],
"title": "PermissionsRequestApprovalParams",
"type": "object"
}
}
+3 -3
View File
@@ -1593,8 +1593,8 @@
"environmentId": {
"default": null,
"type": [
"null",
"string"
"string",
"null"
]
},
"itemId": {
@@ -2005,4 +2005,4 @@
}
],
"title": "ServerRequest"
}
}
@@ -2005,6 +2005,30 @@
"title": "Config/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"config/inMemory/set"
],
"title": "Config/inMemory/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ConfigInMemorySetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Config/inMemory/setRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -3786,8 +3810,8 @@
"environmentId": {
"default": null,
"type": [
"null",
"string"
"string",
"null"
]
},
"itemId": {
@@ -7530,6 +7554,26 @@
],
"type": "object"
},
"ConfigInMemorySetParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"config": {
"additionalProperties": true,
"description": "Full replacement in-memory config layer, shaped like config.toml. An empty object clears the layer.",
"type": "object"
}
},
"required": [
"config"
],
"title": "ConfigInMemorySetParams",
"type": "object"
},
"ConfigInMemorySetResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ConfigInMemorySetResponse",
"type": "object"
},
"ConfigLayer": {
"properties": {
"config": true,
@@ -7681,6 +7725,23 @@
"title": "UserConfigLayerSource",
"type": "object"
},
{
"description": "In-memory config supplied by the host for the current process.",
"properties": {
"type": {
"enum": [
"inMemory"
],
"title": "InMemoryConfigLayerSourceType",
"type": "string"
}
},
"required": [
"type"
],
"title": "InMemoryConfigLayerSource",
"type": "object"
},
{
"description": "Path to a .codex/ folder within a project. There could be multiple of these between `cwd` and the project/repo root.",
"properties": {
@@ -19239,4 +19300,4 @@
},
"title": "CodexAppServerProtocol",
"type": "object"
}
}
@@ -2764,6 +2764,30 @@
"title": "Config/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"config/inMemory/set"
],
"title": "Config/inMemory/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ConfigInMemorySetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Config/inMemory/setRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -3892,6 +3916,26 @@
],
"type": "object"
},
"ConfigInMemorySetParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"config": {
"additionalProperties": true,
"description": "Full replacement in-memory config layer, shaped like config.toml. An empty object clears the layer.",
"type": "object"
}
},
"required": [
"config"
],
"title": "ConfigInMemorySetParams",
"type": "object"
},
"ConfigInMemorySetResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ConfigInMemorySetResponse",
"type": "object"
},
"ConfigLayer": {
"properties": {
"config": true,
@@ -4043,6 +4087,23 @@
"title": "UserConfigLayerSource",
"type": "object"
},
{
"description": "In-memory config supplied by the host for the current process.",
"properties": {
"type": {
"enum": [
"inMemory"
],
"title": "InMemoryConfigLayerSourceType",
"type": "string"
}
},
"required": [
"type"
],
"title": "InMemoryConfigLayerSource",
"type": "object"
},
{
"description": "Path to a .codex/ folder within a project. There could be multiple of these between `cwd` and the project/repo root.",
"properties": {
@@ -0,0 +1,15 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"config": {
"additionalProperties": true,
"description": "Full replacement in-memory config layer, shaped like config.toml. An empty object clears the layer.",
"type": "object"
}
},
"required": [
"config"
],
"title": "ConfigInMemorySetParams",
"type": "object"
}
@@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ConfigInMemorySetResponse",
"type": "object"
}
@@ -568,6 +568,23 @@
"title": "UserConfigLayerSource",
"type": "object"
},
{
"description": "In-memory config supplied by the host for the current process.",
"properties": {
"type": {
"enum": [
"inMemory"
],
"title": "InMemoryConfigLayerSourceType",
"type": "string"
}
},
"required": [
"type"
],
"title": "InMemoryConfigLayerSource",
"type": "object"
},
{
"description": "Path to a .codex/ folder within a project. There could be multiple of these between `cwd` and the project/repo root.",
"properties": {
@@ -133,6 +133,23 @@
"title": "UserConfigLayerSource",
"type": "object"
},
{
"description": "In-memory config supplied by the host for the current process.",
"properties": {
"type": {
"enum": [
"inMemory"
],
"title": "InMemoryConfigLayerSourceType",
"type": "string"
}
},
"required": [
"type"
],
"title": "InMemoryConfigLayerSource",
"type": "object"
},
{
"description": "Path to a .codex/ folder within a project. There could be multiple of these between `cwd` and the project/repo root.",
"properties": {
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
// 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 { JsonValue } from "../serde_json/JsonValue";
export type ConfigInMemorySetParams = {
/**
* Full replacement in-memory config layer, shaped like config.toml.
* An empty object clears the layer.
*/
config: { [key in string]?: JsonValue }, };
@@ -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 ConfigInMemorySetResponse = Record<string, never>;
@@ -28,4 +28,4 @@ file: AbsolutePathBuf,
* Name of the selected profile-v2 config layered on top of the base
* user config, when this layer represents one.
*/
profile: string | null, } | { "type": "project", dotCodexFolder: AbsolutePathBuf, } | { "type": "sessionFlags" } | { "type": "legacyManagedConfigTomlFromFile", file: AbsolutePathBuf, } | { "type": "legacyManagedConfigTomlFromMdm" };
profile: string | null, } | { "type": "inMemory" } | { "type": "project", dotCodexFolder: AbsolutePathBuf, } | { "type": "sessionFlags" } | { "type": "legacyManagedConfigTomlFromFile", file: AbsolutePathBuf, } | { "type": "legacyManagedConfigTomlFromMdm" };
@@ -68,6 +68,8 @@ export type { ComputerUseRequirements } from "./ComputerUseRequirements";
export type { Config } from "./Config";
export type { ConfigBatchWriteParams } from "./ConfigBatchWriteParams";
export type { ConfigEdit } from "./ConfigEdit";
export type { ConfigInMemorySetParams } from "./ConfigInMemorySetParams";
export type { ConfigInMemorySetResponse } from "./ConfigInMemorySetResponse";
export type { ConfigLayer } from "./ConfigLayer";
export type { ConfigLayerMetadata } from "./ConfigLayerMetadata";
export type { ConfigLayerSource } from "./ConfigLayerSource";
@@ -1020,6 +1020,11 @@ client_request_definitions! {
serialization: global_shared_read("config"),
response: v2::ConfigReadResponse,
},
ConfigInMemorySet => "config/inMemory/set" {
params: v2::ConfigInMemorySetParams,
serialization: global("config"),
response: v2::ConfigInMemorySetResponse,
},
ExternalAgentConfigDetect => "externalAgentConfig/detect" {
params: v2::ExternalAgentConfigDetectParams,
serialization: global("config"),
@@ -72,6 +72,9 @@ pub enum ConfigLayerSource {
profile: Option<String>,
},
/// In-memory config supplied by the host for the current process.
InMemory,
/// Path to a .codex/ folder within a project. There could be multiple of
/// these between `cwd` and the project/repo root.
#[serde(rename_all = "camelCase")]
@@ -111,6 +114,7 @@ impl ConfigLayerSource {
20
}
}
ConfigLayerSource::InMemory => 22,
ConfigLayerSource::Project { .. } => 25,
ConfigLayerSource::SessionFlags => 30,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
@@ -365,6 +369,20 @@ pub struct ConfigReadResponse {
pub layers: Option<Vec<ConfigLayer>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfigInMemorySetParams {
/// Full replacement in-memory config layer, shaped like config.toml.
/// An empty object clears the layer.
pub config: HashMap<String, JsonValue>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfigInMemorySetResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+2 -1
View File
@@ -225,7 +225,8 @@ Example with notification opt-out:
- `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result.
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`.
- `config/read` — fetch the effective config after resolving config layering, including opaque `desktop` values stored in `config.toml`.
- `config/inMemory/set` — replace the in-memory config layer used by subsequent config loads in this app-server process. The layer is shaped like `config.toml`, sits above user config and below project/session overrides, and `{ "config": {} }` clears it. Existing running threads retain their config snapshots, just as when `config.toml` changes.
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata.
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish).
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface.
+34 -1
View File
@@ -1,6 +1,7 @@
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_config::cloud_config_bundle_loader;
use codex_config::CloudConfigBundleLoader;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::LoaderOverrides;
use codex_config::ThreadConfigLoader;
@@ -29,8 +30,9 @@ pub(crate) struct ConfigManager {
codex_home: PathBuf,
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
in_memory_layer: Arc<RwLock<Option<ConfigLayerEntry>>>,
loader_overrides: LoaderOverrides,
strict_config: bool,
pub(super) strict_config: bool,
cloud_config_bundle: Arc<RwLock<CloudConfigBundleLoader>>,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<RwLock<Arc<dyn ThreadConfigLoader>>>,
@@ -50,6 +52,7 @@ impl ConfigManager {
codex_home,
cli_overrides: Arc::new(RwLock::new(cli_overrides)),
runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())),
in_memory_layer: Arc::new(RwLock::new(None)),
loader_overrides,
strict_config,
cloud_config_bundle: Arc::new(RwLock::new(cloud_config_bundle)),
@@ -80,6 +83,13 @@ impl ConfigManager {
.unwrap_or_default()
}
fn current_in_memory_layer(&self) -> std::io::Result<Option<ConfigLayerEntry>> {
self.in_memory_layer
.read()
.map(|guard| guard.clone())
.map_err(|_| std::io::Error::other("in-memory config layer lock poisoned"))
}
pub(crate) fn extend_runtime_feature_enablement<I>(&self, enablement: I) -> Result<(), ()>
where
I: IntoIterator<Item = (String, bool)>,
@@ -115,6 +125,18 @@ impl ConfigManager {
}
}
pub(crate) fn replace_in_memory_layer(
&self,
in_memory_layer: Option<ConfigLayerEntry>,
) -> std::io::Result<()> {
let mut guard = self
.in_memory_layer
.write()
.map_err(|_| std::io::Error::other("in-memory config layer lock poisoned"))?;
*guard = in_memory_layer;
Ok(())
}
fn current_thread_config_loader(&self) -> Arc<dyn ThreadConfigLoader> {
self.thread_config_loader
.read()
@@ -246,6 +268,7 @@ impl ConfigManager {
.harness_overrides(typesafe_overrides)
.fallback_cwd(fallback_cwd)
.cloud_config_bundle(self.current_cloud_config_bundle())
.in_memory_layer(self.current_in_memory_layer()?)
.thread_config_loader(self.current_thread_config_loader())
.build()
.await?;
@@ -264,6 +287,15 @@ impl ConfigManager {
pub(crate) async fn load_config_layers(
&self,
cwd: Option<AbsolutePathBuf>,
) -> std::io::Result<ConfigLayerStack> {
self.load_config_layers_with_in_memory_layer(cwd, self.current_in_memory_layer()?)
.await
}
pub(crate) async fn load_config_layers_with_in_memory_layer(
&self,
cwd: Option<AbsolutePathBuf>,
in_memory_layer: Option<ConfigLayerEntry>,
) -> std::io::Result<ConfigLayerStack> {
let thread_config_loader = self.current_thread_config_loader();
load_config_layers_state(
@@ -275,6 +307,7 @@ impl ConfigManager {
loader_overrides: self.loader_overrides.clone(),
strict_config: self.strict_config,
cloud_config_bundle: self.current_cloud_config_bundle(),
in_memory_layer,
},
thread_config_loader.as_ref(),
)
@@ -1,6 +1,8 @@
use crate::config_manager::ConfigManager;
use codex_app_server_protocol::Config as ApiConfig;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigInMemorySetParams;
use codex_app_server_protocol::ConfigInMemorySetResponse;
use codex_app_server_protocol::ConfigLayerMetadata;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ConfigReadParams;
@@ -17,6 +19,7 @@ use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::ConfigRequirementsToml;
use codex_config::config_toml::ConfigToml;
use codex_config::loader::create_in_memory_config_layer;
use codex_config::merge_toml_values;
use codex_core::config::deserialize_config_toml_with_base;
use codex_core::config::edit::ConfigEdit;
@@ -189,6 +192,78 @@ impl ConfigManager {
.await
}
pub(crate) async fn set_in_memory_config(
&self,
params: ConfigInMemorySetParams,
) -> Result<ConfigInMemorySetResponse, ConfigManagerError> {
let ConfigInMemorySetParams { config } = params;
let candidate_in_memory_layer = if config.is_empty() {
None
} else {
let config = TomlValue::try_from(config).map_err(|err| {
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid in-memory configuration: {err}"),
)
})?;
Some(
create_in_memory_config_layer(config, self.codex_home(), self.strict_config)
.map_err(|err| {
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid in-memory configuration: {err}"),
)
})?,
)
};
let candidate_layers = self
.load_config_layers_with_in_memory_layer(
/*cwd*/ None,
candidate_in_memory_layer.clone(),
)
.await
.map_err(|err| ConfigManagerError::io("failed to load configuration", err))?;
if let Some(layer) = candidate_in_memory_layer.as_ref() {
let in_memory_config_toml =
deserialize_config_toml_with_base(layer.config.clone(), self.codex_home())
.map_err(|err| {
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid in-memory configuration: {err}"),
)
})?;
validate_feature_requirements_for_config_toml(
&in_memory_config_toml,
candidate_layers
.requirements()
.feature_requirements
.as_ref(),
)
.map_err(|err| {
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid in-memory configuration: {err}"),
)
})?;
}
validate_config(&candidate_layers.effective_config()).map_err(|err| {
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid in-memory configuration: {err}"),
)
})?;
self.replace_in_memory_layer(candidate_in_memory_layer)
.map_err(|err| {
ConfigManagerError::io("failed to update in-memory config layer", err)
})?;
Ok(ConfigInMemorySetResponse {})
}
async fn apply_edits(
&self,
file_path: Option<String>,
@@ -627,6 +702,7 @@ fn override_message(layer: &ConfigLayerSource) -> String {
ConfigLayerSource::EnterpriseManaged { id: _, name } => {
format!("Overridden by enterprise-managed config: {name}")
}
ConfigLayerSource::InMemory => "Overridden by in-memory config".to_string(),
ConfigLayerSource::Project { dot_codex_folder } => format!(
"Overridden by project config: {}/{CONFIG_TOML_FILE}",
dot_codex_folder.display(),
@@ -60,6 +60,79 @@ X-Doc = "42"
);
}
#[tokio::test]
async fn in_memory_config_replaces_atomically_and_clears_to_latest_user_config() -> Result<()> {
let tmp = tempdir().expect("tempdir");
let config_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, "model = \"user\"\n")?;
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
service
.set_in_memory_config(ConfigInMemorySetParams {
config: std::collections::HashMap::from([(
"model".to_string(),
serde_json::json!("memory"),
)]),
})
.await
.expect("set in-memory config");
let read = service
.read(ConfigReadParams {
include_layers: true,
cwd: None,
})
.await
.expect("read in-memory config");
assert_eq!(read.config.model.as_deref(), Some("memory"));
assert_eq!(
read.origins.get("model").map(|metadata| &metadata.name),
Some(&ConfigLayerSource::InMemory)
);
let error = service
.set_in_memory_config(ConfigInMemorySetParams {
config: std::collections::HashMap::from([("model".to_string(), serde_json::json!(42))]),
})
.await
.expect_err("invalid replacement should fail");
assert_eq!(
error.write_error_code(),
Some(ConfigWriteErrorCode::ConfigValidationError)
);
assert_eq!(
service
.read(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await
.expect("read after rejected replacement")
.config
.model
.as_deref(),
Some("memory")
);
std::fs::write(&config_path, "model = \"latest-user\"\n")?;
service
.set_in_memory_config(ConfigInMemorySetParams {
config: std::collections::HashMap::new(),
})
.await
.expect("clear in-memory config");
let cleared = service
.read(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await
.expect("read cleared config");
assert_eq!(cleared.config.model.as_deref(), Some("latest-user"));
Ok(())
}
#[tokio::test]
async fn write_value_preserves_comments_and_order() -> Result<()> {
let tmp = tempdir().expect("tempdir");
@@ -879,6 +879,11 @@ impl MessageProcessor {
.read(params)
.await
.map(|response| Some(response.into())),
ClientRequest::ConfigInMemorySet { params, .. } => self
.config_processor
.in_memory_set(params)
.await
.map(|response| Some(response.into())),
ClientRequest::WindowsSandboxReadiness { .. } => self
.windows_sandbox_processor
.windows_sandbox_readiness()
@@ -11,6 +11,8 @@ use codex_app_server_protocol::AppListUpdatedNotification;
use codex_app_server_protocol::ClientResponsePayload;
use codex_app_server_protocol::ComputerUseRequirements;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigInMemorySetParams;
use codex_app_server_protocol::ConfigInMemorySetResponse;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigRequirements;
@@ -146,6 +148,19 @@ impl ConfigRequestProcessor {
.map(ClientResponsePayload::ConfigBatchWrite)
}
pub(crate) async fn in_memory_set(
&self,
params: ConfigInMemorySetParams,
) -> Result<ConfigInMemorySetResponse, JSONRPCErrorError> {
self.handle_config_mutation_result(
self.config_manager
.set_in_memory_config(params)
.await
.map_err(map_error),
)
.await
}
pub(crate) async fn experimental_feature_enablement_set(
&self,
request_id: ConnectionRequestId,
@@ -1076,6 +1076,7 @@ impl TurnRequestProcessor {
))
})?;
// Detached review threads intentionally retain the app-server startup config snapshot.
let mut config = self.config.as_ref().clone();
if let Some(review_model) = &config.review_model {
config.model = Some(review_model.clone());
@@ -14,6 +14,7 @@ pub fn format_config_layer_source(source: &ConfigLayerSource, config_toml_file:
ConfigLayerSource::User { file, .. } => {
format!("user ({})", file.as_path().display())
}
ConfigLayerSource::InMemory => "in-memory".to_string(),
ConfigLayerSource::Project { dot_codex_folder } => {
format!(
"project ({}/{config_toml_file})",
+1
View File
@@ -256,6 +256,7 @@ fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Op
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.to_path_buf()),
ConfigLayerSource::Mdm { .. }
| ConfigLayerSource::EnterpriseManaged { .. }
| ConfigLayerSource::InMemory
| ConfigLayerSource::SessionFlags
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,
}
+5 -4
View File
@@ -29,10 +29,11 @@ Precedence is **top overrides bottom**:
2. `LegacyManagedConfigTomlFromFile` (`managed_config.toml`, while it is being phased out)
3. `SessionFlags` (CLI overrides, applied as dotted-path TOML writes)
4. `Project` config (`.codex/config.toml`)
5. `User` profile config, when present
6. `User` config (`config.toml`)
7. `EnterpriseManaged` cloud-managed config bundle layers
8. `System` config (`/etc/codex/config.toml` or the Windows system config path)
5. `InMemory` process-scoped config supplied by a host
6. `User` profile config, when present
7. `User` config (`config.toml`)
8. `EnterpriseManaged` cloud-managed config bundle layers
9. `System` config (`/etc/codex/config.toml` or the Windows system config path)
`ConfigLayerStack` stores layers in the opposite order internally: lowest
precedence first, highest precedence last, so later layers override earlier
+76 -11
View File
@@ -97,6 +97,7 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O
/// - cloud enterprise-managed cloud config bundle fragments
/// - user `${CODEX_HOME}/config.toml`
/// - profile `${CODEX_HOME}/<name>.config.toml`, when selected
/// - in-memory process-scoped config supplied by a host
/// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted)
/// - tree parent directories up to root looking for `./.codex/config.toml` (loaded but disabled when untrusted)
/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (loaded but disabled when untrusted)
@@ -123,6 +124,7 @@ pub async fn load_config_layers_state(
loader_overrides: overrides,
strict_config,
cloud_config_bundle,
in_memory_layer,
} = options.into();
let active_user_profile = overrides.user_config_profile.clone();
let ignore_managed_requirements = overrides.ignore_managed_requirements;
@@ -207,12 +209,11 @@ pub async fn load_config_layers_state(
.as_ref()
.map(AbsolutePathBuf::as_path)
.unwrap_or(codex_home);
if strict_config {
validate_cli_overrides_strictly(&cli_overrides_layer, base_dir)?;
}
Some(resolve_relative_paths_in_config_toml(
Some(resolve_inline_config_layer(
cli_overrides_layer,
base_dir,
strict_config,
"-c/--config override",
)?)
};
@@ -286,6 +287,16 @@ pub async fn load_config_layers_state(
);
}
if let Some(in_memory_layer) = in_memory_layer {
if in_memory_layer.name != ConfigLayerSource::InMemory {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"in-memory config layer must use the InMemory source",
));
}
layers.push(in_memory_layer);
}
let mut startup_warnings = None;
if let Some(cwd) = cwd {
let mut merged_so_far = TomlValue::Table(toml::map::Map::new());
@@ -537,29 +548,53 @@ fn validate_config_toml_strictly(
}
}
fn validate_cli_overrides_strictly(
cli_overrides_layer: &TomlValue,
fn validate_inline_config_layer_strictly(
config_layer: &TomlValue,
base_dir: &Path,
source_label: &str,
) -> io::Result<()> {
let _guard = AbsolutePathBufGuard::new(base_dir);
if let Some(ignored_path) = ignored_toml_value_field::<ConfigToml>(cli_overrides_layer.clone())
{
if let Some(ignored_path) = ignored_toml_value_field::<ConfigToml>(config_layer.clone()) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unknown configuration field `{ignored_path}` in -c/--config override"),
format!("unknown configuration field `{ignored_path}` in {source_label}"),
));
}
if let Some(ignored_path) = unknown_feature_toml_value_field(cli_overrides_layer) {
if let Some(ignored_path) = unknown_feature_toml_value_field(config_layer) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unknown configuration field `{ignored_path}` in -c/--config override"),
format!("unknown configuration field `{ignored_path}` in {source_label}"),
));
}
Ok(())
}
fn resolve_inline_config_layer(
config_layer: TomlValue,
base_dir: &Path,
strict_config: bool,
source_label: &str,
) -> io::Result<TomlValue> {
if strict_config {
validate_inline_config_layer_strictly(&config_layer, base_dir, source_label)?;
}
resolve_relative_paths_in_config_toml(config_layer, base_dir)
}
/// Creates a normalized process-scoped in-memory config layer.
#[doc(hidden)]
pub fn create_in_memory_config_layer(
config: TomlValue,
codex_home: &Path,
strict_config: bool,
) -> io::Result<ConfigLayerEntry> {
let config =
resolve_inline_config_layer(config, codex_home, strict_config, "in-memory config layer")?;
Ok(ConfigLayerEntry::new(ConfigLayerSource::InMemory, config))
}
/// If available, load requirements from the platform's system `requirements.toml`
/// location as a requirements layer.
pub async fn load_requirements_toml(
@@ -1392,6 +1427,7 @@ struct LegacyManagedConfigToml {
#[cfg(test)]
mod unit_tests {
use super::*;
use pretty_assertions::assert_eq;
#[cfg(windows)]
use std::path::Path;
use tempfile::tempdir;
@@ -1433,6 +1469,35 @@ foo = "xyzzy"
Ok(())
}
#[test]
fn in_memory_config_layer_resolves_paths_against_codex_home() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let layer = create_in_memory_config_layer(
toml::toml! {
model_instructions_file = "./instructions.md"
}
.into(),
codex_home.path(),
/*strict_config*/ false,
)?;
let expected = ConfigLayerEntry::new(
ConfigLayerSource::InMemory,
TomlValue::Table(toml::map::Map::from_iter([(
"model_instructions_file".to_string(),
TomlValue::String(
codex_home
.path()
.join("instructions.md")
.display()
.to_string(),
),
)])),
);
assert_eq!(layer, expected);
Ok(())
}
#[test]
fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() -> io::Result<()> {
let legacy = LegacyManagedConfigToml {
+32
View File
@@ -23,6 +23,7 @@ pub struct ConfigLoadOptions {
pub loader_overrides: LoaderOverrides,
pub strict_config: bool,
pub cloud_config_bundle: CloudConfigBundleLoader,
pub in_memory_layer: Option<ConfigLayerEntry>,
}
impl From<LoaderOverrides> for ConfigLoadOptions {
@@ -31,6 +32,7 @@ impl From<LoaderOverrides> for ConfigLoadOptions {
loader_overrides,
strict_config: false,
cloud_config_bundle: CloudConfigBundleLoader::default(),
in_memory_layer: None,
}
}
}
@@ -206,6 +208,7 @@ impl ConfigLayerEntry {
ConfigLayerSource::System { file } => file.parent(),
ConfigLayerSource::EnterpriseManaged { .. } => None,
ConfigLayerSource::User { file, .. } => file.parent(),
ConfigLayerSource::InMemory => None,
ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()),
ConfigLayerSource::SessionFlags => None,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => None,
@@ -358,6 +361,35 @@ impl ConfigLayerStack {
Some(merged)
}
/// Returns the user-scoped config visible to runtime consumers.
///
/// This includes the process-scoped in-memory layer. Persistence code
/// should continue to use [`Self::effective_user_config`].
pub fn effective_runtime_user_config(&self) -> Option<TomlValue> {
let layers = self
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
)
.into_iter()
.filter(|layer| {
matches!(
layer.name,
ConfigLayerSource::User { .. } | ConfigLayerSource::InMemory
)
})
.collect::<Vec<_>>();
if layers.is_empty() {
return None;
}
let mut merged = TomlValue::Table(toml::map::Map::new());
for layer in layers {
merge_toml_values(&mut merged, &layer.config);
}
Some(merged)
}
pub fn requirements(&self) -> &ConfigRequirements {
&self.requirements
}
+42
View File
@@ -90,6 +90,48 @@ approval_policy = "on-failure"
);
}
#[test]
fn runtime_user_config_includes_in_memory_but_persisted_user_config_does_not() {
let temp_dir = TempDir::new().expect("tempdir");
let stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: test_user_config_path(&temp_dir, "config.toml"),
profile: None,
},
toml::toml! { model = "user" }.into(),
),
ConfigLayerEntry::new(
ConfigLayerSource::InMemory,
toml::toml! { model = "memory" }.into(),
),
ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
toml::toml! { model = "session" }.into(),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("valid config layer stack");
assert_eq!(
stack
.effective_user_config()
.and_then(|config| config.get("model").cloned())
.and_then(|model| model.as_str().map(str::to_string)),
Some("user".to_string())
);
assert_eq!(
stack
.effective_runtime_user_config()
.and_then(|config| config.get("model").cloned())
.and_then(|model| model.as_str().map(str::to_string)),
Some("memory".to_string())
);
}
#[test]
fn with_user_config_updates_matching_user_layer_without_replacing_active_profile() {
let temp_dir = TempDir::new().expect("tempdir");
@@ -18,7 +18,7 @@ pub fn installed_marketplace_roots_from_layer_stack(
config_layer_stack: &ConfigLayerStack,
codex_home: &Path,
) -> Vec<AbsolutePathBuf> {
let Some(user_config) = config_layer_stack.effective_user_config() else {
let Some(user_config) = config_layer_stack.effective_runtime_user_config() else {
return Vec::new();
};
let Some(marketplaces_value) = user_config.get("marketplaces") else {
+1 -1
View File
@@ -438,7 +438,7 @@ fn refresh_non_curated_plugin_cache_with_mode(
fn configured_plugins_from_stack(
config_layer_stack: &ConfigLayerStack,
) -> HashMap<String, PluginConfig> {
let Some(user_config) = config_layer_stack.effective_user_config() else {
let Some(user_config) = config_layer_stack.effective_runtime_user_config() else {
return HashMap::new();
};
configured_plugins_from_user_config_value(&user_config)
+12
View File
@@ -36,6 +36,11 @@ fn configured_plugins_from_stack_merges_user_layers() {
user_config_path(&temp_dir, "work.config.toml"),
"[plugins.profile]\nenabled = false\n",
),
ConfigLayerEntry::new(
ConfigLayerSource::InMemory,
toml::from_str("[plugins.memory]\nenabled = true\n")
.expect("in-memory config toml"),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
@@ -61,6 +66,13 @@ fn configured_plugins_from_stack_merges_user_layers() {
mcp_servers: HashMap::new(),
},
),
(
"memory".to_string(),
PluginConfig {
enabled: true,
mcp_servers: HashMap::new(),
},
),
])
);
}
+9 -3
View File
@@ -1006,7 +1006,14 @@ impl PluginsManager {
)
.await
.map_err(PluginRemoteSyncError::from)?;
let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack);
// Remote sync persists edits, so ephemeral runtime layers must not
// influence the current persisted state.
let configured_plugins = config
.config_layer_stack
.effective_user_config()
.as_ref()
.map(configured_plugins_from_user_config_value)
.unwrap_or_default();
let curated_marketplace_root = curated_plugins_repo_path(self.codex_home.as_path());
let curated_marketplace_path = AbsolutePathBuf::try_from(
curated_marketplace_root.join(".agents/plugins/marketplace.json"),
@@ -2080,8 +2087,7 @@ impl PluginUninstallError {
pub(crate) fn configured_plugins_from_stack(
config_layer_stack: &ConfigLayerStack,
) -> HashMap<String, PluginConfig> {
// Plugin entries remain persisted user config only.
let Some(user_config) = config_layer_stack.effective_user_config() else {
let Some(user_config) = config_layer_stack.effective_runtime_user_config() else {
return HashMap::new();
};
configured_plugins_from_user_config_value(&user_config)
@@ -2886,6 +2886,27 @@ enabled = true
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.config_layer_stack = ConfigLayerStack::new(
vec![
config
.config_layer_stack
.get_active_user_layer()
.unwrap()
.clone(),
ConfigLayerEntry::new(
ConfigLayerSource::InMemory,
toml::from_str(
r#"[plugins."linear@openai-curated"]
enabled = true
"#,
)
.unwrap(),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.unwrap();
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new(tmp.path().to_path_buf());
let result = manager
+3 -1
View File
@@ -35,7 +35,9 @@ pub fn skill_config_rules_from_stack(config_layer_stack: &ConfigLayerStack) -> S
) {
if !matches!(
layer.name,
ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags
ConfigLayerSource::User { .. }
| ConfigLayerSource::InMemory
| ConfigLayerSource::SessionFlags
) {
continue;
}
+1
View File
@@ -351,6 +351,7 @@ fn skill_roots_from_layer_stack_inner(
}
ConfigLayerSource::Mdm { .. }
| ConfigLayerSource::EnterpriseManaged { .. }
| ConfigLayerSource::InMemory
| ConfigLayerSource::SessionFlags
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. }
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {}
@@ -1879,6 +1879,7 @@ async fn strict_config_rejects_unknown_cloud_config_key() {
cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_config(
"unknown_key = true",
),
..Default::default()
},
&codex_config::NoopThreadConfigLoader,
)
@@ -3209,6 +3210,49 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()
.expect("foo entry");
assert_eq!(foo, "child");
make_config_for_test(
&codex_home,
&project_root,
TrustLevel::Trusted,
/*project_root_markers*/ None,
)
.await?;
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(AbsolutePathBuf::from_absolute_path(&nested)?),
&[] as &[(String, TomlValue)],
ConfigLoadOptions {
loader_overrides: LoaderOverrides::default(),
in_memory_layer: Some(ConfigLayerEntry::new(
ConfigLayerSource::InMemory,
toml::toml! {
project_root_markers = [".hg"]
foo = "memory"
}
.into(),
)),
..Default::default()
},
&codex_config::NoopThreadConfigLoader,
)
.await?;
assert_eq!(
layers
.layers_high_to_low()
.into_iter()
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
.count(),
2
);
assert_eq!(
layers
.effective_config()
.get("foo")
.and_then(TomlValue::as_str),
Some("child")
);
Ok(())
}
+9
View File
@@ -8,6 +8,7 @@ use crate::windows_sandbox::WindowsSandboxLevelExt;
use crate::windows_sandbox::resolve_windows_sandbox_mode;
use crate::windows_sandbox::resolve_windows_sandbox_private_desktop;
use codex_config::CloudConfigBundleLoader;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
@@ -1107,6 +1108,7 @@ pub struct ConfigBuilder {
loader_overrides: Option<LoaderOverrides>,
strict_config: bool,
cloud_config_bundle: CloudConfigBundleLoader,
in_memory_layer: Option<ConfigLayerEntry>,
thread_config_loader: Option<Arc<dyn ThreadConfigLoader>>,
fallback_cwd: Option<PathBuf>,
}
@@ -1142,6 +1144,11 @@ impl ConfigBuilder {
self
}
pub fn in_memory_layer(mut self, in_memory_layer: Option<ConfigLayerEntry>) -> Self {
self.in_memory_layer = in_memory_layer;
self
}
pub fn thread_config_loader(
mut self,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
@@ -1168,6 +1175,7 @@ impl ConfigBuilder {
loader_overrides,
strict_config,
cloud_config_bundle,
in_memory_layer,
thread_config_loader,
fallback_cwd,
} = self;
@@ -1193,6 +1201,7 @@ impl ConfigBuilder {
loader_overrides,
strict_config,
cloud_config_bundle,
in_memory_layer,
},
thread_config_loader
.as_deref()
@@ -320,6 +320,7 @@ fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool {
matches!(
layer,
ConfigLayerSource::User { .. }
| ConfigLayerSource::InMemory
| ConfigLayerSource::Project { .. }
| ConfigLayerSource::SessionFlags
)
+1
View File
@@ -625,6 +625,7 @@ async fn load_config_toml_or_exit(
loader_overrides,
strict_config,
cloud_config_bundle,
..Default::default()
},
)
.await
+6 -5
View File
@@ -9,10 +9,9 @@ use codex_config::TomlValue;
/// Build effective hook state from config layers that are allowed to override
/// user preferences.
///
/// This intentionally reads only user and session flag layers, including
/// disabled layers, to match the skills config behavior. Project, managed, and
/// plugin layers can discover hooks, but they do not get to write user hook
/// state.
/// This intentionally reads only user-controlled layers, including disabled
/// layers, to match the skills config behavior. Project, managed, and plugin
/// layers can discover hooks, but they do not get to write user hook state.
pub fn hook_states_from_stack(
config_layer_stack: Option<&ConfigLayerStack>,
) -> HashMap<String, HookStateToml> {
@@ -27,7 +26,9 @@ pub fn hook_states_from_stack(
) {
if !matches!(
layer.name,
ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags
ConfigLayerSource::User { .. }
| ConfigLayerSource::InMemory
| ConfigLayerSource::SessionFlags
) {
continue;
}
+2
View File
@@ -374,6 +374,7 @@ fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf {
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
synthetic_layer_path("<legacy-managed-config.toml-mdm>/managed_config.toml")
}
ConfigLayerSource::InMemory => synthetic_layer_path("<in-memory>/config.toml"),
ConfigLayerSource::SessionFlags => synthetic_layer_path("<session-flags>/config.toml"),
}
}
@@ -609,6 +610,7 @@ fn hook_metadata_for_config_layer_source(source: &ConfigLayerSource) -> (HookSou
match source {
ConfigLayerSource::System { .. } => (HookSource::System, true),
ConfigLayerSource::User { .. } => (HookSource::User, false),
ConfigLayerSource::InMemory => (HookSource::User, false),
ConfigLayerSource::Project { .. } => (HookSource::Project, false),
ConfigLayerSource::Mdm { .. } => (HookSource::Mdm, true),
ConfigLayerSource::EnterpriseManaged { .. } => (HookSource::CloudManagedConfig, true),
+5 -2
View File
@@ -268,7 +268,9 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec<Line<'static>> {
fn render_non_file_layer_details(layer: &ConfigLayerEntry) -> Vec<Line<'static>> {
match &layer.name {
ConfigLayerSource::SessionFlags => render_session_flag_details(&layer.config),
ConfigLayerSource::InMemory | ConfigLayerSource::SessionFlags => {
render_session_flag_details(&layer.config)
}
ConfigLayerSource::Mdm { .. }
| ConfigLayerSource::EnterpriseManaged { .. }
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm => render_non_file_layer_value(layer),
@@ -335,7 +337,8 @@ fn non_file_layer_value_label(source: &ConfigLayerSource) -> &'static str {
"MDM value"
}
ConfigLayerSource::EnterpriseManaged { .. } => "Enterprise-managed config value",
ConfigLayerSource::SessionFlags
ConfigLayerSource::InMemory
| ConfigLayerSource::SessionFlags
| ConfigLayerSource::System { .. }
| ConfigLayerSource::User { .. }
| ConfigLayerSource::Project { .. }
+1
View File
@@ -1968,6 +1968,7 @@ async fn load_config_toml_or_exit(
loader_overrides,
strict_config,
cloud_config_bundle,
..Default::default()
},
)
.await
@@ -253,6 +253,7 @@ async fn start_app_server_for_archive_command(
loader_overrides: loader_overrides.clone(),
strict_config,
cloud_config_bundle: CloudConfigBundleLoader::default(),
..Default::default()
},
)
.await