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 } diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index f61807723..79246b9c4 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).toContain('sandbox_mode = "danger-full-access"'); - expect(resp).toContain('preferred_auth_method = "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).toContain('preferred_auth_method = "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).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]"); }); @@ -330,7 +337,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 +353,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}.*\\]`)); + 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).toContain('model_provider = "aigateway"'); + expect(configToml).toMatch(/model_provider\s*=\s*"aigateway"/); expect(configToml).toContain("[model_providers.aigateway]"); }); @@ -425,6 +432,63 @@ describe("codex", async () => { expect(installLog).toContain("Installed Codex CLI"); }); + 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"', @@ -441,7 +505,341 @@ describe("codex", async () => { 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"); }); + + // --- idempotency tests: marker-block semantics --- + + test("idempotent-user-section-survives-restart", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + + // User adds a custom section after the managed block. + await execContainer(id, [ + "bash", + "-c", + `cat >> /home/coder/.codex/config.toml << 'EOF' + +[mcp_servers.user_tool] +command = "my-tool" +args = ["--serve"] +type = "stdio" +EOF`, + ]); + + // Second run: managed block is regenerated, user section survives. + await runScripts(id, scripts); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + // 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-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: { + model_reasoning_effort: "high", + }, + }); + await runScripts(id, scripts); + + // User modifies a value inside the managed block. + await execContainer(id, [ + "bash", + "-c", + "sed -i 's/model_reasoning_effort.*/model_reasoning_effort = \"low\"/' /home/coder/.codex/config.toml", + ]); + + // 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", + ); + // 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-user-comments-preserved", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + + // 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 top-level setting +my_flag = true + +# My personal MCP server +[mcp_servers.notes] +command = "notes-server" +# This server is for my personal notes +type = "stdio" +EOF`, + ]); + + // Second run + await runScripts(id, scripts); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + // 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]"); + }); + + test("idempotent-stable-after-roundtrip", async () => { + const { id, scripts } = await setup(); + + // First run + await runScripts(id, scripts); + const configAfterFirst = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + + // Second run: no format conversion, should be byte-identical. + await runScripts(id, scripts); + const configAfterSecond = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + + expect(configAfterSecond).toEqual(configAfterFirst); + }); + + test("idempotent-mcp-new-servers-added-existing-kept", async () => { + const mcpConfig = [ + "[mcp_servers.github]", + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-github"]', + 'type = "stdio"', + ].join("\n"); + const { id, scripts } = await setup({ + moduleVariables: { mcp: mcpConfig }, + }); + await runScripts(id, scripts); + + // User adds their own MCP server after the managed block. + 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 + await runScripts(id, scripts); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + // 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-no-markers-preserves-user-config", async () => { + const { id, scripts } = await setup(); + + // Simulate a legacy config without markers (pre-upgrade). + await execContainer(id, [ + "bash", + "-c", + `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, entire file is user content. + await runScripts(id, scripts); + const config = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + // Managed block is written + expect(config).toContain(MANAGED_START); + expect(config).toContain(MANAGED_END); + // 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 () => { + 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 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", + ); + // 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-multiple-restarts-user-content-stable", async () => { + const mcpConfig = [ + "[mcp_servers.github]", + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-github"]', + 'type = "stdio"', + ].join("\n"); + const { id, scripts } = await setup({ + moduleVariables: { mcp: mcpConfig }, + }); + await runScripts(id, scripts); + + // User adds content outside managed block. + await execContainer(id, [ + "bash", + "-c", + `cat >> /home/coder/.codex/config.toml << 'EOF' + +# User customizations +[mcp_servers.custom] +command = "custom-tool" +type = "stdio" +EOF`, + ]); + + // Run 2 + await runScripts(id, scripts); + const configAfterSecond = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + + // Run 3: should be byte-identical to run 2 + await runScripts(id, scripts); + const configAfterThird = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + + 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 584c978b3..d7d62a5fb 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -142,27 +142,84 @@ function populate_config_toml() { local config_path="$HOME/.codex/config.toml" mkdir -p "$(dirname "$${config_path}")" + 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" - echo "$${ARG_BASE_CONFIG_TOML}" > "$${config_path}" + printf '%s\n' "$${ARG_BASE_CONFIG_TOML}" > "$${managed}" else printf "Using minimal default configuration\n" - write_minimal_default_config "$${config_path}" + write_minimal_default_config "$${managed}" fi if [ -n "$${ARG_MCP}" ]; then printf "Adding MCP servers\n" - echo "$${ARG_MCP}" >> "$${config_path}" + printf '%s\n' "$${ARG_MCP}" >> "$${managed}" fi if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] && [ -n "$${ARG_AIBRIDGE_CONFIG}" ]; then - if ! grep -q '\[model_providers\.aigateway\]' "$${config_path}" 2>/dev/null; then + if ! grep -q '\[model_providers\.aigateway\]' "$${managed}" 2>/dev/null; then printf "Adding AI Gateway configuration\n" - echo -e "\n$${ARG_AIBRIDGE_CONFIG}" >> "$${config_path}" + printf '\n%s\n' "$${ARG_AIBRIDGE_CONFIG}" >> "$${managed}" else printf "AI Gateway provider already defined in config, skipping append\n" fi fi + + # Preserve user content from the existing config. + local user_content="" + if [ -s "$${config_path}" ]; then + if grep -qF "$${MANAGED_START}" "$${config_path}"; then + # Strip the managed block; everything else is user content. + user_content=$(sed '/# >>> coder-managed: codex module >>>/,/# <<< coder-managed: codex module << "$${tmp}" && mv "$${tmp}" "$${config_path}" + rm -f "$${managed}" } function setup_workdir() {