mirror of
https://github.com/coder/registry.git
synced 2026-06-03 13:08:14 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c130bcb5a | |||
| 516b9ce4ae | |||
| da8e296b1c | |||
| ce50e52fc5 | |||
| 6940774628 | |||
| 85c51816f9 | |||
| 4fdcf0d712 | |||
| 1460293de4 | |||
| 9606297620 | |||
| a0430e6f83 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.2.0"
|
||||
version = "4.3.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.2.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.2.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 = "<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.
|
||||
@@ -94,7 +89,7 @@ data "coder_task" "me" {}
|
||||
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.2.0"
|
||||
version = "4.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
ai_prompt = data.coder_task.me.prompt
|
||||
@@ -105,6 +100,26 @@ module "codex" {
|
||||
}
|
||||
```
|
||||
|
||||
### Usage with Agent Boundaries
|
||||
|
||||
This example shows how to configure the Codex module to run the agent behind a process-level boundary that restricts its network access.
|
||||
|
||||
By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation.
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.3.1"
|
||||
agent_id = coder_agent.main.id
|
||||
openai_api_key = var.openai_api_key
|
||||
workdir = "/home/coder/project"
|
||||
enable_boundary = true
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> For developers: The module also supports installing boundary from a release version (`use_boundary_directly = true`) or compiling from source (`compile_boundary_from_source = true`). These are escape hatches for development and testing purposes.
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This example shows additional configuration options for custom models, MCP servers, and base configuration.
|
||||
@@ -112,7 +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.2.0"
|
||||
version = "4.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -468,9 +468,49 @@ describe("codex", async () => {
|
||||
id,
|
||||
"/home/coder/.codex/config.toml",
|
||||
);
|
||||
expect(configToml).toContain(
|
||||
"[profiles.aibridge]\n" + 'model_provider = "aibridge"',
|
||||
expect(configToml).toContain('model_provider = "aibridge"');
|
||||
});
|
||||
|
||||
test("boundary-enabled", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
enable_boundary: "true",
|
||||
boundary_config_path: "/tmp/test-boundary.yaml",
|
||||
},
|
||||
});
|
||||
// Write boundary config
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat > /tmp/test-boundary.yaml <<'EOF'
|
||||
jail_type: landjail
|
||||
proxy_port: 8087
|
||||
log_level: warn
|
||||
allowlist:
|
||||
- "domain=api.openai.com"
|
||||
EOF`,
|
||||
]);
|
||||
// Add mock coder binary for boundary setup
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/coder",
|
||||
content: `#!/bin/bash
|
||||
if [ "$1" = "boundary" ]; then
|
||||
if [ "$2" = "--help" ]; then
|
||||
echo "boundary help"
|
||||
exit 0
|
||||
fi
|
||||
shift; shift; exec "$@"
|
||||
fi
|
||||
echo "mock coder"`,
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
// Verify boundary wrapper was used in start script
|
||||
const startLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/agentapi-start.log",
|
||||
);
|
||||
expect(configToml).toContain('profile = "aibridge"');
|
||||
expect(startLog).toContain("boundary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" {
|
||||
@@ -176,6 +176,36 @@ variable "codex_system_prompt" {
|
||||
default = "You are a helpful coding assistant. Start every response with `Codex says:`"
|
||||
}
|
||||
|
||||
variable "enable_boundary" {
|
||||
type = bool
|
||||
description = "Enable coder boundary for network filtering."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "boundary_config_path" {
|
||||
type = string
|
||||
description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "boundary_version" {
|
||||
type = string
|
||||
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release."
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
variable "compile_boundary_from_source" {
|
||||
type = bool
|
||||
description = "Whether to compile boundary from source instead of using the official install script."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "use_boundary_directly" {
|
||||
type = bool
|
||||
description = "Whether to use boundary binary directly instead of coder boundary subcommand."
|
||||
default = false
|
||||
}
|
||||
|
||||
resource "coder_env" "openai_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "OPENAI_API_KEY"
|
||||
@@ -195,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"
|
||||
@@ -203,35 +233,36 @@ 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
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
folder = local.workdir
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = var.web_app_display_name
|
||||
cli_app = var.cli_app
|
||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_subdomain = var.subdomain
|
||||
agentapi_version = var.agentapi_version
|
||||
enable_state_persistence = var.enable_state_persistence
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
agent_id = var.agent_id
|
||||
folder = local.workdir
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = var.web_app_display_name
|
||||
cli_app = var.cli_app
|
||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_subdomain = var.subdomain
|
||||
agentapi_version = var.agentapi_version
|
||||
enable_state_persistence = var.enable_state_persistence
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
enable_boundary = var.enable_boundary
|
||||
boundary_config_path = var.boundary_config_path
|
||||
boundary_version = var.boundary_version
|
||||
compile_boundary_from_source = var.compile_boundary_from_source
|
||||
use_boundary_directly = var.use_boundary_directly
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
@@ -267,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -210,7 +210,16 @@ capture_session_id() {
|
||||
|
||||
start_codex() {
|
||||
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
|
||||
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
|
||||
# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh when
|
||||
# enable_boundary=true. It points to a wrapper script that runs the command
|
||||
# through coder boundary, sandboxing only the agent process.
|
||||
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
|
||||
printf "Starting with coder boundary enabled\n"
|
||||
agentapi server --type codex --term-width 67 --term-height 1190 -- \
|
||||
"${AGENTAPI_BOUNDARY_PREFIX}" codex "${CODEX_ARGS[@]}" &
|
||||
else
|
||||
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
|
||||
fi
|
||||
capture_session_id
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Run [OpenCode](https://opencode.ai) AI coding assistant in your workspace for in
|
||||
```tf
|
||||
module "opencode" {
|
||||
source = "registry.coder.com/coder-labs/opencode/coder"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
}
|
||||
@@ -34,7 +34,7 @@ resource "coder_ai_task" "task" {
|
||||
|
||||
module "opencode" {
|
||||
source = "registry.coder.com/coder-labs/opencode/coder"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -89,7 +89,7 @@ Run OpenCode as a command-line tool without web interface or task reporting:
|
||||
```tf
|
||||
module "opencode" {
|
||||
source = "registry.coder.com/coder-labs/opencode/coder"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder"
|
||||
report_tasks = false
|
||||
|
||||
@@ -39,7 +39,7 @@ install_opencode() {
|
||||
if [ "$ARG_OPENCODE_VERSION" = "latest" ]; then
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
else
|
||||
VERSION=$ARG_OPENCODE_VERSION curl -fsSL https://opencode.ai/install | bash
|
||||
curl -fsSL https://opencode.ai/install | VERSION="${ARG_OPENCODE_VERSION}" bash
|
||||
fi
|
||||
export PATH=/home/coder/.opencode/bin:$PATH
|
||||
printf "Opencode location: %s\n" "$(which opencode)"
|
||||
|
||||
@@ -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.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_api_key = "xxxx-xxxxx-xxxx"
|
||||
@@ -60,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_boundary = true
|
||||
@@ -81,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_aibridge = true
|
||||
@@ -110,7 +110,7 @@ data "coder_task" "me" {}
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
ai_prompt = data.coder_task.me.prompt
|
||||
@@ -133,7 +133,7 @@ This example shows additional configuration options for version pinning, custom
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -189,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
install_claude_code = true
|
||||
@@ -211,32 +211,13 @@ variable "claude_code_oauth_token" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_code_oauth_token = var.claude_code_oauth_token
|
||||
}
|
||||
```
|
||||
|
||||
### Usage with Remote Control
|
||||
|
||||
[Remote Control](https://code.claude.com/docs/en/remote-control.md) allows you to access your Claude Code session from [claude.ai/code](https://claude.ai/code) or the Claude mobile app. The session runs locally in your workspace while you interact with it from any browser or device.
|
||||
|
||||
> [!NOTE]
|
||||
> Remote Control requires a Claude subscription (Pro, Max, Team, or Enterprise). API keys are not supported. You must authenticate using `claude_code_oauth_token`.
|
||||
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_code_oauth_token = var.claude_code_oauth_token
|
||||
enable_remote_control = true
|
||||
# remote_control_name = "Custom Name" # Optional: defaults to workspace name
|
||||
}
|
||||
```
|
||||
|
||||
### Usage with AWS Bedrock
|
||||
|
||||
#### Prerequisites
|
||||
@@ -303,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.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
@@ -360,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.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "claude-sonnet-4@20250514"
|
||||
|
||||
@@ -267,18 +267,6 @@ variable "enable_state_persistence" {
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "enable_remote_control" {
|
||||
type = bool
|
||||
description = "Enable Claude Code Remote Control, allowing the session to be accessed from claude.ai/code or the Claude mobile app. Requires a Claude subscription (Pro, Max, Team, or Enterprise). API keys are not supported. See https://code.claude.com/docs/en/remote-control.md"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "remote_control_name" {
|
||||
type = string
|
||||
description = "Custom session name for Remote Control, visible in the session list at claude.ai/code. Only used when enable_remote_control is true. Defaults to the workspace name."
|
||||
default = ""
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_code_md_path" {
|
||||
count = var.claude_md_path == "" ? 0 : 1
|
||||
agent_id = var.agent_id
|
||||
@@ -299,7 +287,7 @@ resource "coder_env" "claude_code_oauth_token" {
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_api_key" {
|
||||
count = local.claude_api_key != "" ? 1 : 0
|
||||
count = (var.enable_aibridge || (var.claude_api_key != "")) ? 1 : 0
|
||||
|
||||
agent_id = var.agent_id
|
||||
name = "CLAUDE_API_KEY"
|
||||
@@ -413,8 +401,6 @@ module "agentapi" {
|
||||
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
|
||||
ARG_CODER_HOST='${local.coder_host}' \
|
||||
ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}' \
|
||||
ARG_ENABLE_REMOTE_CONTROL='${var.enable_remote_control}' \
|
||||
ARG_REMOTE_CONTROL_NAME='${var.remote_control_name != "" ? var.remote_control_name : data.coder_workspace.me.name}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
|
||||
@@ -416,47 +416,6 @@ run "test_disable_state_persistence" {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
run "test_enable_remote_control_default" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_remote_control == false
|
||||
error_message = "enable_remote_control should default to false"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.remote_control_name == ""
|
||||
error_message = "remote_control_name should default to empty string"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_enable_remote_control" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-rc"
|
||||
workdir = "/home/coder/project"
|
||||
enable_remote_control = true
|
||||
remote_control_name = "My Project"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_remote_control == true
|
||||
error_message = "enable_remote_control should be true when explicitly enabled"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.remote_control_name == "My Project"
|
||||
error_message = "remote_control_name should be set to 'My Project'"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_no_api_key_no_env" {
|
||||
command = plan
|
||||
|
||||
@@ -471,3 +430,18 @@ run "test_no_api_key_no_env" {
|
||||
error_message = "CLAUDE_API_KEY should not be created when no API key is provided and aibridge is disabled"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_api_key_count_with_aibridge_no_override" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-count"
|
||||
workdir = "/home/coder/test"
|
||||
enable_aibridge = true
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(coder_env.claude_api_key) == 1
|
||||
error_message = "CLAUDE_API_KEY env should be created when aibridge is enabled, regardless of session_token value"
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,6 @@ ARG_BOUNDARY_VERSION=${ARG_BOUNDARY_VERSION:-"latest"}
|
||||
ARG_COMPILE_FROM_SOURCE=${ARG_COMPILE_FROM_SOURCE:-false}
|
||||
ARG_USE_BOUNDARY_DIRECTLY=${ARG_USE_BOUNDARY_DIRECTLY:-false}
|
||||
ARG_CODER_HOST=${ARG_CODER_HOST:-}
|
||||
ARG_ENABLE_REMOTE_CONTROL=${ARG_ENABLE_REMOTE_CONTROL:-false}
|
||||
ARG_REMOTE_CONTROL_NAME=${ARG_REMOTE_CONTROL_NAME:-}
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
@@ -41,8 +39,6 @@ printf "ARG_BOUNDARY_VERSION: %s\n" "$ARG_BOUNDARY_VERSION"
|
||||
printf "ARG_COMPILE_FROM_SOURCE: %s\n" "$ARG_COMPILE_FROM_SOURCE"
|
||||
printf "ARG_USE_BOUNDARY_DIRECTLY: %s\n" "$ARG_USE_BOUNDARY_DIRECTLY"
|
||||
printf "ARG_CODER_HOST: %s\n" "$ARG_CODER_HOST"
|
||||
printf "ARG_ENABLE_REMOTE_CONTROL: %s\n" "$ARG_ENABLE_REMOTE_CONTROL"
|
||||
printf "ARG_REMOTE_CONTROL_NAME: %s\n" "$ARG_REMOTE_CONTROL_NAME"
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
@@ -92,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}"
|
||||
}
|
||||
|
||||
@@ -224,15 +220,6 @@ function start_agentapi() {
|
||||
[ -n "$ARG_AI_PROMPT" ] && ARGS+=(-- "$ARG_AI_PROMPT")
|
||||
fi
|
||||
|
||||
# Build the claude command - either regular or remote-control mode.
|
||||
CLAUDE_CMD=("claude")
|
||||
if [ "$ARG_ENABLE_REMOTE_CONTROL" = "true" ]; then
|
||||
CLAUDE_CMD+=("remote-control")
|
||||
if [ -n "$ARG_REMOTE_CONTROL_NAME" ]; then
|
||||
CLAUDE_CMD+=("--name" "$ARG_REMOTE_CONTROL_NAME")
|
||||
fi
|
||||
fi
|
||||
|
||||
printf "Running claude code with args: %s\n" "$(printf '%q ' "${ARGS[@]}")"
|
||||
|
||||
if [ "$ARG_ENABLE_BOUNDARY" = "true" ]; then
|
||||
@@ -259,9 +246,9 @@ function start_agentapi() {
|
||||
|
||||
agentapi server --type claude --term-width 67 --term-height 1190 -- \
|
||||
"${BOUNDARY_CMD[@]}" "${BOUNDARY_ARGS[@]}" -- \
|
||||
"${CLAUDE_CMD[@]}" "${ARGS[@]}"
|
||||
claude "${ARGS[@]}"
|
||||
else
|
||||
agentapi server --type claude --term-width 67 --term-height 1190 -- "${CLAUDE_CMD[@]}" "${ARGS[@]}"
|
||||
agentapi server --type claude --term-width 67 --term-height 1190 -- claude "${ARGS[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -31,7 +31,7 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -42,7 +42,7 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
}
|
||||
@@ -54,14 +54,14 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
module "dotfiles-root" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
dotfiles_uri = module.dotfiles.dotfiles_uri
|
||||
@@ -90,7 +90,7 @@ You can set a default dotfiles repository for all users by setting the `default_
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
default_dotfiles_uri = "https://github.com/coder/dotfiles"
|
||||
}
|
||||
|
||||
@@ -56,13 +56,62 @@ describe("dotfiles", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("command uses bash for fish shell compatibility", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
manual_update: "true",
|
||||
dotfiles_uri: "https://github.com/test/dotfiles",
|
||||
});
|
||||
|
||||
const app = state.resources.find(
|
||||
(r) => r.type === "coder_app" && r.name === "dotfiles",
|
||||
);
|
||||
|
||||
expect(app).toBeDefined();
|
||||
expect(app?.instances[0]?.attributes?.command).toContain("/bin/bash -c");
|
||||
});
|
||||
|
||||
it("set custom order for coder_parameter", async () => {
|
||||
const order = 99;
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
coder_parameter_order: order.toString(),
|
||||
});
|
||||
expect(state.resources).toHaveLength(3);
|
||||
const parameters = state.resources.filter(
|
||||
(r) => r.type === "coder_parameter",
|
||||
);
|
||||
for (const param of parameters) {
|
||||
expect(param.instances[0].attributes.order).toBe(order);
|
||||
}
|
||||
});
|
||||
|
||||
it("set custom dotfiles_branch", async () => {
|
||||
const branch = "develop";
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
dotfiles_branch: branch,
|
||||
});
|
||||
expect(state.resources).toHaveLength(2);
|
||||
expect(state.resources[0].instances[0].attributes.order).toBe(order);
|
||||
const scriptResource = state.resources.find(
|
||||
(r) => r.type === "coder_script",
|
||||
);
|
||||
expect(scriptResource?.instances[0].attributes.script).toContain(
|
||||
`DOTFILES_BRANCH="${branch}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("default dotfiles_branch creates parameter", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
});
|
||||
expect(state.resources).toHaveLength(3);
|
||||
const branchParameter = state.resources.find(
|
||||
(r) =>
|
||||
r.type === "coder_parameter" &&
|
||||
r.instances[0].attributes.name === "dotfiles_branch",
|
||||
);
|
||||
expect(branchParameter).toBeDefined();
|
||||
expect(branchParameter?.instances[0].attributes.default).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,12 @@ variable "default_dotfiles_uri" {
|
||||
}
|
||||
}
|
||||
|
||||
variable "default_dotfiles_branch" {
|
||||
type = string
|
||||
description = "The default dotfiles branch if the workspace user does not provide one"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "dotfiles_uri" {
|
||||
type = string
|
||||
description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
|
||||
@@ -61,6 +67,17 @@ variable "dotfiles_uri" {
|
||||
}
|
||||
}
|
||||
|
||||
variable "dotfiles_branch" {
|
||||
type = string
|
||||
description = "The branch to use for the dotfiles repository (optional, when set, the user isn't prompted for the branch)"
|
||||
default = null
|
||||
|
||||
validation {
|
||||
condition = var.dotfiles_branch == null || var.dotfiles_branch != ""
|
||||
error_message = "dotfiles_branch cannot be an empty string. Use null to prompt the user or provide a valid branch name."
|
||||
}
|
||||
}
|
||||
|
||||
variable "user" {
|
||||
type = string
|
||||
description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
|
||||
@@ -107,8 +124,21 @@ data "coder_parameter" "dotfiles_uri" {
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "dotfiles_branch" {
|
||||
count = var.dotfiles_branch == null ? 1 : 0
|
||||
type = "string"
|
||||
name = "dotfiles_branch"
|
||||
display_name = "Dotfiles Branch"
|
||||
order = var.coder_parameter_order
|
||||
default = var.default_dotfiles_branch
|
||||
description = "The branch to use for the dotfiles repository"
|
||||
mutable = true
|
||||
icon = "/icon/dotfiles.svg"
|
||||
}
|
||||
|
||||
locals {
|
||||
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
|
||||
dotfiles_branch = var.dotfiles_branch != null ? var.dotfiles_branch : data.coder_parameter.dotfiles_branch[0].value
|
||||
user = var.user != null ? var.user : ""
|
||||
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
|
||||
}
|
||||
@@ -118,6 +148,7 @@ resource "coder_script" "dotfiles" {
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
DOTFILES_URI : local.dotfiles_uri,
|
||||
DOTFILES_USER : local.user,
|
||||
DOTFILES_BRANCH : local.dotfiles_branch,
|
||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
||||
})
|
||||
display_name = "Dotfiles"
|
||||
@@ -133,11 +164,12 @@ resource "coder_app" "dotfiles" {
|
||||
icon = "/icon/dotfiles.svg"
|
||||
order = var.order
|
||||
group = var.group
|
||||
command = templatefile("${path.module}/run.sh", {
|
||||
command = "/bin/bash -c \"$(echo ${base64encode(templatefile("${path.module}/run.sh", {
|
||||
DOTFILES_URI : local.dotfiles_uri,
|
||||
DOTFILES_USER : local.user,
|
||||
DOTFILES_BRANCH : local.dotfiles_branch,
|
||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
||||
})
|
||||
}))} | base64 -d)\""
|
||||
}
|
||||
|
||||
output "dotfiles_uri" {
|
||||
|
||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
|
||||
DOTFILES_URI="${DOTFILES_URI}"
|
||||
DOTFILES_USER="${DOTFILES_USER}"
|
||||
DOTFILES_BRANCH="${DOTFILES_BRANCH}"
|
||||
|
||||
# Validate DOTFILES_URI to prevent command injection (defense in depth)
|
||||
if [ -n "$DOTFILES_URI" ]; then
|
||||
@@ -24,10 +25,18 @@ if [ -n "$${DOTFILES_URI// }" ]; then
|
||||
DOTFILES_USER="$USER"
|
||||
fi
|
||||
|
||||
echo "✨ Applying dotfiles for user $DOTFILES_USER"
|
||||
if [ -n "$DOTFILES_BRANCH" ]; then
|
||||
echo "✨ Applying dotfiles for user $DOTFILES_USER from branch $DOTFILES_BRANCH"
|
||||
else
|
||||
echo "✨ Applying dotfiles for user $DOTFILES_USER"
|
||||
fi
|
||||
|
||||
if [ "$DOTFILES_USER" = "$USER" ]; then
|
||||
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
|
||||
if [ -n "$DOTFILES_BRANCH" ]; then
|
||||
coder dotfiles "$DOTFILES_URI" --branch "$DOTFILES_BRANCH" -y 2>&1 | tee ~/.dotfiles.log
|
||||
else
|
||||
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
|
||||
fi
|
||||
else
|
||||
if command -v getent > /dev/null 2>&1; then
|
||||
DOTFILES_USER_HOME=$(getent passwd "$DOTFILES_USER" | cut -d: -f6)
|
||||
@@ -40,7 +49,11 @@ if [ -n "$${DOTFILES_URI// }" ]; then
|
||||
fi
|
||||
|
||||
CODER_BIN=$(command -v coder)
|
||||
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
|
||||
if [ -n "$DOTFILES_BRANCH" ]; then
|
||||
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" --branch "$DOTFILES_BRANCH" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
|
||||
else
|
||||
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user