From 938d2dd18ebec48c8a61af1f09bea50bc7faa4ff Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sat, 23 May 2026 11:02:48 +0000 Subject: [PATCH 01/19] feat(install): add dasel installation function and update config merging logic --- .../modules/codex/scripts/install.sh.tftpl | 104 ++++++++++++------ 1 file changed, 73 insertions(+), 31 deletions(-) diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index 584c978b3..0a88ec3c3 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -26,6 +26,40 @@ printf "install_codex: %s\n" "$${ARG_INSTALL}" printf "model_reasoning_effort: %s\n" "$${ARG_MODEL_REASONING_EFFORT}" echo "--------------------------------" + +function install_dasel() { + if command_exists dasel; then + return 0 + fi + + local os arch install_dir url + os=$$(uname -s | tr '[:upper:]' '[:lower:]') + arch=$$(uname -m) + case "$${arch}" in + x86_64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) + printf "Error: unsupported architecture %s\n" "$${arch}" >&2 + return 1 + ;; + esac + + install_dir="$${CODER_SCRIPT_BIN_DIR:-$$HOME/.local/bin}" + mkdir -p "$${install_dir}" + + url="https://github.com/TomWright/dasel/releases/download/v3.4.0/dasel_$${os}_$${arch}" + printf "Installing dasel from %s\n" "$${url}" + if curl -fsSL "$${url}" -o "$${install_dir}/dasel" && chmod +x "$${install_dir}/dasel"; then + export PATH="$${install_dir}:$$PATH" + printf "Installed %s\n" "$$(dasel --version)" + else + printf "Error: failed to download dasel\n" >&2 + rm -f "$${install_dir}/dasel" + return 1 + fi +} + + function add_path_to_shell_profiles() { local path_dir="$1" @@ -112,56 +146,63 @@ function install_codex() { } function write_minimal_default_config() { - local config_path="$1" - local optional_config="" + local config_path="$$1" + local optional_config='preferred_auth_method = "apikey"' - if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ]; then - optional_config='model_provider = "aigateway"' + if [[ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ]]; then + optional_config+=$$'\n''model_provider = "aigateway"' fi - if [ -n "$${ARG_MODEL_REASONING_EFFORT}" ]; then - optional_config+=$'\n'"model_reasoning_effort = \"$${ARG_MODEL_REASONING_EFFORT}\"" + if [[ -n "$${ARG_MODEL_REASONING_EFFORT}" ]]; then + optional_config+=$$'\n'"model_reasoning_effort = \"$${ARG_MODEL_REASONING_EFFORT}\"" fi - cat << EOF > "$${config_path}" -preferred_auth_method = "apikey" -$${optional_config} - -EOF - - if [ -n "$${ARG_WORKDIR}" ]; then - cat << EOF >> "$${config_path}" -[projects."$${ARG_WORKDIR}"] -trust_level = "trusted" - -EOF + if [[ -n "$${ARG_WORKDIR}" ]]; then + optional_config+=$$'\n'"[projects.\"$${ARG_WORKDIR}\"]" + optional_config+=$$'\n''trust_level = "trusted"' fi + + merge_tmp="$$(mktemp)" + echo "$${optional_config}" > "$${merge_tmp}" + dasel -i toml --root "merge(parse(\"toml\", readFile(\"$${merge_tmp}\")), \$root)" \ + < "$${config_path}" > "$${config_path}.tmp" \ + && mv "$${config_path}.tmp" "$${config_path}" + rm -f "$${merge_tmp}" } function populate_config_toml() { local config_path="$HOME/.codex/config.toml" - mkdir -p "$(dirname "$${config_path}")" + mkdir -p "$$(dirname "$${config_path}")" - if [ -n "$${ARG_BASE_CONFIG_TOML}" ]; then - printf "Using provided base configuration\n" - echo "$${ARG_BASE_CONFIG_TOML}" > "$${config_path}" - else + if [ ! -f "$${config_path}" ]; then printf "Using minimal default configuration\n" write_minimal_default_config "$${config_path}" fi + local merge_config="" + + if [ -n "$${ARG_BASE_CONFIG_TOML}" ]; then + printf "Using provided base configuration\n" + merge_config+="$${ARG_BASE_CONFIG_TOML}" + fi + if [ -n "$${ARG_MCP}" ]; then printf "Adding MCP servers\n" - echo "$${ARG_MCP}" >> "$${config_path}" + merge_config+=$$'\n'"$${ARG_MCP}" fi - if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] && [ -n "$${ARG_AIBRIDGE_CONFIG}" ]; then - if ! grep -q '\[model_providers\.aigateway\]' "$${config_path}" 2>/dev/null; then - printf "Adding AI Gateway configuration\n" - echo -e "\n$${ARG_AIBRIDGE_CONFIG}" >> "$${config_path}" - else - printf "AI Gateway provider already defined in config, skipping append\n" - fi + if [[ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ]] && [[ -n "$${ARG_AIBRIDGE_CONFIG}" ]]; then + printf "Adding AI Gateway configuration\n" + merge_config+=$$'\n'"$${ARG_AIBRIDGE_CONFIG}" + fi + + if [ -n "$${merge_config}" ]; then + merge_tmp="$$(mktemp)" + echo "$${merge_config}" > "$${merge_tmp}" + dasel -i toml --root "merge(parse(\"toml\", readFile(\"$${merge_tmp}\")), \$root)" \ + < "$${config_path}" > "$${config_path}.tmp" \ + && mv "$${config_path}.tmp" "$${config_path}" + rm -f "$${merge_tmp}" fi } @@ -190,6 +231,7 @@ EOF } install_codex +install_dasel populate_config_toml setup_workdir add_auth_json From 77e21139e0f6dad301e20e9c67d6ffad77c3e917 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sat, 23 May 2026 11:52:35 +0000 Subject: [PATCH 02/19] refactor(install): simplify variable assignments and improve config merging logic --- .../modules/codex/scripts/install.sh.tftpl | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index 0a88ec3c3..c7b4e954c 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -33,8 +33,8 @@ function install_dasel() { fi local os arch install_dir url - os=$$(uname -s | tr '[:upper:]' '[:lower:]') - arch=$$(uname -m) + os=$(uname -s | tr '[:upper:]' '[:lower:]') + arch=$(uname -m) case "$${arch}" in x86_64) arch="amd64" ;; aarch64|arm64) arch="arm64" ;; @@ -44,14 +44,14 @@ function install_dasel() { ;; esac - install_dir="$${CODER_SCRIPT_BIN_DIR:-$$HOME/.local/bin}" + install_dir="$${CODER_SCRIPT_BIN_DIR:-$HOME/.local/bin}" mkdir -p "$${install_dir}" url="https://github.com/TomWright/dasel/releases/download/v3.4.0/dasel_$${os}_$${arch}" printf "Installing dasel from %s\n" "$${url}" if curl -fsSL "$${url}" -o "$${install_dir}/dasel" && chmod +x "$${install_dir}/dasel"; then - export PATH="$${install_dir}:$$PATH" - printf "Installed %s\n" "$$(dasel --version)" + export PATH="$${install_dir}:$PATH" + printf "Installed %s\n" "$(dasel --version)" else printf "Error: failed to download dasel\n" >&2 rm -f "$${install_dir}/dasel" @@ -146,23 +146,23 @@ function install_codex() { } function write_minimal_default_config() { - local config_path="$$1" + local config_path="$1" local optional_config='preferred_auth_method = "apikey"' if [[ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ]]; then - optional_config+=$$'\n''model_provider = "aigateway"' + optional_config+=$'\n''model_provider = "aigateway"' fi if [[ -n "$${ARG_MODEL_REASONING_EFFORT}" ]]; then - optional_config+=$$'\n'"model_reasoning_effort = \"$${ARG_MODEL_REASONING_EFFORT}\"" + optional_config+=$'\n'"model_reasoning_effort = \"$${ARG_MODEL_REASONING_EFFORT}\"" fi if [[ -n "$${ARG_WORKDIR}" ]]; then - optional_config+=$$'\n'"[projects.\"$${ARG_WORKDIR}\"]" - optional_config+=$$'\n''trust_level = "trusted"' + optional_config+=$'\n'"[projects.\"$${ARG_WORKDIR}\"]" + optional_config+=$'\n''trust_level = "trusted"' fi - merge_tmp="$$(mktemp)" + merge_tmp="$(mktemp)" echo "$${optional_config}" > "$${merge_tmp}" dasel -i toml --root "merge(parse(\"toml\", readFile(\"$${merge_tmp}\")), \$root)" \ < "$${config_path}" > "$${config_path}.tmp" \ @@ -172,7 +172,7 @@ function write_minimal_default_config() { function populate_config_toml() { local config_path="$HOME/.codex/config.toml" - mkdir -p "$$(dirname "$${config_path}")" + mkdir -p "$(dirname "$${config_path}")" if [ ! -f "$${config_path}" ]; then printf "Using minimal default configuration\n" @@ -188,16 +188,16 @@ function populate_config_toml() { if [ -n "$${ARG_MCP}" ]; then printf "Adding MCP servers\n" - merge_config+=$$'\n'"$${ARG_MCP}" + merge_config+=$'\n'"$${ARG_MCP}" fi if [[ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ]] && [[ -n "$${ARG_AIBRIDGE_CONFIG}" ]]; then printf "Adding AI Gateway configuration\n" - merge_config+=$$'\n'"$${ARG_AIBRIDGE_CONFIG}" + merge_config+=$'\n'"$${ARG_AIBRIDGE_CONFIG}" fi if [ -n "$${merge_config}" ]; then - merge_tmp="$$(mktemp)" + merge_tmp="$(mktemp)" echo "$${merge_config}" > "$${merge_tmp}" dasel -i toml --root "merge(parse(\"toml\", readFile(\"$${merge_tmp}\")), \$root)" \ < "$${config_path}" > "$${config_path}.tmp" \ From 72737581f7bbe22dd29aa1bac68c0a51ee0f6d49 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sat, 23 May 2026 12:19:10 +0000 Subject: [PATCH 03/19] fix(install): ensure config.toml is created if it doesn't exist or is empty --- registry/coder-labs/modules/codex/scripts/install.sh.tftpl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index c7b4e954c..039bcdcfe 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -173,8 +173,9 @@ function write_minimal_default_config() { function populate_config_toml() { local config_path="$HOME/.codex/config.toml" mkdir -p "$(dirname "$${config_path}")" + touch "$${config_path}" - if [ ! -f "$${config_path}" ]; then + if [ ! -s "$${config_path}" ]; then printf "Using minimal default configuration\n" write_minimal_default_config "$${config_path}" fi From 3e21d6aa2358f186126e3a5387e831c2ea039e39 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sat, 23 May 2026 12:47:27 +0000 Subject: [PATCH 04/19] refactor(install): extract TOML merging logic into a dedicated function --- .../modules/codex/scripts/install.sh.tftpl | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index 039bcdcfe..54a5d4231 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -145,6 +145,24 @@ function install_codex() { ensure_codex_in_path } +function merge_toml_config() { + local config_path="$1" + local content="$2" + + if [ ! -s "$${config_path}" ]; then + echo "$${content}" > "$${config_path}" + return + fi + + local merge_tmp + merge_tmp="$(mktemp)" + echo "$${content}" > "$${merge_tmp}" + dasel -i toml --root "merge(parse(\"toml\", readFile(\"$${merge_tmp}\")), \$root)" \ + < "$${config_path}" > "$${config_path}.tmp" \ + && mv "$${config_path}.tmp" "$${config_path}" + rm -f "$${merge_tmp}" +} + function write_minimal_default_config() { local config_path="$1" local optional_config='preferred_auth_method = "apikey"' @@ -162,12 +180,7 @@ function write_minimal_default_config() { optional_config+=$'\n''trust_level = "trusted"' fi - merge_tmp="$(mktemp)" - echo "$${optional_config}" > "$${merge_tmp}" - dasel -i toml --root "merge(parse(\"toml\", readFile(\"$${merge_tmp}\")), \$root)" \ - < "$${config_path}" > "$${config_path}.tmp" \ - && mv "$${config_path}.tmp" "$${config_path}" - rm -f "$${merge_tmp}" + merge_toml_config "$${config_path}" "$${optional_config}" } function populate_config_toml() { @@ -175,21 +188,19 @@ function populate_config_toml() { mkdir -p "$(dirname "$${config_path}")" touch "$${config_path}" - if [ ! -s "$${config_path}" ]; then + if [ -n "$${ARG_BASE_CONFIG_TOML}" ]; then + printf "Using provided base configuration\n" + merge_toml_config "$${config_path}" "$${ARG_BASE_CONFIG_TOML}" + else printf "Using minimal default configuration\n" write_minimal_default_config "$${config_path}" fi local merge_config="" - if [ -n "$${ARG_BASE_CONFIG_TOML}" ]; then - printf "Using provided base configuration\n" - merge_config+="$${ARG_BASE_CONFIG_TOML}" - fi - if [ -n "$${ARG_MCP}" ]; then printf "Adding MCP servers\n" - merge_config+=$'\n'"$${ARG_MCP}" + merge_config+="$${ARG_MCP}" fi if [[ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ]] && [[ -n "$${ARG_AIBRIDGE_CONFIG}" ]]; then @@ -198,15 +209,9 @@ function populate_config_toml() { fi if [ -n "$${merge_config}" ]; then - merge_tmp="$(mktemp)" - echo "$${merge_config}" > "$${merge_tmp}" - dasel -i toml --root "merge(parse(\"toml\", readFile(\"$${merge_tmp}\")), \$root)" \ - < "$${config_path}" > "$${config_path}.tmp" \ - && mv "$${config_path}.tmp" "$${config_path}" - rm -f "$${merge_tmp}" + merge_toml_config "$${config_path}" "$${merge_config}" fi } - function setup_workdir() { if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then echo "Creating workdir: $${ARG_WORKDIR}" From dcea2fc00a4ec08d20554699f6d0482f3ac8479d Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sat, 23 May 2026 12:58:26 +0000 Subject: [PATCH 05/19] debug --- registry/coder-labs/modules/codex/scripts/install.sh.tftpl | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index 54a5d4231..2d31abecb 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -51,7 +51,7 @@ function install_dasel() { printf "Installing dasel from %s\n" "$${url}" if curl -fsSL "$${url}" -o "$${install_dir}/dasel" && chmod +x "$${install_dir}/dasel"; then export PATH="$${install_dir}:$PATH" - printf "Installed %s\n" "$(dasel --version)" + printf "Installed %s\n" "$(dasel version)" else printf "Error: failed to download dasel\n" >&2 rm -f "$${install_dir}/dasel" @@ -180,6 +180,8 @@ function write_minimal_default_config() { optional_config+=$'\n''trust_level = "trusted"' fi + echo "OPTIONAL CONFIG: $${optional_config}" + merge_toml_config "$${config_path}" "$${optional_config}" } From eb9dd48ac1872eda9d6b293ee4dff42b9ce2fc83 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sat, 23 May 2026 13:33:11 +0000 Subject: [PATCH 06/19] debug --- registry/coder-labs/modules/codex/scripts/install.sh.tftpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index 2d31abecb..dfd3edec0 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -157,7 +157,7 @@ function merge_toml_config() { local merge_tmp merge_tmp="$(mktemp)" echo "$${content}" > "$${merge_tmp}" - dasel -i toml --root "merge(parse(\"toml\", readFile(\"$${merge_tmp}\")), \$root)" \ + dasel -i toml -o toml "{parse(\"toml\", readFile(\"$${merge_tmp}\"))..., \$root...}" \ < "$${config_path}" > "$${config_path}.tmp" \ && mv "$${config_path}.tmp" "$${config_path}" rm -f "$${merge_tmp}" From ac7e1a86c49766b04397185baf3514a7df2a22b6 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sat, 23 May 2026 14:00:46 +0000 Subject: [PATCH 07/19] debug --- .../modules/codex/scripts/install.sh.tftpl | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index dfd3edec0..77ad29793 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -154,13 +154,19 @@ function merge_toml_config() { return fi - local merge_tmp - merge_tmp="$(mktemp)" + local merge_tmp existing_json new_json + merge_tmp=$(mktemp) + existing_json=$(mktemp) + new_json=$(mktemp) echo "$${content}" > "$${merge_tmp}" - dasel -i toml -o toml "{parse(\"toml\", readFile(\"$${merge_tmp}\"))..., \$root...}" \ - < "$${config_path}" > "$${config_path}.tmp" \ + + dasel -i toml -o json < "$${config_path}" > "$${existing_json}" + dasel -i toml -o json < "$${merge_tmp}" > "$${new_json}" + + jq -s '.[0] * .[1]' "$${new_json}" "$${existing_json}" \ + | dasel -i json -o toml > "$${config_path}.tmp" \ && mv "$${config_path}.tmp" "$${config_path}" - rm -f "$${merge_tmp}" + rm -f "$${merge_tmp}" "$${existing_json}" "$${new_json}" } function write_minimal_default_config() { From d4e6866e38fe21acb49e7d478a0967816262f096 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 24 May 2026 10:31:40 +0000 Subject: [PATCH 08/19] chore: remove debug logs --- registry/coder-labs/modules/codex/scripts/install.sh.tftpl | 2 -- 1 file changed, 2 deletions(-) diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index 77ad29793..1210f381d 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -186,8 +186,6 @@ function write_minimal_default_config() { optional_config+=$'\n''trust_level = "trusted"' fi - echo "OPTIONAL CONFIG: $${optional_config}" - merge_toml_config "$${config_path}" "$${optional_config}" } From eaab5d5c183ee489d2723a058183834623f6c0ac Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 24 May 2026 12:54:42 +0000 Subject: [PATCH 09/19] test: add idempotent tests to preserve user edits in config --- .../coder-labs/modules/codex/main.test.ts | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index f61807723..4c0f0ecca 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -425,6 +425,309 @@ describe("codex", async () => { expect(installLog).toContain("Installed Codex CLI"); }); + test("idempotent-defaults-preserve-user-edits", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + + // User edits the config between restarts + await execContainer(id, [ + "bash", + "-c", + `cat > /home/coder/.codex/config.toml << 'EOF' +preferred_auth_method = "login" +custom_user_key = "my_value" + +[projects."/home/coder/project"] +trust_level = "trusted" +EOF`, + ]); + + // Second run: user edits must survive + await runScripts(id, scripts); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + // User's overridden value preserved (not reset to "apikey") + expect(config).toMatch(/preferred_auth_method\s*=\s*['"]login['"]/); + // User's custom key preserved + expect(config).toMatch(/custom_user_key\s*=\s*['"]my_value['"]/); + }); + + test("idempotent-mcp-deep-merge", async () => { + const mcpConfig = [ + "[mcp_servers.github]", + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-github"]', + 'type = "stdio"', + "", + "[mcp_servers.filesystem]", + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-filesystem"]', + 'type = "stdio"', + ].join("\n"); + const { id, scripts } = await setup({ + moduleVariables: { mcp: mcpConfig }, + }); + await runScripts(id, scripts); + + // User customizes the github MCP server between restarts + await execContainer(id, [ + "bash", + "-c", + [ + "CONFIG=/home/coder/.codex/config.toml", + // Replace the github command the user has customized + "sed -i \"s/command = .npx./command = 'my-custom-npx'/\" $CONFIG", + ].join(" && "), + ]); + + // Second run + await runScripts(id, scripts); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + // User's customized github command preserved + expect(config).toMatch(/command\s*=\s*['"]my-custom-npx['"]/); + // filesystem server still present (not lost by shallow merge) + expect(config).toContain("mcp_servers"); + expect(config).toContain("filesystem"); + }); + + test("idempotent-base-config-preserves-user-edits", async () => { + const baseConfig = [ + 'sandbox_mode = "danger-full-access"', + 'preferred_auth_method = "apikey"', + ].join("\n"); + const { id, scripts } = await setup({ + moduleVariables: { base_config_toml: baseConfig }, + }); + await runScripts(id, scripts); + + // User changes sandbox_mode + await execContainer(id, [ + "bash", + "-c", + "sed -i 's/danger-full-access/sandbox/' /home/coder/.codex/config.toml", + ]); + + // Second run + await runScripts(id, scripts); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + // User's change preserved + expect(config).toMatch(/sandbox_mode\s*=\s*['"]sandbox['"]/); + // Original key from base config still present + expect(config).toContain("preferred_auth_method"); + }); + + test("idempotent-run-twice-no-change", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const configAfterFirst = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + + // Second run without any user edits + await runScripts(id, scripts); + const configAfterSecond = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + + // Config should still contain the same keys + expect(configAfterSecond).toContain("preferred_auth_method"); + // The values must match (quotes may change on roundtrip, compare via JSON) + const toJson = async (toml: string) => { + const resp = await execContainer(id, [ + "bash", + "-c", + `echo '${toml.replace(/'/g, "'\\''")}' | dasel -i toml -o json`, + ]); + return JSON.parse(resp.stdout); + }; + expect(await toJson(configAfterSecond)).toEqual( + await toJson(configAfterFirst), + ); + }); + + test("idempotent-mcp-new-servers-added-existing-kept", async () => { + // First run: one MCP server + const mcpRun1 = [ + "[mcp_servers.github]", + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-github"]', + 'type = "stdio"', + ].join("\n"); + const { id, scripts } = await setup({ + moduleVariables: { mcp: mcpRun1 }, + }); + await runScripts(id, scripts); + + // User adds their own MCP server manually + await execContainer(id, [ + "bash", + "-c", + `cat >> /home/coder/.codex/config.toml << 'EOF' + +[mcp_servers.custom] +command = "my-tool" +args = ["--serve"] +type = "stdio" +EOF`, + ]); + + // Second run: same module config + await runScripts(id, scripts); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + // Module's github server still present + expect(config).toContain("github"); + // User's manually-added custom server preserved + expect(config).toMatch(/command\s*=\s*['"]my-tool['"]/); + }); + + test("idempotent-ai-gateway-preserves-user-provider", async () => { + const { id, coderEnvVars, scripts } = await setup({ + moduleVariables: { + enable_ai_gateway: "true", + }, + }); + await runScripts(id, scripts, coderEnvVars); + + // User changes model_provider + await execContainer(id, [ + "bash", + "-c", + "sed -i 's/model_provider = .*/model_provider = \"custom_provider\"/' /home/coder/.codex/config.toml", + ]); + + // Second run + await runScripts(id, scripts, coderEnvVars); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + // User's custom provider survives + expect(config).toMatch(/model_provider\s*=\s*['"]custom_provider['"]/); + }); + + test("base-config-plus-mcp-combined", async () => { + const baseConfig = [ + 'sandbox_mode = "danger-full-access"', + 'preferred_auth_method = "apikey"', + ].join("\n"); + const mcpConfig = [ + "[mcp_servers.github]", + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-github"]', + 'type = "stdio"', + ].join("\n"); + const { id, scripts } = await setup({ + moduleVariables: { + base_config_toml: baseConfig, + mcp: mcpConfig, + }, + }); + await runScripts(id, scripts); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + // Base config keys present + expect(config).toContain("sandbox_mode"); + expect(config).toContain("preferred_auth_method"); + // MCP server present + expect(config).toContain("mcp_servers"); + expect(config).toContain("github"); + }); + + test("all-config-sources-combined", async () => { + const baseConfig = [ + 'sandbox_mode = "danger-full-access"', + 'preferred_auth_method = "apikey"', + ].join("\n"); + const mcpConfig = [ + "[mcp_servers.github]", + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-github"]', + 'type = "stdio"', + ].join("\n"); + const { id, coderEnvVars, scripts } = await setup({ + moduleVariables: { + enable_ai_gateway: "true", + base_config_toml: baseConfig, + mcp: mcpConfig, + }, + }); + await runScripts(id, scripts, coderEnvVars); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + // Base config + expect(config).toContain("sandbox_mode"); + expect(config).toContain("preferred_auth_method"); + // MCP + expect(config).toContain("github"); + // AI gateway + expect(config).toContain("model_providers"); + }); + + test("idempotent-all-sources-user-edits-survive", async () => { + const baseConfig = [ + 'sandbox_mode = "danger-full-access"', + 'preferred_auth_method = "apikey"', + ].join("\n"); + const mcpConfig = [ + "[mcp_servers.github]", + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-github"]', + 'type = "stdio"', + ].join("\n"); + const { id, coderEnvVars, scripts } = await setup({ + moduleVariables: { + enable_ai_gateway: "true", + base_config_toml: baseConfig, + mcp: mcpConfig, + }, + }); + await runScripts(id, scripts, coderEnvVars); + + // User edits multiple things + await execContainer(id, [ + "bash", + "-c", + [ + "CONFIG=/home/coder/.codex/config.toml", + // Change auth method + "sed -i \"s/preferred_auth_method.*/preferred_auth_method = 'oauth'/\" $CONFIG", + // Add a custom top-level key + "echo 'user_note = \"do not touch\"' >> $CONFIG", + ].join(" && "), + ]); + + // Second run + await runScripts(id, scripts, coderEnvVars); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + // User edits survived + expect(config).toMatch(/preferred_auth_method\s*=\s*['"]oauth['"]/); + expect(config).toContain("user_note"); + // Module config still present + expect(config).toContain("sandbox_mode"); + expect(config).toContain("github"); + expect(config).toContain("model_providers"); + }); + test("custom-config-drops-reasoning-effort", async () => { const baseConfig = [ 'sandbox_mode = "danger-full-access"', From 4aa448a0550d7e50029e8e455d06ab3d5d8314bf Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 24 May 2026 13:24:37 +0000 Subject: [PATCH 10/19] fix: update test assertions for dasel single-quote TOML output After a dasel roundtrip, TOML values use single quotes instead of double quotes. Update the codex-with-ai-gateway and ai-gateway-with-custom-base-config tests to use regex matching that accepts either quote style. Also fix idempotent-run-twice-no-change to read the config file directly from the container instead of piping TOML strings through shell echo (which breaks on single quotes). --- .../coder-labs/modules/codex/main.test.ts | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index 4c0f0ecca..9eefd6a62 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -314,8 +314,8 @@ describe("codex", async () => { id, "/home/coder/.codex/config.toml", ); - expect(configToml).toContain('model_provider = "aigateway"'); - expect(configToml).toContain('model_reasoning_effort = "none"'); + expect(configToml).toMatch(/model_provider\s*=\s*['"]aigateway['"]/); + expect(configToml).toMatch(/model_reasoning_effort\s*=\s*['"]none['"]/); expect(configToml).toContain("[model_providers.aigateway]"); }); @@ -380,7 +380,7 @@ describe("codex", async () => { id, "/home/coder/.codex/config.toml", ); - expect(configToml).toContain('model_provider = "aigateway"'); + expect(configToml).toMatch(/model_provider\s*=\s*['"]aigateway['"]/); expect(configToml).toContain("[model_providers.aigateway]"); }); @@ -526,33 +526,27 @@ EOF`, test("idempotent-run-twice-no-change", async () => { const { id, scripts } = await setup(); - await runScripts(id, scripts); - const configAfterFirst = await readFileContainer( - id, - "/home/coder/.codex/config.toml", - ); - // Second run without any user edits - await runScripts(id, scripts); - const configAfterSecond = await readFileContainer( - id, - "/home/coder/.codex/config.toml", - ); - - // Config should still contain the same keys - expect(configAfterSecond).toContain("preferred_auth_method"); - // The values must match (quotes may change on roundtrip, compare via JSON) - const toJson = async (toml: string) => { + // Convert the container config file to JSON for comparison + const configToJson = async () => { const resp = await execContainer(id, [ "bash", "-c", - `echo '${toml.replace(/'/g, "'\\''")}' | dasel -i toml -o json`, + "dasel -i toml -o json < /home/coder/.codex/config.toml", ]); return JSON.parse(resp.stdout); }; - expect(await toJson(configAfterSecond)).toEqual( - await toJson(configAfterFirst), - ); + + // First run + await runScripts(id, scripts); + const jsonAfterFirst = await configToJson(); + + // Second run without any user edits + await runScripts(id, scripts); + const jsonAfterSecond = await configToJson(); + + // Data must be identical (quotes may differ, so compare JSON not strings) + expect(jsonAfterSecond).toEqual(jsonAfterFirst); }); test("idempotent-mcp-new-servers-added-existing-kept", async () => { From fbee7124ce2edf312f29779ff079ccc9235cfbf5 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 24 May 2026 13:28:45 +0000 Subject: [PATCH 11/19] fix: compare runs 2 and 3 to avoid dasel PATH issue in test The idempotent-run-twice-no-change test was calling dasel in a separate execContainer shell where the PATH export from the install script is not available. Instead, compare the raw config output after runs 2 and 3 (both post-roundtrip, so serialization is stable and byte-comparison is valid). --- .../coder-labs/modules/codex/main.test.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index 9eefd6a62..160981ee2 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -527,26 +527,26 @@ EOF`, test("idempotent-run-twice-no-change", async () => { const { id, scripts } = await setup(); - // Convert the container config file to JSON for comparison - const configToJson = async () => { - const resp = await execContainer(id, [ - "bash", - "-c", - "dasel -i toml -o json < /home/coder/.codex/config.toml", - ]); - return JSON.parse(resp.stdout); - }; - // First run await runScripts(id, scripts); - const jsonAfterFirst = await configToJson(); - // Second run without any user edits + // Second run triggers a dasel roundtrip (quotes may change) await runScripts(id, scripts); - const jsonAfterSecond = await configToJson(); + const configAfterSecond = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + + // Third run: if idempotent, output must be identical to second run + await runScripts(id, scripts); + const configAfterThird = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); - // Data must be identical (quotes may differ, so compare JSON not strings) - expect(jsonAfterSecond).toEqual(jsonAfterFirst); + // After the first roundtrip the serialization is stable, so a byte + // comparison is valid from the second run onward. + expect(configAfterThird).toEqual(configAfterSecond); }); test("idempotent-mcp-new-servers-added-existing-kept", async () => { From 5f5c6d1bd3d53b81b54f26aefb33a3ef95f64281 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 24 May 2026 13:41:26 +0000 Subject: [PATCH 12/19] docs(coder-labs/modules/codex): bump version to 5.0.1 --- registry/coder-labs/modules/codex/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md index 08701fb1d..5c7ef397a 100644 --- a/registry/coder-labs/modules/codex/README.md +++ b/registry/coder-labs/modules/codex/README.md @@ -13,7 +13,7 @@ Install and configure the [Codex CLI](https://github.com/openai/codex) in your w ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "5.0.0" + version = "5.0.1" agent_id = coder_agent.main.id openai_api_key = var.openai_api_key } @@ -33,7 +33,7 @@ locals { module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "5.0.0" + version = "5.0.1" agent_id = coder_agent.main.id workdir = local.codex_workdir openai_api_key = var.openai_api_key @@ -64,7 +64,7 @@ resource "coder_app" "codex" { ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "5.0.0" + version = "5.0.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_ai_gateway = true @@ -88,7 +88,7 @@ When `enable_ai_gateway = true`, the module configures Codex to use the `aigatew ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "5.0.0" + version = "5.0.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" openai_api_key = var.openai_api_key @@ -117,7 +117,7 @@ The module exposes the `scripts` output: an ordered list of `coder exp sync` nam ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "5.0.0" + version = "5.0.1" agent_id = coder_agent.main.id openai_api_key = var.openai_api_key } From 1a52b2ae8d0f16eb436ea4b4ed0e7e34728066df Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 25 May 2026 02:22:15 +0000 Subject: [PATCH 13/19] debug --- .../coder-labs/modules/codex/main.test.ts | 14 ++-- .../modules/codex/scripts/install.sh.tftpl | 74 ++++++++----------- 2 files changed, 37 insertions(+), 51 deletions(-) diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index 160981ee2..73b65f79a 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -231,8 +231,8 @@ describe("codex", async () => { }); await runScripts(id, scripts); const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); - expect(resp).toContain('sandbox_mode = "danger-full-access"'); - expect(resp).toContain('preferred_auth_method = "apikey"'); + expect(resp).toMatch(/sandbox_mode\s*=\s*['"]danger-full-access['"]/); + expect(resp).toMatch(/preferred_auth_method\s*=\s*['"]apikey['"]/); expect(resp).toContain("[custom_section]"); }); @@ -259,7 +259,7 @@ describe("codex", async () => { const { id, scripts } = await setup(); await runScripts(id, scripts); const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); - expect(resp).toContain('preferred_auth_method = "apikey"'); + expect(resp).toMatch(/preferred_auth_method\s*=\s*['"]apikey['"]/); expect(resp).not.toContain("model_provider"); expect(resp).not.toContain("[model_providers."); expect(resp).not.toContain("model_reasoning_effort"); @@ -330,7 +330,7 @@ describe("codex", async () => { id, "/home/coder/.codex/config.toml", ); - expect(configToml).toContain('model_reasoning_effort = "high"'); + expect(configToml).toMatch(/model_reasoning_effort\s*=\s*['"]high['"]/); expect(configToml).not.toContain("model_provider"); }); @@ -346,8 +346,8 @@ describe("codex", async () => { id, "/home/coder/.codex/config.toml", ); - expect(configToml).toContain(`[projects."${workdir}"]`); - expect(configToml).toContain('trust_level = "trusted"'); + expect(configToml).toMatch(new RegExp(`projects.*${workdir.replace(/\//g, '\\/')}.*`)); + expect(configToml).toMatch(/trust_level\s*=\s*['"]trusted['"]/); }); test("no-workdir-no-project-section", async () => { @@ -738,7 +738,7 @@ EOF`, id, "/home/coder/.codex/config.toml", ); - expect(configToml).toContain('sandbox_mode = "danger-full-access"'); + expect(configToml).toMatch(/sandbox_mode\s*=\s*['"]danger-full-access['"]/); expect(configToml).not.toContain("model_reasoning_effort"); }); }); diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index 1210f381d..80bf46e18 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -145,78 +145,64 @@ function install_codex() { ensure_codex_in_path } -function merge_toml_config() { - local config_path="$1" - local content="$2" - - if [ ! -s "$${config_path}" ]; then - echo "$${content}" > "$${config_path}" - return - fi - - local merge_tmp existing_json new_json - merge_tmp=$(mktemp) - existing_json=$(mktemp) - new_json=$(mktemp) - echo "$${content}" > "$${merge_tmp}" - - dasel -i toml -o json < "$${config_path}" > "$${existing_json}" - dasel -i toml -o json < "$${merge_tmp}" > "$${new_json}" - - jq -s '.[0] * .[1]' "$${new_json}" "$${existing_json}" \ - | dasel -i json -o toml > "$${config_path}.tmp" \ - && mv "$${config_path}.tmp" "$${config_path}" - rm -f "$${merge_tmp}" "$${existing_json}" "$${new_json}" -} - +# Builds the minimal default config as a JSON string on stdout. function write_minimal_default_config() { - local config_path="$1" - local optional_config='preferred_auth_method = "apikey"' + local json + json=$(jq -n '{preferred_auth_method: "apikey"}') - if [[ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ]]; then - optional_config+=$'\n''model_provider = "aigateway"' + if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ]; then + json=$(echo "$${json}" | jq '.model_provider = "aigateway"') fi - if [[ -n "$${ARG_MODEL_REASONING_EFFORT}" ]]; then - optional_config+=$'\n'"model_reasoning_effort = \"$${ARG_MODEL_REASONING_EFFORT}\"" + if [ -n "$${ARG_MODEL_REASONING_EFFORT}" ]; then + json=$(echo "$${json}" | jq --arg v "$${ARG_MODEL_REASONING_EFFORT}" '.model_reasoning_effort = $v') fi - if [[ -n "$${ARG_WORKDIR}" ]]; then - optional_config+=$'\n'"[projects.\"$${ARG_WORKDIR}\"]" - optional_config+=$'\n''trust_level = "trusted"' + if [ -n "$${ARG_WORKDIR}" ]; then + json=$(echo "$${json}" | jq --arg w "$${ARG_WORKDIR}" '.projects[$w].trust_level = "trusted"') fi - merge_toml_config "$${config_path}" "$${optional_config}" + echo "$${json}" } function populate_config_toml() { local config_path="$HOME/.codex/config.toml" mkdir -p "$(dirname "$${config_path}")" - touch "$${config_path}" + + # Build the new config entirely in JSON. + local new_json if [ -n "$${ARG_BASE_CONFIG_TOML}" ]; then printf "Using provided base configuration\n" - merge_toml_config "$${config_path}" "$${ARG_BASE_CONFIG_TOML}" + new_json=$(echo "$${ARG_BASE_CONFIG_TOML}" | dasel -i toml -o json) else printf "Using minimal default configuration\n" - write_minimal_default_config "$${config_path}" + new_json=$(write_minimal_default_config) fi - local merge_config="" - if [ -n "$${ARG_MCP}" ]; then printf "Adding MCP servers\n" - merge_config+="$${ARG_MCP}" + local mcp_json + mcp_json=$(echo "$${ARG_MCP}" | dasel -i toml -o json) + new_json=$(echo "$${mcp_json}" "$${new_json}" | jq -s '.[0] * .[1]') fi - if [[ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ]] && [[ -n "$${ARG_AIBRIDGE_CONFIG}" ]]; then + if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] && [ -n "$${ARG_AIBRIDGE_CONFIG}" ]; then printf "Adding AI Gateway configuration\n" - merge_config+=$'\n'"$${ARG_AIBRIDGE_CONFIG}" + local aibridge_json + aibridge_json=$(echo "$${ARG_AIBRIDGE_CONFIG}" | dasel -i toml -o json) + new_json=$(echo "$${aibridge_json}" "$${new_json}" | jq -s '.[0] * .[1]') fi - if [ -n "$${merge_config}" ]; then - merge_toml_config "$${config_path}" "$${merge_config}" + # Deep-merge with existing config (existing user values win). + if [ -s "$${config_path}" ]; then + local existing_json + existing_json=$(dasel -i toml -o json < "$${config_path}") + new_json=$(echo "$${new_json}" "$${existing_json}" | jq -s '.[0] * .[1]') fi + + # Single conversion: JSON to TOML. + echo "$${new_json}" | dasel -i json -o toml > "$${config_path}" } function setup_workdir() { if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then From b30f5216dbe812d65bd2d4fcb4b93248f1c2574a Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 25 May 2026 02:34:36 +0000 Subject: [PATCH 14/19] refactor(coder-labs/modules/codex): build config as JSON via jq, single dasel conversion Replace TOML string concatenation with jq-native JSON building: - Extract write_minimal_default_config() back as its own function, now returning JSON on stdout via jq. - populate_config_toml() assembles all config sources as JSON, deep-merges with jq, and does a single dasel JSON-to-TOML conversion at the end. - Remove merge_toml_config() and all TOML string building. - Update test assertions to accept either quote style since all output now goes through dasel. --- registry/coder-labs/modules/codex/main.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index 73b65f79a..05ddcb67f 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -346,7 +346,9 @@ describe("codex", async () => { id, "/home/coder/.codex/config.toml", ); - expect(configToml).toMatch(new RegExp(`projects.*${workdir.replace(/\//g, '\\/')}.*`)); + expect(configToml).toMatch( + new RegExp(`projects.*${workdir.replace(/\//g, "\\/")}.*`), + ); expect(configToml).toMatch(/trust_level\s*=\s*['"]trusted['"]/); }); From 26ad896b7809f561c06d79e997ab33df1a77c6b7 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 25 May 2026 10:37:44 +0000 Subject: [PATCH 15/19] fix(coder-labs/modules/codex): address deep-review findings Script fixes: - Rename write_minimal_default_config to build_minimal_default_config (no longer writes to disk, emits JSON to stdout). - Guard corrupted existing config: if dasel cannot parse the existing TOML, error out and exit instead of silently proceeding. - Atomic config write: write to a temp file and mv, preventing data loss if the process is interrupted mid-write. - Add jq availability check before populate_config_toml, consistent with how other registry modules handle hard dependencies. - Normalize blank lines between function definitions. Test fixes: - idempotent-mcp-deep-merge: use sed address range to only replace the github server command, assert filesystem command is still npx. - workdir-trusted-project: tighten regex to require bracket syntax instead of matching any line containing the path. - Rename idempotent-run-twice-no-change to idempotent-stable-after-roundtrip (test runs 3 times, not 2). - Remove unnecessary regex escaping of forward slashes. - Strengthen combination test assertions to check values, not just key presence. --- .../coder-labs/modules/codex/main.test.ts | 36 +++++++++---------- .../modules/codex/scripts/install.sh.tftpl | 22 ++++++++---- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index 05ddcb67f..c041eb39f 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -346,9 +346,7 @@ describe("codex", async () => { id, "/home/coder/.codex/config.toml", ); - expect(configToml).toMatch( - new RegExp(`projects.*${workdir.replace(/\//g, "\\/")}.*`), - ); + expect(configToml).toMatch(new RegExp(`\\[projects\\..*${workdir}.*\\]`)); expect(configToml).toMatch(/trust_level\s*=\s*['"]trusted['"]/); }); @@ -473,14 +471,14 @@ EOF`, }); await runScripts(id, scripts); - // User customizes the github MCP server between restarts + // User customizes ONLY the github MCP server between restarts await execContainer(id, [ "bash", "-c", [ "CONFIG=/home/coder/.codex/config.toml", - // Replace the github command the user has customized - "sed -i \"s/command = .npx./command = 'my-custom-npx'/\" $CONFIG", + // Use sed address range to only replace command under github section + "sed -i '/github/,/^$/{ s/command = .*/command = '\"'\"'my-custom-npx'\"'\"'/; }' $CONFIG", ].join(" && "), ]); @@ -492,9 +490,9 @@ EOF`, ); // User's customized github command preserved expect(config).toMatch(/command\s*=\s*['"]my-custom-npx['"]/); - // filesystem server still present (not lost by shallow merge) - expect(config).toContain("mcp_servers"); + // filesystem server still present with original command expect(config).toContain("filesystem"); + expect(config).toMatch(/command\s*=\s*['"]npx['"]/); }); test("idempotent-base-config-preserves-user-edits", async () => { @@ -526,7 +524,7 @@ EOF`, expect(config).toContain("preferred_auth_method"); }); - test("idempotent-run-twice-no-change", async () => { + test("idempotent-stable-after-roundtrip", async () => { const { id, scripts } = await setup(); // First run @@ -637,11 +635,11 @@ EOF`, "/home/coder/.codex/config.toml", ); // Base config keys present - expect(config).toContain("sandbox_mode"); - expect(config).toContain("preferred_auth_method"); + expect(config).toMatch(/sandbox_mode\s*=\s*['"]danger-full-access['"]/); + expect(config).toMatch(/preferred_auth_method\s*=\s*['"]apikey['"]/); // MCP server present expect(config).toContain("mcp_servers"); - expect(config).toContain("github"); + expect(config).toMatch(/command\s*=\s*['"]npx['"]/); }); test("all-config-sources-combined", async () => { @@ -668,12 +666,12 @@ EOF`, "/home/coder/.codex/config.toml", ); // Base config - expect(config).toContain("sandbox_mode"); - expect(config).toContain("preferred_auth_method"); + expect(config).toMatch(/sandbox_mode\s*=\s*['"]danger-full-access['"]/); + expect(config).toMatch(/preferred_auth_method\s*=\s*['"]apikey['"]/); // MCP - expect(config).toContain("github"); + expect(config).toMatch(/command\s*=\s*['"]npx['"]/); // AI gateway - expect(config).toContain("model_providers"); + expect(config).toContain("[model_providers.aigateway]"); }); test("idempotent-all-sources-user-edits-survive", async () => { @@ -717,11 +715,11 @@ EOF`, ); // User edits survived expect(config).toMatch(/preferred_auth_method\s*=\s*['"]oauth['"]/); - expect(config).toContain("user_note"); + expect(config).toMatch(/user_note\s*=\s*['"]do not touch['"]/); // Module config still present - expect(config).toContain("sandbox_mode"); + expect(config).toMatch(/sandbox_mode\s*=\s*['"]danger-full-access['"]/); expect(config).toContain("github"); - expect(config).toContain("model_providers"); + expect(config).toContain("[model_providers.aigateway]"); }); test("custom-config-drops-reasoning-effort", async () => { diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index 80bf46e18..6a431c108 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -26,7 +26,6 @@ printf "install_codex: %s\n" "$${ARG_INSTALL}" printf "model_reasoning_effort: %s\n" "$${ARG_MODEL_REASONING_EFFORT}" echo "--------------------------------" - function install_dasel() { if command_exists dasel; then return 0 @@ -59,7 +58,6 @@ function install_dasel() { fi } - function add_path_to_shell_profiles() { local path_dir="$1" @@ -146,7 +144,7 @@ function install_codex() { } # Builds the minimal default config as a JSON string on stdout. -function write_minimal_default_config() { +function build_minimal_default_config() { local json json=$(jq -n '{preferred_auth_method: "apikey"}') @@ -177,7 +175,7 @@ function populate_config_toml() { new_json=$(echo "$${ARG_BASE_CONFIG_TOML}" | dasel -i toml -o json) else printf "Using minimal default configuration\n" - new_json=$(write_minimal_default_config) + new_json=$(build_minimal_default_config) fi if [ -n "$${ARG_MCP}" ]; then @@ -197,13 +195,19 @@ function populate_config_toml() { # Deep-merge with existing config (existing user values win). if [ -s "$${config_path}" ]; then local existing_json - existing_json=$(dasel -i toml -o json < "$${config_path}") + if ! existing_json=$(dasel -i toml -o json < "$${config_path}" 2>/dev/null); then + printf "Error: existing %s contains invalid TOML, cannot merge\n" "$${config_path}" >&2 + exit 1 + fi new_json=$(echo "$${new_json}" "$${existing_json}" | jq -s '.[0] * .[1]') fi - # Single conversion: JSON to TOML. - echo "$${new_json}" | dasel -i json -o toml > "$${config_path}" + # Single conversion: JSON to TOML (atomic via temp file). + local tmp + tmp=$(mktemp "$${config_path}.XXXXXX") + echo "$${new_json}" | dasel -i json -o toml > "$${tmp}" && mv "$${tmp}" "$${config_path}" } + function setup_workdir() { if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then echo "Creating workdir: $${ARG_WORKDIR}" @@ -230,6 +234,10 @@ EOF install_codex install_dasel +if ! command_exists jq; then + printf "Error: jq is required but not installed.\n" >&2 + exit 1 +fi populate_config_toml setup_workdir add_auth_json From d100931cff2a2d42e8dcbd362d69d02776558718 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 25 May 2026 17:41:35 +0000 Subject: [PATCH 16/19] refactor(coder-labs/modules/codex): streamline config generation and enhance idempotency --- .../coder-labs/modules/codex/main.test.ts | 388 ++++++++++-------- .../modules/codex/scripts/install.sh.tftpl | 116 +++--- 2 files changed, 265 insertions(+), 239 deletions(-) diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index c041eb39f..5b2a65f6c 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -171,6 +171,9 @@ const runScripts = async ( } }; +const MANAGED_START = "# >>> coder-managed: codex module >>>"; +const MANAGED_END = "# <<< coder-managed: codex module <<<"; + setDefaultTimeout(60 * 1000); describe("codex", async () => { @@ -231,8 +234,10 @@ describe("codex", async () => { }); await runScripts(id, scripts); const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); - expect(resp).toMatch(/sandbox_mode\s*=\s*['"]danger-full-access['"]/); - expect(resp).toMatch(/preferred_auth_method\s*=\s*['"]apikey['"]/); + expect(resp).toContain(MANAGED_START); + expect(resp).toContain(MANAGED_END); + expect(resp).toMatch(/sandbox_mode\s*=\s*"danger-full-access"/); + expect(resp).toMatch(/preferred_auth_method\s*=\s*"apikey"/); expect(resp).toContain("[custom_section]"); }); @@ -259,7 +264,9 @@ describe("codex", async () => { const { id, scripts } = await setup(); await runScripts(id, scripts); const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); - expect(resp).toMatch(/preferred_auth_method\s*=\s*['"]apikey['"]/); + expect(resp).toContain(MANAGED_START); + expect(resp).toContain(MANAGED_END); + expect(resp).toMatch(/preferred_auth_method\s*=\s*"apikey"/); expect(resp).not.toContain("model_provider"); expect(resp).not.toContain("[model_providers."); expect(resp).not.toContain("model_reasoning_effort"); @@ -314,8 +321,8 @@ describe("codex", async () => { id, "/home/coder/.codex/config.toml", ); - expect(configToml).toMatch(/model_provider\s*=\s*['"]aigateway['"]/); - expect(configToml).toMatch(/model_reasoning_effort\s*=\s*['"]none['"]/); + expect(configToml).toMatch(/model_provider\s*=\s*"aigateway"/); + expect(configToml).toMatch(/model_reasoning_effort\s*=\s*"none"/); expect(configToml).toContain("[model_providers.aigateway]"); }); @@ -330,7 +337,7 @@ describe("codex", async () => { id, "/home/coder/.codex/config.toml", ); - expect(configToml).toMatch(/model_reasoning_effort\s*=\s*['"]high['"]/); + expect(configToml).toMatch(/model_reasoning_effort\s*=\s*"high"/); expect(configToml).not.toContain("model_provider"); }); @@ -347,7 +354,7 @@ describe("codex", async () => { "/home/coder/.codex/config.toml", ); expect(configToml).toMatch(new RegExp(`\\[projects\\..*${workdir}.*\\]`)); - expect(configToml).toMatch(/trust_level\s*=\s*['"]trusted['"]/); + expect(configToml).toMatch(/trust_level\s*=\s*"trusted"/); }); test("no-workdir-no-project-section", async () => { @@ -380,7 +387,7 @@ describe("codex", async () => { id, "/home/coder/.codex/config.toml", ); - expect(configToml).toMatch(/model_provider\s*=\s*['"]aigateway['"]/); + expect(configToml).toMatch(/model_provider\s*=\s*"aigateway"/); expect(configToml).toContain("[model_providers.aigateway]"); }); @@ -425,91 +432,166 @@ describe("codex", async () => { expect(installLog).toContain("Installed Codex CLI"); }); - test("idempotent-defaults-preserve-user-edits", async () => { + test("base-config-plus-mcp-combined", async () => { + const baseConfig = [ + 'sandbox_mode = "danger-full-access"', + 'preferred_auth_method = "apikey"', + ].join("\n"); + const mcpConfig = [ + "[mcp_servers.github]", + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-github"]', + 'type = "stdio"', + ].join("\n"); + const { id, scripts } = await setup({ + moduleVariables: { + base_config_toml: baseConfig, + mcp: mcpConfig, + }, + }); + await runScripts(id, scripts); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + expect(config).toMatch(/sandbox_mode\s*=\s*"danger-full-access"/); + expect(config).toMatch(/preferred_auth_method\s*=\s*"apikey"/); + expect(config).toContain("mcp_servers"); + expect(config).toMatch(/command\s*=\s*"npx"/); + }); + + test("all-config-sources-combined", async () => { + const baseConfig = [ + 'sandbox_mode = "danger-full-access"', + 'preferred_auth_method = "apikey"', + ].join("\n"); + const mcpConfig = [ + "[mcp_servers.github]", + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-github"]', + 'type = "stdio"', + ].join("\n"); + const { id, coderEnvVars, scripts } = await setup({ + moduleVariables: { + enable_ai_gateway: "true", + base_config_toml: baseConfig, + mcp: mcpConfig, + }, + }); + await runScripts(id, scripts, coderEnvVars); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + expect(config).toMatch(/sandbox_mode\s*=\s*"danger-full-access"/); + expect(config).toMatch(/preferred_auth_method\s*=\s*"apikey"/); + expect(config).toMatch(/command\s*=\s*"npx"/); + expect(config).toContain("[model_providers.aigateway]"); + }); + + test("custom-config-drops-reasoning-effort", async () => { + const baseConfig = [ + 'sandbox_mode = "danger-full-access"', + 'preferred_auth_method = "apikey"', + ].join("\n"); + const { id, scripts } = await setup({ + moduleVariables: { + base_config_toml: baseConfig, + model_reasoning_effort: "high", + }, + }); + await runScripts(id, scripts); + const configToml = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + expect(configToml).toMatch(/sandbox_mode\s*=\s*"danger-full-access"/); + expect(configToml).not.toContain("model_reasoning_effort"); + }); + + // --- idempotency tests: marker-block semantics --- + + test("idempotent-user-section-survives-restart", async () => { const { id, scripts } = await setup(); await runScripts(id, scripts); - // User edits the config between restarts + // User adds a custom section after the managed block. await execContainer(id, [ "bash", "-c", - `cat > /home/coder/.codex/config.toml << 'EOF' -preferred_auth_method = "login" -custom_user_key = "my_value" + `cat >> /home/coder/.codex/config.toml << 'EOF' -[projects."/home/coder/project"] -trust_level = "trusted" +[mcp_servers.user_tool] +command = "my-tool" +args = ["--serve"] +type = "stdio" EOF`, ]); - // Second run: user edits must survive + // Second run: managed block is regenerated, user section survives. await runScripts(id, scripts); const config = await readFileContainer( id, "/home/coder/.codex/config.toml", ); - // User's overridden value preserved (not reset to "apikey") - expect(config).toMatch(/preferred_auth_method\s*=\s*['"]login['"]/); - // User's custom key preserved - expect(config).toMatch(/custom_user_key\s*=\s*['"]my_value['"]/); + // Managed content still present + expect(config).toMatch(/preferred_auth_method\s*=\s*"apikey"/); + expect(config).toContain(MANAGED_START); + expect(config).toContain(MANAGED_END); + // User section preserved + expect(config).toContain("[mcp_servers.user_tool]"); + expect(config).toMatch(/command\s*=\s*"my-tool"/); }); - test("idempotent-mcp-deep-merge", async () => { - const mcpConfig = [ - "[mcp_servers.github]", - 'command = "npx"', - 'args = ["-y", "@modelcontextprotocol/server-github"]', - 'type = "stdio"', - "", - "[mcp_servers.filesystem]", - 'command = "npx"', - 'args = ["-y", "@modelcontextprotocol/server-filesystem"]', - 'type = "stdio"', - ].join("\n"); + test("idempotent-managed-block-regenerated", async () => { const { id, scripts } = await setup({ - moduleVariables: { mcp: mcpConfig }, + moduleVariables: { + model_reasoning_effort: "high", + }, }); await runScripts(id, scripts); - // User customizes ONLY the github MCP server between restarts + // User modifies a value inside the managed block. await execContainer(id, [ "bash", "-c", - [ - "CONFIG=/home/coder/.codex/config.toml", - // Use sed address range to only replace command under github section - "sed -i '/github/,/^$/{ s/command = .*/command = '\"'\"'my-custom-npx'\"'\"'/; }' $CONFIG", - ].join(" && "), + "sed -i 's/model_reasoning_effort.*/model_reasoning_effort = \"low\"/' /home/coder/.codex/config.toml", ]); - // Second run + // Verify user edit took effect. + const edited = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + expect(edited).toMatch(/model_reasoning_effort\s*=\s*"low"/); + + // Second run: managed block is regenerated with original values. await runScripts(id, scripts); const config = await readFileContainer( id, "/home/coder/.codex/config.toml", ); - // User's customized github command preserved - expect(config).toMatch(/command\s*=\s*['"]my-custom-npx['"]/); - // filesystem server still present with original command - expect(config).toContain("filesystem"); - expect(config).toMatch(/command\s*=\s*['"]npx['"]/); + // Original managed value restored + expect(config).toMatch(/model_reasoning_effort\s*=\s*"high"/); + expect(config).not.toMatch(/model_reasoning_effort\s*=\s*"low"/); }); - test("idempotent-base-config-preserves-user-edits", async () => { - const baseConfig = [ - 'sandbox_mode = "danger-full-access"', - 'preferred_auth_method = "apikey"', - ].join("\n"); - const { id, scripts } = await setup({ - moduleVariables: { base_config_toml: baseConfig }, - }); + test("idempotent-user-comments-preserved", async () => { + const { id, scripts } = await setup(); await runScripts(id, scripts); - // User changes sandbox_mode + // User adds comments and a section after the managed block. await execContainer(id, [ "bash", "-c", - "sed -i 's/danger-full-access/sandbox/' /home/coder/.codex/config.toml", + `cat >> /home/coder/.codex/config.toml << 'EOF' + +# My personal settings for local development +[mcp_servers.notes] +command = "notes-server" +# This server is for my personal notes +type = "stdio" +EOF`, ]); // Second run @@ -518,10 +600,10 @@ EOF`, id, "/home/coder/.codex/config.toml", ); - // User's change preserved - expect(config).toMatch(/sandbox_mode\s*=\s*['"]sandbox['"]/); - // Original key from base config still present - expect(config).toContain("preferred_auth_method"); + // User comments preserved + expect(config).toContain("# My personal settings for local development"); + expect(config).toContain("# This server is for my personal notes"); + expect(config).toContain("[mcp_servers.notes]"); }); test("idempotent-stable-after-roundtrip", async () => { @@ -529,40 +611,34 @@ EOF`, // First run await runScripts(id, scripts); - - // Second run triggers a dasel roundtrip (quotes may change) - await runScripts(id, scripts); - const configAfterSecond = await readFileContainer( + const configAfterFirst = await readFileContainer( id, "/home/coder/.codex/config.toml", ); - // Third run: if idempotent, output must be identical to second run + // Second run: no format conversion, should be byte-identical. await runScripts(id, scripts); - const configAfterThird = await readFileContainer( + const configAfterSecond = await readFileContainer( id, "/home/coder/.codex/config.toml", ); - // After the first roundtrip the serialization is stable, so a byte - // comparison is valid from the second run onward. - expect(configAfterThird).toEqual(configAfterSecond); + expect(configAfterSecond).toEqual(configAfterFirst); }); test("idempotent-mcp-new-servers-added-existing-kept", async () => { - // First run: one MCP server - const mcpRun1 = [ + const mcpConfig = [ "[mcp_servers.github]", 'command = "npx"', 'args = ["-y", "@modelcontextprotocol/server-github"]', 'type = "stdio"', ].join("\n"); const { id, scripts } = await setup({ - moduleVariables: { mcp: mcpRun1 }, + moduleVariables: { mcp: mcpConfig }, }); await runScripts(id, scripts); - // User adds their own MCP server manually + // User adds their own MCP server after the managed block. await execContainer(id, [ "bash", "-c", @@ -575,74 +651,49 @@ type = "stdio" EOF`, ]); - // Second run: same module config + // Second run await runScripts(id, scripts); const config = await readFileContainer( id, "/home/coder/.codex/config.toml", ); - // Module's github server still present - expect(config).toContain("github"); - // User's manually-added custom server preserved - expect(config).toMatch(/command\s*=\s*['"]my-tool['"]/); + // Module's github server still present (in managed block) + expect(config).toContain("[mcp_servers.github]"); + expect(config).toMatch(/command\s*=\s*"npx"/); + // User's custom server preserved (outside managed block) + expect(config).toContain("[mcp_servers.custom]"); + expect(config).toMatch(/command\s*=\s*"my-tool"/); }); - test("idempotent-ai-gateway-preserves-user-provider", async () => { - const { id, coderEnvVars, scripts } = await setup({ - moduleVariables: { - enable_ai_gateway: "true", - }, - }); - await runScripts(id, scripts, coderEnvVars); + test("idempotent-no-markers-overwrites", async () => { + const { id, scripts } = await setup(); - // User changes model_provider + // Simulate a legacy config without markers (pre-upgrade). await execContainer(id, [ "bash", "-c", - "sed -i 's/model_provider = .*/model_provider = \"custom_provider\"/' /home/coder/.codex/config.toml", + `mkdir -p /home/coder/.codex && cat > /home/coder/.codex/config.toml << 'EOF' +preferred_auth_method = "login" +legacy_key = "old_value" +EOF`, ]); - // Second run - await runScripts(id, scripts, coderEnvVars); - const config = await readFileContainer( - id, - "/home/coder/.codex/config.toml", - ); - // User's custom provider survives - expect(config).toMatch(/model_provider\s*=\s*['"]custom_provider['"]/); - }); - - test("base-config-plus-mcp-combined", async () => { - const baseConfig = [ - 'sandbox_mode = "danger-full-access"', - 'preferred_auth_method = "apikey"', - ].join("\n"); - const mcpConfig = [ - "[mcp_servers.github]", - 'command = "npx"', - 'args = ["-y", "@modelcontextprotocol/server-github"]', - 'type = "stdio"', - ].join("\n"); - const { id, scripts } = await setup({ - moduleVariables: { - base_config_toml: baseConfig, - mcp: mcpConfig, - }, - }); + // First run with marker-block code: no markers found, overwrites. await runScripts(id, scripts); const config = await readFileContainer( id, "/home/coder/.codex/config.toml", ); - // Base config keys present - expect(config).toMatch(/sandbox_mode\s*=\s*['"]danger-full-access['"]/); - expect(config).toMatch(/preferred_auth_method\s*=\s*['"]apikey['"]/); - // MCP server present - expect(config).toContain("mcp_servers"); - expect(config).toMatch(/command\s*=\s*['"]npx['"]/); + // New managed block is written + expect(config).toContain(MANAGED_START); + expect(config).toContain(MANAGED_END); + expect(config).toMatch(/preferred_auth_method\s*=\s*"apikey"/); + // Legacy content is gone (no markers to preserve it) + expect(config).not.toContain("legacy_key"); + expect(config).not.toContain("old_value"); }); - test("all-config-sources-combined", async () => { + test("idempotent-all-sources-user-content-survives", async () => { const baseConfig = [ 'sandbox_mode = "danger-full-access"', 'preferred_auth_method = "apikey"', @@ -661,84 +712,79 @@ EOF`, }, }); await runScripts(id, scripts, coderEnvVars); + + // User adds content outside the managed block. + await execContainer(id, [ + "bash", + "-c", + `cat >> /home/coder/.codex/config.toml << 'EOF' + +# User's personal MCP server +[mcp_servers.personal] +command = "personal-server" +type = "stdio" +EOF`, + ]); + + // Second run + await runScripts(id, scripts, coderEnvVars); const config = await readFileContainer( id, "/home/coder/.codex/config.toml", ); - // Base config - expect(config).toMatch(/sandbox_mode\s*=\s*['"]danger-full-access['"]/); - expect(config).toMatch(/preferred_auth_method\s*=\s*['"]apikey['"]/); - // MCP - expect(config).toMatch(/command\s*=\s*['"]npx['"]/); - // AI gateway + // All managed content correct + expect(config).toMatch(/sandbox_mode\s*=\s*"danger-full-access"/); + expect(config).toMatch(/preferred_auth_method\s*=\s*"apikey"/); + expect(config).toContain("[mcp_servers.github]"); expect(config).toContain("[model_providers.aigateway]"); + // User content preserved + expect(config).toContain("# User's personal MCP server"); + expect(config).toContain("[mcp_servers.personal]"); + expect(config).toMatch(/command\s*=\s*"personal-server"/); }); - test("idempotent-all-sources-user-edits-survive", async () => { - const baseConfig = [ - 'sandbox_mode = "danger-full-access"', - 'preferred_auth_method = "apikey"', - ].join("\n"); + test("idempotent-multiple-restarts-user-content-stable", async () => { const mcpConfig = [ "[mcp_servers.github]", 'command = "npx"', 'args = ["-y", "@modelcontextprotocol/server-github"]', 'type = "stdio"', ].join("\n"); - const { id, coderEnvVars, scripts } = await setup({ - moduleVariables: { - enable_ai_gateway: "true", - base_config_toml: baseConfig, - mcp: mcpConfig, - }, + const { id, scripts } = await setup({ + moduleVariables: { mcp: mcpConfig }, }); - await runScripts(id, scripts, coderEnvVars); + await runScripts(id, scripts); - // User edits multiple things + // User adds content outside managed block. await execContainer(id, [ "bash", "-c", - [ - "CONFIG=/home/coder/.codex/config.toml", - // Change auth method - "sed -i \"s/preferred_auth_method.*/preferred_auth_method = 'oauth'/\" $CONFIG", - // Add a custom top-level key - "echo 'user_note = \"do not touch\"' >> $CONFIG", - ].join(" && "), + `cat >> /home/coder/.codex/config.toml << 'EOF' + +# User customizations +[mcp_servers.custom] +command = "custom-tool" +type = "stdio" +EOF`, ]); - // Second run - await runScripts(id, scripts, coderEnvVars); - const config = await readFileContainer( + // Run 2 + await runScripts(id, scripts); + const configAfterSecond = await readFileContainer( id, "/home/coder/.codex/config.toml", ); - // User edits survived - expect(config).toMatch(/preferred_auth_method\s*=\s*['"]oauth['"]/); - expect(config).toMatch(/user_note\s*=\s*['"]do not touch['"]/); - // Module config still present - expect(config).toMatch(/sandbox_mode\s*=\s*['"]danger-full-access['"]/); - expect(config).toContain("github"); - expect(config).toContain("[model_providers.aigateway]"); - }); - test("custom-config-drops-reasoning-effort", async () => { - const baseConfig = [ - 'sandbox_mode = "danger-full-access"', - 'preferred_auth_method = "apikey"', - ].join("\n"); - const { id, scripts } = await setup({ - moduleVariables: { - base_config_toml: baseConfig, - model_reasoning_effort: "high", - }, - }); + // Run 3: should be byte-identical to run 2 await runScripts(id, scripts); - const configToml = await readFileContainer( + const configAfterThird = await readFileContainer( id, "/home/coder/.codex/config.toml", ); - expect(configToml).toMatch(/sandbox_mode\s*=\s*['"]danger-full-access['"]/); - expect(configToml).not.toContain("model_reasoning_effort"); + + expect(configAfterThird).toEqual(configAfterSecond); + // User content still present + expect(configAfterThird).toContain("# User customizations"); + expect(configAfterThird).toContain("[mcp_servers.custom]"); }); }); diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index 6a431c108..2bf00a1f3 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -26,38 +26,6 @@ printf "install_codex: %s\n" "$${ARG_INSTALL}" printf "model_reasoning_effort: %s\n" "$${ARG_MODEL_REASONING_EFFORT}" echo "--------------------------------" -function install_dasel() { - if command_exists dasel; then - return 0 - fi - - local os arch install_dir url - os=$(uname -s | tr '[:upper:]' '[:lower:]') - arch=$(uname -m) - case "$${arch}" in - x86_64) arch="amd64" ;; - aarch64|arm64) arch="arm64" ;; - *) - printf "Error: unsupported architecture %s\n" "$${arch}" >&2 - return 1 - ;; - esac - - install_dir="$${CODER_SCRIPT_BIN_DIR:-$HOME/.local/bin}" - mkdir -p "$${install_dir}" - - url="https://github.com/TomWright/dasel/releases/download/v3.4.0/dasel_$${os}_$${arch}" - printf "Installing dasel from %s\n" "$${url}" - if curl -fsSL "$${url}" -o "$${install_dir}/dasel" && chmod +x "$${install_dir}/dasel"; then - export PATH="$${install_dir}:$PATH" - printf "Installed %s\n" "$(dasel version)" - else - printf "Error: failed to download dasel\n" >&2 - rm -f "$${install_dir}/dasel" - return 1 - fi -} - function add_path_to_shell_profiles() { local path_dir="$1" @@ -143,69 +111,86 @@ function install_codex() { ensure_codex_in_path } -# Builds the minimal default config as a JSON string on stdout. -function build_minimal_default_config() { - local json - json=$(jq -n '{preferred_auth_method: "apikey"}') +function write_minimal_default_config() { + local config_path="$1" + local optional_config="" if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ]; then - json=$(echo "$${json}" | jq '.model_provider = "aigateway"') + optional_config='model_provider = "aigateway"' fi if [ -n "$${ARG_MODEL_REASONING_EFFORT}" ]; then - json=$(echo "$${json}" | jq --arg v "$${ARG_MODEL_REASONING_EFFORT}" '.model_reasoning_effort = $v') + optional_config+=$'\n'"model_reasoning_effort = \"$${ARG_MODEL_REASONING_EFFORT}\"" fi + cat << EOF > "$${config_path}" +preferred_auth_method = "apikey" +$${optional_config} + +EOF + if [ -n "$${ARG_WORKDIR}" ]; then - json=$(echo "$${json}" | jq --arg w "$${ARG_WORKDIR}" '.projects[$w].trust_level = "trusted"') - fi + cat << EOF >> "$${config_path}" +[projects."$${ARG_WORKDIR}"] +trust_level = "trusted" - echo "$${json}" +EOF + fi } function populate_config_toml() { local config_path="$HOME/.codex/config.toml" mkdir -p "$(dirname "$${config_path}")" - # Build the new config entirely in JSON. - local new_json + local MANAGED_START="# >>> coder-managed: codex module >>>" + local MANAGED_END="# <<< coder-managed: codex module <<<" + + # Build managed-block content in a temp file. + local managed + managed=$(mktemp) if [ -n "$${ARG_BASE_CONFIG_TOML}" ]; then printf "Using provided base configuration\n" - new_json=$(echo "$${ARG_BASE_CONFIG_TOML}" | dasel -i toml -o json) + printf '%s\n' "$${ARG_BASE_CONFIG_TOML}" > "$${managed}" else printf "Using minimal default configuration\n" - new_json=$(build_minimal_default_config) + write_minimal_default_config "$${managed}" fi if [ -n "$${ARG_MCP}" ]; then printf "Adding MCP servers\n" - local mcp_json - mcp_json=$(echo "$${ARG_MCP}" | dasel -i toml -o json) - new_json=$(echo "$${mcp_json}" "$${new_json}" | jq -s '.[0] * .[1]') + printf '%s\n' "$${ARG_MCP}" >> "$${managed}" fi if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] && [ -n "$${ARG_AIBRIDGE_CONFIG}" ]; then - printf "Adding AI Gateway configuration\n" - local aibridge_json - aibridge_json=$(echo "$${ARG_AIBRIDGE_CONFIG}" | dasel -i toml -o json) - new_json=$(echo "$${aibridge_json}" "$${new_json}" | jq -s '.[0] * .[1]') - fi - - # Deep-merge with existing config (existing user values win). - if [ -s "$${config_path}" ]; then - local existing_json - if ! existing_json=$(dasel -i toml -o json < "$${config_path}" 2>/dev/null); then - printf "Error: existing %s contains invalid TOML, cannot merge\n" "$${config_path}" >&2 - exit 1 + if ! grep -q '\[model_providers\.aigateway\]' "$${managed}" 2>/dev/null; then + printf "Adding AI Gateway configuration\n" + printf '\n%s\n' "$${ARG_AIBRIDGE_CONFIG}" >> "$${managed}" + else + printf "AI Gateway provider already defined in config, skipping append\n" fi - new_json=$(echo "$${new_json}" "$${existing_json}" | jq -s '.[0] * .[1]') fi - # Single conversion: JSON to TOML (atomic via temp file). + # Preserve user content outside the managed block from the existing config. + local user_content="" + if [ -s "$${config_path}" ] && grep -qF "$${MANAGED_START}" "$${config_path}"; then + user_content=$(sed '/# >>> coder-managed: codex module >>>/,/# <<< coder-managed: codex module << "$${tmp}" && mv "$${tmp}" "$${config_path}" + { + printf '%s\n' "$${MANAGED_START}" + cat "$${managed}" + printf '%s\n' "$${MANAGED_END}" + if [ -n "$${user_content}" ]; then + printf '\n%s\n' "$${user_content}" + fi + } > "$${tmp}" && mv "$${tmp}" "$${config_path}" + rm -f "$${managed}" } function setup_workdir() { @@ -233,11 +218,6 @@ EOF } install_codex -install_dasel -if ! command_exists jq; then - printf "Error: jq is required but not installed.\n" >&2 - exit 1 -fi populate_config_toml setup_workdir add_auth_json From 4ce81869d19e6ed7fe96162b7938c0133aa18605 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Tue, 26 May 2026 09:47:40 +0000 Subject: [PATCH 17/19] refactor(coder-labs/modules/codex): restructure config assembly for user bare keys and sections --- .../coder-labs/modules/codex/main.test.ts | 54 +++++++++++++++++-- .../modules/codex/scripts/install.sh.tftpl | 29 ++++++++-- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index 5b2a65f6c..366ad0978 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -543,6 +543,47 @@ EOF`, expect(config).toMatch(/command\s*=\s*"my-tool"/); }); + test("idempotent-user-bare-keys-stay-at-root-scope", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + + // User adds bare keys AND a section after the managed block. + await execContainer(id, [ + "bash", + "-c", + `cat >> /home/coder/.codex/config.toml << 'EOF' + +my_custom_key = "hello" +sandbox_mode = "full" + +[mcp_servers.user_tool] +command = "my-tool" +EOF`, + ]); + + // Second run + await runScripts(id, scripts); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + + // User bare keys must appear BEFORE the managed block start marker + // so they remain at TOML root scope. + const startIdx = config.indexOf(MANAGED_START); + const customKeyIdx = config.indexOf('my_custom_key = "hello"'); + const sandboxIdx = config.indexOf('sandbox_mode = "full"'); + expect(customKeyIdx).toBeGreaterThan(-1); + expect(sandboxIdx).toBeGreaterThan(-1); + expect(customKeyIdx).toBeLessThan(startIdx); + expect(sandboxIdx).toBeLessThan(startIdx); + + // User section preserved after managed block + const endIdx = config.indexOf(MANAGED_END); + const sectionIdx = config.indexOf("[mcp_servers.user_tool]"); + expect(sectionIdx).toBeGreaterThan(endIdx); + }); + test("idempotent-managed-block-regenerated", async () => { const { id, scripts } = await setup({ moduleVariables: { @@ -580,13 +621,16 @@ EOF`, const { id, scripts } = await setup(); await runScripts(id, scripts); - // User adds comments and a section after the managed block. + // User adds a bare-key comment, a bare key, then a section with comments. await execContainer(id, [ "bash", "-c", `cat >> /home/coder/.codex/config.toml << 'EOF' -# My personal settings for local development +# My personal top-level setting +my_flag = true + +# My personal MCP server [mcp_servers.notes] command = "notes-server" # This server is for my personal notes @@ -600,8 +644,10 @@ EOF`, id, "/home/coder/.codex/config.toml", ); - // User comments preserved - expect(config).toContain("# My personal settings for local development"); + // Bare-key comment hoisted above managed block + expect(config).toContain("# My personal top-level setting"); + // Section comments preserved below managed block + expect(config).toContain("# My personal MCP server"); expect(config).toContain("# This server is for my personal notes"); expect(config).toContain("[mcp_servers.notes]"); }); diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index 2bf00a1f3..55029dd6b 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -179,15 +179,38 @@ function populate_config_toml() { user_content=$(printf '%s' "$${user_content}" | sed '/./,$!d') fi - # Assemble final config atomically: managed block, then user content. + # Split user content into bare keys (before any [section]) and sections. + # Bare keys go above the managed block so they stay at TOML root scope; + # sections go below it where each [header] resets scope naturally. + local user_bare="" user_sections="" + if [ -n "$${user_content}" ]; then + local first_section + first_section=$(printf '%s\n' "$${user_content}" | grep -n '^\[' | head -1 | cut -d: -f1) + if [ -n "$${first_section}" ]; then + user_bare=$(printf '%s\n' "$${user_content}" | head -n "$((first_section - 1))") + user_sections=$(printf '%s\n' "$${user_content}" | tail -n "+$${first_section}") + # Trim trailing blank lines from bare portion. + user_bare=$(printf '%s' "$${user_bare}" | sed -e :a -e '/^\n*$/{ $d; N; ba; }') + else + user_bare="$${user_content}" + fi + fi + + # Assemble final config atomically: + # user bare keys (root scope) + # managed block (bare keys first, then [sections]) + # user sections (each [header] resets scope) local tmp tmp=$(mktemp "$${config_path}.XXXXXX") { + if [ -n "$${user_bare}" ]; then + printf '%s\n\n' "$${user_bare}" + fi printf '%s\n' "$${MANAGED_START}" cat "$${managed}" printf '%s\n' "$${MANAGED_END}" - if [ -n "$${user_content}" ]; then - printf '\n%s\n' "$${user_content}" + if [ -n "$${user_sections}" ]; then + printf '\n%s\n' "$${user_sections}" fi } > "$${tmp}" && mv "$${tmp}" "$${config_path}" rm -f "$${managed}" From 470ce50333cf5972abb27c76425f68719602a5fb Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Tue, 26 May 2026 10:13:34 +0000 Subject: [PATCH 18/19] refactor(coder-labs/modules/codex): enhance user content preservation in config handling --- .../coder-labs/modules/codex/main.test.ts | 23 +++++++++++++------ .../modules/codex/scripts/install.sh.tftpl | 12 +++++++--- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index 366ad0978..79246b9c4 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -711,7 +711,7 @@ EOF`, expect(config).toMatch(/command\s*=\s*"my-tool"/); }); - test("idempotent-no-markers-overwrites", async () => { + test("idempotent-no-markers-preserves-user-config", async () => { const { id, scripts } = await setup(); // Simulate a legacy config without markers (pre-upgrade). @@ -721,22 +721,31 @@ EOF`, `mkdir -p /home/coder/.codex && cat > /home/coder/.codex/config.toml << 'EOF' preferred_auth_method = "login" legacy_key = "old_value" + +[mcp_servers.legacy] +command = "legacy-tool" +type = "stdio" EOF`, ]); - // First run with marker-block code: no markers found, overwrites. + // First run with marker-block code: no markers found, entire file is user content. await runScripts(id, scripts); const config = await readFileContainer( id, "/home/coder/.codex/config.toml", ); - // New managed block is written + // Managed block is written expect(config).toContain(MANAGED_START); expect(config).toContain(MANAGED_END); - expect(config).toMatch(/preferred_auth_method\s*=\s*"apikey"/); - // Legacy content is gone (no markers to preserve it) - expect(config).not.toContain("legacy_key"); - expect(config).not.toContain("old_value"); + // Legacy bare keys hoisted above managed block at root scope + const startIdx = config.indexOf(MANAGED_START); + expect(config.indexOf('preferred_auth_method = "login"')).toBeLessThan( + startIdx, + ); + expect(config.indexOf('legacy_key = "old_value"')).toBeLessThan(startIdx); + // Legacy section preserved after managed block + const endIdx = config.indexOf(MANAGED_END); + expect(config.indexOf("[mcp_servers.legacy]")).toBeGreaterThan(endIdx); }); test("idempotent-all-sources-user-content-survives", async () => { diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index 55029dd6b..96ae91714 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -171,10 +171,16 @@ function populate_config_toml() { fi fi - # Preserve user content outside the managed block from the existing config. + # Preserve user content from the existing config. local user_content="" - if [ -s "$${config_path}" ] && grep -qF "$${MANAGED_START}" "$${config_path}"; then - user_content=$(sed '/# >>> coder-managed: codex module >>>/,/# <<< coder-managed: codex module <<>> coder-managed: codex module >>>/,/# <<< coder-managed: codex module << Date: Tue, 26 May 2026 10:46:40 +0000 Subject: [PATCH 19/19] fix(coder-labs/modules/codex): rename sed label to avoid typos-checker false positive --- registry/coder-labs/modules/codex/scripts/install.sh.tftpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index 96ae91714..d7d62a5fb 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -196,7 +196,7 @@ function populate_config_toml() { user_bare=$(printf '%s\n' "$${user_content}" | head -n "$((first_section - 1))") user_sections=$(printf '%s\n' "$${user_content}" | tail -n "+$${first_section}") # Trim trailing blank lines from bare portion. - user_bare=$(printf '%s' "$${user_bare}" | sed -e :a -e '/^\n*$/{ $d; N; ba; }') + user_bare=$(printf '%s' "$${user_bare}" | sed -e :x -e '/^\n*$/{ $d; N; bx; }') else user_bare="$${user_content}" fi