Merge branch 'main' into feat/nodejs-pre-post-install-scripts

This commit is contained in:
DevCats 2026-03-20 08:11:52 -05:00 committed by GitHub
commit 11a64e9b08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 949 additions and 67 deletions

View File

@ -14,7 +14,7 @@ jobs:
- name: Check out code - name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Detect changed files - name: Detect changed files
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter id: filter
with: with:
list-files: shell list-files: shell
@ -37,9 +37,9 @@ jobs:
all: all:
- '**' - '**'
- name: Set up Terraform - 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 - name: Set up Bun
uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with: with:
# We're using the latest version of Bun for now, but it might be worth # We're using the latest version of Bun for now, but it might be worth
# reconsidering. They've pushed breaking changes in patch releases # reconsidering. They've pushed breaking changes in patch releases
@ -82,12 +82,12 @@ jobs:
- name: Check out code - name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Bun - name: Install Bun
uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with: with:
bun-version: latest bun-version: latest
# Need Terraform for its formatter # Need Terraform for its formatter
- name: Install Terraform - 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 - name: Install dependencies
run: bun install run: bun install
- name: Validate formatting - name: Validate formatting

View File

@ -26,12 +26,12 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Bun - name: Set up Bun
uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with: with:
bun-version: latest bun-version: latest
- name: Set up Terraform - 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 - name: Install dependencies
run: bun install run: bun install

View File

