From e3abbb9aa0d95f260d1de78fd4938b1d884bfab1 Mon Sep 17 00:00:00 2001 From: 35C4n0r <70096901+35C4n0r@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:35:45 +0530 Subject: [PATCH 01/28] maintenance(coder-labs/modules/codex): skip migration notice and add agentapi type flag (#781) This PR introduces: 1. Adding --type flag to agentapi command 2. Introduce `[notice.model_migrations]` to skip migration notice, improves tasks UX 3. Set profile = "aibridge" rather than passing it using --profile flag ## Description ## Type of Change - [ ] New module - [ ] New template - [x] Bug fix - [x] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder-labs/modules/codex` **New version:** `v4.1.2` **Breaking change:** [ ] Yes [x] No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally ## Related Issues Closes: #740 --- registry/coder-labs/modules/codex/README.md | 10 +++++----- .../coder-labs/modules/codex/main.test.ts | 11 +---------- registry/coder-labs/modules/codex/main.tf | 19 +++++++++++-------- .../modules/codex/scripts/install.sh | 12 ++++++++++++ .../coder-labs/modules/codex/scripts/start.sh | 9 +++------ 5 files changed, 32 insertions(+), 29 deletions(-) diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md index b4a895de..16e6c105 100644 --- a/registry/coder-labs/modules/codex/README.md +++ b/registry/coder-labs/modules/codex/README.md @@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.1" + version = "4.1.2" agent_id = coder_agent.example.id openai_api_key = var.openai_api_key workdir = "/home/coder/project" @@ -32,7 +32,7 @@ module "codex" { module "codex" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.1" + version = "4.1.2" agent_id = coder_agent.example.id openai_api_key = "..." workdir = "/home/coder/project" @@ -51,7 +51,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.1" + version = "4.1.2" agent_id = coder_agent.example.id workdir = "/home/coder/project" enable_aibridge = true @@ -94,7 +94,7 @@ data "coder_task" "me" {} module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.1" + version = "4.1.2" agent_id = coder_agent.example.id openai_api_key = "..." ai_prompt = data.coder_task.me.prompt @@ -112,7 +112,7 @@ This example shows additional configuration options for custom models, MCP serve ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.1" + version = "4.1.2" agent_id = coder_agent.example.id openai_api_key = "..." workdir = "/home/coder/project" diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index a4edd818..f8d9f0a5 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -464,22 +464,13 @@ describe("codex", async () => { }); await execModuleScript(id); - - const startLog = await readFileContainer( - id, - "/home/coder/.codex-module/agentapi-start.log", - ); - const configToml = await readFileContainer( id, "/home/coder/.codex/config.toml", ); - expect(startLog).toContain("AI Bridge is enabled, using profile aibridge"); - expect(startLog).toContain( - "Starting Codex with arguments: --profile aibridge", - ); expect(configToml).toContain( "[profiles.aibridge]\n" + 'model_provider = "aibridge"', ); + expect(configToml).toContain('profile = "aibridge"'); }); }); diff --git a/registry/coder-labs/modules/codex/main.tf b/registry/coder-labs/modules/codex/main.tf index cc07ce2f..41bf86ee 100644 --- a/registry/coder-labs/modules/codex/main.tf +++ b/registry/coder-labs/modules/codex/main.tf @@ -136,8 +136,8 @@ variable "agentapi_version" { variable "codex_model" { type = string - description = "The model for Codex to use. Defaults to gpt-5.2-codex." - default = "gpt-5.2-codex" + description = "The model for Codex to use. Defaults to gpt-5.3-codex." + default = "gpt-5.3-codex" } variable "pre_install_script" { @@ -184,12 +184,13 @@ resource "coder_env" "coder_aibridge_session_token" { } locals { - workdir = trimsuffix(var.workdir, "/") - app_slug = "codex" - install_script = file("${path.module}/scripts/install.sh") - start_script = file("${path.module}/scripts/start.sh") - module_dir_name = ".codex-module" - aibridge_config = <<-EOF + workdir = trimsuffix(var.workdir, "/") + app_slug = "codex" + install_script = file("${path.module}/scripts/install.sh") + start_script = file("${path.module}/scripts/start.sh") + module_dir_name = ".codex-module" + latest_codex_model = "gpt-5.3-codex" + aibridge_config = <<-EOF [model_providers.aibridge] name = "AI Bridge" base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1" @@ -249,6 +250,8 @@ module "agentapi" { chmod +x /tmp/install.sh ARG_OPENAI_API_KEY='${var.openai_api_key}' \ ARG_REPORT_TASKS='${var.report_tasks}' \ + ARG_CODEX_MODEL='${var.codex_model}' \ + ARG_LATEST_CODEX_MODEL='${local.latest_codex_model}' \ ARG_INSTALL='${var.install_codex}' \ ARG_CODEX_VERSION='${var.codex_version}' \ ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \ diff --git a/registry/coder-labs/modules/codex/scripts/install.sh b/registry/coder-labs/modules/codex/scripts/install.sh index 97d539a8..4742c413 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh +++ b/registry/coder-labs/modules/codex/scripts/install.sh @@ -20,6 +20,8 @@ echo "=== Codex Module Configuration ===" printf "Install Codex: %s\n" "$ARG_INSTALL" printf "Codex Version: %s\n" "$ARG_CODEX_VERSION" printf "App Slug: %s\n" "$ARG_CODER_MCP_APP_STATUS_SLUG" +printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}" +printf "Latest Codex Model: %s\n" "${ARG_LATEST_CODEX_MODEL}" printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY" printf "Has Base Config: %s\n" "$([ -n "$ARG_BASE_CONFIG_TOML" ] && echo "Yes" || echo "No")" printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && echo "Yes" || echo "No")" @@ -90,15 +92,25 @@ function install_codex() { write_minimal_default_config() { local config_path="$1" + + ARG_DEFAULT_PROFILE="" + + if [[ "${ARG_ENABLE_AIBRIDGE}" = "true" ]]; then + ARG_DEFAULT_PROFILE='profile = "aibridge"' + fi + cat << EOF > "$config_path" # Minimal Default Codex Configuration sandbox_mode = "workspace-write" approval_policy = "never" preferred_auth_method = "apikey" +${ARG_DEFAULT_PROFILE} [sandbox_workspace_write] network_access = true +[notice.model_migrations] +"${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}" EOF } diff --git a/registry/coder-labs/modules/codex/scripts/start.sh b/registry/coder-labs/modules/codex/scripts/start.sh index 3e55dc70..e0e7d972 100644 --- a/registry/coder-labs/modules/codex/scripts/start.sh +++ b/registry/coder-labs/modules/codex/scripts/start.sh @@ -155,11 +155,8 @@ setup_workdir() { build_codex_args() { CODEX_ARGS=() - if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then - printf "AI Bridge is enabled, using profile aibridge\n" - CODEX_ARGS+=("--profile" "aibridge") - elif [ -n "$ARG_CODEX_MODEL" ]; then - CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL") + if [[ -n "${ARG_CODEX_MODEL}" ]] && [[ "${ARG_ENABLE_AIBRIDGE}" != "true" ]]; then + CODEX_ARGS+=("--model" "${ARG_CODEX_MODEL}") fi if [ "$ARG_CONTINUE" = "true" ]; then @@ -213,7 +210,7 @@ capture_session_id() { start_codex() { printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}" - agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" & + agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" & capture_session_id } From 2169fb00ee64ff0666f05a69e30bfa090a73eb9b Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Mar 2026 13:27:23 +0200 Subject: [PATCH 02/28] feat(coder/modules/agentapi): add state persistence support (#736) AgentAPI can now save and restore conversation state across workspace restarts. The module exports env vars (AGENTAPI_STATE_FILE, AGENTAPI_SAVE_STATE, AGENTAPI_LOAD_STATE, AGENTAPI_PID_FILE) that the binary reads directly. No consumer module changes needed. New variables: enable_state_persistence (default false), state_file_path, pid_file_path. State and PID files default to $HOME//. Requires agentapi >= v0.12.0. A shared version_at_least function in lib.sh gates the env var exports and SIGUSR1 in the shutdown script. Old binaries get a warning and graceful skip. Shutdown script now does SIGUSR1 (state save), log snapshot capture (existing, now fault-tolerant via subshell), then SIGTERM with wait. Closes coder/internal#1257 Refs coder/internal#1256 Refs #696 --- registry/coder/modules/agentapi/README.md | 29 ++- .../modules/agentapi/agentapi.tftest.hcl | 108 +++++++++ registry/coder/modules/agentapi/main.test.ts | 207 +++++++++++++++++- registry/coder/modules/agentapi/main.tf | 26 +++ .../agentapi/scripts/agentapi-shutdown.sh | 79 +++++-- .../coder/modules/agentapi/scripts/lib.sh | 45 ++++ .../coder/modules/agentapi/scripts/main.sh | 19 ++ .../testdata/agentapi-mock-shutdown.js | 18 ++ .../agentapi/testdata/agentapi-mock.js | 29 +++ 9 files changed, 542 insertions(+), 18 deletions(-) create mode 100644 registry/coder/modules/agentapi/agentapi.tftest.hcl create mode 100644 registry/coder/modules/agentapi/scripts/lib.sh diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index e7a9869f..c5e9ae42 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI ```tf module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.1.1" + version = "2.2.0" agent_id = var.agent_id web_app_slug = local.app_slug @@ -62,6 +62,33 @@ module "agentapi" { } ``` +## State Persistence + +AgentAPI can save and restore conversation state across workspace restarts. +This is disabled by default and requires agentapi binary >= v0.12.0. + +State and PID files are stored in `$HOME//` alongside other +module files (e.g. `$HOME/.claude-module/agentapi-state.json`). + +To enable: + +```tf +module "agentapi" { + # ... other config + enable_state_persistence = true +} +``` + +To override file paths: + +```tf +module "agentapi" { + # ... other config + state_file_path = "/custom/path/state.json" + pid_file_path = "/custom/path/agentapi.pid" +} +``` + ## For module developers For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf). diff --git a/registry/coder/modules/agentapi/agentapi.tftest.hcl b/registry/coder/modules/agentapi/agentapi.tftest.hcl new file mode 100644 index 00000000..87404c62 --- /dev/null +++ b/registry/coder/modules/agentapi/agentapi.tftest.hcl @@ -0,0 +1,108 @@ +mock_provider "coder" {} + +variables { + agent_id = "test-agent" + web_app_icon = "/icon/test.svg" + web_app_display_name = "Test" + web_app_slug = "test" + cli_app_display_name = "Test CLI" + cli_app_slug = "test-cli" + start_script = "echo test" + module_dir_name = ".test-module" +} + +run "default_values" { + command = plan + + assert { + condition = var.enable_state_persistence == false + error_message = "enable_state_persistence should default to false" + } + + assert { + condition = var.state_file_path == "" + error_message = "state_file_path should default to empty string" + } + + assert { + condition = var.pid_file_path == "" + error_message = "pid_file_path should default to empty string" + } + + # Verify start script contains state persistence ARG_ vars. + assert { + condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi.script)) + error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE" + } + + assert { + condition = can(regex("ARG_STATE_FILE_PATH", coder_script.agentapi.script)) + error_message = "start script should contain ARG_STATE_FILE_PATH" + } + + assert { + condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi.script)) + error_message = "start script should contain ARG_PID_FILE_PATH" + } + + # Verify shutdown script contains PID-related ARG_ vars. + assert { + condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain ARG_PID_FILE_PATH" + } + + assert { + condition = can(regex("ARG_MODULE_DIR_NAME", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain ARG_MODULE_DIR_NAME" + } + + assert { + condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain ARG_ENABLE_STATE_PERSISTENCE" + } +} + +run "state_persistence_disabled" { + command = plan + + variables { + enable_state_persistence = false + } + + assert { + condition = var.enable_state_persistence == false + error_message = "enable_state_persistence should be false" + } + + # Even when disabled, the ARG_ vars should still be in the script + # (the shell script handles the conditional logic). + assert { + condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE='false'", coder_script.agentapi.script)) + error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE='false'" + } +} + +run "custom_paths" { + command = plan + + variables { + state_file_path = "/custom/state.json" + pid_file_path = "/custom/agentapi.pid" + } + + assert { + condition = can(regex("/custom/state.json", coder_script.agentapi.script)) + error_message = "start script should contain custom state_file_path" + } + + assert { + condition = can(regex("/custom/agentapi.pid", coder_script.agentapi.script)) + error_message = "start script should contain custom pid_file_path" + } + + # Verify custom paths also appear in shutdown script. + assert { + condition = can(regex("/custom/agentapi.pid", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain custom pid_file_path" + } +} diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index 20b47b1a..cedf840c 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -258,11 +258,76 @@ describe("agentapi", async () => { expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *"); }); + test("state-persistence-disabled", async () => { + const { id } = await setup({ + moduleVariables: { + enable_state_persistence: "false", + }, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + // PID file should always be exported + expect(mockLog).toContain("AGENTAPI_PID_FILE:"); + // State vars should NOT be present when disabled + expect(mockLog).not.toContain("AGENTAPI_STATE_FILE:"); + expect(mockLog).not.toContain("AGENTAPI_SAVE_STATE:"); + expect(mockLog).not.toContain("AGENTAPI_LOAD_STATE:"); + }); + + test("state-persistence-custom-paths", async () => { + const { id } = await setup({ + moduleVariables: { + enable_state_persistence: "true", + state_file_path: "/home/coder/custom/state.json", + pid_file_path: "/home/coder/custom/agentapi.pid", + }, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + expect(mockLog).toContain( + "AGENTAPI_STATE_FILE: /home/coder/custom/state.json", + ); + expect(mockLog).toContain( + "AGENTAPI_PID_FILE: /home/coder/custom/agentapi.pid", + ); + }); + + test("state-persistence-default-paths", async () => { + const { id } = await setup({ + moduleVariables: { + enable_state_persistence: "true", + }, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + expect(mockLog).toContain( + `AGENTAPI_STATE_FILE: /home/coder/${moduleDirName}/agentapi-state.json`, + ); + expect(mockLog).toContain( + `AGENTAPI_PID_FILE: /home/coder/${moduleDirName}/agentapi.pid`, + ); + expect(mockLog).toContain("AGENTAPI_SAVE_STATE: true"); + expect(mockLog).toContain("AGENTAPI_LOAD_STATE: true"); + }); + describe("shutdown script", async () => { const setupMocks = async ( containerId: string, agentapiPreset: string, httpCode: number = 204, + pidFilePath: string = "", ) => { const agentapiMock = await loadTestFile( import.meta.dir, @@ -285,10 +350,11 @@ describe("agentapi", async () => { content: coderMock, }); + const pidFileEnv = pidFilePath ? `AGENTAPI_PID_FILE=${pidFilePath}` : ""; await execContainer(containerId, [ "bash", "-c", - `PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`, + `PRESET=${agentapiPreset} ${pidFileEnv} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`, ]); await execContainer(containerId, [ @@ -303,12 +369,25 @@ describe("agentapi", async () => { const runShutdownScript = async ( containerId: string, taskId: string = "test-task", + pidFilePath: string = "", + enableStatePersistence: string = "false", ) => { const shutdownScript = await loadTestFile( import.meta.dir, "../scripts/agentapi-shutdown.sh", ); + const libScript = await loadTestFile( + import.meta.dir, + "../scripts/lib.sh", + ); + + await writeExecutable({ + containerId, + filePath: "/tmp/agentapi-lib.sh", + content: libScript, + }); + await writeExecutable({ containerId, filePath: "/tmp/shutdown.sh", @@ -318,7 +397,7 @@ describe("agentapi", async () => { return await execContainer(containerId, [ "bash", "-c", - `ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`, + `ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 ARG_PID_FILE_PATH=${pidFilePath} ARG_ENABLE_STATE_PERSISTENCE=${enableStatePersistence} CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`, ]); }; @@ -334,6 +413,7 @@ describe("agentapi", async () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Retrieved 5 messages for log snapshot"); expect(result.stdout).toContain("Log snapshot posted successfully"); + expect(result.stdout).not.toContain("Log snapshot capture failed"); const posted = await readFileContainer(id, "/tmp/snapshot-posted.json"); const snapshot = JSON.parse(posted); @@ -409,5 +489,128 @@ describe("agentapi", async () => { "Log snapshot endpoint not supported by this Coder version", ); }); + + test("sends SIGUSR1 before shutdown", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + const pidFile = "/tmp/agentapi-test.pid"; + await setupMocks(id, "normal", 204, pidFile); + const result = await runShutdownScript(id, "test-task", pidFile, "true"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI"); + + const sigusr1Log = await readFileContainer(id, "/tmp/sigusr1-received"); + expect(sigusr1Log).toContain("SIGUSR1 received"); + }); + + test("handles missing PID file gracefully", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + await setupMocks(id, "normal"); + // Pass a non-existent PID file path with persistence enabled to + // exercise the SIGUSR1 path with a missing PID. + const result = await runShutdownScript( + id, + "test-task", + "/tmp/nonexistent.pid", + "true", + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Shutdown complete"); + }); + + test("sends SIGTERM even when snapshot fails", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + const pidFile = "/tmp/agentapi-test.pid"; + // HTTP 500 will cause snapshot to fail + await setupMocks(id, "normal", 500, pidFile); + const result = await runShutdownScript(id, "test-task", pidFile, "true"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "Log snapshot capture failed, continuing shutdown", + ); + expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); + }); + + test("resolves default PID path from MODULE_DIR_NAME", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + // Start mock with PID file at the module_dir_name default location. + const defaultPidPath = `/home/coder/${moduleDirName}/agentapi.pid`; + await setupMocks(id, "normal", 204, defaultPidPath); + // Don't pass pidFilePath - let shutdown script compute it from MODULE_DIR_NAME. + const shutdownScript = await loadTestFile( + import.meta.dir, + "../scripts/agentapi-shutdown.sh", + ); + const libScript = await loadTestFile( + import.meta.dir, + "../scripts/lib.sh", + ); + await writeExecutable({ + containerId: id, + filePath: "/tmp/agentapi-lib.sh", + content: libScript, + }); + await writeExecutable({ + containerId: id, + filePath: "/tmp/shutdown.sh", + content: shutdownScript, + }); + const result = await execContainer(id, [ + "bash", + "-c", + `ARG_TASK_ID=test-task ARG_AGENTAPI_PORT=3284 ARG_MODULE_DIR_NAME=${moduleDirName} ARG_ENABLE_STATE_PERSISTENCE=true CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI"); + expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); + }); + + test("skips SIGUSR1 when no PID file available", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + await setupMocks(id, "normal", 204); + // No pidFilePath and no MODULE_DIR_NAME, so no PID file can be resolved. + const result = await runShutdownScript(id, "test-task", "", "false"); + + expect(result.exitCode).toBe(0); + // Should not send SIGUSR1 or SIGTERM (no PID to signal). + expect(result.stdout).not.toContain("Sending SIGUSR1"); + expect(result.stdout).not.toContain("Sending SIGTERM"); + expect(result.stdout).toContain("Shutdown complete"); + }); + + test("skips SIGUSR1 when state persistence disabled", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + const pidFile = "/tmp/agentapi-test.pid"; + await setupMocks(id, "normal", 204, pidFile); + // PID file exists but state persistence is disabled. + const result = await runShutdownScript(id, "test-task", pidFile, "false"); + + expect(result.exitCode).toBe(0); + // Should NOT send SIGUSR1 (persistence disabled). + expect(result.stdout).not.toContain("Sending SIGUSR1"); + // Should still send SIGTERM (graceful shutdown always happens). + expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); + }); }); }); diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 6914be77..8818736d 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -164,6 +164,23 @@ variable "module_dir_name" { description = "Name of the subdirectory in the home directory for module files." } +variable "enable_state_persistence" { + type = bool + description = "Enable AgentAPI conversation state persistence across restarts." + default = false +} + +variable "state_file_path" { + type = string + description = "Path to the AgentAPI state file. Defaults to $HOME//agentapi-state.json." + default = "" +} + +variable "pid_file_path" { + type = string + description = "Path to the AgentAPI PID file. Defaults to $HOME//agentapi.pid." + default = "" +} locals { # we always trim the slash for consistency @@ -182,6 +199,7 @@ locals { agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat" main_script = file("${path.module}/scripts/main.sh") shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh") + lib_script = file("${path.module}/scripts/lib.sh") } resource "coder_script" "agentapi" { @@ -195,6 +213,7 @@ resource "coder_script" "agentapi" { echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh chmod +x /tmp/main.sh + echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh ARG_MODULE_DIR_NAME='${var.module_dir_name}' \ ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \ @@ -209,6 +228,9 @@ resource "coder_script" "agentapi" { ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \ ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ + ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \ + ARG_STATE_FILE_PATH='${var.state_file_path}' \ + ARG_PID_FILE_PATH='${var.pid_file_path}' \ /tmp/main.sh EOT run_on_start = true @@ -225,10 +247,14 @@ resource "coder_script" "agentapi_shutdown" { echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh chmod +x /tmp/agentapi-shutdown.sh + echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ ARG_AGENTAPI_PORT='${var.agentapi_port}' \ + ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \ + ARG_MODULE_DIR_NAME='${var.module_dir_name}' \ + ARG_PID_FILE_PATH='${var.pid_file_path}' \ /tmp/agentapi-shutdown.sh EOT } diff --git a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh index bbee7628..8de176e4 100644 --- a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh +++ b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # AgentAPI shutdown script. # -# Captures the last 10 messages from AgentAPI and posts them to Coder instance -# as a snapshot. This script is called during workspace shutdown to access -# conversation history for paused tasks. +# Performs a graceful shutdown of AgentAPI: sends SIGUSR1 to trigger state save, +# captures the last 10 messages as a log snapshot posted to the Coder instance, +# then sends SIGTERM for graceful termination. set -euo pipefail @@ -11,6 +11,13 @@ set -euo pipefail readonly TASK_ID="${ARG_TASK_ID:-}" readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}" +readonly ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}" +readonly MODULE_DIR_NAME="${ARG_MODULE_DIR_NAME:-}" +readonly PID_FILE_PATH="${ARG_PID_FILE_PATH:-${MODULE_DIR_NAME:+$HOME/$MODULE_DIR_NAME/agentapi.pid}}" + +# Source shared utilities (written by the coder_script wrapper). +# shellcheck source=lib.sh +source /tmp/agentapi-lib.sh # Runtime environment variables. readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}" @@ -20,7 +27,7 @@ readonly CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN:-}" readonly MAX_PAYLOAD_SIZE=65536 # 64KB readonly MAX_MESSAGE_CONTENT=57344 # 56KB readonly MAX_MESSAGES=10 -readonly FETCH_TIMEOUT=5 +readonly FETCH_TIMEOUT=10 readonly POST_TIMEOUT=10 log() { @@ -138,44 +145,45 @@ post_task_log_snapshot() { capture_task_log_snapshot() { if [[ -z $TASK_ID ]]; then log "No task ID, skipping log snapshot" - exit 0 + return 0 fi if [[ -z $CODER_AGENT_URL ]]; then error "CODER_AGENT_URL not set, cannot capture log snapshot" - exit 1 + return 1 fi if [[ -z $CODER_AGENT_TOKEN ]]; then error "CODER_AGENT_TOKEN not set, cannot capture log snapshot" - exit 1 + return 1 fi if ! command -v jq > /dev/null 2>&1; then error "jq not found, cannot capture log snapshot" - exit 1 + return 1 fi if ! command -v curl > /dev/null 2>&1; then error "curl not found, cannot capture log snapshot" - exit 1 + return 1 fi + # Not local, must be visible to the EXIT trap after the function returns. tmpdir=$(mktemp -d) - trap 'rm -rf "$tmpdir"' EXIT + trap 'trap - EXIT; rm -rf "$tmpdir"' EXIT local payload_file="${tmpdir}/payload.json" if ! fetch_and_build_messages_payload "$payload_file"; then error "Cannot capture log snapshot without messages" - exit 1 + return 1 fi local message_count message_count=$(jq '.messages | length' < "$payload_file") if ((message_count == 0)); then log "No messages for log snapshot" - exit 0 + return 0 fi log "Retrieved $message_count messages for log snapshot" @@ -183,7 +191,7 @@ capture_task_log_snapshot() { # Ensure payload fits within size limit. if ! truncate_messages_payload_to_size "$payload_file" "$MAX_PAYLOAD_SIZE"; then error "Failed to truncate payload to size limit" - exit 1 + return 1 fi local final_size final_count @@ -193,19 +201,60 @@ capture_task_log_snapshot() { if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then error "Log snapshot capture failed" - exit 1 + return 1 fi } main() { log "Shutting down AgentAPI" + local agentapi_pid= + if [[ -n $PID_FILE_PATH ]]; then + agentapi_pid=$(cat "$PID_FILE_PATH" 2> /dev/null || echo "") + fi + + # State persistence is only enabled when the binary supports it (>= v0.12.0). + # The default SIGUSR1 disposition on Linux is terminate, so sending it to an + # older binary would kill the process. + local state_persistence=0 + if [[ $ENABLE_STATE_PERSISTENCE == true ]] && version_at_least 0.12.0 "$(agentapi_version)"; then + state_persistence=1 + fi + + # Trigger state save via SIGUSR1 (saves without exiting). + if ((state_persistence)) && [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then + log "Sending SIGUSR1 to AgentAPI (pid $agentapi_pid) to save state" + kill -USR1 "$agentapi_pid" || true + # Allow time for state save to complete before proceeding. + sleep 1 + fi + + # Capture log snapshot for task history. if [[ $TASK_LOG_SNAPSHOT == true ]]; then - capture_task_log_snapshot + # Subshell scopes the EXIT trap (tmpdir cleanup) inside + # capture_task_log_snapshot and preserves set -e, which + # || would otherwise disable for the function body. + (capture_task_log_snapshot) || log "Log snapshot capture failed, continuing shutdown" else log "Log snapshot disabled, skipping" fi + # Graceful termination. + if [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then + log "Sending SIGTERM to AgentAPI (pid $agentapi_pid)" + kill -TERM "$agentapi_pid" 2> /dev/null || true + + # Wait for process to exit to guarantee a clean shutdown. + local elapsed=0 + while kill -0 "$agentapi_pid" 2> /dev/null; do + sleep 1 + ((elapsed++)) || true + if ((elapsed % 5 == 0)); then + log "Warning: AgentAPI (pid $agentapi_pid) still running after ${elapsed}s" + fi + done + fi + log "Shutdown complete" } diff --git a/registry/coder/modules/agentapi/scripts/lib.sh b/registry/coder/modules/agentapi/scripts/lib.sh new file mode 100644 index 00000000..20bdef47 --- /dev/null +++ b/registry/coder/modules/agentapi/scripts/lib.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Shared utility functions for agentapi module scripts. + +# version_at_least checks if an actual version meets a minimum requirement. +# Non-semver strings (e.g. "latest", custom builds) always pass. +# Usage: version_at_least +# version_at_least v0.12.0 v0.10.0 # returns 1 (false) +# version_at_least v0.12.0 v0.12.0 # returns 0 (true) +# version_at_least v0.12.0 latest # returns 0 (true) +version_at_least() { + local min="${1#v}" + local actual="${2#v}" + + # Non-semver versions pass through (e.g. "latest", custom builds). + if ! [[ $actual =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + return 0 + fi + + local act_major="${BASH_REMATCH[1]}" + local act_minor="${BASH_REMATCH[2]}" + local act_patch="${BASH_REMATCH[3]}" + + [[ $min =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 0 + + local min_major="${BASH_REMATCH[1]}" + local min_minor="${BASH_REMATCH[2]}" + local min_patch="${BASH_REMATCH[3]}" + + # Arithmetic expressions set exit status: 0 (true) if non-zero, 1 (false) if zero. + if ((act_major != min_major)); then + ((act_major > min_major)) + return + fi + if ((act_minor != min_minor)); then + ((act_minor > min_minor)) + return + fi + ((act_patch >= min_patch)) +} + +# agentapi_version returns the installed agentapi binary version (e.g. "0.11.8"). +# Returns empty string if the binary is missing or doesn't support --version. +agentapi_version() { + agentapi --version 2> /dev/null | awk '{print $NF}' +} diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 63e013eb..928132c8 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -16,8 +16,14 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT" AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}" TASK_ID="${ARG_TASK_ID:-}" TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" +ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}" +STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}" +PID_FILE_PATH="${ARG_PID_FILE_PATH:-}" set +o nounset +# shellcheck source=lib.sh +source /tmp/agentapi-lib.sh + command_exists() { command -v "$1" > /dev/null 2>&1 } @@ -106,5 +112,18 @@ cd "${WORKDIR}" export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}" # Disable host header check since AgentAPI is proxied by Coder (which does its own validation) export AGENTAPI_ALLOWED_HOSTS="*" +export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}" +# Only set state env vars when persistence is enabled and the binary supports +# it. State persistence requires agentapi >= v0.12.0. +if [ "${ENABLE_STATE_PERSISTENCE}" = "true" ]; then + actual_version=$(agentapi_version) + if version_at_least 0.12.0 "$actual_version"; then + export AGENTAPI_STATE_FILE="${STATE_FILE_PATH:-$module_path/agentapi-state.json}" + export AGENTAPI_SAVE_STATE="true" + export AGENTAPI_LOAD_STATE="true" + else + echo "Warning: State persistence requires agentapi >= v0.12.0 (current: ${actual_version:-unknown}), skipping." + fi +fi nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" & "$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}" diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js b/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js index c6b0fb7f..c53a0757 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js @@ -3,8 +3,26 @@ // Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port] const http = require("http"); +const fs = require("fs"); const port = process.argv[2] || 3284; +// Write PID file for shutdown script. +if (process.env.AGENTAPI_PID_FILE) { + const path = require("path"); + fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), { + recursive: true, + }); + fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid)); +} + +// Handle SIGUSR1 (state save signal from shutdown script). +process.on("SIGUSR1", () => { + fs.writeFileSync( + "/tmp/sigusr1-received", + `SIGUSR1 received at ${Date.now()}\n`, + ); +}); + // Parse messages from environment or use default let messages = []; if (process.env.MESSAGES) { diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock.js b/registry/coder/modules/agentapi/testdata/agentapi-mock.js index 72db716a..84a88c04 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-mock.js +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock.js @@ -6,12 +6,41 @@ const args = process.argv.slice(2); const portIdx = args.findIndex((arg) => arg === "--port") + 1; const port = portIdx ? args[portIdx] : 3284; +if (args.includes("--version")) { + console.log("agentapi version 99.99.99"); + process.exit(0); +} + console.log(`starting server on port ${port}`); fs.writeFileSync( "/home/coder/agentapi-mock.log", `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`, ); +// Log state persistence env vars. +for (const v of [ + "AGENTAPI_STATE_FILE", + "AGENTAPI_PID_FILE", + "AGENTAPI_SAVE_STATE", + "AGENTAPI_LOAD_STATE", +]) { + if (process.env[v]) { + fs.appendFileSync( + "/home/coder/agentapi-mock.log", + `\n${v}: ${process.env[v]}`, + ); + } +} + +// Write PID file for shutdown script. +if (process.env.AGENTAPI_PID_FILE) { + const path = require("path"); + fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), { + recursive: true, + }); + fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid)); +} + http .createServer(function (_request, response) { response.writeHead(200); From 7b245549ec59354d4394a3d6008b2a2cfc785eb6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Mar 2026 18:03:57 +0200 Subject: [PATCH 03/28] feat(coder/modules/claude-code): add enable_state_persistence variable (#749) feat(coder/modules/claude-code): add enable_state_persistence variable Expose the agentapi module's state persistence toggle so users can control conversation state persistence across workspace restarts. Enabled by default, set `enable_state_persistence = false` to disable. Also bumps agentapi dependency from 2.0.0 to 2.2.0 and claude-code to 4.8.0. Refs coder/internal#1258 --- registry/coder/modules/claude-code/README.md | 31 +++++++++---- registry/coder/modules/claude-code/main.tf | 43 +++++++++++-------- .../coder/modules/claude-code/main.tftest.hcl | 30 +++++++++++++ 3 files changed, 77 insertions(+), 27 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 340eb175..3d875046 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_api_key = "xxxx-xxxxx-xxxx" @@ -36,6 +36,19 @@ module "claude-code" { By default, Claude Code automatically resumes existing conversations when your workspace restarts. Sessions are tracked per workspace directory, so conversations continue where you left off. If no session exists (first start), your `ai_prompt` will run normally. To disable this behavior and always start fresh, set `continue = false` +## State Persistence + +AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Claude CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning). + +To disable: + +```tf +module "claude-code" { + # ... other config + enable_state_persistence = false +} +``` + ## Examples ### Usage with Agent Boundaries @@ -47,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_boundary = true @@ -68,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_aibridge = true @@ -97,7 +110,7 @@ data "coder_task" "me" {} module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" ai_prompt = data.coder_task.me.prompt @@ -120,7 +133,7 @@ This example shows additional configuration options for version pinning, custom ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -176,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace. ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" install_claude_code = true @@ -198,7 +211,7 @@ variable "claude_code_oauth_token" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_code_oauth_token = var.claude_code_oauth_token @@ -271,7 +284,7 @@ resource "coder_env" "bedrock_api_key" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" @@ -328,7 +341,7 @@ resource "coder_env" "google_application_credentials" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "claude-sonnet-4@20250514" diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 07e3eb5a..8f517ebc 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -261,6 +261,12 @@ variable "enable_aibridge" { } } +variable "enable_state_persistence" { + type = bool + description = "Enable AgentAPI conversation state persistence across restarts." + default = true +} + resource "coder_env" "claude_code_md_path" { count = var.claude_md_path == "" ? 0 : 1 agent_id = var.agent_id @@ -356,25 +362,26 @@ locals { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.0.0" + version = "2.2.0" - agent_id = var.agent_id - web_app_slug = local.app_slug - web_app_order = var.order - web_app_group = var.group - web_app_icon = var.icon - web_app_display_name = var.web_app_display_name - folder = local.workdir - cli_app = var.cli_app - cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null - cli_app_display_name = var.cli_app ? var.cli_app_display_name : null - agentapi_subdomain = var.subdomain - module_dir_name = local.module_dir_name - install_agentapi = var.install_agentapi - agentapi_version = var.agentapi_version - pre_install_script = var.pre_install_script - post_install_script = var.post_install_script - start_script = <<-EOT + agent_id = var.agent_id + web_app_slug = local.app_slug + web_app_order = var.order + web_app_group = var.group + web_app_icon = var.icon + web_app_display_name = var.web_app_display_name + folder = local.workdir + cli_app = var.cli_app + cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null + cli_app_display_name = var.cli_app ? var.cli_app_display_name : null + agentapi_subdomain = var.subdomain + module_dir_name = local.module_dir_name + install_agentapi = var.install_agentapi + agentapi_version = var.agentapi_version + enable_state_persistence = var.enable_state_persistence + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + start_script = <<-EOT #!/bin/bash set -o errexit set -o pipefail diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index e273d321..3d11989b 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -387,6 +387,36 @@ run "test_aibridge_disabled_with_api_key" { } } +run "test_enable_state_persistence_default" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = var.enable_state_persistence == true + error_message = "enable_state_persistence should default to true" + } +} + +run "test_disable_state_persistence" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + enable_state_persistence = false + } + + assert { + condition = var.enable_state_persistence == false + error_message = "enable_state_persistence should be false when explicitly disabled" + } +} + + run "test_no_api_key_no_env" { command = plan From eed8e6c29ace63511a726274cbea27e4a89605d4 Mon Sep 17 00:00:00 2001 From: DevCats Date: Tue, 3 Mar 2026 11:30:32 -0600 Subject: [PATCH 04/28] feat(vscode-web): enhance settings management and testing for VS Code Web (#758) This pull request enhances the VS Code Web module by improving how machine settings are handled and merged, updating documentation to clarify the settings behavior, and adding robust automated tests for the new functionality. The most significant changes are grouped below. **Machine Settings Handling and Merging:** * Introduced a new `merge_settings` function in `run.sh` that merges provided settings with any existing machine settings using `jq` or `python3` if available, falling back gracefully if neither is present. Settings are now passed as base64-encoded JSON to avoid quoting issues. [[1]](diffhunk://#diff-c6d09ac3d801a2417c0e3cf8c2cd0f093ba2cf245bad8c213f70115c75276323R7-R54) [[2]](diffhunk://#diff-c6d09ac3d801a2417c0e3cf8c2cd0f093ba2cf245bad8c213f70115c75276323L31-R76) [[3]](diffhunk://#diff-0c7f0791e2c2556eb4ed7666ac44534ea3ff5c7f652e01716e5d7b5c31180d92L180-R184) [[4]](diffhunk://#diff-0c7f0791e2c2556eb4ed7666ac44534ea3ff5c7f652e01716e5d7b5c31180d92R170-R173) * Updated the `settings` variable in `main.tf` to clarify that it applies to VS Code Web's Machine settings and will be merged with any existing settings on startup. **Documentation Improvements:** * Updated the README to clarify that settings are merged with existing machine settings, not simply overwritten, and added a note about the requirements (`jq` or `python3`) and limitations regarding persistence of user settings. [[1]](diffhunk://#diff-24e2e305e46a08f8a30243bdc916241586e4561d97861b4397b14e871f9f085dL54-R56) [[2]](diffhunk://#diff-24e2e305e46a08f8a30243bdc916241586e4561d97861b4397b14e871f9f085dR72-R73) **Automated Testing:** * Expanded `main.test.ts` to include integration tests that verify settings file creation and merging behavior inside a container, as well as improved error handling for invalid configuration combinations. These changes collectively make machine settings management more robust, user-friendly, and well-documented. --- registry/coder/modules/vscode-web/README.md | 19 +- .../coder/modules/vscode-web/main.test.ts | 310 ++++++++++++++++-- registry/coder/modules/vscode-web/main.tf | 9 +- registry/coder/modules/vscode-web/run.sh | 56 +++- 4 files changed, 350 insertions(+), 44 deletions(-) diff --git a/registry/coder/modules/vscode-web/README.md b/registry/coder/modules/vscode-web/README.md index 43b1eb9d..35cad1ad 100644 --- a/registry/coder/modules/vscode-web/README.md +++ b/registry/coder/modules/vscode-web/README.md @@ -14,7 +14,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/ module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "1.5.0" agent_id = coder_agent.example.id accept_license = true } @@ -30,7 +30,7 @@ module "vscode-web" { module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "1.5.0" agent_id = coder_agent.example.id install_prefix = "/home/coder/.vscode-web" folder = "/home/coder" @@ -44,22 +44,22 @@ module "vscode-web" { module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "1.5.0" agent_id = coder_agent.example.id extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"] accept_license = true } ``` -### Pre-configure Settings +### Pre-configure Machine Settings -Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file: +Configure VS Code's [Machine settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file). These settings are merged with any existing machine settings on startup: ```tf module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "1.5.0" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { @@ -69,6 +69,9 @@ module "vscode-web" { } ``` +> [!WARNING] +> Merging settings requires `jq` or `python3`. If neither is available, existing machine settings will be preserved. User settings configured through the VS Code UI are stored in browser local storage and will not persist across different browsers or devices. + ### Pin a specific VS Code Web version By default, this module installs the latest. To pin a specific version, retrieve the commit ID from the [VS Code Update API](https://update.code.visualstudio.com/api/commits/stable/server-linux-x64-web) and verify its corresponding release on the [VS Code GitHub Releases](https://github.com/microsoft/vscode/releases). @@ -77,7 +80,7 @@ By default, this module installs the latest. To pin a specific version, retrieve module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "1.5.0" agent_id = coder_agent.example.id commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447" accept_license = true @@ -93,7 +96,7 @@ Note: Either `workspace` or `folder` can be used, but not both simultaneously. T module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "1.5.0" agent_id = coder_agent.example.id workspace = "/home/coder/coder.code-workspace" } diff --git a/registry/coder/modules/vscode-web/main.test.ts b/registry/coder/modules/vscode-web/main.test.ts index 860fc176..96c787c8 100644 --- a/registry/coder/modules/vscode-web/main.test.ts +++ b/registry/coder/modules/vscode-web/main.test.ts @@ -1,42 +1,298 @@ -import { describe, expect, it } from "bun:test"; -import { runTerraformApply, runTerraformInit } from "~test"; +import { + describe, + expect, + it, + beforeAll, + afterEach, + setDefaultTimeout, +} from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + runContainer, + execContainer, + removeContainer, + findResourceInstance, +} from "~test"; + +// Set timeout to 2 minutes for tests that install packages +setDefaultTimeout(2 * 60 * 1000); + +let cleanupContainers: string[] = []; + +afterEach(async () => { + for (const id of cleanupContainers) { + try { + await removeContainer(id); + } catch { + // Ignore cleanup errors + } + } + cleanupContainers = []; +}); describe("vscode-web", async () => { - await runTerraformInit(import.meta.dir); - - it("accept_license should be set to true", () => { - const t = async () => { - await runTerraformApply(import.meta.dir, { - agent_id: "foo", - accept_license: "false", - }); - }; - expect(t).toThrow("Invalid value for variable"); + beforeAll(async () => { + await runTerraformInit(import.meta.dir); }); - it("use_cached and offline can not be used together", () => { - const t = async () => { + it("accept_license should be set to true", async () => { + try { await runTerraformApply(import.meta.dir, { agent_id: "foo", - accept_license: "true", - use_cached: "true", - offline: "true", + accept_license: false, }); - }; - expect(t).toThrow("Offline and Use Cached can not be used together"); + throw new Error("Expected terraform apply to fail"); + } catch (ex) { + expect((ex as Error).message).toContain("Invalid value for variable"); + } }); - it("offline and extensions can not be used together", () => { - const t = async () => { + it("use_cached and offline can not be used together", async () => { + try { await runTerraformApply(import.meta.dir, { agent_id: "foo", - accept_license: "true", - offline: "true", - extensions: '["1", "2"]', + accept_license: true, + use_cached: true, + offline: true, }); - }; - expect(t).toThrow("Offline mode does not allow extensions to be installed"); + throw new Error("Expected terraform apply to fail"); + } catch (ex) { + expect((ex as Error).message).toContain( + "Offline and Use Cached can not be used together", + ); + } }); - // More tests depend on shebang refactors + it("offline and extensions can not be used together", async () => { + try { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + offline: true, + extensions: '["ms-python.python"]', + }); + throw new Error("Expected terraform apply to fail"); + } catch (ex) { + expect((ex as Error).message).toContain( + "Offline mode does not allow extensions to be installed", + ); + } + }); + + it("creates settings file with correct content", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + use_cached: true, + settings: '{"editor.fontSize": 14}', + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Create a mock code-server CLI that the script expects + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF' +#!/bin/bash +echo "Mock code-server running" +exit 0 +MOCKEOF +chmod +x /tmp/vscode-web/bin/code-server`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + const scriptResult = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + expect(scriptResult.exitCode).toBe(0); + + // Check that settings file was created + const settingsResult = await execContainer(containerId, [ + "cat", + "/root/.vscode-server/data/Machine/settings.json", + ]); + + expect(settingsResult.exitCode).toBe(0); + expect(settingsResult.stdout).toContain("editor.fontSize"); + expect(settingsResult.stdout).toContain("14"); + }); + + it("merges settings with existing settings file", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + use_cached: true, + settings: '{"new.setting": "new_value"}', + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Install jq and create mock code-server CLI + await execContainer(containerId, ["apt-get", "update", "-qq"]); + await execContainer(containerId, ["apt-get", "install", "-y", "-qq", "jq"]); + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF' +#!/bin/bash +echo "Mock code-server running" +exit 0 +MOCKEOF +chmod +x /tmp/vscode-web/bin/code-server`, + ]); + + // Pre-create an existing settings file + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + const scriptResult = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + expect(scriptResult.exitCode).toBe(0); + + // Check that settings were merged (both existing and new should be present) + const settingsResult = await execContainer(containerId, [ + "cat", + "/root/.vscode-server/data/Machine/settings.json", + ]); + + expect(settingsResult.exitCode).toBe(0); + // Should contain both existing and new settings + expect(settingsResult.stdout).toContain("existing.setting"); + expect(settingsResult.stdout).toContain("existing_value"); + expect(settingsResult.stdout).toContain("new.setting"); + expect(settingsResult.stdout).toContain("new_value"); + }); + + it("merges settings using python3 fallback when jq unavailable", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + use_cached: true, + settings: '{"new.setting": "new_value"}', + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Install python3 (ubuntu:22.04 doesn't have it by default) + await execContainer(containerId, ["apt-get", "update", "-qq"]); + await execContainer(containerId, [ + "apt-get", + "install", + "-y", + "-qq", + "python3", + ]); + + // Create mock code-server CLI (no jq installed) + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF' +#!/bin/bash +echo "Mock code-server running" +exit 0 +MOCKEOF +chmod +x /tmp/vscode-web/bin/code-server`, + ]); + + // Pre-create an existing settings file + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + const scriptResult = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + expect(scriptResult.exitCode).toBe(0); + + // Check that settings were merged using python3 fallback + const settingsResult = await execContainer(containerId, [ + "cat", + "/root/.vscode-server/data/Machine/settings.json", + ]); + + expect(settingsResult.exitCode).toBe(0); + // Should contain both existing and new settings + expect(settingsResult.stdout).toContain("existing.setting"); + expect(settingsResult.stdout).toContain("existing_value"); + expect(settingsResult.stdout).toContain("new.setting"); + expect(settingsResult.stdout).toContain("new_value"); + }); + + it("preserves existing settings when neither jq nor python3 available", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + use_cached: true, + settings: '{"new.setting": "new_value"}', + }); + + // Use ubuntu without installing jq or python3 (neither available by default) + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Create mock code-server CLI + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF' +#!/bin/bash +echo "Mock code-server running" +exit 0 +MOCKEOF +chmod +x /tmp/vscode-web/bin/code-server`, + ]); + + // Pre-create an existing settings file + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + // Run script - should warn but not fail + const scriptResult = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + expect(scriptResult.exitCode).toBe(0); + expect(scriptResult.stdout).toContain("Could not merge settings"); + + // Existing settings should be preserved (not overwritten) + const settingsResult = await execContainer(containerId, [ + "cat", + "/root/.vscode-server/data/Machine/settings.json", + ]); + + expect(settingsResult.exitCode).toBe(0); + expect(settingsResult.stdout).toContain("existing.setting"); + expect(settingsResult.stdout).toContain("existing_value"); + expect(settingsResult.stdout).not.toContain("new.setting"); + expect(settingsResult.stdout).not.toContain("new_value"); + }); }); diff --git a/registry/coder/modules/vscode-web/main.tf b/registry/coder/modules/vscode-web/main.tf index 7a2029c8..ff86e455 100644 --- a/registry/coder/modules/vscode-web/main.tf +++ b/registry/coder/modules/vscode-web/main.tf @@ -105,7 +105,7 @@ variable "group" { variable "settings" { type = any - description = "A map of settings to apply to VS Code web." + description = "A map of settings to apply to VS Code Web's Machine settings. These settings are merged with any existing machine settings on startup." default = {} } @@ -167,6 +167,10 @@ variable "workspace" { data "coder_workspace_owner" "me" {} data "coder_workspace" "me" {} +locals { + settings_b64 = var.settings != {} ? base64encode(jsonencode(var.settings)) : "" +} + resource "coder_script" "vscode-web" { agent_id = var.agent_id display_name = "VS Code Web" @@ -177,8 +181,7 @@ resource "coder_script" "vscode-web" { INSTALL_PREFIX : var.install_prefix, EXTENSIONS : join(",", var.extensions), TELEMETRY_LEVEL : var.telemetry_level, - // This is necessary otherwise the quotes are stripped! - SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""), + SETTINGS_B64 : local.settings_b64, OFFLINE : var.offline, USE_CACHED : var.use_cached, DISABLE_TRUST : var.disable_trust, diff --git a/registry/coder/modules/vscode-web/run.sh b/registry/coder/modules/vscode-web/run.sh index 57bb760f..dea8e585 100644 --- a/registry/coder/modules/vscode-web/run.sh +++ b/registry/coder/modules/vscode-web/run.sh @@ -4,13 +4,54 @@ BOLD='\033[0;1m' EXTENSIONS=("${EXTENSIONS}") VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server" +# Merge settings from module with existing settings file +# Uses jq if available, falls back to Python3 for deep merge +merge_settings() { + local new_settings="$1" + local settings_file="$2" + + if [ -z "$new_settings" ] || [ "$new_settings" = "{}" ]; then + return 0 + fi + + if [ ! -f "$settings_file" ]; then + mkdir -p "$(dirname "$settings_file")" + printf '%s\n' "$new_settings" > "$settings_file" + printf "⚙️ Creating settings file...\n" + return 0 + fi + + local tmpfile + tmpfile="$(mktemp)" + + if command -v jq > /dev/null 2>&1; then + if jq -s '.[0] * .[1]' "$settings_file" <(printf '%s\n' "$new_settings") > "$tmpfile" 2> /dev/null; then + mv "$tmpfile" "$settings_file" + printf "⚙️ Merging settings...\n" + return 0 + fi + fi + + if command -v python3 > /dev/null 2>&1; then + if python3 -c "import json,sys;m=lambda a,b:{**a,**{k:m(a[k],v)if k in a and type(a[k])==type(v)==dict else v for k,v in b.items()}};print(json.dumps(m(json.load(open(sys.argv[1])),json.loads(sys.argv[2])),indent=2))" "$settings_file" "$new_settings" > "$tmpfile" 2> /dev/null; then + mv "$tmpfile" "$settings_file" + printf "⚙️ Merging settings...\n" + return 0 + fi + fi + + rm -f "$tmpfile" + printf "Warning: Could not merge settings (jq or python3 required). Keeping existing settings.\n" + return 0 +} + # Set extension directory EXTENSION_ARG="" if [ -n "${EXTENSIONS_DIR}" ]; then EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}" fi -# Set extension directory +# Set server base path SERVER_BASE_PATH_ARG="" if [ -n "${SERVER_BASE_PATH}" ]; then SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}" @@ -28,11 +69,14 @@ run_vscode_web() { "$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" "$DISABLE_TRUST_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 & } -# Check if the settings file exists... -if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then - echo "⚙️ Creating settings file..." - mkdir -p ~/.vscode-server/data/Machine - echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json +# Apply machine settings (merge with existing if present) +SETTINGS_B64='${SETTINGS_B64}' +if [ -n "$SETTINGS_B64" ]; then + if SETTINGS_JSON="$(echo -n "$SETTINGS_B64" | base64 -d 2> /dev/null)" && [ -n "$SETTINGS_JSON" ]; then + merge_settings "$SETTINGS_JSON" ~/.vscode-server/data/Machine/settings.json + else + printf "Warning: Failed to decode settings. Skipping settings configuration.\n" + fi fi # Check if vscode-server is already installed for offline or cached mode From 63e28c0e95e33ed53f3d391e55fbc8b2e968672c Mon Sep 17 00:00:00 2001 From: justmanuel <56279646+justmanuel@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:01:05 -0500 Subject: [PATCH 05/28] Enable Devcontainer-cli module to block user login until script finishes running (#759) ## Description Allow for devcontainer-cli module to prevent users from logging in until its finished running. ## Type of Change - [ ] New module - [ ] New template - [ ] Bug fix - [x ] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder/modules/devcontainers-cli` **New version:** `1.1.0` **Breaking change:** [ ] Yes [x ] No ## Testing & Validation - [x] Tests pass (`bun test`) - [ ] Code formatted (`bun fmt`) - [ x] Changes tested locally ## Related Issues None --------- Co-authored-by: DevCats Co-authored-by: DevCats --- .../coder/modules/devcontainers-cli/README.md | 7 ++++--- .../coder/modules/devcontainers-cli/main.tf | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/registry/coder/modules/devcontainers-cli/README.md b/registry/coder/modules/devcontainers-cli/README.md index bb5ec6de..771be25d 100644 --- a/registry/coder/modules/devcontainers-cli/README.md +++ b/registry/coder/modules/devcontainers-cli/README.md @@ -14,8 +14,9 @@ The devcontainers-cli module provides an easy way to install [`@devcontainers/cl ```tf module "devcontainers-cli" { - source = "registry.coder.com/coder/devcontainers-cli/coder" - version = "1.0.34" - agent_id = coder_agent.example.id + source = "registry.coder.com/coder/devcontainers-cli/coder" + version = "1.1.0" + agent_id = coder_agent.example.id + start_blocks_login = false } ``` diff --git a/registry/coder/modules/devcontainers-cli/main.tf b/registry/coder/modules/devcontainers-cli/main.tf index a2aee348..16fa35fe 100644 --- a/registry/coder/modules/devcontainers-cli/main.tf +++ b/registry/coder/modules/devcontainers-cli/main.tf @@ -14,10 +14,17 @@ variable "agent_id" { description = "The ID of a Coder agent." } -resource "coder_script" "devcontainers-cli" { - agent_id = var.agent_id - display_name = "devcontainers-cli" - icon = "/icon/devcontainers.svg" - script = templatefile("${path.module}/run.sh", {}) - run_on_start = true +variable "start_blocks_login" { + type = bool + default = false + description = "Boolean, This option determines whether users can log in immediately or must wait for the workspace to finish running this script upon startup." +} + +resource "coder_script" "devcontainers-cli" { + agent_id = var.agent_id + display_name = "devcontainers-cli" + icon = "/icon/devcontainers.svg" + script = templatefile("${path.module}/run.sh", {}) + run_on_start = true + start_blocks_login = var.start_blocks_login } From ac49e6eef5e62751d83b8a18b092bcf0f242c5eb Mon Sep 17 00:00:00 2001 From: Jason Barnett Date: Tue, 3 Mar 2026 14:28:48 -0700 Subject: [PATCH 06/28] docs(claude-code): document pre_install_script for module dependency ordering (#613) ## Summary Clarifies that the existing `pre_install_script` variable can be used to handle dependencies between modules during workspace startup. ## Problem When using multiple startup modules (e.g., git-clone and claude-code), there's a race condition where scripts execute in parallel. Module dependencies need to be managed, such as ensuring git-clone completes before Claude Code tries to access a workdir. ## Solution The existing `pre_install_script` variable already provides this capability. Updated documentation to clarify this use case. ## Example ```hcl module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" workdir = "/path/to/repo" # Wait for git-clone to complete before starting pre_install_script = <<-EOT #!/bin/bash set -e while [ ! -f /tmp/.git-clone-complete ]; do sleep 1 done EOT } ``` Resolves issue #609. Co-authored-by: Jason Barnett Co-authored-by: DevCats --- registry/coder/modules/claude-code/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 8f517ebc..337ebd20 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -67,7 +67,7 @@ variable "cli_app_display_name" { variable "pre_install_script" { type = string - description = "Custom script to run before installing Claude Code." + description = "Custom script to run before installing Claude Code. Can be used for dependency ordering between modules (e.g., waiting for git-clone to complete before Claude Code initialization)." default = null } From b6c2998eb31cdc874d88d17b021a6a269bec8dc9 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Thu, 5 Mar 2026 09:27:01 +0000 Subject: [PATCH 07/28] feat: add aibridge-proxy module for AI Bridge Proxy workspace setup (#721) ## Description Add `aibridge-proxy` module that configures workspaces to use AI Bridge Proxy. Downloads the proxy's CA certificate and exposes `proxy_auth_url` and `cert_path` outputs for tool-specific modules to configure the proxy scoped to their process. The module does not set proxy environment variables globally in the workspace. ## Type of Change - [x] New module - [ ] New template - [ ] Bug fix - [ ] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder/modules/aibridge-proxy` **New version:** `v1.0.0` **Breaking change:** [ ] Yes [x] No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally ## Related Issues Closes: https://github.com/coder/internal/issues/1187 --- .../coder/modules/aibridge-proxy/README.md | 89 ++++++ .../coder/modules/aibridge-proxy/main.test.ts | 254 ++++++++++++++++++ registry/coder/modules/aibridge-proxy/main.tf | 81 ++++++ .../modules/aibridge-proxy/main.tftest.hcl | 210 +++++++++++++++ .../modules/aibridge-proxy/scripts/setup.sh | 79 ++++++ 5 files changed, 713 insertions(+) create mode 100644 registry/coder/modules/aibridge-proxy/README.md create mode 100644 registry/coder/modules/aibridge-proxy/main.test.ts create mode 100644 registry/coder/modules/aibridge-proxy/main.tf create mode 100644 registry/coder/modules/aibridge-proxy/main.tftest.hcl create mode 100644 registry/coder/modules/aibridge-proxy/scripts/setup.sh diff --git a/registry/coder/modules/aibridge-proxy/README.md b/registry/coder/modules/aibridge-proxy/README.md new file mode 100644 index 00000000..41243365 --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/README.md @@ -0,0 +1,89 @@ +--- +display_name: AI Bridge Proxy +description: Configure a workspace to route AI tool traffic through AI Bridge via AI Bridge Proxy. +icon: ../../../../.icons/coder.svg +verified: true +tags: [helper, aibridge] +--- + +# AI Bridge Proxy + +This module configures a Coder workspace to use [AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy). +It downloads the proxy's CA certificate from the Coder deployment and provides Terraform outputs (`proxy_auth_url` and `cert_path`) that tool-specific modules can use to route their traffic through the proxy. + +```tf +module "aibridge-proxy" { + source = "registry.coder.com/coder/aibridge-proxy/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + proxy_url = "https://aiproxy.example.com" +} +``` + +> [!NOTE] +> AI Bridge Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance). +> See the [AI Bridge Proxy setup guide](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup) for details on configuring the proxy on your Coder deployment. + +## How it works + +[AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy) is an HTTP proxy that intercepts traffic to AI providers and forwards it through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge), enabling centralized LLM management, governance, and cost tracking. +Any process with the proxy environment variables set will route **all** its traffic through the proxy. + +This module **does not** set proxy environment variables globally on the workspace. +Instead, it provides Terraform outputs (`proxy_auth_url` and `cert_path`) that tool-specific modules consume to configure proxy routing. +See the [Copilot module](https://registry.coder.com/modules/coder-labs/copilot) for a working integration example. + +It is recommended that tool modules scope the proxy environment variables to their own process rather than setting them globally on the workspace, to avoid routing unnecessary traffic through the proxy. + +> [!WARNING] +> If the setup script fails (e.g. the proxy is unreachable), the workspace will still start but the agent will report a startup script error. +> Tools that depend on the proxy will not work until the issue is resolved. Check the workspace build logs for details. + +## Startup Coordination + +When used with tool-specific modules (e.g. [Copilot](https://registry.coder.com/modules/coder-labs/copilot)), +the setup script signals completion via [`coder exp sync`](https://coder.com/docs/admin/templates/startup-coordination) so dependent modules can wait for the `aibridge-proxy` module to complete before starting. + +Dependent modules are unblocked once the setup script finishes, regardless of success or failure. +If the setup fails, dependent modules are expected to detect the failure and handle the error accordingly. + +To enable startup coordination, set `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment: + +```hcl +env = [ + "CODER_AGENT_TOKEN=${coder_agent.main.token}", + "CODER_AGENT_SOCKET_SERVER_ENABLED=true", +] +``` + +> [!NOTE] +> [Startup coordination](https://coder.com/docs/admin/templates/startup-coordination) requires Coder >= v2.30. +> Without it, the sync calls are skipped gracefully but dependent modules may fail to start if the `aibridge-proxy` setup has not completed in time. + +## Examples + +### Custom certificate path + +```tf +module "aibridge-proxy" { + source = "registry.coder.com/coder/aibridge-proxy/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + proxy_url = "https://aiproxy.example.com" + cert_path = "/home/coder/.certs/aibridge-proxy-ca.pem" +} +``` + +### Proxy with custom port + +For deployments where the proxy is accessed directly on a configured port. +See [security considerations](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup#security-considerations) for network access guidelines. + +```tf +module "aibridge-proxy" { + source = "registry.coder.com/coder/aibridge-proxy/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + proxy_url = "http://internal-proxy:8888" +} +``` diff --git a/registry/coder/modules/aibridge-proxy/main.test.ts b/registry/coder/modules/aibridge-proxy/main.test.ts new file mode 100644 index 00000000..29274d3d --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/main.test.ts @@ -0,0 +1,254 @@ +import { serve } from "bun"; +import { + afterEach, + beforeAll, + describe, + expect, + it, + setDefaultTimeout, +} from "bun:test"; +import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +let cleanupFunctions: (() => Promise)[] = []; +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; +afterEach(async () => { + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +const FAKE_CERT = + "-----BEGIN CERTIFICATE-----\nMIIBfakecert\n-----END CERTIFICATE-----\n"; + +// Runs terraform apply to render the setup script, then starts a Docker +// container where we can execute it against a mock server. +const setupContainer = async (vars: Record = {}) => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + proxy_url: "https://aiproxy.example.com", + ...vars, + }); + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer("lorello/alpine-bash"); + + registerCleanup(async () => { + await removeContainer(id); + }); + + return { id, instance }; +}; + +// Starts a mock HTTP server that simulates the Coder API certificate endpoint. +// Returns the server and its base URL. +const setupServer = (handler: (req: Request) => Response) => { + const server = serve({ + fetch: handler, + port: 0, + }); + registerCleanup(async () => { + server.stop(); + }); + return { + server, + // Base URL without trailing slash + url: server.url.toString().slice(0, -1), + }; +}; + +setDefaultTimeout(30 * 1000); + +describe("aibridge-proxy", () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + // Verify that agent_id and proxy_url are required. + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + proxy_url: "https://aiproxy.example.com", + }); + + it("downloads the CA certificate successfully", async () => { + let receivedToken = ""; + const { url } = setupServer((req) => { + const reqUrl = new URL(req.url); + if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") { + receivedToken = req.headers.get("Coder-Session-Token") || ""; + return new Response(FAKE_CERT, { + status: 200, + headers: { "Content-Type": "application/x-pem-file" }, + }); + } + return new Response("not found", { status: 404 }); + }); + + const { id, instance } = await setupContainer(); + + // Override ACCESS_URL and SESSION_TOKEN at runtime to point at the mock server. + const exec = await execContainer(id, [ + "env", + `ACCESS_URL=${url}`, + "SESSION_TOKEN=test-session-token-123", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy CA certificate saved to /tmp/aibridge-proxy/ca-cert.pem", + ); + + // Verify the cert was written to the default path. + const certContent = await execContainer(id, [ + "cat", + "/tmp/aibridge-proxy/ca-cert.pem", + ]); + expect(certContent.stdout).toContain("BEGIN CERTIFICATE"); + + // Verify the session token was sent in the request header. + expect(receivedToken).toBe("test-session-token-123"); + }); + + it("fails when the server is unreachable", async () => { + const { id, instance } = await setupContainer(); + + // Port 9999 has nothing listening, so curl will fail to connect. + const exec = await execContainer(id, [ + "env", + "ACCESS_URL=http://localhost:9999", + "SESSION_TOKEN=mock-token", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).not.toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy setup failed: could not connect to", + ); + }); + + it("fails when the server returns a non-200 status", async () => { + const { url } = setupServer(() => { + return new Response("not found", { status: 404 }); + }); + + const { id, instance } = await setupContainer(); + + const exec = await execContainer(id, [ + "env", + `ACCESS_URL=${url}`, + "SESSION_TOKEN=mock-token", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).not.toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy setup failed: unexpected response", + ); + }); + + it("fails when the server returns an empty response", async () => { + const { url } = setupServer((req) => { + const reqUrl = new URL(req.url); + if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") { + return new Response("", { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const { id, instance } = await setupContainer(); + + const exec = await execContainer(id, [ + "env", + `ACCESS_URL=${url}`, + "SESSION_TOKEN=mock-token", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).not.toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy setup failed: downloaded certificate is empty.", + ); + }); + + it("saves the certificate to a custom path", async () => { + const { url } = setupServer((req) => { + const reqUrl = new URL(req.url); + if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") { + return new Response(FAKE_CERT, { + status: 200, + headers: { "Content-Type": "application/x-pem-file" }, + }); + } + return new Response("not found", { status: 404 }); + }); + + // Pass a custom cert_path to terraform apply so the script uses it. + const { id, instance } = await setupContainer({ + cert_path: "/tmp/custom/certs/proxy-ca.pem", + }); + + const exec = await execContainer(id, [ + "env", + `ACCESS_URL=${url}`, + "SESSION_TOKEN=mock-token", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy CA certificate saved to /tmp/custom/certs/proxy-ca.pem", + ); + + const certContent = await execContainer(id, [ + "cat", + "/tmp/custom/certs/proxy-ca.pem", + ]); + expect(certContent.stdout).toContain("BEGIN CERTIFICATE"); + }); + + it("does not create global proxy env vars via coder_env", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + proxy_url: "https://aiproxy.example.com", + }); + + // Proxy env vars should NOT be set globally via coder_env. + // They are intended to be scoped to specific tool processes. + const proxyEnvVarNames = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + ]; + const proxyEnvVars = state.resources.filter( + (r) => + r.type === "coder_env" && + r.instances.some((i) => + proxyEnvVarNames.includes(i.attributes.name as string), + ), + ); + expect(proxyEnvVars.length).toBe(0); + }); +}); diff --git a/registry/coder/modules/aibridge-proxy/main.tf b/registry/coder/modules/aibridge-proxy/main.tf new file mode 100644 index 00000000..62200a31 --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/main.tf @@ -0,0 +1,81 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "proxy_url" { + type = string + description = "The full URL of the AI Bridge Proxy. Include the port if not using standard ports (e.g. https://aiproxy.example.com or http://internal-proxy:8888)." + + validation { + condition = can(regex("^https?://", var.proxy_url)) + error_message = "proxy_url must start with http:// or https://." + } +} + +variable "cert_path" { + type = string + description = "Absolute path where the AI Bridge Proxy CA certificate will be saved." + default = "/tmp/aibridge-proxy/ca-cert.pem" + + validation { + condition = startswith(var.cert_path, "/") + error_message = "cert_path must be an absolute path." + } +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +locals { + # Build the proxy URL with Coder authentication embedded. + # AI Bridge Proxy expects the Coder session token as the password + # in basic auth: http://coder:@host:port + proxy_auth_url = replace( + var.proxy_url, + "://", + "://coder:${data.coder_workspace_owner.me.session_token}@" + ) +} + +# These outputs are intended to be consumed by tool-specific modules, +# to set proxy environment variables scoped to their process, rather than globally. +output "proxy_auth_url" { + description = "The AI Bridge Proxy URL with Coder authentication embedded (http://coder:@host:port)." + value = local.proxy_auth_url + sensitive = true +} + +output "cert_path" { + description = "Path to the downloaded AI Bridge Proxy CA certificate." + value = var.cert_path +} + +# Downloads the CA certificate from the Coder deployment. +# This runs on workspace start but does not block login, if the script +# fails, the workspace remains usable and the error is visible in the build logs. +# Tools that depend on the proxy will fail until the certificate is available. +resource "coder_script" "aibridge_proxy_setup" { + agent_id = var.agent_id + display_name = "AI Bridge Proxy Setup" + icon = "/icon/coder.svg" + run_on_start = true + start_blocks_login = false + script = templatefile("${path.module}/scripts/setup.sh", { + CERT_PATH = var.cert_path, + ACCESS_URL = data.coder_workspace.me.access_url, + SESSION_TOKEN = data.coder_workspace_owner.me.session_token, + }) +} diff --git a/registry/coder/modules/aibridge-proxy/main.tftest.hcl b/registry/coder/modules/aibridge-proxy/main.tftest.hcl new file mode 100644 index 00000000..08e329a5 --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/main.tftest.hcl @@ -0,0 +1,210 @@ +run "test_aibridge_proxy_basic" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + } + + assert { + condition = var.agent_id == "test-agent-id" + error_message = "Agent ID should match the input variable" + } + + assert { + condition = var.proxy_url == "https://aiproxy.example.com" + error_message = "Proxy URL should match the input variable" + } + + assert { + condition = var.cert_path == "/tmp/aibridge-proxy/ca-cert.pem" + error_message = "cert_path should default to /tmp/aibridge-proxy/ca-cert.pem" + } +} + +run "test_aibridge_proxy_empty_url_validation" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "" + } + + expect_failures = [ + var.proxy_url, + ] +} + +run "test_aibridge_proxy_invalid_url_validation" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "aiproxy.example.com" + } + + expect_failures = [ + var.proxy_url, + ] +} + +run "test_aibridge_proxy_url_formats" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + } + + assert { + condition = can(regex("^https?://", var.proxy_url)) + error_message = "Proxy URL should be a valid URL with scheme" + } +} + +run "test_aibridge_proxy_https_with_port" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com:8443" + } + + assert { + condition = can(regex("^https?://", var.proxy_url)) + error_message = "Proxy URL should support HTTPS with custom port" + } +} + +run "test_aibridge_proxy_http_with_port" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "http://internal-proxy:8888" + } + + assert { + condition = can(regex("^https?://", var.proxy_url)) + error_message = "Proxy URL should support HTTP with custom port" + } +} + +run "test_aibridge_proxy_empty_cert_path_validation" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + cert_path = "" + } + + expect_failures = [ + var.cert_path, + ] +} + +run "test_aibridge_proxy_relative_cert_path_validation" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + cert_path = "relative/path/ca-cert.pem" + } + + expect_failures = [ + var.cert_path, + ] +} + +run "test_aibridge_proxy_custom_cert_path" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + cert_path = "/home/coder/.certs/ca-cert.pem" + } + + assert { + condition = var.cert_path == "/home/coder/.certs/ca-cert.pem" + error_message = "cert_path should match the input variable" + } +} + +run "test_aibridge_proxy_script" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + } + + assert { + condition = coder_script.aibridge_proxy_setup.run_on_start == true + error_message = "Script should run on start" + } + + assert { + condition = coder_script.aibridge_proxy_setup.start_blocks_login == false + error_message = "Script should not block login" + } + + assert { + condition = coder_script.aibridge_proxy_setup.display_name == "AI Bridge Proxy Setup" + error_message = "Script display name should be 'AI Bridge Proxy Setup'" + } +} + +run "test_aibridge_proxy_auth_url_https" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + } + + override_data { + target = data.coder_workspace_owner.me + values = { + session_token = "mock-session-token" + } + } + + assert { + condition = output.proxy_auth_url == "https://coder:mock-session-token@aiproxy.example.com" + error_message = "proxy_auth_url should contain the mocked session token" + } + + assert { + condition = output.cert_path == "/tmp/aibridge-proxy/ca-cert.pem" + error_message = "cert_path output should match the default" + } +} + +run "test_aibridge_proxy_auth_url_http_with_port" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "http://internal-proxy:8888" + } + + override_data { + target = data.coder_workspace_owner.me + values = { + session_token = "mock-session-token" + } + } + + assert { + condition = output.proxy_auth_url == "http://coder:mock-session-token@internal-proxy:8888" + error_message = "proxy_auth_url should preserve the port" + } + + assert { + condition = output.cert_path == "/tmp/aibridge-proxy/ca-cert.pem" + error_message = "cert_path output should match the default" + } +} diff --git a/registry/coder/modules/aibridge-proxy/scripts/setup.sh b/registry/coder/modules/aibridge-proxy/scripts/setup.sh new file mode 100644 index 00000000..c63b60e3 --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/scripts/setup.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +if [ -z "$CERT_PATH" ]; then + CERT_PATH="${CERT_PATH}" +fi + +if [ -z "$ACCESS_URL" ]; then + ACCESS_URL="${ACCESS_URL}" +fi + +if [ -z "$SESSION_TOKEN" ]; then + SESSION_TOKEN="${SESSION_TOKEN}" +fi + +set -euo pipefail + +# Signal startup coordination. +# The trap ensures 'complete' is always called (even on failure) so dependent +# scripts unblock promptly and can check for the certificate themselves. +if command -v coder > /dev/null 2>&1; then + coder exp sync start "aibridge-proxy-setup" > /dev/null 2>&1 || true + trap 'coder exp sync complete "aibridge-proxy-setup" > /dev/null 2>&1 || true' EXIT +fi + +if [ -z "$ACCESS_URL" ]; then + echo "Error: Coder access URL is not set." + exit 1 +fi + +if [ -z "$SESSION_TOKEN" ]; then + echo "Error: Coder session token is not set." + exit 1 +fi + +if ! command -v curl > /dev/null; then + echo "Error: curl is not installed." + exit 1 +fi + +echo "--------------------------------" +echo "AI Bridge Proxy Setup" +printf "Certificate path: %s\n" "$CERT_PATH" +printf "Access URL: %s\n" "$ACCESS_URL" +echo "--------------------------------" + +CERT_DIR=$(dirname "$CERT_PATH") +mkdir -p "$CERT_DIR" + +CERT_URL="$ACCESS_URL/api/v2/aibridge/proxy/ca-cert.pem" +echo "Downloading AI Bridge Proxy CA certificate from $CERT_URL..." + +# Download the certificate with a 5s connection timeout and 10s total timeout +# to avoid the script hanging indefinitely. +if ! HTTP_STATUS=$(curl -s -o "$CERT_PATH" -w "%%{http_code}" \ + --connect-timeout 5 \ + --max-time 10 \ + -H "Coder-Session-Token: $SESSION_TOKEN" \ + "$CERT_URL"); then + echo "❌ AI Bridge Proxy setup failed: could not connect to $CERT_URL." + echo "Ensure AI Bridge Proxy is enabled and reachable from the workspace." + rm -f "$CERT_PATH" + exit 1 +fi + +if [ "$HTTP_STATUS" -ne 200 ]; then + echo "❌ AI Bridge Proxy setup failed: unexpected response (HTTP $HTTP_STATUS)." + echo "Ensure AI Bridge Proxy is enabled and reachable from the workspace." + rm -f "$CERT_PATH" + exit 1 +fi + +if [ ! -s "$CERT_PATH" ]; then + echo "❌ AI Bridge Proxy setup failed: downloaded certificate is empty." + rm -f "$CERT_PATH" + exit 1 +fi + +echo "AI Bridge Proxy CA certificate saved to $CERT_PATH" +echo "✅ AI Bridge Proxy setup complete." From 7e75d5d76217814e725366dab1532cee812ea7e1 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Thu, 5 Mar 2026 09:34:41 +0000 Subject: [PATCH 08/28] feat: add AI Bridge Proxy support to copilot module (#725) ## Description Add AI Bridge Proxy support to the copilot module. When enabled, the module configures proxy environment variables (`HTTPS_PROXY`, `NODE_EXTRA_CA_CERTS`) scoped to the copilot process tree (agentapi and copilot), routing Copilot traffic through AI Bridge Proxy without affecting other workspace traffic. GitHub authentication is still required, the proxy authenticates with AI Bridge using the Coder session token but does not replace GitHub authentication. Note: Uses [coder exp sync](https://coder.com/docs/admin/templates/startup-coordination) for startup coordination, ensuring the copilot module waits for the `aibridge-proxy` setup to complete before starting. ## Type of Change - [ ] New module - [ ] New template - [ ] Bug fix - [x] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder-labs/modules/copilot` **New version:** `v0.4.0` **Breaking change:** [ ] Yes [x] No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally ## Related Issues Depends on: #721 Related to: https://github.com/coder/internal/issues/1187 --- registry/coder-labs/modules/copilot/README.md | 45 ++++++- .../modules/copilot/copilot.tftest.hcl | 113 ++++++++++++++++++ registry/coder-labs/modules/copilot/main.tf | 34 +++++- .../modules/copilot/scripts/start.sh | 46 +++++++ 4 files changed, 231 insertions(+), 7 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 76b8f025..7c0e5693 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -3,7 +3,7 @@ display_name: Copilot CLI description: GitHub Copilot CLI agent for AI-powered terminal assistance icon: ../../../../.icons/github.svg verified: false -tags: [agent, copilot, ai, github, tasks] +tags: [agent, copilot, ai, github, tasks, aibridge] --- # Copilot @@ -13,7 +13,7 @@ Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-c ```tf module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.3.0" + version = "0.4.0" agent_id = coder_agent.example.id workdir = "/home/coder/projects" } @@ -51,7 +51,7 @@ data "coder_parameter" "ai_prompt" { module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.3.0" + version = "0.4.0" agent_id = coder_agent.example.id workdir = "/home/coder/projects" @@ -71,7 +71,7 @@ Customize tool permissions, MCP servers, and Copilot settings: ```tf module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.3.0" + version = "0.4.0" agent_id = coder_agent.example.id workdir = "/home/coder/projects" @@ -142,7 +142,7 @@ variable "github_token" { module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.3.0" + version = "0.4.0" agent_id = coder_agent.example.id workdir = "/home/coder/projects" github_token = var.github_token @@ -156,7 +156,7 @@ Run Copilot as a command-line tool without task reporting or web interface. This ```tf module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.3.0" + version = "0.4.0" agent_id = coder_agent.example.id workdir = "/home/coder" report_tasks = false @@ -164,6 +164,39 @@ module "copilot" { } ``` +### Usage with AI Bridge Proxy + +[AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy) routes Copilot traffic through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) for centralized LLM management and governance. +The proxy environment variables are scoped to the Copilot process only and do not affect other workspace traffic. + +```tf +module "aibridge-proxy" { + source = "registry.coder.com/coder/aibridge-proxy/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + proxy_url = "https://aiproxy.example.com" +} + +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "0.4.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/projects" + enable_aibridge_proxy = true + aibridge_proxy_auth_url = module.aibridge-proxy.proxy_auth_url + aibridge_proxy_cert_path = module.aibridge-proxy.cert_path +} +``` + +> [!NOTE] +> AI Bridge Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance). +> See the [AI Bridge Proxy setup guide](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup) for details on configuring the proxy on your Coder deployment. +> GitHub authentication is still required for Copilot as the proxy authenticates with AI Bridge using the Coder session token, but does not replace GitHub authentication. + +> [!IMPORTANT] +> When using AI Bridge Proxy, enable [startup coordination](https://coder.com/docs/admin/templates/startup-coordination) by setting `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment. +> This ensures the Copilot module waits for the `aibridge-proxy` module to complete before starting. Without it, the Copilot start script may fail if the AI Bridge Proxy setup has not completed in time. + ## Authentication The module supports multiple authentication methods (in priority order): diff --git a/registry/coder-labs/modules/copilot/copilot.tftest.hcl b/registry/coder-labs/modules/copilot/copilot.tftest.hcl index 185c019b..0ff2379a 100644 --- a/registry/coder-labs/modules/copilot/copilot.tftest.hcl +++ b/registry/coder-labs/modules/copilot/copilot.tftest.hcl @@ -234,3 +234,116 @@ run "app_slug_is_consistent" { error_message = "module_dir_name should be '.copilot-module'" } } + +run "aibridge_proxy_defaults" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = var.enable_aibridge_proxy == false + error_message = "enable_aibridge_proxy should default to false" + } + + assert { + condition = var.aibridge_proxy_auth_url == null + error_message = "aibridge_proxy_auth_url should default to null" + } + + assert { + condition = var.aibridge_proxy_cert_path == null + error_message = "aibridge_proxy_cert_path should default to null" + } +} + +run "aibridge_proxy_enabled" { + command = plan + + variables { + agent_id = "test-agent-aibridge-proxy" + workdir = "/home/coder" + enable_aibridge_proxy = true + aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com" + aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem" + } + + assert { + condition = var.enable_aibridge_proxy == true + error_message = "AI Bridge Proxy should be enabled" + } + + assert { + condition = var.aibridge_proxy_auth_url == "https://coder:mock-token@aiproxy.example.com" + error_message = "AI Bridge Proxy auth URL should match the input variable" + } + + assert { + condition = var.aibridge_proxy_cert_path == "/tmp/aibridge-proxy/ca-cert.pem" + error_message = "AI Bridge Proxy cert path should match the input variable" + } +} + +run "aibridge_proxy_validation_missing_proxy_auth_url" { + command = plan + + variables { + agent_id = "test-agent-validation" + workdir = "/home/coder" + enable_aibridge_proxy = true + aibridge_proxy_auth_url = "" + aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem" + } + + expect_failures = [ + var.enable_aibridge_proxy, + ] +} + +run "aibridge_proxy_validation_missing_cert_path" { + command = plan + + variables { + agent_id = "test-agent-validation" + workdir = "/home/coder" + enable_aibridge_proxy = true + aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com" + aibridge_proxy_cert_path = "" + } + + expect_failures = [ + var.enable_aibridge_proxy, + ] +} + +run "aibridge_proxy_with_copilot_config" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + copilot_model = "gpt-5" + github_token = "ghp_test123" + allow_all_tools = true + enable_aibridge_proxy = true + aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com" + aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem" + } + + assert { + condition = var.enable_aibridge_proxy == true + error_message = "AI Bridge Proxy should be enabled" + } + + assert { + condition = length(resource.coder_env.github_token) == 1 + error_message = "github_token environment variable should be set alongside proxy" + } + + assert { + condition = length(resource.coder_env.copilot_model) == 1 + error_message = "copilot_model environment variable should be set alongside proxy" + } +} diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index 218184d7..2837961f 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.0" + required_version = ">= 1.9" required_providers { coder = { source = "coder/coder" @@ -173,6 +173,35 @@ variable "post_install_script" { default = null } +variable "enable_aibridge_proxy" { + type = bool + description = "Route Copilot traffic through AI Bridge Proxy. See https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy" + default = false + + validation { + condition = !var.enable_aibridge_proxy || (var.aibridge_proxy_auth_url != null && length(var.aibridge_proxy_auth_url) > 0) + error_message = "aibridge_proxy_auth_url is required when enable_aibridge_proxy is true." + } + + validation { + condition = !var.enable_aibridge_proxy || (var.aibridge_proxy_cert_path != null && length(var.aibridge_proxy_cert_path) > 0) + error_message = "aibridge_proxy_cert_path is required when enable_aibridge_proxy is true." + } +} + +variable "aibridge_proxy_auth_url" { + type = string + description = "AI Bridge Proxy URL with authentication. Use the proxy_auth_url output from the aibridge-proxy module." + default = null + sensitive = true +} + +variable "aibridge_proxy_cert_path" { + type = string + description = "Path to the AI Bridge Proxy CA certificate. Use the cert_path output from the aibridge-proxy module." + default = null +} + data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} @@ -279,6 +308,9 @@ module "agentapi" { ARG_TRUSTED_DIRECTORIES='${join(",", var.trusted_directories)}' \ ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \ ARG_RESUME_SESSION='${var.resume_session}' \ + ARG_ENABLE_AIBRIDGE_PROXY='${var.enable_aibridge_proxy}' \ + ARG_AIBRIDGE_PROXY_AUTH_URL='${var.aibridge_proxy_auth_url != null ? var.aibridge_proxy_auth_url : ""}' \ + ARG_AIBRIDGE_PROXY_CERT_PATH='${var.aibridge_proxy_cert_path != null ? var.aibridge_proxy_cert_path : ""}' \ /tmp/start.sh EOT diff --git a/registry/coder-labs/modules/copilot/scripts/start.sh b/registry/coder-labs/modules/copilot/scripts/start.sh index 98341e9b..0aecb1fe 100644 --- a/registry/coder-labs/modules/copilot/scripts/start.sh +++ b/registry/coder-labs/modules/copilot/scripts/start.sh @@ -22,6 +22,9 @@ ARG_DENY_TOOLS=${ARG_DENY_TOOLS:-} ARG_TRUSTED_DIRECTORIES=${ARG_TRUSTED_DIRECTORIES:-} ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github} ARG_RESUME_SESSION=${ARG_RESUME_SESSION:-true} +ARG_ENABLE_AIBRIDGE_PROXY=${ARG_ENABLE_AIBRIDGE_PROXY:-false} +ARG_AIBRIDGE_PROXY_AUTH_URL=${ARG_AIBRIDGE_PROXY_AUTH_URL:-} +ARG_AIBRIDGE_PROXY_CERT_PATH=${ARG_AIBRIDGE_PROXY_CERT_PATH:-} validate_copilot_installation() { if ! command_exists copilot; then @@ -118,6 +121,48 @@ setup_github_authentication() { return 0 } +setup_aibridge_proxy() { + if [ "$ARG_ENABLE_AIBRIDGE_PROXY" != "true" ]; then + return 0 + fi + + echo "Setting up AI Bridge Proxy..." + + # Wait for the aibridge-proxy module to finish. + # Uses startup coordination to block until aibridge-proxy-setup signals completion. + if command -v coder > /dev/null 2>&1; then + coder exp sync want "copilot-aibridge" "aibridge-proxy-setup" > /dev/null 2>&1 || true + coder exp sync start "copilot-aibridge" > /dev/null 2>&1 || true + trap 'coder exp sync complete "copilot-aibridge" > /dev/null 2>&1 || true' EXIT + fi + + if [ -z "$ARG_AIBRIDGE_PROXY_AUTH_URL" ]; then + echo "ERROR: AI Bridge Proxy is enabled but no proxy auth URL provided." + exit 1 + fi + + if [ -z "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then + echo "ERROR: AI Bridge Proxy is enabled but no certificate path provided." + exit 1 + fi + + if [ ! -f "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then + echo "ERROR: AI Bridge Proxy certificate not found at $ARG_AIBRIDGE_PROXY_CERT_PATH." + echo " Ensure the aibridge-proxy module has successfully completed setup." + exit 1 + fi + + # Set proxy environment variables scoped to this process tree only. + # These are inherited by the agentapi/copilot process below, + # but do not affect other workspace processes, avoiding routing + # unnecessary traffic through the proxy. + export HTTPS_PROXY="$ARG_AIBRIDGE_PROXY_AUTH_URL" + export NODE_EXTRA_CA_CERTS="$ARG_AIBRIDGE_PROXY_CERT_PATH" + + echo "✓ AI Bridge Proxy configured" + echo " CA certificate: $ARG_AIBRIDGE_PROXY_CERT_PATH" +} + start_agentapi() { echo "Starting in directory: $ARG_WORKDIR" cd "$ARG_WORKDIR" @@ -157,5 +202,6 @@ start_agentapi() { } setup_github_authentication +setup_aibridge_proxy validate_copilot_installation start_agentapi From f6a09d4c3421b16851000d50e546095b29206672 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Thu, 5 Mar 2026 10:39:14 +0000 Subject: [PATCH 09/28] ci: remove branch filter to support stacked PRs (#786) --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c0204f9f..8b994182 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,7 +1,7 @@ name: CI on: pull_request: - branches: [main] + # Cancel in-progress runs for pull requests when developers push new changes concurrency: group: ${{ github.workflow }}-${{ github.ref }} From f1748c80f7d123d9f3a7867ea1835feaf227d1f2 Mon Sep 17 00:00:00 2001 From: 35C4n0r <70096901+35C4n0r@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:20:21 +0530 Subject: [PATCH 10/28] feat(coder-labs/modules/codex): add support for agentapi state_persistence (#785) ## Description - add support for agentapi state_persistence ## Type of Change - [ ] New module - [ ] New template - [ ] Bug fix - [x] Feature/enhancement - [x] Documentation - [ ] Other ## Module Information **Path:** `registry/coder-labs/modules/codex` **New version:** `v4.2.0` **Breaking change:** [ ] Yes [x] No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally ## Related Issues Closes: #783 --- registry/coder-labs/modules/codex/README.md | 27 ++- registry/coder-labs/modules/codex/main.tf | 45 +++-- .../coder-labs/modules/codex/main.tftest.hcl | 187 ++++++++++++++++++ 3 files changed, 233 insertions(+), 26 deletions(-) create mode 100644 registry/coder-labs/modules/codex/main.tftest.hcl diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md index 16e6c105..df486812 100644 --- a/registry/coder-labs/modules/codex/README.md +++ b/registry/coder-labs/modules/codex/README.md @@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.2" + version = "4.2.0" agent_id = coder_agent.example.id openai_api_key = var.openai_api_key workdir = "/home/coder/project" @@ -32,7 +32,7 @@ module "codex" { module "codex" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.2" + version = "4.2.0" agent_id = coder_agent.example.id openai_api_key = "..." workdir = "/home/coder/project" @@ -51,7 +51,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.2" + version = "4.2.0" agent_id = coder_agent.example.id workdir = "/home/coder/project" enable_aibridge = true @@ -63,6 +63,8 @@ When `enable_aibridge = true`, the module: - Configures Codex to use the AI Bridge profile with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token ```toml +profile = "aibridge" # sets the default profile to aibridge + [model_providers.aibridge] name = "AI Bridge" base_url = "https://example.coder.com/api/v2/aibridge/openai/v1" @@ -75,8 +77,6 @@ model = "" # as configured in the module input model_reasoning_effort = "" # as configured in the module input ``` -Codex then runs with `--profile aibridge` - This allows Codex to route API requests through Coder's AI Bridge instead of directly to OpenAI's API. Template build will fail if `openai_api_key` is provided alongside `enable_aibridge = true`. @@ -94,7 +94,7 @@ data "coder_task" "me" {} module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.2" + version = "4.2.0" agent_id = coder_agent.example.id openai_api_key = "..." ai_prompt = data.coder_task.me.prompt @@ -112,7 +112,7 @@ This example shows additional configuration options for custom models, MCP serve ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.2" + version = "4.2.0" agent_id = coder_agent.example.id openai_api_key = "..." workdir = "/home/coder/project" @@ -148,6 +148,19 @@ module "codex" { - **Configuration**: Sets `OPENAI_API_KEY` environment variable and passes `--model` flag to Codex CLI (if variables provided) - **Session Continuity**: When `continue = true` (default), the module automatically tracks task sessions in `~/.codex-module/.codex-task-session`. On workspace restart, it resumes the existing session with full conversation history. Set `continue = false` to always start fresh sessions. +## State Persistence + +AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Codex CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning). + +To disable: + +```tf +module "codex" { + # ... other config + enable_state_persistence = false +} +``` + ## Configuration ### Default Configuration diff --git a/registry/coder-labs/modules/codex/main.tf b/registry/coder-labs/modules/codex/main.tf index 41bf86ee..dd70fdc4 100644 --- a/registry/coder-labs/modules/codex/main.tf +++ b/registry/coder-labs/modules/codex/main.tf @@ -131,7 +131,7 @@ variable "install_agentapi" { variable "agentapi_version" { type = string description = "The version of AgentAPI to install." - default = "v0.11.8" + default = "v0.12.1" } variable "codex_model" { @@ -164,6 +164,12 @@ variable "continue" { default = true } +variable "enable_state_persistence" { + type = bool + description = "Enable AgentAPI conversation state persistence across restarts." + default = true +} + variable "codex_system_prompt" { type = string description = "System instructions written to AGENTS.md in the ~/.codex directory" @@ -206,25 +212,26 @@ locals { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.0.0" + version = "2.2.0" - agent_id = var.agent_id - folder = local.workdir - web_app_slug = local.app_slug - web_app_order = var.order - web_app_group = var.group - web_app_icon = var.icon - web_app_display_name = var.web_app_display_name - cli_app = var.cli_app - cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null - cli_app_display_name = var.cli_app ? var.cli_app_display_name : null - module_dir_name = local.module_dir_name - install_agentapi = var.install_agentapi - agentapi_subdomain = var.subdomain - agentapi_version = var.agentapi_version - pre_install_script = var.pre_install_script - post_install_script = var.post_install_script - start_script = <<-EOT + agent_id = var.agent_id + folder = local.workdir + web_app_slug = local.app_slug + web_app_order = var.order + web_app_group = var.group + web_app_icon = var.icon + web_app_display_name = var.web_app_display_name + cli_app = var.cli_app + cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null + cli_app_display_name = var.cli_app ? var.cli_app_display_name : null + module_dir_name = local.module_dir_name + install_agentapi = var.install_agentapi + agentapi_subdomain = var.subdomain + agentapi_version = var.agentapi_version + enable_state_persistence = var.enable_state_persistence + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + start_script = <<-EOT #!/bin/bash set -o errexit set -o pipefail diff --git a/registry/coder-labs/modules/codex/main.tftest.hcl b/registry/coder-labs/modules/codex/main.tftest.hcl new file mode 100644 index 00000000..1237df5d --- /dev/null +++ b/registry/coder-labs/modules/codex/main.tftest.hcl @@ -0,0 +1,187 @@ +run "test_codex_basic" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + openai_api_key = "test-key" + } + + assert { + condition = var.agent_id == "test-agent" + error_message = "Agent ID should be set correctly" + } + + assert { + condition = var.workdir == "/home/coder" + error_message = "Workdir should be set correctly" + } + + assert { + condition = var.install_codex == true + error_message = "install_codex should default to true" + } + + assert { + condition = var.install_agentapi == true + error_message = "install_agentapi should default to true" + } + + assert { + condition = var.report_tasks == true + error_message = "report_tasks should default to true" + } + + assert { + condition = var.continue == true + error_message = "continue should default to true" + } +} + +run "test_enable_state_persistence_default" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + openai_api_key = "test-key" + } + + assert { + condition = var.enable_state_persistence == true + error_message = "enable_state_persistence should default to true" + } +} + +run "test_disable_state_persistence" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + openai_api_key = "test-key" + enable_state_persistence = false + } + + assert { + condition = var.enable_state_persistence == false + error_message = "enable_state_persistence should be false when explicitly disabled" + } +} + +run "test_codex_with_aibridge" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + enable_aibridge = true + } + + assert { + condition = var.enable_aibridge == true + error_message = "enable_aibridge should be set to true" + } +} + +run "test_aibridge_disabled_with_api_key" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + openai_api_key = "test-key" + enable_aibridge = false + } + + assert { + condition = var.enable_aibridge == false + error_message = "enable_aibridge should be false" + } + + assert { + condition = coder_env.openai_api_key.value == "test-key" + error_message = "OpenAI API key should be set correctly" + } +} + +run "test_custom_options" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project" + openai_api_key = "test-key" + order = 5 + group = "ai-tools" + icon = "/icon/custom.svg" + web_app_display_name = "Custom Codex" + cli_app = true + cli_app_display_name = "Codex Terminal" + subdomain = true + report_tasks = false + continue = false + codex_model = "gpt-4o" + codex_version = "0.1.0" + agentapi_version = "v0.12.0" + } + + assert { + condition = var.order == 5 + error_message = "Order should be set to 5" + } + + assert { + condition = var.group == "ai-tools" + error_message = "Group should be set to 'ai-tools'" + } + + assert { + condition = var.icon == "/icon/custom.svg" + error_message = "Icon should be set to custom icon" + } + + assert { + condition = var.cli_app == true + error_message = "cli_app should be enabled" + } + + assert { + condition = var.subdomain == true + error_message = "subdomain should be enabled" + } + + assert { + condition = var.report_tasks == false + error_message = "report_tasks should be disabled" + } + + assert { + condition = var.continue == false + error_message = "continue should be disabled" + } + + assert { + condition = var.codex_model == "gpt-4o" + error_message = "codex_model should be set to 'gpt-4o'" + } +} + +run "test_no_api_key_no_aibridge" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = var.openai_api_key == "" + error_message = "openai_api_key should be empty when not provided" + } + + assert { + condition = var.enable_aibridge == false + error_message = "enable_aibridge should default to false" + } +} From 40c2916fa930a97cd0095faccca5d48d7ead0b17 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 07:45:33 -0600 Subject: [PATCH 11/28] feat: add JFrog Xray vulnerability scanning module (#410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a new Terraform module that fetches JFrog Xray vulnerability scanning results for container images stored in Artifactory. ## Features - Fetches vulnerability scan results from JFrog Xray - Outputs vulnerability counts (Critical, High, Medium, Low, Total) - Supports flexible image path formats - Works with any workspace type using container images - Provides secure token handling ## Design Decisions During testing, we found two issues with the original approach of defining the `xray` provider and `coder_metadata` inside the module: 1. **`coder_metadata` defined inside modules does not display in the Coder dashboard** — this is a known limitation 2. **Inline provider blocks prevent using `count`/`for_each` on the module** — which is needed when attaching metadata to resources like `docker_container` that use `start_count` The module now **outputs** vulnerability counts instead, and the caller creates the `coder_metadata` and configures the `xray` provider in their root template. This matches the pattern used by other registry modules. ## Usage ```hcl provider "xray" { url = "${var.jfrog_url}/xray" access_token = var.artifactory_access_token skip_xray_version_check = true } module "jfrog_xray" { source = "registry.coder.com/coder/jfrog-xray/coder" version = "1.0.0" xray_url = "${var.jfrog_url}/xray" xray_token = var.artifactory_access_token image = "docker-local/codercom/enterprise-base:latest" } resource "coder_metadata" "xray_vulnerabilities" { count = data.coder_workspace.me.start_count resource_id = docker_container.workspace[0].id icon = "/icon/shield.svg" item { key = "Total Vulnerabilities" value = module.jfrog_xray.total } item { key = "Critical" value = module.jfrog_xray.critical } item { key = "High" value = module.jfrog_xray.high } item { key = "Medium" value = module.jfrog_xray.medium } item { key = "Low" value = module.jfrog_xray.low } } ``` ## Related Issues - Resolves coder/coder#12838 - Addresses coder/registry#65 Tested with a JFrog Cloud trial instance using Docker remote repository and Xray scanning. --------- Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: matifali <10648092+matifali@users.noreply.github.com> Co-authored-by: DevelopmentCats --- .icons/jfrog-xray.svg | 10 + registry/coder/modules/jfrog-xray/README.md | 75 ++++++ .../coder/modules/jfrog-xray/main.test.ts | 244 ++++++++++++++++++ registry/coder/modules/jfrog-xray/main.tf | 135 ++++++++++ 4 files changed, 464 insertions(+) create mode 100644 .icons/jfrog-xray.svg create mode 100644 registry/coder/modules/jfrog-xray/README.md create mode 100644 registry/coder/modules/jfrog-xray/main.test.ts create mode 100644 registry/coder/modules/jfrog-xray/main.tf diff --git a/.icons/jfrog-xray.svg b/.icons/jfrog-xray.svg new file mode 100644 index 00000000..e507de13 --- /dev/null +++ b/.icons/jfrog-xray.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/registry/coder/modules/jfrog-xray/README.md b/registry/coder/modules/jfrog-xray/README.md new file mode 100644 index 00000000..f97b1966 --- /dev/null +++ b/registry/coder/modules/jfrog-xray/README.md @@ -0,0 +1,75 @@ +--- +display_name: JFrog Xray +description: Fetch container image vulnerability scan results from JFrog Xray +icon: ../../../../.icons/jfrog-xray.svg +verified: true +tags: [jfrog, xray] +--- + +# JFrog Xray + +This module fetches vulnerability scan results from JFrog Xray for container images stored in Artifactory. Use the outputs to display security information as workspace metadata. + +```tf +module "jfrog_xray" { + source = "registry.coder.com/coder/jfrog-xray/coder" + version = "1.0.0" + + xray_url = "https://example.jfrog.io/xray" + xray_token = var.artifactory_access_token + image = "docker-local/myapp/backend:v1.0.0" +} + +resource "coder_metadata" "xray_scan" { + count = data.coder_workspace.me.start_count + resource_id = docker_container.workspace[0].id + icon = "/icon/shield.svg" + + item { + key = "Image" + value = "docker-local/myapp/backend:v1.0.0" + } + item { + key = "Total Vulnerabilities" + value = module.jfrog_xray.total + } + item { + key = "Critical" + value = module.jfrog_xray.critical + } + item { + key = "High" + value = module.jfrog_xray.high + } + item { + key = "Medium" + value = module.jfrog_xray.medium + } + item { + key = "Low" + value = module.jfrog_xray.low + } +} +``` + +## Prerequisites + +1. Container images must be stored in JFrog Artifactory +2. JFrog Xray must be configured to scan your repositories +3. A valid JFrog access token with Xray read permissions + +## Remote Repositories + +When scanning images from remote (proxy) repositories, set `use_cache_repo = true`. This is because Artifactory stores cached images in a companion `-cache` repository where Xray indexes the scan results. + +```tf +module "jfrog_xray" { + source = "registry.coder.com/coder/jfrog-xray/coder" + version = "1.0.0" + + xray_url = "https://example.jfrog.io/xray" + xray_token = var.artifactory_access_token + image = "docker-remote/library/nginx:latest" + use_cache_repo = true +} +``` diff --git a/registry/coder/modules/jfrog-xray/main.test.ts b/registry/coder/modules/jfrog-xray/main.test.ts new file mode 100644 index 00000000..34af7f6e --- /dev/null +++ b/registry/coder/modules/jfrog-xray/main.test.ts @@ -0,0 +1,244 @@ +import { serve } from "bun"; +import { describe, expect, it } from "bun:test"; +import { createJSONResponse, runTerraformInit, runTerraformApply } from "~test"; + +describe("jfrog-xray", async () => { + await runTerraformInit(import.meta.dir); + + // Mock server simulating a local repo with direct scan results + const mockLocalRepo = serve({ + fetch: (req) => { + const url = new URL(req.url); + if (url.pathname === "/xray/api/v1/system/version") + return createJSONResponse({ + xray_version: "3.80.0", + xray_revision: "abc123", + }); + if (url.pathname === "/xray/api/v1/artifacts") + return createJSONResponse({ + data: [ + { + name: "myapp/backend/v1.0.0", + repo_path: "/myapp/backend/v1.0.0/manifest.json", + size: "50.00 MB", + sec_issues: { + critical: 1, + high: 3, + medium: 5, + low: 10, + total: 19, + }, + scans_status: { + overall: { + status: "DONE", + time: "2026-03-04T22:00:02Z", + }, + }, + violations: 0, + }, + ], + offset: 0, + }); + return createJSONResponse({}); + }, + port: 0, + }); + + // Mock server simulating a remote repo with cache behavior + // Returns both tag manifest (0 vulns, 0 size) and SHA manifest (real vulns, real size) + const mockRemoteRepo = serve({ + fetch: (req) => { + const url = new URL(req.url); + if (url.pathname === "/xray/api/v1/system/version") + return createJSONResponse({ + xray_version: "3.80.0", + xray_revision: "abc123", + }); + if (url.pathname === "/xray/api/v1/artifacts") + return createJSONResponse({ + data: [ + { + name: "codercom/enterprise-base/ubuntu", + repo_path: "/codercom/enterprise-base/ubuntu/list.manifest.json", + size: "0.00 B", + sec_issues: { total: 0 }, + scans_status: { + overall: { status: "DONE" }, + }, + violations: 0, + }, + { + name: "codercom/enterprise-base/sha256__abc123def456", + repo_path: + "/codercom/enterprise-base/sha256__abc123def456/manifest.json", + size: "359.33 MB", + sec_issues: { + critical: 2, + high: 6, + medium: 20, + low: 23, + total: 51, + }, + scans_status: { + overall: { status: "DONE" }, + }, + violations: 2, + }, + ], + offset: 0, + }); + return createJSONResponse({}); + }, + port: 0, + }); + + // Mock server returning empty results (image not scanned) + const mockEmptyResults = serve({ + fetch: (req) => { + const url = new URL(req.url); + if (url.pathname === "/xray/api/v1/system/version") + return createJSONResponse({ + xray_version: "3.80.0", + xray_revision: "abc123", + }); + if (url.pathname === "/xray/api/v1/artifacts") + return createJSONResponse({ data: [], offset: -1 }); + return createJSONResponse({}); + }, + port: 0, + }); + + const localRepoUrl = `http://${mockLocalRepo.hostname}:${mockLocalRepo.port}`; + const remoteRepoUrl = `http://${mockRemoteRepo.hostname}:${mockRemoteRepo.port}`; + const emptyResultsUrl = `http://${mockEmptyResults.hostname}:${mockEmptyResults.port}`; + + const getProviderEnv = (url: string) => ({ + XRAY_URL: url, + XRAY_ACCESS_TOKEN: "test-token", + }); + + it("validates required variable: xray_url", async () => { + try { + await runTerraformApply( + import.meta.dir, + { + xray_token: "test-token", + image: "docker-local/test/image:latest", + }, + getProviderEnv(localRepoUrl), + ); + throw new Error("Expected apply to fail without xray_url"); + } catch (ex) { + if (!(ex instanceof Error)) throw new Error("Unknown error"); + expect(ex.message).toContain('input variable "xray_url" is not set'); + } + }); + + it("validates required variable: xray_token", async () => { + try { + await runTerraformApply( + import.meta.dir, + { + xray_url: localRepoUrl, + image: "docker-local/test/image:latest", + }, + getProviderEnv(localRepoUrl), + ); + throw new Error("Expected apply to fail without xray_token"); + } catch (ex) { + if (!(ex instanceof Error)) throw new Error("Unknown error"); + expect(ex.message).toContain('input variable "xray_token" is not set'); + } + }); + + it("validates required variable: image", async () => { + try { + await runTerraformApply( + import.meta.dir, + { + xray_url: localRepoUrl, + xray_token: "test-token", + }, + getProviderEnv(localRepoUrl), + ); + throw new Error("Expected apply to fail without image"); + } catch (ex) { + if (!(ex instanceof Error)) throw new Error("Unknown error"); + expect(ex.message).toContain('input variable "image" is not set'); + } + }); + + it("returns vulnerability counts for local repository", async () => { + const state = await runTerraformApply( + import.meta.dir, + { + xray_url: localRepoUrl, + xray_token: "test-token", + image: "docker-local/myapp/backend:v1.0.0", + }, + getProviderEnv(localRepoUrl), + ); + + expect(state.outputs.critical.value).toBe(1); + expect(state.outputs.high.value).toBe(3); + expect(state.outputs.medium.value).toBe(5); + expect(state.outputs.low.value).toBe(10); + expect(state.outputs.total.value).toBe(19); + }); + + it("returns zero counts when image has no scan results", async () => { + const state = await runTerraformApply( + import.meta.dir, + { + xray_url: emptyResultsUrl, + xray_token: "test-token", + image: "docker-local/unscanned/image:latest", + }, + getProviderEnv(emptyResultsUrl), + ); + + expect(state.outputs.critical.value).toBe(0); + expect(state.outputs.high.value).toBe(0); + expect(state.outputs.medium.value).toBe(0); + expect(state.outputs.low.value).toBe(0); + expect(state.outputs.total.value).toBe(0); + }); + + it("uses cache repo when use_cache_repo is enabled", async () => { + const state = await runTerraformApply( + import.meta.dir, + { + xray_url: remoteRepoUrl, + xray_token: "test-token", + image: "docker-remote/codercom/enterprise-base:ubuntu", + use_cache_repo: true, + }, + getProviderEnv(remoteRepoUrl), + ); + + // Should find the SHA artifact with actual vulnerabilities + expect(state.outputs.critical.value).toBe(2); + expect(state.outputs.high.value).toBe(6); + expect(state.outputs.medium.value).toBe(20); + expect(state.outputs.low.value).toBe(23); + expect(state.outputs.total.value).toBe(51); + expect(state.outputs.violations.value).toBe(2); + expect(state.outputs.artifact_name.value).toContain("sha256__"); + }); + + it("allows custom repo and repo_path override", async () => { + const state = await runTerraformApply( + import.meta.dir, + { + xray_url: localRepoUrl, + xray_token: "test-token", + image: "ignored/path:tag", + repo: "docker-local", + repo_path: "/myapp/backend/v1.0.0", + }, + getProviderEnv(localRepoUrl), + ); + + expect(state.outputs.total.value).toBe(19); + }); +}); diff --git a/registry/coder/modules/jfrog-xray/main.tf b/registry/coder/modules/jfrog-xray/main.tf new file mode 100644 index 00000000..b90f6bc2 --- /dev/null +++ b/registry/coder/modules/jfrog-xray/main.tf @@ -0,0 +1,135 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + xray = { + source = "jfrog/xray" + version = ">= 2.0" + } + } +} + +provider "xray" { + url = var.xray_url + access_token = var.xray_token +} + +variable "xray_url" { + description = "The URL of your JFrog Xray instance (e.g., https://mycompany.jfrog.io/xray). This should point to the Xray API endpoint, not Artifactory." + type = string + validation { + condition = can(regex("^https?://", var.xray_url)) + error_message = "The xray_url must be a valid URL starting with http:// or https://." + } +} + +variable "xray_token" { + description = "The access token for authenticating with JFrog Xray. This token needs read permissions on Xray scan results. You can generate one in JFrog Platform under User Management > Access Tokens." + type = string + sensitive = true +} + +variable "image" { + description = "The Docker image to check for vulnerabilities, in the format 'repo/path/image:tag'. For example: 'docker-local/myapp/backend:v1.0.0' or 'docker-remote/library/nginx:latest'. The repository name is extracted from the first path segment." + type = string + validation { + condition = length(split("/", var.image)) >= 2 + error_message = "The image must include at least a repository and image name (e.g., 'docker-local/myimage:tag')." + } +} + +variable "repo" { + description = "Override the repository name extracted from the image path. Use this when your Artifactory repository name differs from the first segment of your image path." + type = string + default = "" +} + +variable "repo_path" { + description = "Override the full Xray repository path. Use this for custom path structures that don't follow the standard 'repo/image:tag' format. When set, this takes precedence over automatic path construction." + type = string + default = "" +} + +variable "use_cache_repo" { + description = "Set to true when scanning images from remote (proxy) repositories. Remote repositories in Artifactory store cached artifacts in a companion '-cache' repository (e.g., 'docker-remote-cache'), which is where Xray indexes the scan results." + type = bool + default = false +} + +locals { + # Parse the image string into components + # Example: "docker-local/myapp/backend:v1.0.0" + # -> repo: "docker-local", image_name: "myapp/backend", tag: "v1.0.0" + image_parts = split("/", var.image) + base_repo = var.repo != "" ? var.repo : local.image_parts[0] + parsed_repo = var.use_cache_repo ? "${local.base_repo}-cache" : local.base_repo + image_path = join("/", slice(local.image_parts, 1, length(local.image_parts))) + image_name = split(":", local.image_path)[0] + image_tag = length(split(":", local.image_path)) > 1 ? split(":", local.image_path)[1] : "latest" + + # Construct the Xray query path based on repository type: + # - Local repositories: Query the exact tag path (e.g., /myapp/backend/v1.0.0) + # - Remote repositories: Query by image name only (e.g., /myapp/backend) because + # the Terraform provider only returns the SHA manifest (with actual scan data) + # when querying the broader path + parsed_path = var.repo_path != "" ? var.repo_path : ( + var.use_cache_repo ? "/${local.image_name}" : "/${local.image_name}/${local.image_tag}" + ) + + results = coalesce(try(data.xray_artifacts_scan.image_scan.results, []), []) + + # For remote repositories, filter to find the actual scanned image (not tag pointers): + # - Tag manifests have size "0.00 B" (they're just pointers to SHA manifests) + # - SHA manifests have actual size (e.g., "359.33 MB") and contain the real scan data + # For local repositories, there's typically only one result which is the actual image + scanned_images = var.use_cache_repo ? [ + for r in local.results : r if r.size != "0.00 B" + ] : local.results + + # The artifact we'll report scan results for + scan_result = ( + length(local.scanned_images) > 0 ? local.scanned_images[0] : + length(local.results) > 0 ? local.results[0] : + null + ) +} + +data "xray_artifacts_scan" "image_scan" { + repo = local.parsed_repo + repo_path = local.parsed_path +} + +output "critical" { + description = "The number of critical severity vulnerabilities found in the image. Critical vulnerabilities typically require immediate attention." + value = try(local.scan_result.sec_issues.critical, 0) +} + +output "high" { + description = "The number of high severity vulnerabilities found in the image." + value = try(local.scan_result.sec_issues.high, 0) +} + +output "medium" { + description = "The number of medium severity vulnerabilities found in the image." + value = try(local.scan_result.sec_issues.medium, 0) +} + +output "low" { + description = "The number of low severity vulnerabilities found in the image." + value = try(local.scan_result.sec_issues.low, 0) +} + +output "total" { + description = "The total number of vulnerabilities found across all severity levels." + value = try(local.scan_result.sec_issues.total, 0) +} + +output "artifact_name" { + description = "The name of the artifact that was scanned, as reported by Xray. For remote repositories, this will be the SHA-based manifest name (e.g., 'myimage/sha256__abc123...')." + value = try(local.scan_result.name, "") +} + +output "violations" { + description = "The number of Xray policy violations detected. Violations are triggered when vulnerabilities match rules defined in your Xray security policies." + value = try(local.scan_result.violations, 0) +} From d7566cc6182e961c0ce3ad5795e0f9a90f304544 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:20:08 +0500 Subject: [PATCH 12/28] chore(deps): bump the github-actions group across 1 directory with 5 updates (#791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the github-actions group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [coder/coder](https://github.com/coder/coder) | `2.29.2` | `2.31.3` | | [oven-sh/setup-bun](https://github.com/oven-sh/setup-bun) | `2.1.2` | `2.1.3` | | [crate-ci/typos](https://github.com/crate-ci/typos) | `1.42.1` | `1.44.0` | | [actions/setup-go](https://github.com/actions/setup-go) | `6.2.0` | `6.3.0` | | [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.4.1` | `0.5.2` | Updates `coder/coder` from 2.29.2 to 2.31.3
Release notes

Sourced from coder/coder's releases.

v2.31.3

Changelog

[!NOTE] This is a mainline Coder release. We advise enterprise customers without a staging environment to install our latest stable release while we refine this version. Learn more about our Release Schedule.

Chores

Bug Fixes

  • fix: early oidc refresh with fake idp tests (cherry 2.31) (#22716, deaacff84) (@​Emyrk)

Compare: v2.31.2...v2.31.3

Container image

  • docker pull ghcr.io/coder/coder:v2.31.2

Install/upgrade

Refer to our docs to install or upgrade Coder, or use a release asset below.

v2.31.2

Changelog

[!NOTE] This is a mainline Coder release. We advise enterprise customers without a staging environment to install our latest stable release while we refine this version. Learn more about our Release Schedule.

Chores

  • Prematurely refresh oidc token near expiry during workspace (cherry 2.31) (#22606, 2828d28e0) (@​Emyrk)

Compare: v2.31.1...v2.31.2

Container image

  • docker pull ghcr.io/coder/coder:v2.31.2

Install/upgrade

Refer to our docs to install or upgrade Coder, or use a release asset below.

v2.31.1

Changelog

[!NOTE] This is a mainline Coder release. We advise enterprise customers without a staging environment to install our latest stable release while we refine this version. Learn more about our Release Schedule.

Normally, our monthly releases are 2.X.0. This mainline release is 2.X.1 due to an issue in the release process, but it should be considered a standard mainline release for customers.

... (truncated)

Commits
  • deaacff fix: early oidc refresh with fake idp tests (#22712) (cherry 2.31) (#22716)
  • 2828d28 chore: prematurely refresh oidc token near expiry during workspace (cherry 2....
  • 4b95b8b fix(coderd): add organization_name label to insights Prometheus metrics (cher...
  • 3a061cc refactor(site): use dedicated task pause/resume API endpoints (#22303) (cherr...
  • 22c2da5 fix: register task pause/resume routes under /api/v2 (#22544) (#22550)
  • ccb529e fix: disable sharing ui when sharing is unavailable (#22390) (#22561)
  • 107fd97 fix: avoid derp-related panic during wsproxy registration (backport release/2...
  • 955637a fix(codersdk): use header auth for non-browser websocket dials (#22461) (cher...
  • 85f1d70 ci: add temporary deploy override (#22378) (#22475)
  • e9e438b fix(stringutil): operate on runes instead of bytes in Truncate (#22388) (#22469)
  • Additional commits viewable in compare view

Updates `oven-sh/setup-bun` from 2.1.2 to 2.1.3
Release notes

Sourced from oven-sh/setup-bun's releases.

v2.1.3

oven-sh/setup-bun is the github action for setting up Bun.

What's Changed

New Contributors

Full Changelog: https://github.com/oven-sh/setup-bun/compare/v2...v2.1.3

Commits

Updates `crate-ci/typos` from 1.42.1 to 1.44.0
Release notes

Sourced from crate-ci/typos's releases.

v1.44.0

[1.44.0] - 2026-02-27

Features

v1.43.5

[1.43.5] - 2026-02-16

Fixes

  • (pypi) Hopefully fix the sdist build

v1.43.4

[1.43.4] - 2026-02-09

Fixes

  • Don't correct pincher

v1.43.3

[1.43.3] - 2026-02-06

Fixes

  • (action) Adjust how typos are reported to github

v1.43.2

[1.43.2] - 2026-02-05

Fixes

  • Don't correct certifi in Python

v1.43.1

[1.43.1] - 2026-02-03

Fixes

  • Don't correct consts

v1.43.0

[1.43.0] - 2026-02-02

Features

v1.42.3

... (truncated)

Changelog

Sourced from crate-ci/typos's changelog.

Change Log

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog and this project adheres to Semantic Versioning.

[Unreleased] - ReleaseDate

[1.44.0] - 2026-02-27

Features

[1.43.5] - 2026-02-16

Fixes

  • (pypi) Hopefully fix the sdist build

[1.43.4] - 2026-02-09

Fixes

  • Don't correct pincher

[1.43.3] - 2026-02-06

Fixes

  • (action) Adjust how typos are reported to github

[1.43.2] - 2026-02-05

Fixes

  • Don't correct certifi in Python

[1.43.1] - 2026-02-03

Fixes

  • Don't correct consts

[1.43.0] - 2026-02-02

Compatibility

  • Bumped MSRV to 1.91

... (truncated)

Commits

Updates `actions/setup-go` from 6.2.0 to 6.3.0
Release notes

Sourced from actions/setup-go's releases.

v6.3.0

What's Changed

Full Changelog: https://github.com/actions/setup-go/compare/v6...v6.3.0

Commits

Updates `zizmorcore/zizmor-action` from 0.4.1 to 0.5.2
Release notes

Sourced from zizmorcore/zizmor-action's releases.

v0.5.2

What's Changed

  • zizmor 1.23.1 is now the default used by this action.

Full Changelog: https://github.com/zizmorcore/zizmor-action/compare/v0.5.1...v0.5.2

v0.5.1

What's Changed

  • zizmor 1.23.0 is now the default used by this action.

Full Changelog: https://github.com/zizmorcore/zizmor-action/compare/v0.5.0...v0.5.1

v0.5.0

What's Changed

New Contributors

Full Changelog: https://github.com/zizmorcore/zizmor-action/compare/v0.4.1...v0.5.0

Commits
  • 71321a2 Sync zizmor versions (#96)
  • 5ed31db Bump pins (#95)
  • 195d10a Sync zizmor versions (#94)
  • c65bc88 chore(deps): bump github/codeql-action in the github-actions group (#93)
  • c2c887f chore(deps): bump zizmorcore/zizmor-action in the github-actions group (#91)
  • 5507ab0 Bump pins in README (#90)
  • 0dce257 chore(deps): bump peter-evans/create-pull-request (#88)
  • fb94974 Expose output-file as an output when advanced-security: true (#87)
  • 867562a chore(deps): bump the github-actions group with 2 updates (#85)
  • 7462f07 Bump pins in README (#84)
  • See full diff in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 12 ++++++------ .github/workflows/golangci-lint.yml | 2 +- .github/workflows/version-bump.yaml | 4 ++-- .github/workflows/zizmor.yaml | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8b994182..6a8c79d2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,9 +37,9 @@ jobs: all: - '**' - name: Set up Terraform - uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2 + uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3 - name: Set up Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2 + uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2 with: # We're using the latest version of Bun for now, but it might be worth # reconsidering. They've pushed breaking changes in patch releases @@ -82,18 +82,18 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2 + uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2 with: bun-version: latest # Need Terraform for its formatter - name: Install Terraform - uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2 + uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3 - name: Install dependencies run: bun install - name: Validate formatting run: bun fmt:ci - name: Check for typos - uses: crate-ci/typos@65120634e79d8374d1aa2f27e54baa0c364fff5a # v1.42.1 + uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0 with: config: .github/typos.toml validate-readme-files: @@ -106,7 +106,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version: "1.24.0" - name: Validate contributors diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 599ad548..c922d344 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version: stable - name: golangci-lint diff --git a/.github/workflows/version-bump.yaml b/.github/workflows/version-bump.yaml index 2e255414..c5dbc1b8 100644 --- a/.github/workflows/version-bump.yaml +++ b/.github/workflows/version-bump.yaml @@ -26,12 +26,12 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2 + uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2 with: bun-version: latest - name: Set up Terraform - uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2 + uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3 - name: Install dependencies run: bun install diff --git a/.github/workflows/zizmor.yaml b/.github/workflows/zizmor.yaml index 8dc3a171..ad349429 100644 --- a/.github/workflows/zizmor.yaml +++ b/.github/workflows/zizmor.yaml @@ -27,7 +27,7 @@ jobs: persist-credentials: false - name: Run zizmor (blocking, HIGH only) - uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1 + uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 with: advanced-security: false annotations: true @@ -49,7 +49,7 @@ jobs: persist-credentials: false - name: Run zizmor (SARIF) - uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1 + uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 with: inputs: | .github/workflows From 4b3045e637942d6c40996b7267c6be4b821cd3f7 Mon Sep 17 00:00:00 2001 From: "blinkagent[bot]" <237617714+blinkagent[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:16:28 +0000 Subject: [PATCH 13/28] docs: clarify that READMEs should not include input/output variable tables (#787) The registry auto-generates input/output documentation from `variables.tf` and `outputs.tf`, so including these tables in module/template READMEs is redundant and prone to drift. This adds two bullets to the **Code Style** section of `AGENTS.md`: - Do not include input/output variable tables in READMEs - Usage examples (e.g., `module "..." { }` blocks) are still encouraged Created on behalf of @DevelopmentCats --------- Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: DevCats --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 42ac3ed2..5623f13c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,8 @@ bun test main.test.ts # Run single TS test (from - Use semantic versioning; bump version via script when modifying modules - Docker tests require Linux or Colima/OrbStack (not Docker Desktop) - Use `tf` (not `hcl`) for code blocks in README; use relative icon paths (e.g., `../../../../.icons/`) +- **Do NOT include input/output variable tables in module or template READMEs.** The registry automatically generates these from the Terraform source (e.g., variable and output blocks in `main.tf`). Adding them to the README is redundant and creates maintenance drift. +- Usage examples (e.g., a `module "..." { }` block) are encouraged, but not tables enumerating inputs/outputs. ## PR Review Checklist From 5a241ebce2e461ab355a371e8010c6275c68b21b Mon Sep 17 00:00:00 2001 From: DevCats Date: Mon, 9 Mar 2026 11:19:10 -0500 Subject: [PATCH 14/28] feat: ttyd module (#790) ## Description Add ttyd module that exposes any command as a web-based terminal via [ttyd](https://github.com/tsl0922/ttyd). - Run commands like `bash`, `htop`, or `tmux` accessible in the browser - Supports readonly mode for log viewers - Configurable sharing (owner/authenticated/public) - Auto-installs ttyd binary (x86_64, aarch64, ARM) - Works with subdomain or path-based routing ![TTYD-Module-Demo](https://github.com/user-attachments/assets/1c884e89-b1b1-4f1b-ab5b-56df3dd6d9af) ## Type of Change - [X] New module - [ ] New template - [ ] Bug fix - [ ] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder-labs/modules/ttyd` **New version:** `v1.0.0` **Breaking change:** [ ] Yes [ ] No ## Testing & Validation - [X] Tests pass (`bun test`) - [X] Code formatted (`bun fmt`) - [X] Changes tested locally --------- Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .icons/terminal.svg | 3 + registry/coder-labs/modules/ttyd/README.md | 57 ++++++ registry/coder-labs/modules/ttyd/main.test.ts | 112 ++++++++++++ registry/coder-labs/modules/ttyd/main.tf | 165 ++++++++++++++++++ registry/coder-labs/modules/ttyd/run.sh | 87 +++++++++ 5 files changed, 424 insertions(+) create mode 100644 .icons/terminal.svg create mode 100644 registry/coder-labs/modules/ttyd/README.md create mode 100644 registry/coder-labs/modules/ttyd/main.test.ts create mode 100644 registry/coder-labs/modules/ttyd/main.tf create mode 100644 registry/coder-labs/modules/ttyd/run.sh diff --git a/.icons/terminal.svg b/.icons/terminal.svg new file mode 100644 index 00000000..6cb6efec --- /dev/null +++ b/.icons/terminal.svg @@ -0,0 +1,3 @@ + + + diff --git a/registry/coder-labs/modules/ttyd/README.md b/registry/coder-labs/modules/ttyd/README.md new file mode 100644 index 00000000..6fca5e36 --- /dev/null +++ b/registry/coder-labs/modules/ttyd/README.md @@ -0,0 +1,57 @@ +--- +display_name: ttyd +description: Share a terminal command over the web via a Coder app +icon: ../../../../.icons/terminal.svg +verified: true +tags: [terminal, web, ttyd] +--- + +# ttyd + +Run any command and expose it as a web-based terminal via [ttyd](https://github.com/tsl0922/ttyd). Each connection spawns a new process for the configured command. The terminal is accessible as a Coder app in the workspace UI. + +```tf +module "ttyd" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder-labs/ttyd/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + command = "bash" +} +``` + +## Examples + +### Custom command + +```tf +module "ttyd" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder-labs/ttyd/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + display_name = "Shared Terminal" + command = "tmux new-session -A -s main" + share = "authenticated" +} +``` + +### Readonly with custom ttyd options + +```tf +module "ttyd" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder-labs/ttyd/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + command = "tail -f /var/log/app.log" + writable = false + additional_args = "-t fontSize=18" +} +``` + +## Session Behavior + +By default, each browser tab that opens the ttyd app spawns a **new process** for the configured command. Closing the tab kills that process. + +To get a **persistent, shared session** that survives tab closes and allows multiple viewers, use tmux as the command (see example above). This requires tmux to be installed in the workspace image. diff --git a/registry/coder-labs/modules/ttyd/main.test.ts b/registry/coder-labs/modules/ttyd/main.test.ts new file mode 100644 index 00000000..ab12879b --- /dev/null +++ b/registry/coder-labs/modules/ttyd/main.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + type scriptOutput, + testRequiredVariables, +} from "~test"; + +function testBaseLine(output: scriptOutput) { + expect(output.exitCode).toBe(0); + + const stdout = output.stdout.join("\n"); + expect(stdout).toContain("Installing ttyd"); + expect(stdout).toContain("Installation complete!"); + expect(stdout).toContain("Starting ttyd in background..."); +} + +describe("ttyd", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + command: "bash", + }); + + it("runs with bash", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + command: "bash", + }); + + const output = await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add bash", + ); + + testBaseLine(output); + }, 30000); + + it("runs with custom command", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + command: "htop", + }); + + const output = await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add bash", + ); + + testBaseLine(output); + expect(output.stdout.join("\n")).toContain("htop"); + }, 30000); + + it("runs with writable=false", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + command: "bash", + writable: "false", + }); + + const output = await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add bash", + ); + + testBaseLine(output); + }, 30000); + + it("runs with subdomain=false", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + command: "bash", + agent_name: "main", + subdomain: "false", + }); + + const output = await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add bash", + ); + + testBaseLine(output); + }, 30000); + + it("runs with additional_args", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + command: "bash", + additional_args: "-t fontSize=18", + }); + + const output = await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add bash", + ); + + testBaseLine(output); + expect(output.stdout.join("\n")).toContain("fontSize=18"); + }, 30000); +}); diff --git a/registry/coder-labs/modules/ttyd/main.tf b/registry/coder-labs/modules/ttyd/main.tf new file mode 100644 index 00000000..b84ac361 --- /dev/null +++ b/registry/coder-labs/modules/ttyd/main.tf @@ -0,0 +1,165 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "agent_name" { + type = string + description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)" + default = null +} + +variable "slug" { + type = string + description = "The slug of the coder_app resource." + default = "ttyd" +} + +variable "display_name" { + type = string + description = "The display name for the ttyd application." + default = "Web Terminal" +} + +variable "port" { + type = number + description = "The port to run ttyd on." + default = 7681 +} + +variable "command" { + type = string + description = "The command for ttyd to run (e.g., bash, fish, htop)." +} + +variable "writable" { + type = bool + description = "Allow clients to write to the terminal." + default = true +} + +variable "max_clients" { + type = number + description = "Maximum number of concurrent clients (0 for unlimited)." + default = 0 +} + +variable "additional_args" { + type = string + description = "Additional arguments to pass to ttyd." + default = "" +} + +variable "log_path" { + type = string + description = "The path to log ttyd output to. Defaults to ~/.local/state/ttyd/ttyd.log (XDG-compliant)." + default = "" +} + +variable "ttyd_version" { + type = string + description = "The version of ttyd to install." + default = "1.7.7" +} + +variable "share" { + type = string + description = "Who can access the app: 'owner' (workspace owner only), 'authenticated' (logged-in users), or 'public' (anyone)." + default = "owner" + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +variable "subdomain" { + type = bool + description = <<-EOT + Determines whether the app will be accessed via its own subdomain or whether it will be accessed via a path on Coder. + If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible. + EOT + default = true +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "open_in" { + type = string + description = <<-EOT + Determines where the app will be opened. Valid values are "tab" and "slim-window" (default). + "tab" opens in a new tab in the same browser window. + "slim-window" opens a new browser window without navigation controls. + EOT + default = "slim-window" + validation { + condition = contains(["tab", "slim-window"], var.open_in) + error_message = "The 'open_in' variable must be one of: 'tab', 'slim-window'." + } +} + +resource "coder_script" "ttyd" { + agent_id = var.agent_id + display_name = var.display_name + icon = "/icon/terminal.svg" + script = templatefile("${path.module}/run.sh", { + PORT = var.port, + COMMAND = var.command, + WRITABLE = var.writable, + MAX_CLIENTS = var.max_clients, + ADDITIONAL_ARGS = var.additional_args, + LOG_PATH = local.log_path, + VERSION = var.ttyd_version, + BASE_PATH = local.base_path, + }) + run_on_start = true +} + +resource "coder_app" "ttyd" { + count = var.command != "" ? 1 : 0 + agent_id = var.agent_id + slug = var.slug + display_name = var.display_name + url = "http://localhost:${var.port}${local.base_path}/" + icon = "/icon/terminal.svg" + subdomain = var.subdomain + share = var.share + order = var.order + group = var.group + open_in = var.open_in + + healthcheck { + url = "http://localhost:${var.port}${local.base_path}/token" + interval = 5 + threshold = 6 + } +} + +locals { + base_path = var.subdomain ? "" : format("/@%s/%s%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name != null ? ".${var.agent_name}" : "", var.slug) + log_path = var.log_path != "" ? var.log_path : "~/.local/state/ttyd/ttyd.log" +} diff --git a/registry/coder-labs/modules/ttyd/run.sh b/registry/coder-labs/modules/ttyd/run.sh new file mode 100644 index 00000000..141beb63 --- /dev/null +++ b/registry/coder-labs/modules/ttyd/run.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BOLD='\033[[0;1m' + +if command -v ttyd &> /dev/null; then + printf "%sFound existing ttyd installation\n\n" "$${BOLD}" +else + printf "%sInstalling ttyd %s\n\n" "$${BOLD}" "${VERSION}" + + ARCH=$(uname -m) + # shellcheck disable=SC2195 + case "$${ARCH}" in + x86_64) BINARY="ttyd.x86_64" ;; + aarch64) BINARY="ttyd.aarch64" ;; + armv7l) BINARY="ttyd.armhf" ;; + armv6l) BINARY="ttyd.arm" ;; + *) + echo "ERROR: Unsupported architecture: $${ARCH}" >&2 + exit 1 + ;; + esac + + BIN_DIR="$${HOME}/.local/bin" + mkdir -p "$${BIN_DIR}" + export PATH="$${BIN_DIR}:$${PATH}" + + TTYD_BIN="$${BIN_DIR}/ttyd" + LOCK_DIR="/tmp/ttyd-install.lock" + + if [[ ! -f "$${TTYD_BIN}" ]]; then + if mkdir "$${LOCK_DIR}" 2> /dev/null; then + if [[ ! -f "$${TTYD_BIN}" ]]; then + DOWNLOAD_URL="https://github.com/tsl0922/ttyd/releases/download/${VERSION}/$${BINARY}" + printf "Downloading ttyd from %s\n" "$${DOWNLOAD_URL}" + curl -fsSL "$${DOWNLOAD_URL}" -o "$${TTYD_BIN}.tmp" + chmod +x "$${TTYD_BIN}.tmp" + mv "$${TTYD_BIN}.tmp" "$${TTYD_BIN}" + fi + rmdir "$${LOCK_DIR}" 2> /dev/null || true + else + printf "Waiting for ttyd installation to complete...\n" + while [[ -d "$${LOCK_DIR}" ]] && [[ ! -f "$${TTYD_BIN}" ]]; do + sleep 0.5 + done + fi + fi + + printf "Installation complete!\n\n" +fi + +if [[ -z "${COMMAND}" ]]; then + printf "No command specified, skipping ttyd startup.\n" + exit 0 +fi + +ARGS="-p ${PORT}" + +if [[ "${WRITABLE}" = "true" ]]; then + ARGS="$${ARGS} -W" +fi + +if [[ "${MAX_CLIENTS}" -gt 0 ]] 2> /dev/null; then + ARGS="$${ARGS} -m ${MAX_CLIENTS}" +fi + +if [[ -n "${BASE_PATH}" ]]; then + ARGS="$${ARGS} -b ${BASE_PATH}" +fi + +if [[ -n "${ADDITIONAL_ARGS}" ]]; then + ARGS="$${ARGS} ${ADDITIONAL_ARGS}" +fi + +TTYD_LOG_PATH="${LOG_PATH}" +TTYD_LOG_PATH="$${TTYD_LOG_PATH/#\~/$${HOME}}" +TTYD_LOG_DIR="$${TTYD_LOG_PATH%/*}" +mkdir -p "$${TTYD_LOG_DIR}" + +printf "Starting ttyd in background...\n" +printf "Running: ttyd %s -- %s\n\n" "$${ARGS}" "${COMMAND}" + +# shellcheck disable=SC2086 +ttyd $${ARGS} -- ${COMMAND} >> "$${TTYD_LOG_PATH}" 2>&1 & + +printf "Logs at %s\n" "$${TTYD_LOG_PATH}" From 183bd57061b27615658faf578e5b719ceb8a9b97 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:32:58 +0100 Subject: [PATCH 15/28] fix: log external mux server exits in launcher (#796) ## Summary Keep the Mux module's launcher around after startup so it can append useful diagnostics when `mux server` is killed outside the Node runtime. ## Background The module previously forked `mux server` and returned immediately, which meant external kills (for example `SIGKILL` or an OOM kill) could leave users with only a stopped app and no launcher-side clue about what happened. ## Implementation - keep the existing module inputs and startup shape intact - launch `mux server` under a detached Bash watcher that waits for the child process to exit - append signal/exit-code diagnostics to `log_path` when the server dies unexpectedly - include a best-effort kernel OOM/SIGKILL hint in the log when the host exposes it - add Terraform and Bun tests that cover the new launcher diagnostics - bump the module examples from `1.3.1` to `1.4.0` ## Validation - `bun x prettier --check registry/coder/modules/mux/README.md registry/coder/modules/mux/main.test.ts registry/coder/modules/mux/mux.tftest.hcl registry/coder/modules/mux/run.sh` - `terraform fmt -check -recursive registry/coder/modules/mux` - `cd registry/coder/modules/mux && terraform validate` - `cd registry/coder/modules/mux && terraform test -verbose` - `cd registry/coder/modules/mux && bun test main.test.ts` - `bun run shellcheck -- registry/coder/modules/mux/run.sh` --- Generated with mux (exec mode) using openai:gpt-5.4. --- registry/coder/modules/mux/README.md | 23 +++--- registry/coder/modules/mux/main.test.ts | 49 +++++++++++++ registry/coder/modules/mux/mux.tftest.hcl | 18 +++++ registry/coder/modules/mux/run.sh | 86 ++++++++++++++++++++++- 4 files changed, 162 insertions(+), 14 deletions(-) diff --git a/registry/coder/modules/mux/README.md b/registry/coder/modules/mux/README.md index 6a5c3b0f..46bf295b 100644 --- a/registry/coder/modules/mux/README.md +++ b/registry/coder/modules/mux/README.md @@ -8,13 +8,13 @@ tags: [ai, agents, development, multiplexer] # Mux -Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces. +Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The launcher now keeps watching the mux process after startup and appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces. ```tf module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id } ``` @@ -37,7 +37,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id } ``` @@ -48,7 +48,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id # Default is "latest"; set to a specific version to pin install_version = "0.4.0" @@ -63,7 +63,7 @@ Start Mux with `mux server --add-project /path/to/project`: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id add_project = "/path/to/project" } @@ -78,7 +78,7 @@ The module parses quoted values, so grouped arguments remain intact. module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'" } @@ -90,7 +90,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id port = 8080 } @@ -104,7 +104,7 @@ Force a specific package manager instead of auto-detection: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id package_manager = "pnpm" # or "npm", "bun" } @@ -118,7 +118,7 @@ Use a private or mirrored npm registry: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id registry_url = "https://npm.pkg.github.com" } @@ -132,7 +132,7 @@ Run an existing copy of Mux if found, otherwise install from npm: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id use_cached = true } @@ -146,7 +146,7 @@ Run without installing from the network (requires Mux to be pre-installed): module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id install = false } @@ -163,3 +163,4 @@ module "mux" { - Auto-detects `npm`, `pnpm`, or `bun` by default; set `package_manager` to force a specific one - Installs `mux@next` from the npm registry by default; set `registry_url` to use a private or mirrored registry - Falls back to a direct tarball download when no package manager is found +- Appends best-effort signal and external-kill diagnostics to `log_path` if the mux process dies after startup diff --git a/registry/coder/modules/mux/main.test.ts b/registry/coder/modules/mux/main.test.ts index cc2e70db..9537e9de 100644 --- a/registry/coder/modules/mux/main.test.ts +++ b/registry/coder/modules/mux/main.test.ts @@ -96,6 +96,55 @@ chmod +x /tmp/mux/mux`, } }, 60000); + it("logs signal-based exits after startup", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install: false, + log_path: "/tmp/mux.log", + }); + + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer("alpine/curl"); + + try { + const setup = await execContainer(id, [ + "sh", + "-c", + `apk add --no-cache bash >/dev/null +mkdir -p /tmp/mux +cat <<'EOF' > /tmp/mux/mux +#!/usr/bin/env sh +target_pid="$$" +( + sleep 1 + kill -9 "$target_pid" +) & +while true; do + sleep 1 +done +EOF +chmod +x /tmp/mux/mux`, + ]); + expect(setup.exitCode).toBe(0); + + const output = await execContainer(id, ["sh", "-c", instance.script]); + if (output.exitCode !== 0) { + console.log("STDOUT:\n" + output.stdout); + console.log("STDERR:\n" + output.stderr); + } + expect(output.exitCode).toBe(0); + + await execContainer(id, ["sh", "-c", "sleep 2"]); + const log = await readFileContainer(id, "/tmp/mux.log"); + expect(log).toContain("shell exit code 137"); + expect(log).toContain( + "SIGKILL usually means the process was killed externally or by the OOM killer.", + ); + } finally { + await removeContainer(id); + } + }, 60000); + it("runs with npm present", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", diff --git a/registry/coder/modules/mux/mux.tftest.hcl b/registry/coder/modules/mux/mux.tftest.hcl index 42569997..e7816de8 100644 --- a/registry/coder/modules/mux/mux.tftest.hcl +++ b/registry/coder/modules/mux/mux.tftest.hcl @@ -93,6 +93,24 @@ run "custom_additional_arguments" { } } +run "launcher_logs_external_kills" { + command = plan + + variables { + agent_id = "foo" + } + + assert { + condition = strcontains(resource.coder_script.mux.script, "shell exit code $exit_code") + error_message = "mux launcher must log the shell exit code when the server dies unexpectedly" + } + + assert { + condition = strcontains(resource.coder_script.mux.script, "SIGKILL usually means the process was killed externally or by the OOM killer.") + error_message = "mux launcher must explain SIGKILL exits in the log" + } +} + run "custom_version" { command = plan diff --git a/registry/coder/modules/mux/run.sh b/registry/coder/modules/mux/run.sh index 2dbd5ea9..fb583480 100644 --- a/registry/coder/modules/mux/run.sh +++ b/registry/coder/modules/mux/run.sh @@ -15,6 +15,9 @@ function run_mux() { if [ -z "$port_value" ]; then port_value="4000" fi + + mkdir -p "$(dirname "${LOG_PATH}")" + # Build args for mux (POSIX-compatible, avoid bash arrays) set -- server --port "$port_value" if [ -n "${ADD_PROJECT}" ]; then @@ -31,16 +34,93 @@ function run_mux() { while IFS= read -r parsed_arg; do [ -n "$parsed_arg" ] || continue set -- "$@" "$parsed_arg" - done << EOF + done << EOF_ARGS $${parsed_additional_arguments} -EOF +EOF_ARGS fi echo "🚀 Starting mux server on port $port_value..." echo "Check logs at ${LOG_PATH}!" - MUX_SERVER_AUTH_TOKEN="$auth_token_value" PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 & + echo "ℹ️ Unexpected exits will be appended to ${LOG_PATH} by the launcher." + + nohup env \ + LOG_PATH="${LOG_PATH}" \ + MUX_BINARY="$MUX_BINARY" \ + AUTH_TOKEN="$auth_token_value" \ + PORT_VALUE="$port_value" \ + bash -s -- "$@" > /dev/null 2>&1 << 'EOF_LAUNCHER' & +signal_name() { + local signal_number="$1" + local resolved_signal + + resolved_signal="$(kill -l "$signal_number" 2> /dev/null || true)" + if [ -n "$resolved_signal" ]; then + printf '%s' "$resolved_signal" + return 0 + fi + + printf 'SIG%s' "$signal_number" } +append_kernel_kill_context() { + local mux_pid="$1" + local kernel_context="" + + if command -v dmesg > /dev/null 2>&1; then + kernel_context="$(dmesg -T 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)" + fi + + if [ -z "$kernel_context" ] && command -v journalctl > /dev/null 2>&1; then + kernel_context="$(journalctl -k -n 200 --no-pager 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)" + fi + + if [ -n "$kernel_context" ]; then + echo "Recent kernel kill context:" + echo "$kernel_context" + else + echo "No kernel OOM/kill context was available (dmesg/journalctl unavailable or permission denied)." + fi +} + +log_mux_exit() { + local mux_pid="$1" + local exit_code="$2" + local timestamp + + timestamp="$(date -Iseconds 2> /dev/null || date)" + + if [ "$exit_code" -eq 0 ]; then + echo "[$timestamp] mux server exited cleanly." + return 0 + fi + + if [ "$exit_code" -gt 128 ]; then + local signal_number=$((exit_code - 128)) + local signal_label + + signal_label="$(signal_name "$signal_number")" + echo "[$timestamp] mux server exited due to signal $signal_label ($signal_number); shell exit code $exit_code." + + if [ "$signal_number" -eq 9 ]; then + echo "[$timestamp] SIGKILL usually means the process was killed externally or by the OOM killer." + append_kernel_kill_context "$mux_pid" + fi + + echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself." + return 0 + fi + + echo "[$timestamp] mux server exited with code $exit_code." + echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself." +} + +MUX_SERVER_AUTH_TOKEN="$AUTH_TOKEN" PORT="$PORT_VALUE" "$MUX_BINARY" "$@" >> "$LOG_PATH" 2>&1 & +mux_pid=$! +wait "$mux_pid" +exit_code=$? +log_mux_exit "$mux_pid" "$exit_code" >> "$LOG_PATH" 2>&1 +EOF_LAUNCHER +} # Check if mux is already installed for offline mode if [ "${OFFLINE}" = true ]; then if [ -f "$MUX_BINARY" ]; then From 2ee14fdf6eeca4cd440a3f82ce718aa438c5ea1a Mon Sep 17 00:00:00 2001 From: Shane White <85908724+shanewhite97@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:31:50 +0000 Subject: [PATCH 16/28] feat: provide boundary support for agent modules (#780) ## Description Enable any agent module to run its AI agent inside Coder's Agent Boundaries. The agentapi module handles boundary installation, config setup, and wrapper script creation, then exports AGENTAPI_BOUNDARY_PREFIX for consuming modules to use in their start scripts. Supports three boundary installation modes: - coder boundary subcommand (default, Coder v2.30+) - Standalone binary via install script (use_boundary_directly) - Compiled from source (compile_boundary_from_source) Users must provide a boundary config.yaml with their allowlist and settings when enabling boundary. Closes #457 ## Type of Change - [x] Feature/enhancement ## Module Information **Path:** `registry/coder/modules/agentapi` **Breaking change:** No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally --------- Co-authored-by: Shane White Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: 35C4n0r <70096901+35C4n0r@users.noreply.github.com> --- registry/coder/modules/agentapi/README.md | 46 +++++++- registry/coder/modules/agentapi/main.test.ts | 105 ++++++++++++++++++ registry/coder/modules/agentapi/main.tf | 45 ++++++++ .../modules/agentapi/scripts/boundary.sh | 95 ++++++++++++++++ .../coder/modules/agentapi/scripts/main.sh | 13 +++ .../agentapi/testdata/agentapi-mock.js | 9 ++ .../agentapi/testdata/agentapi-start.sh | 16 ++- 7 files changed, 323 insertions(+), 6 deletions(-) create mode 100644 registry/coder/modules/agentapi/scripts/boundary.sh diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index c5e9ae42..22ce50fe 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI ```tf module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.2.0" + version = "2.3.0" agent_id = var.agent_id web_app_slug = local.app_slug @@ -67,8 +67,7 @@ module "agentapi" { AgentAPI can save and restore conversation state across workspace restarts. This is disabled by default and requires agentapi binary >= v0.12.0. -State and PID files are stored in `$HOME//` alongside other -module files (e.g. `$HOME/.claude-module/agentapi-state.json`). +State and PID files are stored in `$HOME//` alongside other module files (e.g. `$HOME/.claude-module/agentapi-state.json`). To enable: @@ -89,6 +88,47 @@ module "agentapi" { } ``` +## Boundary (Network Filtering) + +The agentapi module supports optional [Agent Boundaries](https://coder.com/docs/ai-coder/agent-boundaries) +for network filtering. When enabled, the module sets up a `AGENTAPI_BOUNDARY_PREFIX` environment +variable that points to a wrapper script. Agent modules should use this prefix in their +start scripts to run the agent process through boundary. + +Boundary requires a `config.yaml` file with your allowlist, jail type, proxy port, and log +level. See the [Agent Boundaries documentation](https://coder.com/docs/ai-coder/agent-boundaries) +for configuration details. +To enable: + +```tf +module "agentapi" { + # ... other config + enable_boundary = true + boundary_config_path = "/home/coder/.config/coder_boundary/config.yaml" + + # Optional: install boundary binary instead of using coder subcommand + # use_boundary_directly        = true + # boundary_version              = "0.6.0" + # compile_boundary_from_source  = false +} +``` + +### Contract for agent modules + +When `enable_boundary = true`, the agentapi module exports `AGENTAPI_BOUNDARY_PREFIX` +as an environment variable pointing to a wrapper script. Agent module start scripts +should check for this variable and use it to prefix the agent command: + +```bash +if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then + agentapi server -- "${AGENTAPI_BOUNDARY_PREFIX}" my-agent "${ARGS[@]}" & +else + agentapi server -- my-agent "${ARGS[@]}" & +fi +``` + +This ensures only the agent process is sandboxed while agentapi itself runs unrestricted. + ## For module developers For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf). diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index cedf840c..39d10ca7 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -613,4 +613,109 @@ describe("agentapi", async () => { expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); }); }); + + describe("boundary", async () => { + test("boundary-disabled-by-default", async () => { + const { id } = await setup(); + await execModuleScript(id); + await expectAgentAPIStarted(id); + // Config file should NOT exist when boundary is disabled + const configCheck = await execContainer(id, [ + "bash", + "-c", + "test -f /home/coder/.config/coder_boundary/config.yaml && echo exists || echo missing", + ]); + expect(configCheck.stdout.trim()).toBe("missing"); + // AGENTAPI_BOUNDARY_PREFIX should NOT be in the mock log + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + expect(mockLog).not.toContain("AGENTAPI_BOUNDARY_PREFIX:"); + }); + + test("boundary-enabled", async () => { + const { id } = await setup({ + moduleVariables: { + enable_boundary: "true", + boundary_config_path: "/tmp/test-boundary.yaml", + }, + }); + // Write boundary config to the path before running the module + await execContainer(id, [ + "bash", + "-c", + `cat > /tmp/test-boundary.yaml <<'EOF' +jail_type: landjail +proxy_port: 8087 +log_level: warn +allowlist: + - "domain=api.example.com" +EOF`, + ]); + // Add mock coder binary for boundary setup + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: `#!/bin/bash +if [ "$1" = "boundary" ]; then + shift; shift; exec "$@" +fi +echo "mock coder"`, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + // Verify the config file exists at the specified path + const config = await readFileContainer(id, "/tmp/test-boundary.yaml"); + expect(config).toContain("jail_type: landjail"); + expect(config).toContain("proxy_port: 8087"); + expect(config).toContain("domain=api.example.com"); + // AGENTAPI_BOUNDARY_PREFIX should be exported + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + expect(mockLog).toContain("AGENTAPI_BOUNDARY_PREFIX:"); + // E2E: start script should have used the wrapper + const startLog = await readFileContainer( + id, + "/home/coder/test-agentapi-start.log", + ); + expect(startLog).toContain("Starting with boundary:"); + }); + + test("boundary-enabled-no-coder-binary", async () => { + const { id } = await setup({ + moduleVariables: { + enable_boundary: "true", + boundary_config_path: "/tmp/test-boundary.yaml", + }, + }); + // Write boundary config + await execContainer(id, [ + "bash", + "-c", + `cat > /tmp/test-boundary.yaml <<'EOF' +jail_type: landjail +proxy_port: 8087 +log_level: warn +EOF`, + ]); + // Remove coder binary to simulate it not being available + await execContainer( + id, + [ + "bash", + "-c", + "rm -f /usr/bin/coder /usr/local/bin/coder 2>/dev/null; hash -r", + ], + ["--user", "root"], + ); + const resp = await execModuleScript(id); + // Script should fail because coder binary is required + expect(resp.exitCode).not.toBe(0); + const scriptLog = await readFileContainer(id, "/home/coder/script.log"); + expect(scriptLog).toContain("Boundary cannot be enabled"); + }); + }); }); diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 8818736d..6f177036 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -164,6 +164,36 @@ variable "module_dir_name" { description = "Name of the subdirectory in the home directory for module files." } +variable "enable_boundary" { + type = bool + description = "Enable coder boundary for network filtering. Requires boundary_config to be set." + default = false +} + +variable "boundary_config_path" { + type = string + description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var." + default = "" +} + +variable "boundary_version" { + type = string + description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release. When compile_boundary_from_source is true, a valid git reference should be provided (tag, commit, branch)." + default = "latest" +} + +variable "compile_boundary_from_source" { + type = bool + description = "Whether to compile boundary from source instead of using the official install script." + default = false +} + +variable "use_boundary_directly" { + type = bool + description = "Whether to use boundary binary directly instead of coder boundary subcommand. When false (default), uses coder boundary subcommand. When true, installs and uses boundary binary from release." + default = false +} + variable "enable_state_persistence" { type = bool description = "Enable AgentAPI conversation state persistence across restarts." @@ -182,6 +212,13 @@ variable "pid_file_path" { default = "" } +resource "coder_env" "boundary_config" { + count = var.enable_boundary && var.boundary_config_path != "" ? 1 : 0 + agent_id = var.agent_id + name = "BOUNDARY_CONFIG" + value = var.boundary_config_path +} + locals { # we always trim the slash for consistency workdir = trimsuffix(var.folder, "/") @@ -200,6 +237,7 @@ locals { main_script = file("${path.module}/scripts/main.sh") shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh") lib_script = file("${path.module}/scripts/lib.sh") + boundary_script = file("${path.module}/scripts/boundary.sh") } resource "coder_script" "agentapi" { @@ -214,6 +252,9 @@ resource "coder_script" "agentapi" { echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh chmod +x /tmp/main.sh echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh + + echo -n '${base64encode(local.boundary_script)}' | base64 -d > /tmp/agentapi-boundary.sh + chmod +x /tmp/agentapi-boundary.sh ARG_MODULE_DIR_NAME='${var.module_dir_name}' \ ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \ @@ -228,6 +269,10 @@ resource "coder_script" "agentapi" { ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \ ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ + ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \ + ARG_BOUNDARY_VERSION='${var.boundary_version}' \ + ARG_COMPILE_BOUNDARY_FROM_SOURCE='${var.compile_boundary_from_source}' \ + ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \ ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \ ARG_STATE_FILE_PATH='${var.state_file_path}' \ ARG_PID_FILE_PATH='${var.pid_file_path}' \ diff --git a/registry/coder/modules/agentapi/scripts/boundary.sh b/registry/coder/modules/agentapi/scripts/boundary.sh new file mode 100644 index 00000000..d57f2261 --- /dev/null +++ b/registry/coder/modules/agentapi/scripts/boundary.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# boundary.sh - Boundary installation and setup for agentapi module. +# Sourced by main.sh when ENABLE_BOUNDARY=true. +# Exports AGENTAPI_BOUNDARY_PREFIX for use by module start scripts. + +validate_boundary_subcommand() { + if command_exists coder; then + if coder boundary --help > /dev/null 2>&1; then + return 0 + else + echo "Error: 'coder' command found but does not support 'boundary' subcommand. Please enable install_boundary." + exit 1 + fi + else + echo "Error: ENABLE_BOUNDARY=true, but 'coder' command not found. Boundary cannot be enabled." >&2 + exit 1 + fi +} + +# Install boundary binary if needed. +# Uses one of three strategies: +# 1. Compile from source (compile_boundary_from_source=true) +# 2. Install from release (use_boundary_directly=true) +# 3. Use coder boundary subcommand (default, no installation needed) +install_boundary() { + if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]; then + echo "Compiling boundary from source (version: ${BOUNDARY_VERSION})" + + # Remove existing boundary directory to allow re-running safely + if [ -d boundary ]; then + rm -rf boundary + fi + + echo "Cloning boundary repository" + git clone https://github.com/coder/boundary.git + cd boundary || exit 1 + git checkout "${BOUNDARY_VERSION}" + + make build + + sudo cp boundary /usr/local/bin/ + sudo chmod +x /usr/local/bin/boundary + cd - || exit 1 + elif [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then + echo "Installing boundary using official install script (version: ${BOUNDARY_VERSION})" + curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "${BOUNDARY_VERSION}" + else + validate_boundary_subcommand + echo "Using coder boundary subcommand (provided by Coder)" + fi +} + +# Set up boundary: install, write config, create wrapper script. +# Exports AGENTAPI_BOUNDARY_PREFIX pointing to the wrapper script. +setup_boundary() { + local module_path="$1" + + echo "Setting up coder boundary..." + + # Install boundary binary if needed + install_boundary + + # Determine which boundary command to use and create wrapper script + BOUNDARY_WRAPPER_SCRIPT="$module_path/boundary-wrapper.sh" + + if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ] || [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then + # Use boundary binary directly (from compilation or release installation) + cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF' +#!/usr/bin/env bash +set -euo pipefail +exec boundary -- "$@" +WRAPPER_EOF + else + # Use coder boundary subcommand (default) + # Copy coder binary to strip CAP_NET_ADMIN capabilities. + # This is necessary because boundary doesn't work with privileged binaries + # (you can't launch privileged binaries inside network namespaces unless + # you have sys_admin). + CODER_NO_CAPS="$module_path/coder-no-caps" + if ! cp "$(which coder)" "$CODER_NO_CAPS"; then + echo "Error: Failed to copy coder binary to ${CODER_NO_CAPS}. Boundary cannot be enabled." >&2 + exit 1 + fi + cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF' +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "${SCRIPT_DIR}/coder-no-caps" boundary -- "$@" +WRAPPER_EOF + fi + + chmod +x "${BOUNDARY_WRAPPER_SCRIPT}" + export AGENTAPI_BOUNDARY_PREFIX="${BOUNDARY_WRAPPER_SCRIPT}" + echo "Boundary wrapper configured: ${AGENTAPI_BOUNDARY_PREFIX}" +} diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 928132c8..b0afa24a 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -16,6 +16,10 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT" AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}" TASK_ID="${ARG_TASK_ID:-}" TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" +ENABLE_BOUNDARY="${ARG_ENABLE_BOUNDARY:-false}" +BOUNDARY_VERSION="${ARG_BOUNDARY_VERSION:-latest}" +COMPILE_BOUNDARY_FROM_SOURCE="${ARG_COMPILE_BOUNDARY_FROM_SOURCE:-false}" +USE_BOUNDARY_DIRECTLY="${ARG_USE_BOUNDARY_DIRECTLY:-false}" ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}" STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}" PID_FILE_PATH="${ARG_PID_FILE_PATH:-}" @@ -109,9 +113,18 @@ export LC_ALL=en_US.UTF-8 cd "${WORKDIR}" +# Set up boundary if enabled +export AGENTAPI_BOUNDARY_PREFIX="" +if [ "${ENABLE_BOUNDARY}" = "true" ]; then + # shellcheck source=boundary.sh + source /tmp/agentapi-boundary.sh + setup_boundary "$module_path" +fi + export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}" # Disable host header check since AgentAPI is proxied by Coder (which does its own validation) export AGENTAPI_ALLOWED_HOSTS="*" + export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}" # Only set state env vars when persistence is enabled and the binary supports # it. State persistence requires agentapi >= v0.12.0. diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock.js b/registry/coder/modules/agentapi/testdata/agentapi-mock.js index 84a88c04..e2e2d560 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-mock.js +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock.js @@ -31,6 +31,15 @@ for (const v of [ ); } } +// Log boundary env vars. +for (const v of ["AGENTAPI_BOUNDARY_PREFIX"]) { + if (process.env[v]) { + fs.appendFileSync( + "/home/coder/agentapi-mock.log", + `\n${v}: ${process.env[v]}`, + ); + } +} // Write PID file for shutdown script. if (process.env.AGENTAPI_PID_FILE) { diff --git a/registry/coder/modules/agentapi/testdata/agentapi-start.sh b/registry/coder/modules/agentapi/testdata/agentapi-start.sh index 259eb0c9..417b64d0 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-start.sh +++ b/registry/coder/modules/agentapi/testdata/agentapi-start.sh @@ -17,6 +17,16 @@ if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then export AGENTAPI_CHAT_BASE_PATH fi -agentapi server --port "$port" --term-width 67 --term-height 1190 -- \ - bash -c aiagent \ - > "$log_file_path" 2>&1 +# Use boundary wrapper if configured by agentapi module. +# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh +# and points to a wrapper script that runs the command through coder boundary. +if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then + echo "Starting with boundary: ${AGENTAPI_BOUNDARY_PREFIX}" >> /home/coder/test-agentapi-start.log + agentapi server --port "$port" --term-width 67 --term-height 1190 -- \ + "${AGENTAPI_BOUNDARY_PREFIX}" bash -c aiagent \ + > "$log_file_path" 2>&1 +else + agentapi server --port "$port" --term-width 67 --term-height 1190 -- \ + bash -c aiagent \ + > "$log_file_path" 2>&1 +fi From a0430e6f8375b2cf718aa601ce317f7ccebd681f Mon Sep 17 00:00:00 2001 From: Shane White <85908724+shanewhite97@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:37:37 +0000 Subject: [PATCH 17/28] feat(coder-labs/modules/codex): add boundary support via agentapi module (#795) ## Description Adds boundary support to the Codex module by passing boundary variables through to the agentapi module and using AGENTAPI_BOUNDARY_PREFIX in the start script. Depends on #780 ## Type of Change - [x] Feature/enhancement ## Module Information **Path:** `registry/coder-labs/modules/codex` **Breaking change:** No --------- Co-authored-by: Shane White Co-authored-by: 35C4n0r <70096901+35C4n0r@users.noreply.github.com> --- registry/coder-labs/modules/codex/README.md | 30 ++++++-- .../coder-labs/modules/codex/main.test.ts | 43 +++++++++++ registry/coder-labs/modules/codex/main.tf | 73 ++++++++++++++----- .../coder-labs/modules/codex/scripts/start.sh | 11 ++- 4 files changed, 132 insertions(+), 25 deletions(-) diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md index df486812..5e8ac272 100644 --- a/registry/coder-labs/modules/codex/README.md +++ b/registry/coder-labs/modules/codex/README.md @@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.2.0" + version = "4.3.0" agent_id = coder_agent.example.id openai_api_key = var.openai_api_key workdir = "/home/coder/project" @@ -32,7 +32,7 @@ module "codex" { module "codex" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder-labs/codex/coder" - version = "4.2.0" + version = "4.3.0" agent_id = coder_agent.example.id openai_api_key = "..." workdir = "/home/coder/project" @@ -51,7 +51,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.2.0" + version = "4.3.0" agent_id = coder_agent.example.id workdir = "/home/coder/project" enable_aibridge = true @@ -94,7 +94,7 @@ data "coder_task" "me" {} module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.2.0" + version = "4.3.0" agent_id = coder_agent.example.id openai_api_key = "..." ai_prompt = data.coder_task.me.prompt @@ -105,6 +105,26 @@ module "codex" { } ``` +### Usage with Agent Boundaries + +This example shows how to configure the Codex module to run the agent behind a process-level boundary that restricts its network access. + +By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation. + +```tf +module "codex" { + source = "registry.coder.com/coder-labs/codex/coder" + version = "4.3.0" + agent_id = coder_agent.main.id + openai_api_key = var.openai_api_key + workdir = "/home/coder/project" + enable_boundary = true +} +``` + +> [!NOTE] +> For developers: The module also supports installing boundary from a release version (`use_boundary_directly = true`) or compiling from source (`compile_boundary_from_source = true`). These are escape hatches for development and testing purposes. + ### Advanced Configuration This example shows additional configuration options for custom models, MCP servers, and base configuration. @@ -112,7 +132,7 @@ This example shows additional configuration options for custom models, MCP serve ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.2.0" + version = "4.3.0" agent_id = coder_agent.example.id openai_api_key = "..." workdir = "/home/coder/project" diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index f8d9f0a5..3eff3eca 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -473,4 +473,47 @@ describe("codex", async () => { ); expect(configToml).toContain('profile = "aibridge"'); }); + + test("boundary-enabled", async () => { + const { id } = await setup({ + moduleVariables: { + enable_boundary: "true", + boundary_config_path: "/tmp/test-boundary.yaml", + }, + }); + // Write boundary config + await execContainer(id, [ + "bash", + "-c", + `cat > /tmp/test-boundary.yaml <<'EOF' +jail_type: landjail +proxy_port: 8087 +log_level: warn +allowlist: + - "domain=api.openai.com" +EOF`, + ]); + // Add mock coder binary for boundary setup + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: `#!/bin/bash +if [ "$1" = "boundary" ]; then + if [ "$2" = "--help" ]; then + echo "boundary help" + exit 0 + fi + shift; shift; exec "$@" +fi +echo "mock coder"`, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + // Verify boundary wrapper was used in start script + const startLog = await readFileContainer( + id, + "/home/coder/.codex-module/agentapi-start.log", + ); + expect(startLog).toContain("boundary"); + }); }); diff --git a/registry/coder-labs/modules/codex/main.tf b/registry/coder-labs/modules/codex/main.tf index dd70fdc4..4d9c5ae1 100644 --- a/registry/coder-labs/modules/codex/main.tf +++ b/registry/coder-labs/modules/codex/main.tf @@ -176,6 +176,36 @@ variable "codex_system_prompt" { default = "You are a helpful coding assistant. Start every response with `Codex says:`" } +variable "enable_boundary" { + type = bool + description = "Enable coder boundary for network filtering." + default = false +} + +variable "boundary_config_path" { + type = string + description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var." + default = "" +} + +variable "boundary_version" { + type = string + description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release." + default = "latest" +} + +variable "compile_boundary_from_source" { + type = bool + description = "Whether to compile boundary from source instead of using the official install script." + default = false +} + +variable "use_boundary_directly" { + type = bool + description = "Whether to use boundary binary directly instead of coder boundary subcommand." + default = false +} + resource "coder_env" "openai_api_key" { agent_id = var.agent_id name = "OPENAI_API_KEY" @@ -212,26 +242,31 @@ locals { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.2.0" + version = "2.3.0" - agent_id = var.agent_id - folder = local.workdir - web_app_slug = local.app_slug - web_app_order = var.order - web_app_group = var.group - web_app_icon = var.icon - web_app_display_name = var.web_app_display_name - cli_app = var.cli_app - cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null - cli_app_display_name = var.cli_app ? var.cli_app_display_name : null - module_dir_name = local.module_dir_name - install_agentapi = var.install_agentapi - agentapi_subdomain = var.subdomain - agentapi_version = var.agentapi_version - enable_state_persistence = var.enable_state_persistence - pre_install_script = var.pre_install_script - post_install_script = var.post_install_script - start_script = <<-EOT + agent_id = var.agent_id + folder = local.workdir + web_app_slug = local.app_slug + web_app_order = var.order + web_app_group = var.group + web_app_icon = var.icon + web_app_display_name = var.web_app_display_name + cli_app = var.cli_app + cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null + cli_app_display_name = var.cli_app ? var.cli_app_display_name : null + module_dir_name = local.module_dir_name + install_agentapi = var.install_agentapi + agentapi_subdomain = var.subdomain + agentapi_version = var.agentapi_version + enable_state_persistence = var.enable_state_persistence + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + enable_boundary = var.enable_boundary + boundary_config_path = var.boundary_config_path + boundary_version = var.boundary_version + compile_boundary_from_source = var.compile_boundary_from_source + use_boundary_directly = var.use_boundary_directly + start_script = <<-EOT #!/bin/bash set -o errexit set -o pipefail diff --git a/registry/coder-labs/modules/codex/scripts/start.sh b/registry/coder-labs/modules/codex/scripts/start.sh index e0e7d972..0dbf5a60 100644 --- a/registry/coder-labs/modules/codex/scripts/start.sh +++ b/registry/coder-labs/modules/codex/scripts/start.sh @@ -210,7 +210,16 @@ capture_session_id() { start_codex() { printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}" - agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" & + # AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh when + # enable_boundary=true. It points to a wrapper script that runs the command + # through coder boundary, sandboxing only the agent process. + if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then + printf "Starting with coder boundary enabled\n" + agentapi server --type codex --term-width 67 --term-height 1190 -- \ + "${AGENTAPI_BOUNDARY_PREFIX}" codex "${CODEX_ARGS[@]}" & + else + agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" & + fi capture_session_id } From 960629762072deb407e5816e56481059dc19aaf9 Mon Sep 17 00:00:00 2001 From: "blinkagent[bot]" <237617714+blinkagent[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:11:19 -0500 Subject: [PATCH 18/28] feat: pass branch to coder dotfiles (#789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes #551 (fork branch couldn't be rebased due to GitHub App permission limitations). Original author: @willshu ## Description Adds support for specifying a git branch when cloning dotfiles repositories. ### Changes - Introduces `dotfiles_branch` and `default_dotfiles_branch` Terraform variables - Adds a `coder_parameter` for `dotfiles_branch` when not explicitly set (with `order` matching `dotfiles_uri`) - Conditionally passes the `--branch` flag to `coder dotfiles` only when branch is non-empty - Adds validation to prevent empty string for `dotfiles_branch` (use `null` to prompt the user) - Default branch is empty string — defers to the repo's default branch rather than assuming `main`, matching the behavior of `coder dotfiles --branch` which states: *"If empty, will default to cloning the default branch or using the existing branch in the cloned repo on disk."* - Adds test coverage for custom branch setting and parameter creation ### Review feedback addressed (from Copilot on #551) - Added `order` field to `dotfiles_branch` parameter for UI consistency with `dotfiles_uri` - Conditional echo message — only shows branch info when set - `--branch` flag only passed when `DOTFILES_BRANCH` is non-empty (both current-user and sudo paths) - Added validation block on `var.dotfiles_branch` to reject empty strings ## Type of Change - [x] Feature/enhancement ## Module Information **Path:** `registry/coder/modules/dotfiles` ## Testing & Validation - [ ] Tests pass (`bun test`) - [ ] Code formatted (`bun fmt`) - [ ] Changes tested locally Co-authored-by: William Shu Co-authored-by: DevCats --- registry/coder/modules/dotfiles/README.md | 12 +++---- registry/coder/modules/dotfiles/main.test.ts | 36 +++++++++++++++++++- registry/coder/modules/dotfiles/main.tf | 32 +++++++++++++++++ registry/coder/modules/dotfiles/run.sh | 19 +++++++++-- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/registry/coder/modules/dotfiles/README.md b/registry/coder/modules/dotfiles/README.md index c78b80c3..aae52284 100644 --- a/registry/coder/modules/dotfiles/README.md +++ b/registry/coder/modules/dotfiles/README.md @@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/ module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.3.2" + version = "1.4.0" agent_id = coder_agent.example.id } ``` @@ -31,7 +31,7 @@ module "dotfiles" { module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.3.2" + version = "1.4.0" agent_id = coder_agent.example.id } ``` @@ -42,7 +42,7 @@ module "dotfiles" { module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.3.2" + version = "1.4.0" agent_id = coder_agent.example.id user = "root" } @@ -54,14 +54,14 @@ module "dotfiles" { module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.3.2" + version = "1.4.0" agent_id = coder_agent.example.id } module "dotfiles-root" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.3.2" + version = "1.4.0" agent_id = coder_agent.example.id user = "root" dotfiles_uri = module.dotfiles.dotfiles_uri @@ -90,7 +90,7 @@ You can set a default dotfiles repository for all users by setting the `default_ module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.3.2" + version = "1.4.0" agent_id = coder_agent.example.id default_dotfiles_uri = "https://github.com/coder/dotfiles" } diff --git a/registry/coder/modules/dotfiles/main.test.ts b/registry/coder/modules/dotfiles/main.test.ts index 8cde2510..a9a8bf93 100644 --- a/registry/coder/modules/dotfiles/main.test.ts +++ b/registry/coder/modules/dotfiles/main.test.ts @@ -62,7 +62,41 @@ describe("dotfiles", async () => { agent_id: "foo", coder_parameter_order: order.toString(), }); + expect(state.resources).toHaveLength(3); + const parameters = state.resources.filter( + (r) => r.type === "coder_parameter", + ); + for (const param of parameters) { + expect(param.instances[0].attributes.order).toBe(order); + } + }); + + it("set custom dotfiles_branch", async () => { + const branch = "develop"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + dotfiles_branch: branch, + }); expect(state.resources).toHaveLength(2); - expect(state.resources[0].instances[0].attributes.order).toBe(order); + const scriptResource = state.resources.find( + (r) => r.type === "coder_script", + ); + expect(scriptResource?.instances[0].attributes.script).toContain( + `DOTFILES_BRANCH="${branch}"`, + ); + }); + + it("default dotfiles_branch creates parameter", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + expect(state.resources).toHaveLength(3); + const branchParameter = state.resources.find( + (r) => + r.type === "coder_parameter" && + r.instances[0].attributes.name === "dotfiles_branch", + ); + expect(branchParameter).toBeDefined(); + expect(branchParameter?.instances[0].attributes.default).toBeNull(); }); }); diff --git a/registry/coder/modules/dotfiles/main.tf b/registry/coder/modules/dotfiles/main.tf index 7b15a391..ca1709d0 100644 --- a/registry/coder/modules/dotfiles/main.tf +++ b/registry/coder/modules/dotfiles/main.tf @@ -46,6 +46,12 @@ variable "default_dotfiles_uri" { } } +variable "default_dotfiles_branch" { + type = string + description = "The default dotfiles branch if the workspace user does not provide one" + default = "" +} + variable "dotfiles_uri" { type = string description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)" @@ -61,6 +67,17 @@ variable "dotfiles_uri" { } } +variable "dotfiles_branch" { + type = string + description = "The branch to use for the dotfiles repository (optional, when set, the user isn't prompted for the branch)" + default = null + + validation { + condition = var.dotfiles_branch == null || var.dotfiles_branch != "" + error_message = "dotfiles_branch cannot be an empty string. Use null to prompt the user or provide a valid branch name." + } +} + variable "user" { type = string description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)" @@ -107,8 +124,21 @@ data "coder_parameter" "dotfiles_uri" { } } +data "coder_parameter" "dotfiles_branch" { + count = var.dotfiles_branch == null ? 1 : 0 + type = "string" + name = "dotfiles_branch" + display_name = "Dotfiles Branch" + order = var.coder_parameter_order + default = var.default_dotfiles_branch + description = "The branch to use for the dotfiles repository" + mutable = true + icon = "/icon/dotfiles.svg" +} + locals { dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value + dotfiles_branch = var.dotfiles_branch != null ? var.dotfiles_branch : data.coder_parameter.dotfiles_branch[0].value user = var.user != null ? var.user : "" encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : "" } @@ -118,6 +148,7 @@ resource "coder_script" "dotfiles" { script = templatefile("${path.module}/run.sh", { DOTFILES_URI : local.dotfiles_uri, DOTFILES_USER : local.user, + DOTFILES_BRANCH : local.dotfiles_branch, POST_CLONE_SCRIPT : local.encoded_post_clone_script }) display_name = "Dotfiles" @@ -136,6 +167,7 @@ resource "coder_app" "dotfiles" { command = templatefile("${path.module}/run.sh", { DOTFILES_URI : local.dotfiles_uri, DOTFILES_USER : local.user, + DOTFILES_BRANCH : local.dotfiles_branch, POST_CLONE_SCRIPT : local.encoded_post_clone_script }) } diff --git a/registry/coder/modules/dotfiles/run.sh b/registry/coder/modules/dotfiles/run.sh index 49ab3ec5..f7f275f8 100644 --- a/registry/coder/modules/dotfiles/run.sh +++ b/registry/coder/modules/dotfiles/run.sh @@ -4,6 +4,7 @@ set -euo pipefail DOTFILES_URI="${DOTFILES_URI}" DOTFILES_USER="${DOTFILES_USER}" +DOTFILES_BRANCH="${DOTFILES_BRANCH}" # Validate DOTFILES_URI to prevent command injection (defense in depth) if [ -n "$DOTFILES_URI" ]; then @@ -24,10 +25,18 @@ if [ -n "$${DOTFILES_URI// }" ]; then DOTFILES_USER="$USER" fi - echo "✨ Applying dotfiles for user $DOTFILES_USER" + if [ -n "$DOTFILES_BRANCH" ]; then + echo "✨ Applying dotfiles for user $DOTFILES_USER from branch $DOTFILES_BRANCH" + else + echo "✨ Applying dotfiles for user $DOTFILES_USER" + fi if [ "$DOTFILES_USER" = "$USER" ]; then - coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log + if [ -n "$DOTFILES_BRANCH" ]; then + coder dotfiles "$DOTFILES_URI" --branch "$DOTFILES_BRANCH" -y 2>&1 | tee ~/.dotfiles.log + else + coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log + fi else if command -v getent > /dev/null 2>&1; then DOTFILES_USER_HOME=$(getent passwd "$DOTFILES_USER" | cut -d: -f6) @@ -40,7 +49,11 @@ if [ -n "$${DOTFILES_URI// }" ]; then fi CODER_BIN=$(command -v coder) - sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log" + if [ -n "$DOTFILES_BRANCH" ]; then + sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" --branch "$DOTFILES_BRANCH" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log" + else + sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log" + fi fi fi From 1460293de4deb63a5125260fe7cc66d06d864161 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:16:38 +0100 Subject: [PATCH 19/28] feat(coder/mux): add restart retries for mux exits (#800) ## Summary - add optional mux auto-restarts with delay, lock cleanup, and restart-attempt caps - restart mux after any exit when enabled, including intentional exits and signals - require `max_restart_attempts` to be a non-negative whole number and update docs/tests for the new restart semantics ## Validation - `bash -n registry/coder/modules/mux/run.sh` - `cd registry/coder/modules/mux && terraform validate` - `cd registry/coder/modules/mux && terraform test -verbose` - `cd registry/coder/modules/mux && bun test main.test.ts` Generated with OpenAI using Mux --- registry/coder/modules/mux/README.md | 40 +++++-- registry/coder/modules/mux/main.test.ts | 137 ++++++++++++++++++++++ registry/coder/modules/mux/main.tf | 31 +++++ registry/coder/modules/mux/mux.tftest.hcl | 105 +++++++++++++++++ registry/coder/modules/mux/run.sh | 91 ++++++++++++-- 5 files changed, 384 insertions(+), 20 deletions(-) diff --git a/registry/coder/modules/mux/README.md b/registry/coder/modules/mux/README.md index 46bf295b..fb26d381 100644 --- a/registry/coder/modules/mux/README.md +++ b/registry/coder/modules/mux/README.md @@ -8,13 +8,13 @@ tags: [ai, agents, development, multiplexer] # Mux -Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The launcher now keeps watching the mux process after startup and appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces. +Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The launcher keeps watching the mux process after startup, appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime, and can optionally wait a few seconds, remove the stale server lock, and restart Mux after any exit until an optional restart-attempt cap is reached. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces. ```tf module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.4.0" + version = "1.4.3" agent_id = coder_agent.main.id } ``` @@ -37,7 +37,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.4.0" + version = "1.4.3" agent_id = coder_agent.main.id } ``` @@ -48,7 +48,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.4.0" + version = "1.4.3" agent_id = coder_agent.main.id # Default is "latest"; set to a specific version to pin install_version = "0.4.0" @@ -63,7 +63,7 @@ Start Mux with `mux server --add-project /path/to/project`: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.4.0" + version = "1.4.3" agent_id = coder_agent.main.id add_project = "/path/to/project" } @@ -78,19 +78,35 @@ The module parses quoted values, so grouped arguments remain intact. module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.4.0" + version = "1.4.3" agent_id = coder_agent.main.id additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'" } ``` +### Restart After Mux Exits + +Enable automatic restarts after Mux exits, including clean exits and intentional shutdown signals such as `SIGTERM`. The launcher waits for `restart_delay_seconds`, removes `~/.mux/server.lock`, and starts Mux again. Set `max_restart_attempts` to a whole number to stop retrying after a fixed number of restarts, or leave it at `0` for unlimited retries. + +```tf +module "mux" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/mux/coder" + version = "1.4.3" + agent_id = coder_agent.main.id + restart_on_kill = true + restart_delay_seconds = 3 + max_restart_attempts = 5 +} +``` + ### Custom Port ```tf module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.4.0" + version = "1.4.3" agent_id = coder_agent.main.id port = 8080 } @@ -104,7 +120,7 @@ Force a specific package manager instead of auto-detection: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.4.0" + version = "1.4.3" agent_id = coder_agent.main.id package_manager = "pnpm" # or "npm", "bun" } @@ -118,7 +134,7 @@ Use a private or mirrored npm registry: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.4.0" + version = "1.4.3" agent_id = coder_agent.main.id registry_url = "https://npm.pkg.github.com" } @@ -132,7 +148,7 @@ Run an existing copy of Mux if found, otherwise install from npm: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.4.0" + version = "1.4.3" agent_id = coder_agent.main.id use_cached = true } @@ -146,7 +162,7 @@ Run without installing from the network (requires Mux to be pre-installed): module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.4.0" + version = "1.4.3" agent_id = coder_agent.main.id install = false } @@ -164,3 +180,5 @@ module "mux" { - Installs `mux@next` from the npm registry by default; set `registry_url` to use a private or mirrored registry - Falls back to a direct tarball download when no package manager is found - Appends best-effort signal and external-kill diagnostics to `log_path` if the mux process dies after startup +- Set `restart_on_kill = true` to wait `restart_delay_seconds`, remove `~/.mux/server.lock`, and restart Mux after it exits +- Set `max_restart_attempts` to a whole-number cap on restart attempts, or leave it at `0` for unlimited retries diff --git a/registry/coder/modules/mux/main.test.ts b/registry/coder/modules/mux/main.test.ts index 9537e9de..a8944dee 100644 --- a/registry/coder/modules/mux/main.test.ts +++ b/registry/coder/modules/mux/main.test.ts @@ -145,6 +145,143 @@ chmod +x /tmp/mux/mux`, } }, 60000); + it("restarts after a clean exit when enabled", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install: false, + log_path: "/tmp/mux.log", + restart_on_kill: true, + restart_delay_seconds: 1, + max_restart_attempts: 1, + }); + + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer("alpine/curl"); + + try { + const setup = await execContainer(id, [ + "sh", + "-c", + `apk add --no-cache bash >/dev/null +mkdir -p /tmp/mux +cat <<'EOF' > /tmp/mux/mux +#!/usr/bin/env sh +run_count_file="/tmp/mux-run-count" +run_count=0 +if [ -f "$run_count_file" ]; then + run_count=$(cat "$run_count_file") +fi +run_count=$((run_count + 1)) +printf '%s' "$run_count" > "$run_count_file" +echo "run=$run_count" +if [ "$run_count" -eq 1 ]; then + mkdir -p "$HOME/.mux" + touch "$HOME/.mux/server.lock" + exit 0 +fi +if [ -f "$HOME/.mux/server.lock" ]; then + echo "lock=present" +else + echo "lock=cleaned" +fi +exit 0 +EOF +chmod +x /tmp/mux/mux`, + ]); + expect(setup.exitCode).toBe(0); + + const output = await execContainer(id, ["sh", "-c", instance.script]); + if (output.exitCode !== 0) { + console.log("STDOUT:\n" + output.stdout); + console.log("STDERR:\n" + output.stderr); + } + expect(output.exitCode).toBe(0); + + await execContainer(id, ["sh", "-c", "sleep 4"]); + const log = await readFileContainer(id, "/tmp/mux.log"); + const runCount = await readFileContainer(id, "/tmp/mux-run-count"); + expect(log).toContain("run=1"); + expect(log).toContain("mux server exited cleanly."); + expect(log).toContain( + "Waiting 1 seconds before restarting mux after it exited.", + ); + expect(log).toContain( + "Removing /root/.mux/server.lock before restarting mux.", + ); + expect(log).toContain("run=2"); + expect(log).toContain("lock=cleaned"); + expect(log).toContain( + "Reached the max restart attempts limit (1); not restarting mux again.", + ); + expect(runCount.trim()).toBe("2"); + } finally { + await removeContainer(id); + } + }, 60000); + + it("restarts after SIGTERM when enabled", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install: false, + log_path: "/tmp/mux.log", + restart_on_kill: true, + restart_delay_seconds: 1, + max_restart_attempts: 1, + }); + + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer("alpine/curl"); + + try { + const setup = await execContainer(id, [ + "sh", + "-c", + `apk add --no-cache bash >/dev/null +mkdir -p /tmp/mux +cat <<'EOF' > /tmp/mux/mux +#!/usr/bin/env sh +run_count_file="/tmp/mux-run-count" +run_count=0 +if [ -f "$run_count_file" ]; then + run_count=$(cat "$run_count_file") +fi +run_count=$((run_count + 1)) +printf '%s' "$run_count" > "$run_count_file" +echo "run=$run_count" +if [ "$run_count" -eq 1 ]; then + kill -TERM $$ +fi +exit 0 +EOF +chmod +x /tmp/mux/mux`, + ]); + expect(setup.exitCode).toBe(0); + + const output = await execContainer(id, ["sh", "-c", instance.script]); + if (output.exitCode !== 0) { + console.log("STDOUT:\n" + output.stdout); + console.log("STDERR:\n" + output.stderr); + } + expect(output.exitCode).toBe(0); + + await execContainer(id, ["sh", "-c", "sleep 4"]); + const log = await readFileContainer(id, "/tmp/mux.log"); + const runCount = await readFileContainer(id, "/tmp/mux-run-count"); + expect(log).toContain("run=1"); + expect(log).toContain("signal TERM (15); shell exit code 143."); + expect(log).toContain( + "Waiting 1 seconds before restarting mux after it exited.", + ); + expect(log).toContain("run=2"); + expect(log).toContain( + "Reached the max restart attempts limit (1); not restarting mux again.", + ); + expect(runCount.trim()).toBe("2"); + } finally { + await removeContainer(id); + } + }, 60000); + it("runs with npm present", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", diff --git a/registry/coder/modules/mux/main.tf b/registry/coder/modules/mux/main.tf index ba475b0c..f80b8b3f 100644 --- a/registry/coder/modules/mux/main.tf +++ b/registry/coder/modules/mux/main.tf @@ -49,6 +49,34 @@ variable "log_path" { default = "/tmp/mux.log" } +variable "restart_on_kill" { + type = bool + description = "Restart Mux after it exits by waiting briefly, removing the server lock, and launching it again." + default = false +} + +variable "restart_delay_seconds" { + type = number + description = "How long to wait before restarting Mux after it exits when restart_on_kill is enabled." + default = 5 + + validation { + condition = var.restart_delay_seconds >= 0 + error_message = "The 'restart_delay_seconds' variable must be greater than or equal to 0." + } +} + +variable "max_restart_attempts" { + type = number + description = "Maximum whole-number restart attempts before giving up. Set to 0 for unlimited restarts when restart_on_kill is enabled." + default = 0 + + validation { + condition = var.max_restart_attempts >= 0 && floor(var.max_restart_attempts) == var.max_restart_attempts + error_message = "The 'max_restart_attempts' variable must be a whole number greater than or equal to 0." + } +} + variable "add_project" { type = string description = "Optional path to add/open as a project in Mux on startup." @@ -171,6 +199,9 @@ resource "coder_script" "mux" { OFFLINE : !var.install, USE_CACHED : var.use_cached, AUTH_TOKEN : local.mux_auth_token, + RESTART_ON_KILL : var.restart_on_kill, + RESTART_DELAY_SECONDS : var.restart_delay_seconds, + MAX_RESTART_ATTEMPTS : var.max_restart_attempts, PACKAGE_MANAGER : var.package_manager, REGISTRY_URL : local.registry_url, }) diff --git a/registry/coder/modules/mux/mux.tftest.hcl b/registry/coder/modules/mux/mux.tftest.hcl index e7816de8..af4cbfe2 100644 --- a/registry/coder/modules/mux/mux.tftest.hcl +++ b/registry/coder/modules/mux/mux.tftest.hcl @@ -111,6 +111,111 @@ run "launcher_logs_external_kills" { } } +run "restart_on_kill_enabled" { + command = plan + + variables { + agent_id = "foo" + restart_on_kill = true + restart_delay_seconds = 7 + } + + assert { + condition = strcontains(resource.coder_script.mux.script, "restart_on_kill_value=\"true\"") + error_message = "mux launcher must receive the restart_on_kill setting" + } + + assert { + condition = strcontains(resource.coder_script.mux.script, "restart_delay_seconds_value=\"7\"") + error_message = "mux launcher must receive the configured restart delay" + } + + assert { + condition = strcontains(resource.coder_script.mux.script, "Waiting $${RESTART_DELAY_SECONDS_VALUE} seconds before restarting mux after it exited.") + error_message = "mux launcher must log the restart delay before relaunching" + } + + assert { + condition = strcontains(resource.coder_script.mux.script, "Removing $HOME/.mux/server.lock before restarting mux.") + error_message = "mux launcher must clean up the server lock before relaunching" + } + + assert { + condition = !strcontains(resource.coder_script.mux.script, "\"$exit_code\" -le 128") + error_message = "mux launcher must no longer exclude non-signal exits from restart handling" + } + + assert { + condition = !strcontains(resource.coder_script.mux.script, "1|2|15)") + error_message = "mux launcher must no longer exclude intentional signals from restart handling" + } +} + +run "restart_on_kill_with_restart_cap" { + command = plan + + variables { + agent_id = "foo" + restart_on_kill = true + restart_delay_seconds = 7 + max_restart_attempts = 2 + } + + assert { + condition = strcontains(resource.coder_script.mux.script, "max_restart_attempts_value=\"2\"") + error_message = "mux launcher must receive the configured restart cap" + } + + assert { + condition = strcontains(resource.coder_script.mux.script, "Mux will stop restarting after $${max_restart_attempts_value} restart attempts.") + error_message = "mux launcher must describe the configured restart cap" + } + + assert { + condition = strcontains(resource.coder_script.mux.script, "Reached the max restart attempts limit ($MAX_RESTART_ATTEMPTS_VALUE); not restarting mux again.") + error_message = "mux launcher must log when it hits the restart cap" + } +} + +run "invalid_max_restart_attempts" { + command = plan + + variables { + agent_id = "foo" + max_restart_attempts = -1 + } + + expect_failures = [ + var.max_restart_attempts + ] +} + +run "fractional_max_restart_attempts" { + command = plan + + variables { + agent_id = "foo" + max_restart_attempts = 0.5 + } + + expect_failures = [ + var.max_restart_attempts + ] +} + +run "invalid_restart_delay_seconds" { + command = plan + + variables { + agent_id = "foo" + restart_delay_seconds = -1 + } + + expect_failures = [ + var.restart_delay_seconds + ] +} + run "custom_version" { command = plan diff --git a/registry/coder/modules/mux/run.sh b/registry/coder/modules/mux/run.sh index fb583480..bd2bb811 100644 --- a/registry/coder/modules/mux/run.sh +++ b/registry/coder/modules/mux/run.sh @@ -5,17 +5,30 @@ RESET='\033[0m' MUX_BINARY="${INSTALL_PREFIX}/mux" function run_mux() { - # Remove stale server lock if present - rm -f "$HOME/.mux/server.lock" - local port_value local auth_token_value + local restart_on_kill_value + local restart_delay_seconds_value + local max_restart_attempts_value + port_value="${PORT}" auth_token_value="${AUTH_TOKEN}" + restart_on_kill_value="${RESTART_ON_KILL}" + restart_delay_seconds_value="${RESTART_DELAY_SECONDS}" + max_restart_attempts_value="${MAX_RESTART_ATTEMPTS}" + if [ -z "$port_value" ]; then port_value="4000" fi + if [ -z "$restart_delay_seconds_value" ]; then + restart_delay_seconds_value="5" + fi + + if [ -z "$max_restart_attempts_value" ]; then + max_restart_attempts_value="0" + fi + mkdir -p "$(dirname "${LOG_PATH}")" # Build args for mux (POSIX-compatible, avoid bash arrays) @@ -41,13 +54,24 @@ EOF_ARGS echo "🚀 Starting mux server on port $port_value..." echo "Check logs at ${LOG_PATH}!" - echo "ℹ️ Unexpected exits will be appended to ${LOG_PATH} by the launcher." + echo "ℹ️ Mux exit details will be appended to ${LOG_PATH} by the launcher." + if [ "$restart_on_kill_value" = true ]; then + echo "ℹ️ Auto-restart after mux exits is enabled with a $${restart_delay_seconds_value}-second delay." + if [ "$max_restart_attempts_value" = "0" ]; then + echo "ℹ️ Automatic restarts are unlimited for every mux exit." + else + echo "ℹ️ Mux will stop restarting after $${max_restart_attempts_value} restart attempts." + fi + fi nohup env \ LOG_PATH="${LOG_PATH}" \ MUX_BINARY="$MUX_BINARY" \ AUTH_TOKEN="$auth_token_value" \ PORT_VALUE="$port_value" \ + RESTART_ON_KILL_VALUE="$restart_on_kill_value" \ + RESTART_DELAY_SECONDS_VALUE="$restart_delay_seconds_value" \ + MAX_RESTART_ATTEMPTS_VALUE="$max_restart_attempts_value" \ bash -s -- "$@" > /dev/null 2>&1 << 'EOF_LAUNCHER' & signal_name() { local signal_number="$1" @@ -82,6 +106,14 @@ append_kernel_kill_context() { fi } +cleanup_mux_lock() { + rm -f "$HOME/.mux/server.lock" +} + +should_restart_mux() { + [ "$RESTART_ON_KILL_VALUE" = "true" ] +} + log_mux_exit() { local mux_pid="$1" local exit_code="$2" @@ -114,11 +146,52 @@ log_mux_exit() { echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself." } -MUX_SERVER_AUTH_TOKEN="$AUTH_TOKEN" PORT="$PORT_VALUE" "$MUX_BINARY" "$@" >> "$LOG_PATH" 2>&1 & -mux_pid=$! -wait "$mux_pid" -exit_code=$? -log_mux_exit "$mux_pid" "$exit_code" >> "$LOG_PATH" 2>&1 +log_mux_restart_wait() { + local timestamp + + timestamp="$(date -Iseconds 2> /dev/null || date)" + echo "[$timestamp] Waiting $${RESTART_DELAY_SECONDS_VALUE} seconds before restarting mux after it exited." +} + +log_mux_restart_cleanup() { + local timestamp + + timestamp="$(date -Iseconds 2> /dev/null || date)" + echo "[$timestamp] Removing $HOME/.mux/server.lock before restarting mux." +} + +log_mux_restart_cap_reached() { + local timestamp + + timestamp="$(date -Iseconds 2> /dev/null || date)" + echo "[$timestamp] Reached the max restart attempts limit ($MAX_RESTART_ATTEMPTS_VALUE); not restarting mux again." +} + +restart_attempt_count=0 +while true; do + cleanup_mux_lock + MUX_SERVER_AUTH_TOKEN="$AUTH_TOKEN" PORT="$PORT_VALUE" "$MUX_BINARY" "$@" >> "$LOG_PATH" 2>&1 & + mux_pid=$! + wait "$mux_pid" + exit_code=$? + log_mux_exit "$mux_pid" "$exit_code" >> "$LOG_PATH" 2>&1 + + if should_restart_mux; then + if [ "$MAX_RESTART_ATTEMPTS_VALUE" -gt 0 ] && [ "$restart_attempt_count" -ge "$MAX_RESTART_ATTEMPTS_VALUE" ]; then + log_mux_restart_cap_reached >> "$LOG_PATH" 2>&1 + break + fi + + restart_attempt_count=$((restart_attempt_count + 1)) + log_mux_restart_wait >> "$LOG_PATH" 2>&1 + sleep "$RESTART_DELAY_SECONDS_VALUE" + cleanup_mux_lock + log_mux_restart_cleanup >> "$LOG_PATH" 2>&1 + continue + fi + + break +done EOF_LAUNCHER } # Check if mux is already installed for offline mode From 4fdcf0d71257c741e2d1be6d0f50ec738061341e Mon Sep 17 00:00:00 2001 From: 35C4n0r <70096901+35C4n0r@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:13:31 +0530 Subject: [PATCH 20/28] fix(coder/modules/claude-code): update claude session workdir normalization (#803) ## Description - This lead to a bug where if the folder name is in the form `a.b.c`: - we check for: `-home-coder-ai.coder.com/cd32e253-ca16-4fd3-9825-d837e74ae3c2.jsonl` - But the actual file path for claude-session is: `-home-coder-ai-coder-com/cd32e253-ca16-4fd3-9825-d837e74ae3c2.jsonl` - The above bug might also occur in the case of `a_b_c` - update workdir normalization to handle dot in path ## Type of Change - [ ] New module - [ ] New template - [x] Bug fix - [ ] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder/modules/claude-code` **New version:** `v4.8.1` **Breaking change:** [ ] Yes [x] No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally ## Related Issues --- registry/coder/modules/claude-code/README.md | 18 +++++++++--------- .../coder/modules/claude-code/scripts/start.sh | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 3d875046..6c25c8cc 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.0" + version = "4.8.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_api_key = "xxxx-xxxxx-xxxx" @@ -60,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.0" + version = "4.8.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_boundary = true @@ -81,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.0" + version = "4.8.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_aibridge = true @@ -110,7 +110,7 @@ data "coder_task" "me" {} module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.0" + version = "4.8.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" ai_prompt = data.coder_task.me.prompt @@ -133,7 +133,7 @@ This example shows additional configuration options for version pinning, custom ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.0" + version = "4.8.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -189,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace. ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.0" + version = "4.8.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" install_claude_code = true @@ -211,7 +211,7 @@ variable "claude_code_oauth_token" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.0" + version = "4.8.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_code_oauth_token = var.claude_code_oauth_token @@ -284,7 +284,7 @@ resource "coder_env" "bedrock_api_key" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.0" + version = "4.8.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" @@ -341,7 +341,7 @@ resource "coder_env" "google_application_credentials" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.0" + version = "4.8.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "claude-sonnet-4@20250514" diff --git a/registry/coder/modules/claude-code/scripts/start.sh b/registry/coder/modules/claude-code/scripts/start.sh index 2df8fce1..5ccbc8fa 100644 --- a/registry/coder/modules/claude-code/scripts/start.sh +++ b/registry/coder/modules/claude-code/scripts/start.sh @@ -88,7 +88,7 @@ TASK_SESSION_ID="cd32e253-ca16-4fd3-9825-d837e74ae3c2" get_project_dir() { local workdir_normalized - workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-') + workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/._' '-') echo "$HOME/.claude/projects/${workdir_normalized}" } From 85c51816f9bc7b8c36cd789bc7250b618235f155 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:54:51 +0500 Subject: [PATCH 21/28] chore(deps): bump the github-actions group with 3 updates (#804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the github-actions group with 3 updates: [dorny/paths-filter](https://github.com/dorny/paths-filter), [coder/coder](https://github.com/coder/coder) and [oven-sh/setup-bun](https://github.com/oven-sh/setup-bun). Updates `dorny/paths-filter` from 3.0.2 to 4.0.1
Release notes

Sourced from dorny/paths-filter's releases.

v4.0.0

What's Changed

New Contributors

Full Changelog: https://github.com/dorny/paths-filter/compare/v3.0.3...v4.0.0

v3.0.3

What's Changed

New Contributors

Full Changelog: https://github.com/dorny/paths-filter/compare/v3...v3.0.3

Changelog

Sourced from dorny/paths-filter's changelog.

Changelog

v4.0.0

v3.0.3

v3.0.2

v3.0.1

v3.0.0

v2.11.1

v2.11.0

v2.10.2

v2.10.1

v2.10.0

v2.9.3

v2.9.2

v2.9.1

v2.9.0

... (truncated)

Commits
  • fbd0ab8 feat: add merge_group event support
  • efb1da7 feat: add dist/ freshness check to PR workflow
  • d8f7b06 Merge pull request #302 from dorny/issue-299
  • addbc14 Update README for v4
  • 9d7afb8 Update CHANGELOG for v4.0.0
  • 782470c Merge branch 'releases/v3'
  • d1c1ffe Update CHANGELOG for v3.0.3
  • ce10459 Merge pull request #294 from saschabratton/master
  • 5f40380 feat: update action runtime to node24
  • 668c092 Merge pull request #279 from wardpeet/patch-1
  • Additional commits viewable in compare view

Updates `coder/coder` from 2.31.3 to 2.31.5
Release notes

Sourced from coder/coder's releases.

v2.31.5

Changelog

[!NOTE] This is a mainline Coder release. We advise enterprise customers without a staging environment to install our latest stable release while we refine this version. Learn more about our Release Schedule.

Bug fixes

  • Prevent emitting build duration metric for devcontainer subagents (#22930, 2cd4e03f1)
  • Prevent ui error when last org member is removed (#23019, 581e956b4)
  • Networking: Retry after transport dial timeouts (#22977, 1a774ab7c)

Compare: v2.31.4...v2.31.5

Container image

  • docker pull ghcr.io/coder/coder:2.31.5

Install/upgrade

Refer to our docs to install or upgrade Coder, or use a release asset below.

v2.31.4

Changelog

[!NOTE] This is a mainline Coder release. We advise enterprise customers without a staging environment to install our latest stable release while we refine this version. Learn more about our Release Schedule.

Features

  • Add Prometheus collector for DERP server expvar metrics (#22583, a3792153d)

Bug fixes

  • Filter sub-agents from build duration metric (#22732, 757634c72)
  • Bump aibridge to v1.0.9 to forward Anthropic-Beta header (#22842, 61b513e58)

Compare: v2.31.3...v2.31.4

Container image

  • docker pull ghcr.io/coder/coder:2.31.4

Install/upgrade

Refer to our docs to install or upgrade Coder, or use a release asset below.

Commits
  • 1a774ab fix(tailnet): retry after transport dial timeouts (#22977) (cherry-pick/v2.31...
  • 581e956 fix: prevent ui error when last org member is removed (#23019)
  • 2cd4e03 fix: prevent emitting build duration metric for devcontainer subagents (#22930)
  • 61b513e fix: bump aibridge to v1.0.9 to forward Anthropic-Beta header (#22842)
  • 757634c fix: filter sub-agents from build duration metric (#22732) (#22919)
  • a379215 feat: add Prometheus collector for DERP server expvar metrics (#22583) (#22917)
  • See full diff in compare view

Updates `oven-sh/setup-bun` from 2.1.3 to 2.2.0
Release notes

Sourced from oven-sh/setup-bun's releases.

v2.2.0

oven-sh/setup-bun is the github action for setting up Bun.

What's Changed

New Contributors

Full Changelog: https://github.com/oven-sh/setup-bun/compare/v2...v2.2.0

Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 10 +++++----- .github/workflows/version-bump.yaml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6a8c79d2..cf1e01a2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,7 +14,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Detect changed files - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: filter with: list-files: shell @@ -37,9 +37,9 @@ jobs: all: - '**' - name: Set up Terraform - uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3 + uses: coder/coder/.github/actions/setup-tf@1a774ab7ce99063a2e01beb94de3fcbccaf84dbe # v2.31.5 - name: Set up Bun - uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: # We're using the latest version of Bun for now, but it might be worth # reconsidering. They've pushed breaking changes in patch releases @@ -82,12 +82,12 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Bun - uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest # Need Terraform for its formatter - name: Install Terraform - uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3 + uses: coder/coder/.github/actions/setup-tf@1a774ab7ce99063a2e01beb94de3fcbccaf84dbe # v2.31.5 - name: Install dependencies run: bun install - name: Validate formatting diff --git a/.github/workflows/version-bump.yaml b/.github/workflows/version-bump.yaml index c5dbc1b8..8a8d4d40 100644 --- a/.github/workflows/version-bump.yaml +++ b/.github/workflows/version-bump.yaml @@ -26,12 +26,12 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Bun - uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest - name: Set up Terraform - uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3 + uses: coder/coder/.github/actions/setup-tf@1a774ab7ce99063a2e01beb94de3fcbccaf84dbe # v2.31.5 - name: Install dependencies run: bun install From 69407746285d9985880a73a1fb04ca23def586c6 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 17 Mar 2026 10:07:35 +0100 Subject: [PATCH 22/28] feat: add the portabledesktop module (#805) ## Description Add a module to install https://github.com/coder/portabledesktop in a workspace. This will be required for the virtual desktop feature in Coder Agents. ## Type of Change - [x] New module - [ ] New template - [ ] Bug fix - [ ] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder/modules/portabledesktop` **New version:** `v1.0.0` **Breaking change:** [ ] Yes [x] No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally ## Related Issues None --- .../coder/modules/portabledesktop/README.md | 46 ++++ .../modules/portabledesktop/main.test.ts | 242 ++++++++++++++++++ .../coder/modules/portabledesktop/main.tf | 65 +++++ .../portabledesktop.tftest.hcl | 36 +++ registry/coder/modules/portabledesktop/run.sh | 132 ++++++++++ 5 files changed, 521 insertions(+) create mode 100644 registry/coder/modules/portabledesktop/README.md create mode 100644 registry/coder/modules/portabledesktop/main.test.ts create mode 100644 registry/coder/modules/portabledesktop/main.tf create mode 100644 registry/coder/modules/portabledesktop/portabledesktop.tftest.hcl create mode 100644 registry/coder/modules/portabledesktop/run.sh diff --git a/registry/coder/modules/portabledesktop/README.md b/registry/coder/modules/portabledesktop/README.md new file mode 100644 index 00000000..a5afac77 --- /dev/null +++ b/registry/coder/modules/portabledesktop/README.md @@ -0,0 +1,46 @@ +--- +display_name: Portable Desktop +description: Install the portabledesktop binary for lightweight Linux desktop sessions. +icon: ../../../../.icons/desktop.svg +verified: true +tags: [desktop, vnc, ai] +--- + +# Portable Desktop + +Install [portabledesktop](https://github.com/coder/portabledesktop) for lightweight Linux desktop sessions over VNC. The binary is stored in the agent's script data directory and is automatically available on PATH via `CODER_SCRIPT_BIN_DIR`. + +```tf +module "portabledesktop" { + source = "registry.coder.com/coder/portabledesktop/coder" + version = "0.1.0" + agent_id = coder_agent.example.id +} +``` + +## Examples + +### Custom download URL with checksum verification + +```tf +module "portabledesktop" { + source = "registry.coder.com/coder/portabledesktop/coder" + version = "0.1.0" + agent_id = coder_agent.example.id + url = "https://example.com/portabledesktop-linux-x64" + sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +} +``` + +### Additionally copy to a system path + +Use `install_dir` to copy the binary to a system-wide directory in addition to the default script data directory: + +```tf +module "portabledesktop" { + source = "registry.coder.com/coder/portabledesktop/coder" + version = "0.1.0" + agent_id = coder_agent.example.id + install_dir = "/usr/local/bin" +} +``` diff --git a/registry/coder/modules/portabledesktop/main.test.ts b/registry/coder/modules/portabledesktop/main.test.ts new file mode 100644 index 00000000..dd73f4e7 --- /dev/null +++ b/registry/coder/modules/portabledesktop/main.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from "bun:test"; +import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, + type TerraformState, +} from "~test"; + +interface TestFixture { + state: TerraformState; + server: ReturnType; + [Symbol.asyncDispose](): Promise; +} + +interface ContainerHandle { + id: string; + [Symbol.asyncDispose](): Promise; +} + +async function setupContainer(image: string): Promise { + const id = await runContainer(image); + return { + id, + [Symbol.asyncDispose]: async () => { + await removeContainer(id); + }, + }; +} + +const ENV_PREFIX = + 'export CODER_SCRIPT_DATA_DIR=/tmp/coder-script-data && export CODER_SCRIPT_BIN_DIR=/tmp/coder-script-data/bin && mkdir -p "$CODER_SCRIPT_DATA_DIR" "$CODER_SCRIPT_BIN_DIR" && '; + +async function setupFakeBinaryServer( + dir: string, + extraVars?: Record, +): Promise { + const fakeBinary = "#!/bin/sh\necho portabledesktop"; + const server = Bun.serve({ + port: 0, + fetch() { + return new Response(fakeBinary); + }, + }); + + const state = await runTerraformApply(dir, { + agent_id: "foo", + url: `http://localhost:${server.port}/portabledesktop`, + ...extraVars, + }); + + return { + state, + server, + [Symbol.asyncDispose]: async () => { + server.stop(true); + }, + }; +} + +describe("portabledesktop", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("installs portabledesktop successfully", async () => { + await using fixture = await setupFakeBinaryServer(import.meta.dir); + await using container = await setupContainer("alpine/curl"); + + const script = findResourceInstance(fixture.state, "coder_script").script; + const resp = await execContainer(container.id, [ + "sh", + "-c", + ENV_PREFIX + script, + ]); + + expect(resp.exitCode).toBe(0); + expect(resp.stdout).toContain("portabledesktop installed successfully"); + + // Check binary exists at CODER_SCRIPT_DATA_DIR. + const checkBinary = await execContainer(container.id, [ + "test", + "-x", + "/tmp/coder-script-data/portabledesktop", + ]); + expect(checkBinary.exitCode).toBe(0); + + // Check symlink exists at CODER_SCRIPT_BIN_DIR. + const checkSymlink = await execContainer(container.id, [ + "test", + "-L", + "/tmp/coder-script-data/bin/portabledesktop", + ]); + expect(checkSymlink.exitCode).toBe(0); + }, 30000); + + it("verifies checksum when sha256 is provided", async () => { + const fakeBinary = "#!/bin/sh\necho portabledesktop"; + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(fakeBinary); + const sha256 = hasher.digest("hex"); + + await using fixture = await setupFakeBinaryServer(import.meta.dir, { + sha256, + }); + await using container = await setupContainer("alpine/curl"); + + const script = findResourceInstance(fixture.state, "coder_script").script; + const resp = await execContainer(container.id, [ + "sh", + "-c", + ENV_PREFIX + script, + ]); + + expect(resp.exitCode).toBe(0); + expect(resp.stdout).toContain("Checksum verified successfully"); + expect(resp.stdout).toContain("portabledesktop installed successfully"); + }, 30000); + + it("fails when sha256 does not match", async () => { + const wrongSha256 = + "0000000000000000000000000000000000000000000000000000000000000000"; + + await using fixture = await setupFakeBinaryServer(import.meta.dir, { + sha256: wrongSha256, + }); + await using container = await setupContainer("alpine/curl"); + + const script = findResourceInstance(fixture.state, "coder_script").script; + const resp = await execContainer(container.id, [ + "sh", + "-c", + ENV_PREFIX + script, + ]); + + expect(resp.exitCode).toBe(1); + expect(resp.stdout).toContain("Checksum mismatch"); + }, 30000); + + it("skips checksum verification when sha256 is not set", async () => { + await using fixture = await setupFakeBinaryServer(import.meta.dir); + await using container = await setupContainer("alpine/curl"); + + const script = findResourceInstance(fixture.state, "coder_script").script; + const resp = await execContainer(container.id, [ + "sh", + "-c", + ENV_PREFIX + script, + ]); + + expect(resp.exitCode).toBe(0); + expect(resp.stdout).not.toContain("Checksum verified"); + expect(resp.stdout).toContain("portabledesktop installed successfully"); + }, 30000); + + it("falls back to sudo when install_dir is not writable", async () => { + await using fixture = await setupFakeBinaryServer(import.meta.dir, { + install_dir: "/usr/local/bin", + }); + await using container = await setupContainer("alpine/curl"); + + await execContainer(container.id, [ + "sh", + "-c", + "apk add sudo && " + + "adduser -D testuser && " + + "echo 'testuser ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers && " + + "mkdir -p /usr/local/bin", + ]); + + const script = findResourceInstance(fixture.state, "coder_script").script; + const resp = await execContainer( + container.id, + ["sh", "-c", ENV_PREFIX + script], + ["--user", "testuser"], + ); + + expect(resp.exitCode).toBe(0); + expect(resp.stdout).toContain("via sudo"); + expect(resp.stdout).toContain("portabledesktop installed successfully"); + + // Verify the binary was copied to the install_dir. + const check = await execContainer(container.id, [ + "test", + "-x", + "/usr/local/bin/portabledesktop", + ]); + expect(check.exitCode).toBe(0); + }, 30000); + + it("creates install_dir if it does not exist", async () => { + await using fixture = await setupFakeBinaryServer(import.meta.dir, { + install_dir: "/opt/custom/bin", + }); + await using container = await setupContainer("alpine/curl"); + + const script = findResourceInstance(fixture.state, "coder_script").script; + const resp = await execContainer(container.id, [ + "sh", + "-c", + ENV_PREFIX + script, + ]); + + expect(resp.exitCode).toBe(0); + expect(resp.stdout).toContain("portabledesktop installed successfully"); + + const check = await execContainer(container.id, [ + "test", + "-x", + "/opt/custom/bin/portabledesktop", + ]); + expect(check.exitCode).toBe(0); + }, 30000); + + it("falls back to wget when curl is not available", async () => { + await using fixture = await setupFakeBinaryServer(import.meta.dir); + await using container = await setupContainer("alpine"); + + // Install wget but ensure curl is not present. + await execContainer(container.id, [ + "sh", + "-c", + "apk add wget && ! command -v curl", + ]); + + const script = findResourceInstance(fixture.state, "coder_script").script; + const resp = await execContainer(container.id, [ + "sh", + "-c", + ENV_PREFIX + script, + ]); + + expect(resp.exitCode).toBe(0); + expect(resp.stdout).toContain("via wget"); + expect(resp.stdout).toContain("portabledesktop installed successfully"); + }, 30000); +}); diff --git a/registry/coder/modules/portabledesktop/main.tf b/registry/coder/modules/portabledesktop/main.tf new file mode 100644 index 00000000..68303c17 --- /dev/null +++ b/registry/coder/modules/portabledesktop/main.tf @@ -0,0 +1,65 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "install_dir" { + type = string + description = "Optional directory to copy the binary into (e.g. /usr/local/bin). The binary is always stored in the agent's script data directory and available on PATH via CODER_SCRIPT_BIN_DIR." + default = null +} + +variable "url" { + type = string + description = "Custom download URL. Overrides the default GitHub latest release URL when set." + default = null +} + +variable "sha256" { + type = string + description = "SHA256 checksum. When set, the downloaded binary is verified against it." + default = null +} + +locals { + default_amd64_url = "https://github.com/coder/portabledesktop/releases/latest/download/portabledesktop-linux-x64" + default_arm64_url = "https://github.com/coder/portabledesktop/releases/latest/download/portabledesktop-linux-arm64" + + using_custom_url = var.url != null + + amd64_url = local.using_custom_url ? var.url : local.default_amd64_url + arm64_url = local.using_custom_url ? var.url : local.default_arm64_url + + # Empty string signals "skip verification" to the shell script. + sha256 = var.sha256 != null ? var.sha256 : "" + install_dir = var.install_dir != null ? var.install_dir : "" +} + +resource "coder_script" "portabledesktop" { + agent_id = var.agent_id + display_name = "Portable Desktop" + icon = "/icon/desktop.svg" + script = <<-EOT + #!/bin/sh + set -eu + echo -n '${base64encode(file("${path.module}/run.sh"))}' | base64 -d > /tmp/portabledesktop-install.sh + chmod +x /tmp/portabledesktop-install.sh + ARG_AMD64_URL="$(echo -n '${base64encode(local.amd64_url)}' | base64 -d)" \ + ARG_ARM64_URL="$(echo -n '${base64encode(local.arm64_url)}' | base64 -d)" \ + ARG_SHA256="$(echo -n '${base64encode(local.sha256)}' | base64 -d)" \ + ARG_INSTALL_DIR="$(echo -n '${base64encode(local.install_dir)}' | base64 -d)" \ + /tmp/portabledesktop-install.sh + EOT + run_on_start = true +} diff --git a/registry/coder/modules/portabledesktop/portabledesktop.tftest.hcl b/registry/coder/modules/portabledesktop/portabledesktop.tftest.hcl new file mode 100644 index 00000000..20a23170 --- /dev/null +++ b/registry/coder/modules/portabledesktop/portabledesktop.tftest.hcl @@ -0,0 +1,36 @@ +run "plan_with_required_vars" { + command = plan + + variables { + agent_id = "example-agent-id" + } +} + +run "plan_with_custom_install_dir" { + command = plan + + variables { + agent_id = "example-agent-id" + install_dir = "/opt/bin" + } + + assert { + condition = resource.coder_script.portabledesktop.display_name == "Portable Desktop" + error_message = "Expected coder_script resource to have correct display name" + } +} + +run "plan_with_custom_url" { + command = plan + + variables { + agent_id = "example-agent-id" + url = "https://example.com/custom-portabledesktop" + sha256 = "abc123" + } + + assert { + condition = resource.coder_script.portabledesktop.run_on_start == true + error_message = "Expected coder_script to run on start" + } +} diff --git a/registry/coder/modules/portabledesktop/run.sh b/registry/coder/modules/portabledesktop/run.sh new file mode 100644 index 00000000..f7e62eff --- /dev/null +++ b/registry/coder/modules/portabledesktop/run.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2292 +# SC2292: We use [ ] instead of [[ ]] for POSIX sh compatibility. +set -eu + +error() { + printf "ERROR: %s\n" "$@" + exit 1 +} + +# Check if portabledesktop is already in PATH. +if command -v portabledesktop > /dev/null 2>&1; then + printf "portabledesktop is already installed and in PATH.\n" + exit 0 +fi + +# Determine the storage path. +STORAGE_DIR="${CODER_SCRIPT_DATA_DIR}" +BINARY_PATH="${STORAGE_DIR}/portabledesktop" +mkdir -p "${STORAGE_DIR}" + +# If the binary already exists and is executable, skip download. +if [ -x "${BINARY_PATH}" ]; then + printf "portabledesktop is already installed at %s, skipping download.\n" "${BINARY_PATH}" +else + # Detect architecture and select the appropriate download URL. + ARCH=$(uname -m) + case "${ARCH}" in + x86_64) + URL="${ARG_AMD64_URL}" + ;; + aarch64) + URL="${ARG_ARM64_URL}" + ;; + *) + error "Unsupported architecture: ${ARCH}" + ;; + esac + + # Select download tool. + if command -v curl > /dev/null 2>&1; then + DOWNLOAD_CMD="curl" + elif command -v wget > /dev/null 2>&1; then + DOWNLOAD_CMD="wget" + else + error "No download tool available (curl or wget required)." + fi + + # Download with retry loop (3 attempts, 1s sleep between). + TMPFILE=$(mktemp) + MAX_ATTEMPTS=3 + DOWNLOAD_SUCCESS=false + ATTEMPT=1 + + while [ "${ATTEMPT}" -le "${MAX_ATTEMPTS}" ]; do + printf "Downloading portabledesktop (attempt %s/%s) via %s...\n" "${ATTEMPT}" "${MAX_ATTEMPTS}" "${DOWNLOAD_CMD}" + + DOWNLOAD_OK=false + if [ "${DOWNLOAD_CMD}" = "curl" ]; then + curl -fsSL "${URL}" -o "${TMPFILE}" && DOWNLOAD_OK=true + else + wget -qO "${TMPFILE}" "${URL}" && DOWNLOAD_OK=true + fi + + if [ "${DOWNLOAD_OK}" = "true" ]; then + # Verify checksum when ARG_SHA256 is non-empty. + if [ -n "${ARG_SHA256}" ]; then + CHECKSUM_MATCH=false + if command -v sha256sum > /dev/null 2>&1; then + echo "${ARG_SHA256} ${TMPFILE}" | sha256sum -c - > /dev/null 2>&1 && CHECKSUM_MATCH=true + elif command -v shasum > /dev/null 2>&1; then + echo "${ARG_SHA256} ${TMPFILE}" | shasum -a 256 -c - > /dev/null 2>&1 && CHECKSUM_MATCH=true + else + rm -f "${TMPFILE}" + error "No SHA256 tool available (sha256sum or shasum required)." + fi + + if [ "${CHECKSUM_MATCH}" != "true" ]; then + printf "WARNING: Checksum mismatch (attempt %s/%s): expected %s\n" \ + "${ATTEMPT}" "${MAX_ATTEMPTS}" "${ARG_SHA256}" + rm -f "${TMPFILE}" + if [ "${ATTEMPT}" -lt "${MAX_ATTEMPTS}" ]; then + sleep 1 + fi + ATTEMPT=$((ATTEMPT + 1)) + continue + fi + printf "Checksum verified successfully.\n" + fi + + DOWNLOAD_SUCCESS=true + break + else + printf "WARNING: Download failed (attempt %s/%s).\n" "${ATTEMPT}" "${MAX_ATTEMPTS}" + if [ "${ATTEMPT}" -lt "${MAX_ATTEMPTS}" ]; then + sleep 1 + fi + fi + + ATTEMPT=$((ATTEMPT + 1)) + done + + if [ "${DOWNLOAD_SUCCESS}" != "true" ]; then + rm -f "${TMPFILE}" + error "Failed to download portabledesktop after ${MAX_ATTEMPTS} attempts." + fi + + # Make the binary executable and move to storage path. + chmod 755 "${TMPFILE}" + mv "${TMPFILE}" "${BINARY_PATH}" +fi + +# Symlink into CODER_SCRIPT_BIN_DIR for PATH access. +if [ -n "${CODER_SCRIPT_BIN_DIR}" ] && [ ! -e "${CODER_SCRIPT_BIN_DIR}/portabledesktop" ]; then + ln -s "${CODER_SCRIPT_DATA_DIR}/portabledesktop" "${CODER_SCRIPT_BIN_DIR}/portabledesktop" +fi + +# If ARG_INSTALL_DIR is set, copy the binary there with sudo fallback. +if [ -n "${ARG_INSTALL_DIR}" ]; then + if [ ! -d "${ARG_INSTALL_DIR}" ]; then + mkdir -p "${ARG_INSTALL_DIR}" 2> /dev/null || sudo mkdir -p "${ARG_INSTALL_DIR}" 2> /dev/null || true + fi + if cp "${CODER_SCRIPT_DATA_DIR}/portabledesktop" "${ARG_INSTALL_DIR}/portabledesktop" 2> /dev/null; then + printf "Copied portabledesktop to %s.\n" "${ARG_INSTALL_DIR}/portabledesktop" + elif sudo cp "${CODER_SCRIPT_DATA_DIR}/portabledesktop" "${ARG_INSTALL_DIR}/portabledesktop" 2> /dev/null; then + printf "Copied portabledesktop to %s (via sudo).\n" "${ARG_INSTALL_DIR}/portabledesktop" + else + error "Failed to copy portabledesktop to ${ARG_INSTALL_DIR}/portabledesktop." + fi +fi + +printf "portabledesktop installed successfully.\n" From ce50e52fc5e07576c0457cc646d8f4b3d1ee98ec Mon Sep 17 00:00:00 2001 From: 35C4n0r <70096901+35C4n0r@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:39:59 +0530 Subject: [PATCH 23/28] feat(coder-labs/modules/codex): update default configuration to use model providers instead of profiles (#806) ## Description - update default configuration to use model providers instead of profiles ## Type of Change - [ ] New module - [ ] New template - [ ] Bug fix - [x] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder-labs/modules/codex` **New version:** `v4.3.1` **Breaking change:** [ ] Yes [x] No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally ## Related Issues --- registry/coder-labs/modules/codex/README.md | 21 +++++++------------ .../coder-labs/modules/codex/main.test.ts | 5 +---- registry/coder-labs/modules/codex/main.tf | 15 ++++++------- .../modules/codex/scripts/install.sh | 14 ++++++++++--- .../coder-labs/modules/codex/scripts/start.sh | 2 +- 5 files changed, 27 insertions(+), 30 deletions(-) diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md index 5e8ac272..16786d02 100644 --- a/registry/coder-labs/modules/codex/README.md +++ b/registry/coder-labs/modules/codex/README.md @@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.3.0" + version = "4.3.1" agent_id = coder_agent.example.id openai_api_key = var.openai_api_key workdir = "/home/coder/project" @@ -32,7 +32,7 @@ module "codex" { module "codex" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder-labs/codex/coder" - version = "4.3.0" + version = "4.3.1" agent_id = coder_agent.example.id openai_api_key = "..." workdir = "/home/coder/project" @@ -51,7 +51,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.3.0" + version = "4.3.1" agent_id = coder_agent.example.id workdir = "/home/coder/project" enable_aibridge = true @@ -60,21 +60,16 @@ module "codex" { When `enable_aibridge = true`, the module: -- Configures Codex to use the AI Bridge profile with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token +- Configures Codex to use the aibridge model_provider with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token ```toml -profile = "aibridge" # sets the default profile to aibridge +model_provider = "aibridge" [model_providers.aibridge] name = "AI Bridge" base_url = "https://example.coder.com/api/v2/aibridge/openai/v1" env_key = "CODER_AIBRIDGE_SESSION_TOKEN" wire_api = "responses" - -[profiles.aibridge] -model_provider = "aibridge" -model = "" # as configured in the module input -model_reasoning_effort = "" # as configured in the module input ``` This allows Codex to route API requests through Coder's AI Bridge instead of directly to OpenAI's API. @@ -94,7 +89,7 @@ data "coder_task" "me" {} module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.3.0" + version = "4.3.1" agent_id = coder_agent.example.id openai_api_key = "..." ai_prompt = data.coder_task.me.prompt @@ -114,7 +109,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.3.0" + version = "4.3.1" agent_id = coder_agent.main.id openai_api_key = var.openai_api_key workdir = "/home/coder/project" @@ -132,7 +127,7 @@ This example shows additional configuration options for custom models, MCP serve ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.3.0" + version = "4.3.1" agent_id = coder_agent.example.id openai_api_key = "..." workdir = "/home/coder/project" diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index 3eff3eca..13055867 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -468,10 +468,7 @@ describe("codex", async () => { id, "/home/coder/.codex/config.toml", ); - expect(configToml).toContain( - "[profiles.aibridge]\n" + 'model_provider = "aibridge"', - ); - expect(configToml).toContain('profile = "aibridge"'); + expect(configToml).toContain('model_provider = "aibridge"'); }); test("boundary-enabled", async () => { diff --git a/registry/coder-labs/modules/codex/main.tf b/registry/coder-labs/modules/codex/main.tf index 4d9c5ae1..b5f71cb3 100644 --- a/registry/coder-labs/modules/codex/main.tf +++ b/registry/coder-labs/modules/codex/main.tf @@ -84,10 +84,10 @@ variable "enable_aibridge" { variable "model_reasoning_effort" { type = string - description = "The reasoning effort for the AI Bridge model. One of: none, low, medium, high. https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort" - default = "medium" + description = "The reasoning effort for the model. One of: none, low, medium, high. https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort" + default = "" validation { - condition = contains(["none", "low", "medium", "high"], var.model_reasoning_effort) + condition = contains(["", "none", "minimal", "low", "medium", "high", "xhigh"], var.model_reasoning_effort) error_message = "model_reasoning_effort must be one of: none, low, medium, high." } } @@ -137,7 +137,7 @@ variable "agentapi_version" { variable "codex_model" { type = string description = "The model for Codex to use. Defaults to gpt-5.3-codex." - default = "gpt-5.3-codex" + default = "gpt-5.4" } variable "pre_install_script" { @@ -225,7 +225,7 @@ locals { install_script = file("${path.module}/scripts/install.sh") start_script = file("${path.module}/scripts/start.sh") module_dir_name = ".codex-module" - latest_codex_model = "gpt-5.3-codex" + latest_codex_model = "gpt-5.4" aibridge_config = <<-EOF [model_providers.aibridge] name = "AI Bridge" @@ -233,10 +233,6 @@ locals { env_key = "CODER_AIBRIDGE_SESSION_TOKEN" wire_api = "responses" - [profiles.aibridge] - model_provider = "aibridge" - model = "${var.codex_model}" - model_reasoning_effort = "${var.model_reasoning_effort}" EOF } @@ -302,6 +298,7 @@ module "agentapi" { ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \ ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \ ARG_CODEX_START_DIRECTORY='${local.workdir}' \ + ARG_MODEL_REASONING_EFFORT='${var.model_reasoning_effort}' \ ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \ /tmp/install.sh EOT diff --git a/registry/coder-labs/modules/codex/scripts/install.sh b/registry/coder-labs/modules/codex/scripts/install.sh index 4742c413..9a191a02 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh +++ b/registry/coder-labs/modules/codex/scripts/install.sh @@ -93,10 +93,14 @@ function install_codex() { write_minimal_default_config() { local config_path="$1" - ARG_DEFAULT_PROFILE="" + ARG_OPTIONAL_TOP_LEVEL_CONFIG="" if [[ "${ARG_ENABLE_AIBRIDGE}" = "true" ]]; then - ARG_DEFAULT_PROFILE='profile = "aibridge"' + ARG_OPTIONAL_TOP_LEVEL_CONFIG='model_provider = "aibridge"' + fi + + if [[ "${ARG_MODEL_REASONING_EFFORT}" != "" ]]; then + ARG_OPTIONAL_TOP_LEVEL_CONFIG+=$'\n'"model_reasoning_effort = \"${ARG_MODEL_REASONING_EFFORT}\"" fi cat << EOF > "$config_path" @@ -104,13 +108,17 @@ write_minimal_default_config() { sandbox_mode = "workspace-write" approval_policy = "never" preferred_auth_method = "apikey" -${ARG_DEFAULT_PROFILE} +${ARG_OPTIONAL_TOP_LEVEL_CONFIG} [sandbox_workspace_write] network_access = true [notice.model_migrations] "${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}" + +[projects."${ARG_CODEX_START_DIRECTORY}"] +trust_level = "trusted" + EOF } diff --git a/registry/coder-labs/modules/codex/scripts/start.sh b/registry/coder-labs/modules/codex/scripts/start.sh index 0dbf5a60..bac0cb45 100644 --- a/registry/coder-labs/modules/codex/scripts/start.sh +++ b/registry/coder-labs/modules/codex/scripts/start.sh @@ -155,7 +155,7 @@ setup_workdir() { build_codex_args() { CODEX_ARGS=() - if [[ -n "${ARG_CODEX_MODEL}" ]] && [[ "${ARG_ENABLE_AIBRIDGE}" != "true" ]]; then + if [[ -n "${ARG_CODEX_MODEL}" ]]; then CODEX_ARGS+=("--model" "${ARG_CODEX_MODEL}") fi From da8e296b1cd425196a8063d2726d5ab2d5b14f11 Mon Sep 17 00:00:00 2001 From: Koury Lape Date: Fri, 20 Mar 2026 11:42:34 -0400 Subject: [PATCH 24/28] Fix/dotfiles fish compatibility (#682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The dotfiles module does not work when using non-POSIX shells i.e. Fish. ## Type of Change - [ ] New module - [ ] New template - [x] Bug fix - [x] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder/modules/dotfiles` **New version:** `v1.4.1` **Breaking change:** [ ] Yes [ ] No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally ``` bun test v1.3.8 (b64edcb4) registry/coder/modules/dotfiles/main.test.ts: ✓ dotfiles > required variables [190.40ms] ✓ dotfiles > missing variable: agent_id [43.12ms] ✓ dotfiles > default output [150.15ms] ✓ dotfiles > set a default dotfiles_uri [159.14ms] ✓ dotfiles > command uses bash for fish shell compatibility [164.08ms] ✓ dotfiles > set custom order for coder_parameter [166.50ms] 6 pass 0 fail 7 expect() calls Ran 6 tests across 1 file. [1184.00ms] ``` I tested this with a new workspace on Coder v2.27.3 with fish, zsh, and bash. --------- Co-authored-by: DevCats Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> --- registry/coder/modules/dotfiles/README.md | 12 ++++++------ registry/coder/modules/dotfiles/main.test.ts | 15 +++++++++++++++ registry/coder/modules/dotfiles/main.tf | 4 ++-- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/registry/coder/modules/dotfiles/README.md b/registry/coder/modules/dotfiles/README.md index aae52284..2cab271b 100644 --- a/registry/coder/modules/dotfiles/README.md +++ b/registry/coder/modules/dotfiles/README.md @@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/ module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.4.0" + version = "1.4.1" agent_id = coder_agent.example.id } ``` @@ -31,7 +31,7 @@ module "dotfiles" { module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.4.0" + version = "1.4.1" agent_id = coder_agent.example.id } ``` @@ -42,7 +42,7 @@ module "dotfiles" { module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.4.0" + version = "1.4.1" agent_id = coder_agent.example.id user = "root" } @@ -54,14 +54,14 @@ module "dotfiles" { module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.4.0" + version = "1.4.1" agent_id = coder_agent.example.id } module "dotfiles-root" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.4.0" + version = "1.4.1" agent_id = coder_agent.example.id user = "root" dotfiles_uri = module.dotfiles.dotfiles_uri @@ -90,7 +90,7 @@ You can set a default dotfiles repository for all users by setting the `default_ module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.4.0" + version = "1.4.1" agent_id = coder_agent.example.id default_dotfiles_uri = "https://github.com/coder/dotfiles" } diff --git a/registry/coder/modules/dotfiles/main.test.ts b/registry/coder/modules/dotfiles/main.test.ts index a9a8bf93..67e0f4a9 100644 --- a/registry/coder/modules/dotfiles/main.test.ts +++ b/registry/coder/modules/dotfiles/main.test.ts @@ -56,6 +56,21 @@ describe("dotfiles", async () => { } }); + it("command uses bash for fish shell compatibility", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + manual_update: "true", + dotfiles_uri: "https://github.com/test/dotfiles", + }); + + const app = state.resources.find( + (r) => r.type === "coder_app" && r.name === "dotfiles", + ); + + expect(app).toBeDefined(); + expect(app?.instances[0]?.attributes?.command).toContain("/bin/bash -c"); + }); + it("set custom order for coder_parameter", async () => { const order = 99; const state = await runTerraformApply(import.meta.dir, { diff --git a/registry/coder/modules/dotfiles/main.tf b/registry/coder/modules/dotfiles/main.tf index ca1709d0..226a9ab5 100644 --- a/registry/coder/modules/dotfiles/main.tf +++ b/registry/coder/modules/dotfiles/main.tf @@ -164,12 +164,12 @@ resource "coder_app" "dotfiles" { icon = "/icon/dotfiles.svg" order = var.order group = var.group - command = templatefile("${path.module}/run.sh", { + command = "/bin/bash -c \"$(echo ${base64encode(templatefile("${path.module}/run.sh", { DOTFILES_URI : local.dotfiles_uri, DOTFILES_USER : local.user, DOTFILES_BRANCH : local.dotfiles_branch, POST_CLONE_SCRIPT : local.encoded_post_clone_script - }) + }))} | base64 -d)\"" } output "dotfiles_uri" { From 516b9ce4aefbd18228f2028fa741c2a8047a12a1 Mon Sep 17 00:00:00 2001 From: 35C4n0r <70096901+35C4n0r@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:48:43 +0530 Subject: [PATCH 25/28] fix(coder/modules/claude-code): update resource count logic for claude_api_key (#814) ## Description - update resource count logic for claude_api_key ## Type of Change - [ ] New module - [ ] New template - [x] Bug fix - [ ] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder/modules/claude-code` **New version:** `v4.8.2` **Breaking change:** [ ] Yes [ ] No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally ## Related Issues Closes: #812 --- registry/coder/modules/claude-code/README.md | 18 +++++++++--------- registry/coder/modules/claude-code/main.tf | 2 +- .../coder/modules/claude-code/main.tftest.hcl | 16 +++++++++++++++- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 6c25c8cc..5a2eacd6 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.1" + version = "4.8.2" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_api_key = "xxxx-xxxxx-xxxx" @@ -60,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.1" + version = "4.8.2" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_boundary = true @@ -81,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.1" + version = "4.8.2" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_aibridge = true @@ -110,7 +110,7 @@ data "coder_task" "me" {} module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.1" + version = "4.8.2" agent_id = coder_agent.main.id workdir = "/home/coder/project" ai_prompt = data.coder_task.me.prompt @@ -133,7 +133,7 @@ This example shows additional configuration options for version pinning, custom ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.1" + version = "4.8.2" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -189,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace. ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.1" + version = "4.8.2" agent_id = coder_agent.main.id workdir = "/home/coder/project" install_claude_code = true @@ -211,7 +211,7 @@ variable "claude_code_oauth_token" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.1" + version = "4.8.2" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_code_oauth_token = var.claude_code_oauth_token @@ -284,7 +284,7 @@ resource "coder_env" "bedrock_api_key" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.1" + version = "4.8.2" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" @@ -341,7 +341,7 @@ resource "coder_env" "google_application_credentials" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.8.1" + version = "4.8.2" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "claude-sonnet-4@20250514" diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 337ebd20..ba45d69c 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -287,7 +287,7 @@ resource "coder_env" "claude_code_oauth_token" { } resource "coder_env" "claude_api_key" { - count = local.claude_api_key != "" ? 1 : 0 + count = (var.enable_aibridge || (var.claude_api_key != "")) ? 1 : 0 agent_id = var.agent_id name = "CLAUDE_API_KEY" diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index 3d11989b..66c79bab 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -416,7 +416,6 @@ run "test_disable_state_persistence" { } } - run "test_no_api_key_no_env" { command = plan @@ -431,3 +430,18 @@ run "test_no_api_key_no_env" { error_message = "CLAUDE_API_KEY should not be created when no API key is provided and aibridge is disabled" } } + +run "test_api_key_count_with_aibridge_no_override" { + command = plan + + variables { + agent_id = "test-agent-count" + workdir = "/home/coder/test" + enable_aibridge = true + } + + assert { + condition = length(coder_env.claude_api_key) == 1 + error_message = "CLAUDE_API_KEY env should be created when aibridge is enabled, regardless of session_token value" + } +} \ No newline at end of file From 8c130bcb5a1c7822448ee18964cd51583ca3c4b2 Mon Sep 17 00:00:00 2001 From: Meghea Iulian Date: Fri, 27 Mar 2026 19:55:07 +0200 Subject: [PATCH 26/28] fix(opencode): pass VERSION to bash instead of curl in install pipe (#815) ## Summary - Fix version pinning bug in the OpenCode install script (`registry/coder-labs/modules/opencode/scripts/install.sh`, line 42) **Bug:** The install command was: ```bash VERSION=$ARG_OPENCODE_VERSION curl -fsSL https://opencode.ai/install | bash ``` `VERSION` was set as an environment variable prefix to `curl` (the left side of the pipe), so the `bash` process on the right side of the pipe never received it. In a shell pipeline, each command runs in its own subprocess, so env var prefixes only apply to the immediately following command. This caused the installer script to always install the latest version instead of the pinned version specified by the user. **Fix:** Move `VERSION` to prefix `bash` instead of `curl`: ```bash curl -fsSL https://opencode.ai/install | VERSION=$ARG_OPENCODE_VERSION bash ``` Now the `VERSION` variable is correctly available to the install script executed by `bash`. ## Test plan - [x] Set `opencode_version` to a specific version (e.g., `0.1.0`) and verify that version is installed instead of latest - [x] Set `opencode_version` to `latest` and verify the latest version is still installed (this code path is unchanged) - [x] Verify `opencode --version` output matches the requested version after install --------- Co-authored-by: 35C4n0r <70096901+35C4n0r@users.noreply.github.com> --- registry/coder-labs/modules/opencode/README.md | 6 +++--- registry/coder-labs/modules/opencode/scripts/install.sh | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/registry/coder-labs/modules/opencode/README.md b/registry/coder-labs/modules/opencode/README.md index 711ad522..2eb6baf7 100644 --- a/registry/coder-labs/modules/opencode/README.md +++ b/registry/coder-labs/modules/opencode/README.md @@ -13,7 +13,7 @@ Run [OpenCode](https://opencode.ai) AI coding assistant in your workspace for in ```tf module "opencode" { source = "registry.coder.com/coder-labs/opencode/coder" - version = "0.1.1" + version = "0.1.2" agent_id = coder_agent.main.id workdir = "/home/coder/project" } @@ -34,7 +34,7 @@ resource "coder_ai_task" "task" { module "opencode" { source = "registry.coder.com/coder-labs/opencode/coder" - version = "0.1.1" + version = "0.1.2" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -89,7 +89,7 @@ Run OpenCode as a command-line tool without web interface or task reporting: ```tf module "opencode" { source = "registry.coder.com/coder-labs/opencode/coder" - version = "0.1.1" + version = "0.1.2" agent_id = coder_agent.main.id workdir = "/home/coder" report_tasks = false diff --git a/registry/coder-labs/modules/opencode/scripts/install.sh b/registry/coder-labs/modules/opencode/scripts/install.sh index 6d553108..473e5ac4 100755 --- a/registry/coder-labs/modules/opencode/scripts/install.sh +++ b/registry/coder-labs/modules/opencode/scripts/install.sh @@ -39,7 +39,7 @@ install_opencode() { if [ "$ARG_OPENCODE_VERSION" = "latest" ]; then curl -fsSL https://opencode.ai/install | bash else - VERSION=$ARG_OPENCODE_VERSION curl -fsSL https://opencode.ai/install | bash + curl -fsSL https://opencode.ai/install | VERSION="${ARG_OPENCODE_VERSION}" bash fi export PATH=/home/coder/.opencode/bin:$PATH printf "Opencode location: %s\n" "$(which opencode)" From 962cd16efdf71ddb9c08bad51818a6956dcdf9f5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 07:38:10 +0000 Subject: [PATCH 27/28] chore(deps): bump the github-actions group with 2 updates (#820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the github-actions group with 2 updates: [coder/coder](https://github.com/coder/coder) and [actions/setup-go](https://github.com/actions/setup-go). Updates `coder/coder` from 2.31.5 to 2.31.6
Release notes

Sourced from coder/coder's releases.

v2.31.6

Changelog

[!NOTE] This is a mainline Coder release. We advise enterprise customers without a staging environment to install our latest stable release while we refine this version. Learn more about our Release Schedule.

Bug fixes

  • Open coder_app links in new tab when open_in is tab (#23000, e419eb310)

Chores

  • Switch agent gone response from 502 to 404 (backport #23090) (#23635, f7650296c)

Compare: v2.31.5...v2.31.6

Container image

  • docker pull ghcr.io/coder/coder:2.31.6

Install/upgrade

Refer to our docs to install or upgrade Coder, or use a release asset below.

Commits

Updates `actions/setup-go` from 6.3.0 to 6.4.0
Release notes

Sourced from actions/setup-go's releases.

v6.4.0

What's Changed

Enhancement

Dependency update

Documentation update

New Contributors

Full Changelog: https://github.com/actions/setup-go/compare/v6...v6.4.0

Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 6 +++--- .github/workflows/golangci-lint.yml | 2 +- .github/workflows/version-bump.yaml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cf1e01a2..809cf06b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ jobs: all: - '**' - name: Set up Terraform - uses: coder/coder/.github/actions/setup-tf@1a774ab7ce99063a2e01beb94de3fcbccaf84dbe # v2.31.5 + uses: coder/coder/.github/actions/setup-tf@f7650296ceb9b020c79cd525ac7bd3c7f252ae1d # v2.31.6 - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: @@ -87,7 +87,7 @@ jobs: bun-version: latest # Need Terraform for its formatter - name: Install Terraform - uses: coder/coder/.github/actions/setup-tf@1a774ab7ce99063a2e01beb94de3fcbccaf84dbe # v2.31.5 + uses: coder/coder/.github/actions/setup-tf@f7650296ceb9b020c79cd525ac7bd3c7f252ae1d # v2.31.6 - name: Install dependencies run: bun install - name: Validate formatting @@ -106,7 +106,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: "1.24.0" - name: Validate contributors diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index c922d344..945ed3ec 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: stable - name: golangci-lint diff --git a/.github/workflows/version-bump.yaml b/.github/workflows/version-bump.yaml index 8a8d4d40..77f7e1f6 100644 --- a/.github/workflows/version-bump.yaml +++ b/.github/workflows/version-bump.yaml @@ -31,7 +31,7 @@ jobs: bun-version: latest - name: Set up Terraform - uses: coder/coder/.github/actions/setup-tf@1a774ab7ce99063a2e01beb94de3fcbccaf84dbe # v2.31.5 + uses: coder/coder/.github/actions/setup-tf@f7650296ceb9b020c79cd525ac7bd3c7f252ae1d # v2.31.6 - name: Install dependencies run: bun install From 19f6dc947f678fbfd4c23fdd0d4a292109138678 Mon Sep 17 00:00:00 2001 From: Charlie Voiselle <464492+angrycub@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:08:06 -0400 Subject: [PATCH 28/28] fix: correct description for 'Install multiple extensions' example in `code-server` module documentation (#817) ## Description Update incorrect documentation element for **Install multiple extensions** ## Type of Change - [ ] New module - [ ] New template - [ ] Bug fix - [ ] Feature/enhancement - [x] Documentation - [ ] Other ## Related Issues None --- registry/coder/modules/code-server/README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/registry/coder/modules/code-server/README.md b/registry/coder/modules/code-server/README.md index fdb3f1a7..0589ec3d 100644 --- a/registry/coder/modules/code-server/README.md +++ b/registry/coder/modules/code-server/README.md @@ -14,7 +14,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.3" + version = "1.4.4" agent_id = coder_agent.example.id } ``` @@ -29,7 +29,7 @@ module "code-server" { module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.3" + version = "1.4.4" agent_id = coder_agent.example.id install_version = "4.106.3" } @@ -43,7 +43,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/): module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.3" + version = "1.4.4" agent_id = coder_agent.example.id extensions = [ "dracula-theme.theme-dracula" @@ -61,7 +61,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.3" + version = "1.4.4" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { @@ -72,13 +72,13 @@ module "code-server" { ### Install multiple extensions -Just run code-server in the background, don't fetch it from GitHub: +Install multiple extensions from [OpenVSX](https://open-vsx.org/) by adding them to the `extensions` list: ```tf module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.3" + version = "1.4.4" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] } @@ -92,7 +92,7 @@ You can pass additional command-line arguments to code-server using the `additio module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.3" + version = "1.4.4" agent_id = coder_agent.example.id additional_args = "--disable-workspace-trust" } @@ -108,7 +108,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub: module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.3" + version = "1.4.4" agent_id = coder_agent.example.id use_cached = true extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] @@ -121,7 +121,7 @@ Just run code-server in the background, don't fetch it from GitHub: module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.3" + version = "1.4.4" agent_id = coder_agent.example.id offline = true }