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 1/4] 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 2/4] 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 3/4] 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 4/4] 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