@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
```tf ```tf
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.0" version = "4.3.1"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
openai_api_key = var.openai_api_key openai_api_key = var.openai_api_key
workdir = "/home/coder/project" workdir = "/home/coder/project"
@ -32,7 +32,7 @@ module "codex" {
module "codex" { module "codex" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.0" version = "4.3.1"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
openai_api_key = "..." openai_api_key = "..."
workdir = "/home/coder/project" workdir = "/home/coder/project"
@ -51,7 +51,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
```tf ```tf
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.0" version = "4.3.1"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
enable_aibridge = true enable_aibridge = true
@ -60,21 +60,16 @@ module "codex" {
When `enable_aibridge = true`, the module: 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 ```toml
profile = "aibridge" # sets the default profile to aibridge model_provider = "aibridge"
[model_providers.aibridge] [model_providers.aibridge]
name = "AI Bridge" name = "AI Bridge"
base_url = "https://example.coder.com/api/v2/aibridge/openai/v1" base_url = "https://example.coder.com/api/v2/aibridge/openai/v1"
env_key = "CODER_AIBRIDGE_SESSION_TOKEN" env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses" wire_api = "responses"
[profiles.aibridge]
model_provider = "aibridge"
model = "<model>" # as configured in the module input
model_reasoning_effort = "<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. 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" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.0" version = "4.3.1"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
openai_api_key = "..." openai_api_key = "..."
ai_prompt = data.coder_task.me.prompt ai_prompt = data.coder_task.me.prompt
@ -114,7 +109,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc
```tf ```tf
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.0" version = "4.3.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
openai_api_key = var.openai_api_key openai_api_key = var.openai_api_key
workdir = "/home/coder/project" workdir = "/home/coder/project"
@ -132,7 +127,7 @@ This example shows additional configuration options for custom models, MCP serve
```tf ```tf
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.0" version = "4.3.1"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
openai_api_key = "..." openai_api_key = "..."
workdir = "/home/coder/project" workdir = "/home/coder/project"

View File

@ -468,10 +468,7 @@ describe("codex", async () => {
id, id,
"/home/coder/.codex/config.toml", "/home/coder/.codex/config.toml",
); );
expect(configToml).toContain( expect(configToml).toContain('model_provider = "aibridge"');
"[profiles.aibridge]\n" + 'model_provider = "aibridge"',
);
expect(configToml).toContain('profile = "aibridge"');
}); });
test("boundary-enabled", async () => { test("boundary-enabled", async () => {

View File

@ -84,10 +84,10 @@ variable "enable_aibridge" {
variable "model_reasoning_effort" { variable "model_reasoning_effort" {
type = string 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" 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 = "medium" default = ""
validation { 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." error_message = "model_reasoning_effort must be one of: none, low, medium, high."
} }
} }
@ -137,7 +137,7 @@ variable "agentapi_version" {
variable "codex_model" { variable "codex_model" {
type = string type = string
description = "The model for Codex to use. Defaults to gpt-5.3-codex." 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" { variable "pre_install_script" {
@ -225,7 +225,7 @@ locals {
install_script = file("${path.module}/scripts/install.sh") install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh") start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".codex-module" module_dir_name = ".codex-module"
latest_codex_model = "gpt-5.3-codex" latest_codex_model = "gpt-5.4"
aibridge_config = <<-EOF aibridge_config = <<-EOF
[model_providers.aibridge] [model_providers.aibridge]
name = "AI Bridge" name = "AI Bridge"
@ -233,10 +233,6 @@ locals {
env_key = "CODER_AIBRIDGE_SESSION_TOKEN" env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses" wire_api = "responses"
[profiles.aibridge]
model_provider = "aibridge"
model = "${var.codex_model}"
model_reasoning_effort = "${var.model_reasoning_effort}"
EOF EOF
} }
@ -302,6 +298,7 @@ module "agentapi" {
ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \ ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \ ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
ARG_CODEX_START_DIRECTORY='${local.workdir}' \ ARG_CODEX_START_DIRECTORY='${local.workdir}' \
ARG_MODEL_REASONING_EFFORT='${var.model_reasoning_effort}' \
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \ ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
/tmp/install.sh /tmp/install.sh
EOT EOT

View File

@ -93,10 +93,14 @@ function install_codex() {
write_minimal_default_config() { write_minimal_default_config() {
local config_path="$1" local config_path="$1"
ARG_DEFAULT_PROFILE="" ARG_OPTIONAL_TOP_LEVEL_CONFIG=""
if [[ "${ARG_ENABLE_AIBRIDGE}" = "true" ]]; then 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 fi
cat << EOF > "$config_path" cat << EOF > "$config_path"
@ -104,13 +108,17 @@ write_minimal_default_config() {
sandbox_mode = "workspace-write" sandbox_mode = "workspace-write"
approval_policy = "never" approval_policy = "never"
preferred_auth_method = "apikey" preferred_auth_method = "apikey"
${ARG_DEFAULT_PROFILE} ${ARG_OPTIONAL_TOP_LEVEL_CONFIG}
[sandbox_workspace_write] [sandbox_workspace_write]
network_access = true network_access = true
[notice.model_migrations] [notice.model_migrations]
"${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}" "${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}"
[projects."${ARG_CODEX_START_DIRECTORY}"]
trust_level = "trusted"
EOF EOF
} }

View File

@ -155,7 +155,7 @@ setup_workdir() {
build_codex_args() { build_codex_args() {
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}") CODEX_ARGS+=("--model" "${ARG_CODEX_MODEL}")
fi fi

View File

@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0" version = "4.8.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx" claude_api_key = "xxxx-xxxxx-xxxx"
@ -60,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0" version = "4.8.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
enable_boundary = true enable_boundary = true
@ -81,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0" version = "4.8.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
enable_aibridge = true enable_aibridge = true
@ -110,7 +110,7 @@ data "coder_task" "me" {}
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0" version = "4.8.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
ai_prompt = data.coder_task.me.prompt ai_prompt = data.coder_task.me.prompt
@ -133,7 +133,7 @@ This example shows additional configuration options for version pinning, custom
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0" version = "4.8.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
@ -189,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0" version = "4.8.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
install_claude_code = true install_claude_code = true
@ -211,7 +211,7 @@ variable "claude_code_oauth_token" {
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0" version = "4.8.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token claude_code_oauth_token = var.claude_code_oauth_token
@ -284,7 +284,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0" version = "4.8.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@ -341,7 +341,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0" version = "4.8.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514" model = "claude-sonnet-4@20250514"

View File

@ -88,7 +88,7 @@ TASK_SESSION_ID="cd32e253-ca16-4fd3-9825-d837e74ae3c2"
get_project_dir() { get_project_dir() {
local workdir_normalized local workdir_normalized
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-') workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/._' '-')
echo "$HOME/.claude/projects/${workdir_normalized}" echo "$HOME/.claude/projects/${workdir_normalized}"
} }

View File

@ -8,13 +8,13 @@ tags: [ai, agents, development, multiplexer]
# Mux # 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 ```tf
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.4.0" version = "1.4.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
} }
``` ```
@ -37,7 +37,7 @@ module "mux" {
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.4.0" version = "1.4.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
} }
``` ```
@ -48,7 +48,7 @@ module "mux" {
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.4.0" version = "1.4.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
# Default is "latest"; set to a specific version to pin # Default is "latest"; set to a specific version to pin
install_version = "0.4.0" install_version = "0.4.0"
@ -63,7 +63,7 @@ Start Mux with `mux server --add-project /path/to/project`:
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.4.0" version = "1.4.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
add_project = "/path/to/project" add_project = "/path/to/project"
} }
@ -78,19 +78,35 @@ The module parses quoted values, so grouped arguments remain intact.
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.4.0" version = "1.4.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'" 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 ### Custom Port
```tf ```tf
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.4.0" version = "1.4.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
port = 8080 port = 8080
} }
@ -104,7 +120,7 @@ Force a specific package manager instead of auto-detection:
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.4.0" version = "1.4.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
package_manager = "pnpm" # or "npm", "bun" package_manager = "pnpm" # or "npm", "bun"
} }
@ -118,7 +134,7 @@ Use a private or mirrored npm registry:
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.4.0" version = "1.4.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
registry_url = "https://npm.pkg.github.com" 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" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.4.0" version = "1.4.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
use_cached = true use_cached = true
} }
@ -146,7 +162,7 @@ Run without installing from the network (requires Mux to be pre-installed):
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.4.0" version = "1.4.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
install = false 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 - 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 - 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 - 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

