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 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 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}" } 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 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"