Current section
Files
Jump to
Current section
Files
lib/codex/config/layer_stack.ex
defmodule Codex.Config.LayerStack do
@moduledoc false
@type layer_source :: :system | :user | :project
@type layer :: %{
source: layer_source(),
path: String.t(),
config: map()
}
alias Codex.Config.Defaults
@default_system_config_path Defaults.system_config_path()
@default_project_root_markers Defaults.project_root_markers()
@spec load(String.t(), String.t() | nil) :: {:ok, [layer()]} | {:error, term()}
def load(codex_home, cwd \\ nil) when is_binary(codex_home) do
cwd = normalize_cwd(cwd)
with {:ok, base_layers} <- load_base_layers(codex_home),
{:ok, project_layers} <- maybe_load_project_layers(base_layers, cwd) do
{:ok, base_layers ++ project_layers}
end
end
defp maybe_load_project_layers(_base_layers, nil), do: {:ok, []}
defp maybe_load_project_layers(base_layers, cwd), do: load_project_layers(base_layers, cwd)
@spec effective_config([layer()]) :: map()
def effective_config(layers) when is_list(layers) do
Enum.reduce(layers, %{}, fn layer, acc -> merge_configs(acc, layer.config) end)
end
@spec remote_models_enabled?(String.t(), String.t() | nil) :: boolean()
def remote_models_enabled?(codex_home, cwd \\ nil) when is_binary(codex_home) do
case load(codex_home, cwd) do
{:ok, layers} ->
layers
|> effective_config()
|> get_in(["features", "remote_models"])
|> Kernel.===(true)
{:error, _reason} ->
false
end
end
defp load_base_layers(codex_home) do
with {:ok, system_layers} <- load_system_layers() do
user_path = Path.join(codex_home, "config.toml")
case read_required_config(user_path) do
{:ok, config} ->
{:ok, system_layers ++ [%{source: :user, path: user_path, config: config}]}
{:error, _} = error ->
error
end
end
end
defp load_system_layers do
case read_optional_config(system_config_path()) do
{:ok, config} -> {:ok, [%{source: :system, path: system_config_path(), config: config}]}
:missing -> {:ok, []}
{:error, _} = error -> error
end
end
defp load_project_layers(base_layers, cwd) do
base_config = effective_config(base_layers)
with {:ok, markers} <- project_root_markers(base_config) do
project_root = find_project_root(cwd, markers || @default_project_root_markers)
load_project_layers_between(project_root, cwd)
end
end
defp load_project_layers_between(project_root, cwd) do
dirs = dirs_between(project_root, cwd)
Enum.reduce_while(dirs, {:ok, []}, fn dir, {:ok, layers} ->
case project_layer_for_dir(dir) do
{:ok, nil} ->
{:cont, {:ok, layers}}
{:ok, layer} ->
{:cont, {:ok, layers ++ [layer]}}
{:error, _} = error ->
{:halt, error}
end
end)
end
defp project_layer_for_dir(dir) do
dot_codex = Path.join(dir, ".codex")
if File.dir?(dot_codex) do
config_path = Path.join(dot_codex, "config.toml")
case read_project_config(config_path) do
{:ok, config} -> {:ok, %{source: :project, path: config_path, config: config}}
{:error, _} = error -> error
end
else
{:ok, nil}
end
end
defp project_root_markers(config) when is_map(config) do
case Map.get(config, "project_root_markers") do
nil ->
{:ok, nil}
markers when is_list(markers) ->
if Enum.all?(markers, &is_binary/1) do
{:ok, markers}
else
{:error, {:invalid_project_root_markers, markers}}
end
other ->
{:error, {:invalid_project_root_markers, other}}
end
end
defp read_optional_config(path) do
case File.read(path) do
{:ok, contents} -> parse_config(contents, path)
{:error, :enoent} -> :missing
{:error, reason} -> {:error, {:config_read_failed, path, reason}}
end
end
defp read_required_config(path) do
case File.read(path) do
{:ok, contents} -> parse_config(contents, path)
{:error, :enoent} -> {:ok, %{}}
{:error, reason} -> {:error, {:config_read_failed, path, reason}}
end
end
defp read_project_config(path) do
case File.read(path) do
{:ok, contents} -> parse_config(contents, path)
{:error, :enoent} -> {:ok, %{}}
{:error, reason} -> {:error, {:config_read_failed, path, reason}}
end
end
defp parse_config(contents, path) do
case parse_config_contents(contents) do
{:ok, config} -> {:ok, config}
{:error, reason} -> {:error, {:invalid_toml, path, reason}}
end
end
defp parse_config_contents(contents) do
with {:ok, config} <- parse_toml_subset(contents),
:ok <- validate_config(config) do
{:ok, config}
end
end
defp parse_toml_subset(contents) when is_binary(contents) do
contents
|> String.split(~r/\r?\n/, trim: false)
|> parse_lines(%{}, nil)
end
defp parse_lines([], acc, _section), do: {:ok, acc}
defp parse_lines([line | rest], acc, section) do
line = line |> strip_comments() |> String.trim()
cond do
line == "" ->
parse_lines(rest, acc, section)
table = parse_table(line) ->
parse_lines(rest, acc, table)
true ->
case parse_key_value(line, acc, section) do
{:ok, updated} -> parse_lines(rest, updated, section)
{:error, _} = error -> error
end
end
end
defp parse_table(line) do
case Regex.run(~r/^\[(.+)\]$/, line) do
[_, name] ->
name = String.trim(name)
case name do
"features" -> "features"
"history" -> "history"
"shell_environment_policy" -> "shell_environment_policy"
_ -> :skip
end
_ ->
nil
end
end
defp parse_key_value(line, acc, section) do
case String.split(line, "=", parts: 2) do
[raw_key, raw_value] ->
key = String.trim(raw_key)
raw_value = String.trim(raw_value)
with {:ok, value} <- parse_value(raw_value) do
{:ok, put_config_value(acc, section, key, value)}
end
_ ->
{:error, {:invalid_toml_line, line}}
end
end
defp put_config_value(acc, :skip, _key, _value), do: acc
defp put_config_value(acc, nil, "project_root_markers", value),
do: Map.put(acc, "project_root_markers", value)
defp put_config_value(acc, nil, "cli_auth_credentials_store", value),
do: Map.put(acc, "cli_auth_credentials_store", value)
defp put_config_value(acc, nil, "mcp_oauth_credentials_store", value),
do: Map.put(acc, "mcp_oauth_credentials_store", value)
defp put_config_value(acc, nil, "forced_login_method", value),
do: Map.put(acc, "forced_login_method", value)
defp put_config_value(acc, nil, "forced_chatgpt_workspace_id", value),
do: Map.put(acc, "forced_chatgpt_workspace_id", value)
defp put_config_value(acc, nil, "model", value),
do: Map.put(acc, "model", value)
defp put_config_value(acc, nil, "model_reasoning_effort", value),
do: Map.put(acc, "model_reasoning_effort", value)
defp put_config_value(acc, nil, "model_provider", value),
do: Map.put(acc, "model_provider", value)
defp put_config_value(acc, nil, "features", value) when is_map(value),
do: Map.put(acc, "features", value)
defp put_config_value(acc, nil, "features", _value), do: acc
defp put_config_value(acc, nil, "history", value) when is_map(value),
do: Map.put(acc, "history", value)
defp put_config_value(acc, nil, "history", _value), do: acc
defp put_config_value(acc, nil, "shell_environment_policy", value) when is_map(value),
do: Map.put(acc, "shell_environment_policy", value)
defp put_config_value(acc, nil, "shell_environment_policy", _value), do: acc
defp put_config_value(acc, nil, key, value), do: maybe_put_dotted_value(acc, key, value)
defp put_config_value(acc, "features", key, value) do
Map.update(acc, "features", %{key => value}, &Map.put(&1, key, value))
end
defp put_config_value(acc, "history", key, value) do
Map.update(acc, "history", %{key => value}, &Map.put(&1, key, value))
end
defp put_config_value(acc, "shell_environment_policy", key, value) do
Map.update(acc, "shell_environment_policy", %{key => value}, &Map.put(&1, key, value))
end
defp maybe_put_dotted_value(acc, key, value) do
case String.split(key, ".") do
["features" | rest] when rest != [] ->
put_nested_path(acc, ["features" | rest], value)
["history" | rest] when rest != [] ->
put_nested_path(acc, ["history" | rest], value)
["shell_environment_policy" | rest] when rest != [] ->
put_nested_path(acc, ["shell_environment_policy" | rest], value)
_ ->
acc
end
end
defp put_nested_path(acc, [root | rest], value) do
Map.update(acc, root, build_nested(rest, value), fn existing ->
merge_nested(existing, rest, value)
end)
end
defp build_nested([], value), do: value
defp build_nested([key | rest], value), do: %{key => build_nested(rest, value)}
defp merge_nested(_existing, [], value), do: value
defp merge_nested(%{} = existing, [key | rest], value) do
Map.update(existing, key, build_nested(rest, value), fn nested ->
merge_nested(nested, rest, value)
end)
end
defp merge_nested(_existing, rest, value), do: build_nested(rest, value)
defp strip_comments(line) do
line
|> String.to_charlist()
|> strip_comments([], nil)
|> Enum.reverse()
|> to_string()
end
defp strip_comments([], acc, _quote), do: acc
defp strip_comments([?# | _rest], acc, nil), do: acc
defp strip_comments([char | rest], acc, quote) do
strip_comments(rest, [char | acc], next_quote(char, quote))
end
defp next_quote(char, nil) when char in [?", ?'], do: char
defp next_quote(char, quote) when char == quote, do: nil
defp next_quote(_char, quote), do: quote
defp parse_value(value) do
value = String.trim(value)
cond do
value == "" ->
{:error, {:invalid_toml_value, value}}
boolean_string?(value) ->
{:ok, value == "true"}
array_string?(value) ->
parse_array(value)
inline_table_string?(value) ->
parse_inline_table(value)
quoted_string?(value) ->
{:ok, strip_quotes(value)}
integer_string?(value) ->
{:ok, parse_integer(value)}
true ->
{:ok, value}
end
end
defp boolean_string?("true"), do: true
defp boolean_string?("false"), do: true
defp boolean_string?(_), do: false
defp array_string?(value) do
String.starts_with?(value, "[") and String.ends_with?(value, "]")
end
defp inline_table_string?(value) do
String.starts_with?(value, "{") and String.ends_with?(value, "}")
end
defp quoted_string?(value) do
(String.starts_with?(value, "\"") and String.ends_with?(value, "\"")) or
(String.starts_with?(value, "'") and String.ends_with?(value, "'"))
end
defp strip_quotes(value) do
value
|> String.trim_leading("\"")
|> String.trim_trailing("\"")
|> String.trim_leading("'")
|> String.trim_trailing("'")
end
defp integer_string?(value) do
String.match?(value, ~r/^-?\d+(_\d+)*$/)
end
defp parse_integer(value) do
value
|> String.replace("_", "")
|> String.to_integer()
end
defp parse_array(value) do
value
|> String.trim_leading("[")
|> String.trim_trailing("]")
|> String.trim()
|> parse_array_inner()
end
defp parse_array_inner(""), do: {:ok, []}
defp parse_array_inner(inner) do
inner
|> String.split(",", trim: true)
|> Enum.reduce_while({:ok, []}, fn entry, {:ok, acc} ->
case parse_value(entry) do
{:ok, parsed} -> {:cont, {:ok, acc ++ [parsed]}}
{:error, _} = error -> {:halt, error}
end
end)
end
defp parse_inline_table(value) do
value
|> String.trim_leading("{")
|> String.trim_trailing("}")
|> String.trim()
|> parse_inline_table_inner()
end
defp parse_inline_table_inner(""), do: {:ok, %{}}
defp parse_inline_table_inner(inner) do
inner
|> String.split(",", trim: true)
|> Enum.reduce_while({:ok, %{}}, fn entry, {:ok, acc} ->
case parse_inline_entry(entry) do
{:ok, {key, parsed}} -> {:cont, {:ok, Map.put(acc, key, parsed)}}
{:error, _} = error -> {:halt, error}
end
end)
end
defp parse_inline_entry(entry) do
case String.split(entry, "=", parts: 2) do
[raw_key, raw_value] ->
key = String.trim(raw_key)
raw_value = String.trim(raw_value)
case parse_value(raw_value) do
{:ok, parsed} -> {:ok, {key, parsed}}
{:error, _} = error -> error
end
_ ->
{:error, {:invalid_toml_inline_table, entry}}
end
end
defp validate_config(%{} = config) do
with :ok <- validate_features(config),
:ok <- validate_model_provider(config),
:ok <- validate_history(config),
:ok <- validate_shell_environment_policy(config),
:ok <- validate_project_root_markers(config),
:ok <- validate_cli_auth_store(config),
:ok <- validate_mcp_oauth_store(config) do
validate_forced_login_config(config)
end
end
defp validate_config(_), do: {:error, :invalid_config_root}
defp validate_features(config) do
case fetch_value(config, ["features", :features]) do
nil ->
:ok
%{} = features ->
validate_boolean_map(features, :invalid_features)
other ->
{:error, {:invalid_features, other}}
end
end
defp validate_model_provider(config) do
case fetch_value(config, ["model_provider", :model_provider]) do
nil ->
:ok
value when is_binary(value) ->
:ok
other ->
{:error, {:invalid_model_provider, other}}
end
end
defp validate_history(config) do
case fetch_value(config, ["history", :history]) do
nil ->
:ok
%{} = history ->
with :ok <- validate_history_persistence(history) do
validate_history_max_bytes(history)
end
other ->
{:error, {:invalid_history, other}}
end
end
defp validate_history_persistence(history) do
case fetch_value(history, ["persistence", :persistence]) do
nil -> :ok
value when is_binary(value) -> :ok
other -> {:error, {:invalid_history_persistence, other}}
end
end
defp validate_history_max_bytes(history) do
case fetch_value(history, ["max_bytes", "maxBytes", :max_bytes, :maxBytes]) do
nil ->
:ok
value when is_integer(value) and value >= 0 ->
:ok
other ->
{:error, {:invalid_history_max_bytes, other}}
end
end
defp validate_shell_environment_policy(config) do
case fetch_value(config, ["shell_environment_policy", :shell_environment_policy]) do
nil ->
:ok
%{} = policy ->
with :ok <- validate_shell_env_inherit(policy),
:ok <- validate_shell_env_ignore_default_excludes(policy),
:ok <- validate_shell_env_list(policy, "exclude", :exclude),
:ok <- validate_shell_env_list(policy, "include_only", :include_only) do
validate_shell_env_set(policy)
end
other ->
{:error, {:invalid_shell_environment_policy, other}}
end
end
defp validate_shell_env_inherit(policy) do
case fetch_value(policy, ["inherit", :inherit]) do
nil -> :ok
value when is_binary(value) -> :ok
other -> {:error, {:invalid_shell_environment_inherit, other}}
end
end
defp validate_shell_env_ignore_default_excludes(policy) do
case fetch_value(policy, ["ignore_default_excludes", :ignore_default_excludes]) do
nil -> :ok
value when is_boolean(value) -> :ok
other -> {:error, {:invalid_shell_environment_ignore_default_excludes, other}}
end
end
defp validate_shell_env_list(policy, key, atom_key) do
case fetch_value(policy, [key, atom_key]) do
nil ->
:ok
value when is_list(value) ->
if Enum.all?(value, &is_binary/1) do
:ok
else
{:error, {:invalid_shell_environment_list, key, value}}
end
other ->
{:error, {:invalid_shell_environment_list, key, other}}
end
end
defp validate_shell_env_set(policy) do
case fetch_value(policy, ["set", :set]) do
nil ->
:ok
%{} = set ->
validate_string_map(set, :invalid_shell_environment_set)
other ->
{:error, {:invalid_shell_environment_set, other}}
end
end
defp validate_boolean_map(map, error_tag) do
if Enum.all?(map, fn {_key, value} -> is_boolean(value) end) do
:ok
else
{:error, {error_tag, map}}
end
end
defp validate_string_map(map, error_tag) do
if Enum.all?(map, fn {key, value} -> is_binary(key) and is_binary(value) end) do
:ok
else
{:error, {error_tag, map}}
end
end
defp validate_project_root_markers(config) do
case fetch_value(config, ["project_root_markers", :project_root_markers]) do
nil ->
:ok
value when is_list(value) ->
if Enum.all?(value, &is_binary/1) do
:ok
else
{:error, {:invalid_project_root_markers, value}}
end
other ->
{:error, {:invalid_project_root_markers, other}}
end
end
defp validate_cli_auth_store(config) do
case fetch_value(config, ["cli_auth_credentials_store", :cli_auth_credentials_store]) do
nil ->
:ok
value when is_binary(value) ->
if value in ["file", "keyring", "auto"] do
:ok
else
{:error, {:invalid_cli_auth_credentials_store, value}}
end
other ->
{:error, {:invalid_cli_auth_credentials_store, other}}
end
end
defp validate_mcp_oauth_store(config) do
case fetch_value(config, ["mcp_oauth_credentials_store", :mcp_oauth_credentials_store]) do
nil ->
:ok
value when is_binary(value) ->
if value in ["file", "keyring", "auto"] do
:ok
else
{:error, {:invalid_mcp_oauth_credentials_store, value}}
end
other ->
{:error, {:invalid_mcp_oauth_credentials_store, other}}
end
end
defp validate_forced_login_config(config) do
with :ok <- validate_forced_login_method(config) do
validate_forced_chatgpt_workspace_id(config)
end
end
defp validate_forced_login_method(config) do
case fetch_value(config, ["forced_login_method", :forced_login_method]) do
nil ->
:ok
value when is_binary(value) ->
if value in ["chatgpt", "api"] do
:ok
else
{:error, {:invalid_forced_login_method, value}}
end
other ->
{:error, {:invalid_forced_login_method, other}}
end
end
defp validate_forced_chatgpt_workspace_id(config) do
case fetch_value(config, ["forced_chatgpt_workspace_id", :forced_chatgpt_workspace_id]) do
nil -> :ok
value when is_binary(value) -> :ok
other -> {:error, {:invalid_forced_chatgpt_workspace_id, other}}
end
end
defp fetch_value(map, [key | rest]) do
case Map.fetch(map, key) do
{:ok, value} -> value
:error -> fetch_value(map, rest)
end
end
defp fetch_value(_map, []), do: nil
defp merge_configs(base, override) when is_map(base) and is_map(override) do
Map.merge(base, override, fn _key, left, right ->
if is_map(left) and is_map(right) do
merge_configs(left, right)
else
right
end
end)
end
defp dirs_between(project_root, cwd) do
project_root = Path.expand(project_root)
cwd = Path.expand(cwd)
dirs =
cwd
|> ancestors()
|> Enum.reduce_while([], fn dir, acc ->
acc = [dir | acc]
if dir == project_root do
{:halt, acc}
else
{:cont, acc}
end
end)
dirs
|> Enum.reverse()
|> case do
[] -> [cwd]
list -> list
end
end
defp ancestors(path) do
path = Path.expand(path)
Stream.unfold(path, fn
nil ->
nil
current ->
parent = Path.dirname(current)
next = if parent == current, do: nil, else: parent
{current, next}
end)
end
defp find_project_root(cwd, []), do: Path.expand(cwd)
defp find_project_root(cwd, markers) when is_list(markers) do
Enum.find(ancestors(cwd), Path.expand(cwd), &has_marker?(&1, markers))
end
defp has_marker?(dir, markers) do
Enum.any?(markers, fn marker ->
File.exists?(Path.join(dir, marker))
end)
end
defp normalize_cwd(nil) do
case File.cwd() do
{:ok, cwd} -> cwd
_ -> nil
end
end
defp normalize_cwd(cwd) when is_binary(cwd), do: cwd
defp normalize_cwd(_), do: nil
defp system_config_path do
case Application.get_env(:codex_sdk, :system_config_path) do
nil -> @default_system_config_path
value -> value
end
end
end