View File

@ -145,6 +145,143 @@ chmod +x /tmp/mux/mux`,
} }
}, 60000); }, 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 () => { it("runs with npm present", async () => {
const state = await runTerraformApply(import.meta.dir, { const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo", agent_id: "foo",

View File

@ -49,6 +49,34 @@ variable "log_path" {
default = "/tmp/mux.log" 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" { variable "add_project" {
type = string type = string
description = "Optional path to add/open as a project in Mux on startup." description = "Optional path to add/open as a project in Mux on startup."
@ -171,6 +199,9 @@ resource "coder_script" "mux" {
OFFLINE : !var.install, OFFLINE : !var.install,
USE_CACHED : var.use_cached, USE_CACHED : var.use_cached,
AUTH_TOKEN : local.mux_auth_token, 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, PACKAGE_MANAGER : var.package_manager,
REGISTRY_URL : local.registry_url, REGISTRY_URL : local.registry_url,
}) })

View File

@ -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" { run "custom_version" {
command = plan command = plan

View File

@ -5,17 +5,30 @@ RESET='\033[0m'
MUX_BINARY="${INSTALL_PREFIX}/mux" MUX_BINARY="${INSTALL_PREFIX}/mux"
function run_mux() { function run_mux() {
# Remove stale server lock if present
rm -f "$HOME/.mux/server.lock"
local port_value local port_value
local auth_token_value local auth_token_value
local restart_on_kill_value
local restart_delay_seconds_value
local max_restart_attempts_value
port_value="${PORT}" port_value="${PORT}"
auth_token_value="${AUTH_TOKEN}" 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 if [ -z "$port_value" ]; then
port_value="4000" port_value="4000"
fi 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}")" mkdir -p "$(dirname "${LOG_PATH}")"
# Build args for mux (POSIX-compatible, avoid bash arrays) # Build args for mux (POSIX-compatible, avoid bash arrays)
@ -41,13 +54,24 @@ EOF_ARGS
echo "🚀 Starting mux server on port $port_value..." echo "🚀 Starting mux server on port $port_value..."
echo "Check logs at ${LOG_PATH}!" 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 \ nohup env \
LOG_PATH="${LOG_PATH}" \ LOG_PATH="${LOG_PATH}" \
MUX_BINARY="$MUX_BINARY" \ MUX_BINARY="$MUX_BINARY" \
AUTH_TOKEN="$auth_token_value" \ AUTH_TOKEN="$auth_token_value" \
PORT_VALUE="$port_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' & bash -s -- "$@" > /dev/null 2>&1 << 'EOF_LAUNCHER' &
signal_name() { signal_name() {
local signal_number="$1" local signal_number="$1"
@ -82,6 +106,14 @@ append_kernel_kill_context() {
fi fi
} }
cleanup_mux_lock() {
rm -f "$HOME/.mux/server.lock"
}
should_restart_mux() {
[ "$RESTART_ON_KILL_VALUE" = "true" ]
}
log_mux_exit() { log_mux_exit() {
local mux_pid="$1" local mux_pid="$1"
local exit_code="$2" 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." 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 & log_mux_restart_wait() {
mux_pid=$! local timestamp
wait "$mux_pid"
exit_code=$? timestamp="$(date -Iseconds 2> /dev/null || date)"
log_mux_exit "$mux_pid" "$exit_code" >> "$LOG_PATH" 2>&1 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 EOF_LAUNCHER
} }
# Check if mux is already installed for offline mode # Check if mux is already installed for offline mode

View File

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

View File

@ -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<typeof Bun.serve>;
[Symbol.asyncDispose](): Promise<void>;
}
interface ContainerHandle {
id: string;
[Symbol.asyncDispose](): Promise<void>;
}
async function setupContainer(image: string): Promise<ContainerHandle> {
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<string, string>,
): Promise<TestFixture> {
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);
});

View File

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

View File

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

View File

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