mirror of
https://github.com/coder/registry.git
synced 2026-06-03 13:08:14 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11a64e9b08 | |||
| 0fd0d08d2f | |||
| 817238ea64 | |||
| d0ef879e5f | |||
| 872fdbcf35 | |||
| ce50e52fc5 | |||
| 6940774628 | |||
| 85c51816f9 | |||
| 4fdcf0d712 | |||
| acdbd5777f | |||
| 8bd24f8b44 | |||
| 1460293de4 | |||
| 79cef2ecfc | |||
| 9606297620 | |||
| a0430e6f83 | |||
| 2ee14fdf6e |
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
|
||||
```tf
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
@@ -67,8 +67,7 @@ module "agentapi" {
|
||||
AgentAPI can save and restore conversation state across workspace restarts.
|
||||
This is disabled by default and requires agentapi binary >= v0.12.0.
|
||||
|
||||
State and PID files are stored in `$HOME/<module_dir_name>/` alongside other
|
||||
module files (e.g. `$HOME/.claude-module/agentapi-state.json`).
|
||||
State and PID files are stored in `$HOME/<module_dir_name>/` alongside other module files (e.g. `$HOME/.claude-module/agentapi-state.json`).
|
||||
|
||||
To enable:
|
||||
|
||||
@@ -89,6 +88,47 @@ module "agentapi" {
|
||||
}
|
||||
```
|
||||
|
||||
## Boundary (Network Filtering)
|
||||
|
||||
The agentapi module supports optional [Agent Boundaries](https://coder.com/docs/ai-coder/agent-boundaries)
|
||||
for network filtering. When enabled, the module sets up a `AGENTAPI_BOUNDARY_PREFIX` environment
|
||||
variable that points to a wrapper script. Agent modules should use this prefix in their
|
||||
start scripts to run the agent process through boundary.
|
||||
|
||||
Boundary requires a `config.yaml` file with your allowlist, jail type, proxy port, and log
|
||||
level. See the [Agent Boundaries documentation](https://coder.com/docs/ai-coder/agent-boundaries)
|
||||
for configuration details.
|
||||
To enable:
|
||||
|
||||
```tf
|
||||
module "agentapi" {
|
||||
# ... other config
|
||||
enable_boundary = true
|
||||
boundary_config_path = "/home/coder/.config/coder_boundary/config.yaml"
|
||||
|
||||
# Optional: install boundary binary instead of using coder subcommand
|
||||
# use_boundary_directly = true
|
||||
# boundary_version = "0.6.0"
|
||||
# compile_boundary_from_source = false
|
||||
}
|
||||
```
|
||||
|
||||
### Contract for agent modules
|
||||
|
||||
When `enable_boundary = true`, the agentapi module exports `AGENTAPI_BOUNDARY_PREFIX`
|
||||
as an environment variable pointing to a wrapper script. Agent module start scripts
|
||||
should check for this variable and use it to prefix the agent command:
|
||||
|
||||
```bash
|
||||
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
|
||||
agentapi server -- "${AGENTAPI_BOUNDARY_PREFIX}" my-agent "${ARGS[@]}" &
|
||||
else
|
||||
agentapi server -- my-agent "${ARGS[@]}" &
|
||||
fi
|
||||
```
|
||||
|
||||
This ensures only the agent process is sandboxed while agentapi itself runs unrestricted.
|
||||
|
||||
## For module developers
|
||||
|
||||
For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
|
||||
|
||||
@@ -613,4 +613,109 @@ describe("agentapi", async () => {
|
||||
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
|
||||
});
|
||||
});
|
||||
|
||||
describe("boundary", async () => {
|
||||
test("boundary-disabled-by-default", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
// Config file should NOT exist when boundary is disabled
|
||||
const configCheck = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"test -f /home/coder/.config/coder_boundary/config.yaml && echo exists || echo missing",
|
||||
]);
|
||||
expect(configCheck.stdout.trim()).toBe("missing");
|
||||
// AGENTAPI_BOUNDARY_PREFIX should NOT be in the mock log
|
||||
const mockLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(mockLog).not.toContain("AGENTAPI_BOUNDARY_PREFIX:");
|
||||
});
|
||||
|
||||
test("boundary-enabled", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
enable_boundary: "true",
|
||||
boundary_config_path: "/tmp/test-boundary.yaml",
|
||||
},
|
||||
});
|
||||
// Write boundary config to the path before running the module
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat > /tmp/test-boundary.yaml <<'EOF'
|
||||
jail_type: landjail
|
||||
proxy_port: 8087
|
||||
log_level: warn
|
||||
allowlist:
|
||||
- "domain=api.example.com"
|
||||
EOF`,
|
||||
]);
|
||||
// Add mock coder binary for boundary setup
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/coder",
|
||||
content: `#!/bin/bash
|
||||
if [ "$1" = "boundary" ]; then
|
||||
shift; shift; exec "$@"
|
||||
fi
|
||||
echo "mock coder"`,
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
// Verify the config file exists at the specified path
|
||||
const config = await readFileContainer(id, "/tmp/test-boundary.yaml");
|
||||
expect(config).toContain("jail_type: landjail");
|
||||
expect(config).toContain("proxy_port: 8087");
|
||||
expect(config).toContain("domain=api.example.com");
|
||||
// AGENTAPI_BOUNDARY_PREFIX should be exported
|
||||
const mockLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(mockLog).toContain("AGENTAPI_BOUNDARY_PREFIX:");
|
||||
// E2E: start script should have used the wrapper
|
||||
const startLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/test-agentapi-start.log",
|
||||
);
|
||||
expect(startLog).toContain("Starting with boundary:");
|
||||
});
|
||||
|
||||
test("boundary-enabled-no-coder-binary", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
enable_boundary: "true",
|
||||
boundary_config_path: "/tmp/test-boundary.yaml",
|
||||
},
|
||||
});
|
||||
// Write boundary config
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat > /tmp/test-boundary.yaml <<'EOF'
|
||||
jail_type: landjail
|
||||
proxy_port: 8087
|
||||
log_level: warn
|
||||
EOF`,
|
||||
]);
|
||||
// Remove coder binary to simulate it not being available
|
||||
await execContainer(
|
||||
id,
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
"rm -f /usr/bin/coder /usr/local/bin/coder 2>/dev/null; hash -r",
|
||||
],
|
||||
["--user", "root"],
|
||||
);
|
||||
const resp = await execModuleScript(id);
|
||||
// Script should fail because coder binary is required
|
||||
expect(resp.exitCode).not.toBe(0);
|
||||
const scriptLog = await readFileContainer(id, "/home/coder/script.log");
|
||||
expect(scriptLog).toContain("Boundary cannot be enabled");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,6 +164,36 @@ variable "module_dir_name" {
|
||||
description = "Name of the subdirectory in the home directory for module files."
|
||||
}
|
||||
|
||||
variable "enable_boundary" {
|
||||
type = bool
|
||||
description = "Enable coder boundary for network filtering. Requires boundary_config to be set."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "boundary_config_path" {
|
||||
type = string
|
||||
description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "boundary_version" {
|
||||
type = string
|
||||
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release. When compile_boundary_from_source is true, a valid git reference should be provided (tag, commit, branch)."
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
variable "compile_boundary_from_source" {
|
||||
type = bool
|
||||
description = "Whether to compile boundary from source instead of using the official install script."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "use_boundary_directly" {
|
||||
type = bool
|
||||
description = "Whether to use boundary binary directly instead of coder boundary subcommand. When false (default), uses coder boundary subcommand. When true, installs and uses boundary binary from release."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "enable_state_persistence" {
|
||||
type = bool
|
||||
description = "Enable AgentAPI conversation state persistence across restarts."
|
||||
@@ -182,6 +212,13 @@ variable "pid_file_path" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
resource "coder_env" "boundary_config" {
|
||||
count = var.enable_boundary && var.boundary_config_path != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "BOUNDARY_CONFIG"
|
||||
value = var.boundary_config_path
|
||||
}
|
||||
|
||||
locals {
|
||||
# we always trim the slash for consistency
|
||||
workdir = trimsuffix(var.folder, "/")
|
||||
@@ -200,6 +237,7 @@ locals {
|
||||
main_script = file("${path.module}/scripts/main.sh")
|
||||
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
|
||||
lib_script = file("${path.module}/scripts/lib.sh")
|
||||
boundary_script = file("${path.module}/scripts/boundary.sh")
|
||||
}
|
||||
|
||||
resource "coder_script" "agentapi" {
|
||||
@@ -214,6 +252,9 @@ resource "coder_script" "agentapi" {
|
||||
echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh
|
||||
chmod +x /tmp/main.sh
|
||||
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
|
||||
|
||||
echo -n '${base64encode(local.boundary_script)}' | base64 -d > /tmp/agentapi-boundary.sh
|
||||
chmod +x /tmp/agentapi-boundary.sh
|
||||
|
||||
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
|
||||
ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \
|
||||
@@ -228,6 +269,10 @@ resource "coder_script" "agentapi" {
|
||||
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
|
||||
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
|
||||
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
|
||||
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
|
||||
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
|
||||
ARG_COMPILE_BOUNDARY_FROM_SOURCE='${var.compile_boundary_from_source}' \
|
||||
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
|
||||
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
|
||||
ARG_STATE_FILE_PATH='${var.state_file_path}' \
|
||||
ARG_PID_FILE_PATH='${var.pid_file_path}' \
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
# boundary.sh - Boundary installation and setup for agentapi module.
|
||||
# Sourced by main.sh when ENABLE_BOUNDARY=true.
|
||||
# Exports AGENTAPI_BOUNDARY_PREFIX for use by module start scripts.
|
||||
|
||||
validate_boundary_subcommand() {
|
||||
if command_exists coder; then
|
||||
if coder boundary --help > /dev/null 2>&1; then
|
||||
return 0
|
||||
else
|
||||
echo "Error: 'coder' command found but does not support 'boundary' subcommand. Please enable install_boundary."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Error: ENABLE_BOUNDARY=true, but 'coder' command not found. Boundary cannot be enabled." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Install boundary binary if needed.
|
||||
# Uses one of three strategies:
|
||||
# 1. Compile from source (compile_boundary_from_source=true)
|
||||
# 2. Install from release (use_boundary_directly=true)
|
||||
# 3. Use coder boundary subcommand (default, no installation needed)
|
||||
install_boundary() {
|
||||
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]; then
|
||||
echo "Compiling boundary from source (version: ${BOUNDARY_VERSION})"
|
||||
|
||||
# Remove existing boundary directory to allow re-running safely
|
||||
if [ -d boundary ]; then
|
||||
rm -rf boundary
|
||||
fi
|
||||
|
||||
echo "Cloning boundary repository"
|
||||
git clone https://github.com/coder/boundary.git
|
||||
cd boundary || exit 1
|
||||
git checkout "${BOUNDARY_VERSION}"
|
||||
|
||||
make build
|
||||
|
||||
sudo cp boundary /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/boundary
|
||||
cd - || exit 1
|
||||
elif [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
|
||||
echo "Installing boundary using official install script (version: ${BOUNDARY_VERSION})"
|
||||
curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "${BOUNDARY_VERSION}"
|
||||
else
|
||||
validate_boundary_subcommand
|
||||
echo "Using coder boundary subcommand (provided by Coder)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Set up boundary: install, write config, create wrapper script.
|
||||
# Exports AGENTAPI_BOUNDARY_PREFIX pointing to the wrapper script.
|
||||
setup_boundary() {
|
||||
local module_path="$1"
|
||||
|
||||
echo "Setting up coder boundary..."
|
||||
|
||||
# Install boundary binary if needed
|
||||
install_boundary
|
||||
|
||||
# Determine which boundary command to use and create wrapper script
|
||||
BOUNDARY_WRAPPER_SCRIPT="$module_path/boundary-wrapper.sh"
|
||||
|
||||
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ] || [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
|
||||
# Use boundary binary directly (from compilation or release installation)
|
||||
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec boundary -- "$@"
|
||||
WRAPPER_EOF
|
||||
else
|
||||
# Use coder boundary subcommand (default)
|
||||
# Copy coder binary to strip CAP_NET_ADMIN capabilities.
|
||||
# This is necessary because boundary doesn't work with privileged binaries
|
||||
# (you can't launch privileged binaries inside network namespaces unless
|
||||
# you have sys_admin).
|
||||
CODER_NO_CAPS="$module_path/coder-no-caps"
|
||||
if ! cp "$(which coder)" "$CODER_NO_CAPS"; then
|
||||
echo "Error: Failed to copy coder binary to ${CODER_NO_CAPS}. Boundary cannot be enabled." >&2
|
||||
exit 1
|
||||
fi
|
||||
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "${SCRIPT_DIR}/coder-no-caps" boundary -- "$@"
|
||||
WRAPPER_EOF
|
||||
fi
|
||||
|
||||
chmod +x "${BOUNDARY_WRAPPER_SCRIPT}"
|
||||
export AGENTAPI_BOUNDARY_PREFIX="${BOUNDARY_WRAPPER_SCRIPT}"
|
||||
echo "Boundary wrapper configured: ${AGENTAPI_BOUNDARY_PREFIX}"
|
||||
}
|
||||
@@ -16,6 +16,10 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
|
||||
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
|
||||
TASK_ID="${ARG_TASK_ID:-}"
|
||||
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
|
||||
ENABLE_BOUNDARY="${ARG_ENABLE_BOUNDARY:-false}"
|
||||
BOUNDARY_VERSION="${ARG_BOUNDARY_VERSION:-latest}"
|
||||
COMPILE_BOUNDARY_FROM_SOURCE="${ARG_COMPILE_BOUNDARY_FROM_SOURCE:-false}"
|
||||
USE_BOUNDARY_DIRECTLY="${ARG_USE_BOUNDARY_DIRECTLY:-false}"
|
||||
ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
|
||||
STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}"
|
||||
PID_FILE_PATH="${ARG_PID_FILE_PATH:-}"
|
||||
@@ -109,9 +113,18 @@ export LC_ALL=en_US.UTF-8
|
||||
|
||||
cd "${WORKDIR}"
|
||||
|
||||
# Set up boundary if enabled
|
||||
export AGENTAPI_BOUNDARY_PREFIX=""
|
||||
if [ "${ENABLE_BOUNDARY}" = "true" ]; then
|
||||
# shellcheck source=boundary.sh
|
||||
source /tmp/agentapi-boundary.sh
|
||||
setup_boundary "$module_path"
|
||||
fi
|
||||
|
||||
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
|
||||
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
|
||||
export AGENTAPI_ALLOWED_HOSTS="*"
|
||||
|
||||
export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}"
|
||||
# Only set state env vars when persistence is enabled and the binary supports
|
||||
# it. State persistence requires agentapi >= v0.12.0.
|
||||
|
||||
@@ -31,6 +31,15 @@ for (const v of [
|
||||
);
|
||||
}
|
||||
}
|
||||
// Log boundary env vars.
|
||||
for (const v of ["AGENTAPI_BOUNDARY_PREFIX"]) {
|
||||
if (process.env[v]) {
|
||||
fs.appendFileSync(
|
||||
"/home/coder/agentapi-mock.log",
|
||||
`\n${v}: ${process.env[v]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Write PID file for shutdown script.
|
||||
if (process.env.AGENTAPI_PID_FILE) {
|
||||
|
||||
+13
-3
@@ -17,6 +17,16 @@ if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then
|
||||
export AGENTAPI_CHAT_BASE_PATH
|
||||
fi
|
||||
|
||||
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
|
||||
bash -c aiagent \
|
||||
> "$log_file_path" 2>&1
|
||||
# Use boundary wrapper if configured by agentapi module.
|
||||
# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh
|
||||
# and points to a wrapper script that runs the command through coder boundary.
|
||||
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
|
||||
echo "Starting with boundary: ${AGENTAPI_BOUNDARY_PREFIX}" >> /home/coder/test-agentapi-start.log
|
||||
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
|
||||
"${AGENTAPI_BOUNDARY_PREFIX}" bash -c aiagent \
|
||||
> "$log_file_path" 2>&1
|
||||
else
|
||||
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
|
||||
bash -c aiagent \
|
||||
> "$log_file_path" 2>&1
|
||||
fi
|
||||
|
||||
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_api_key = "xxxx-xxxxx-xxxx"
|
||||
@@ -60,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_boundary = true
|
||||
@@ -81,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_aibridge = true
|
||||
@@ -110,7 +110,7 @@ data "coder_task" "me" {}
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
ai_prompt = data.coder_task.me.prompt
|
||||
@@ -133,7 +133,7 @@ This example shows additional configuration options for version pinning, custom
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -189,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
install_claude_code = true
|
||||
@@ -211,7 +211,7 @@ variable "claude_code_oauth_token" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_code_oauth_token = var.claude_code_oauth_token
|
||||
@@ -284,7 +284,7 @@ resource "coder_env" "bedrock_api_key" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
@@ -341,7 +341,7 @@ resource "coder_env" "google_application_credentials" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "claude-sonnet-4@20250514"
|
||||
|
||||
@@ -88,7 +88,7 @@ TASK_SESSION_ID="cd32e253-ca16-4fd3-9825-d837e74ae3c2"
|
||||
|
||||
get_project_dir() {
|
||||
local workdir_normalized
|
||||
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-')
|
||||
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/._' '-')
|
||||
echo "$HOME/.claude/projects/${workdir_normalized}"
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -31,7 +31,7 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -42,7 +42,7 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
}
|
||||
@@ -54,14 +54,14 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
module "dotfiles-root" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
dotfiles_uri = module.dotfiles.dotfiles_uri
|
||||
@@ -90,7 +90,7 @@ You can set a default dotfiles repository for all users by setting the `default_
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
default_dotfiles_uri = "https://github.com/coder/dotfiles"
|
||||
}
|
||||
|
||||
@@ -62,7 +62,41 @@ describe("dotfiles", async () => {
|
||||
agent_id: "foo",
|
||||
coder_parameter_order: order.toString(),
|
||||
});
|
||||
expect(state.resources).toHaveLength(3);
|
||||
const parameters = state.resources.filter(
|
||||
(r) => r.type === "coder_parameter",
|
||||
);
|
||||
for (const param of parameters) {
|
||||
expect(param.instances[0].attributes.order).toBe(order);
|
||||
}
|
||||
});
|
||||
|
||||
it("set custom dotfiles_branch", async () => {
|
||||
const branch = "develop";
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
dotfiles_branch: branch,
|
||||
});
|
||||
expect(state.resources).toHaveLength(2);
|
||||
expect(state.resources[0].instances[0].attributes.order).toBe(order);
|
||||
const scriptResource = state.resources.find(
|
||||
(r) => r.type === "coder_script",
|
||||
);
|
||||
expect(scriptResource?.instances[0].attributes.script).toContain(
|
||||
`DOTFILES_BRANCH="${branch}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("default dotfiles_branch creates parameter", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
});
|
||||
expect(state.resources).toHaveLength(3);
|
||||
const branchParameter = state.resources.find(
|
||||
(r) =>
|
||||
r.type === "coder_parameter" &&
|
||||
r.instances[0].attributes.name === "dotfiles_branch",
|
||||
);
|
||||
expect(branchParameter).toBeDefined();
|
||||
expect(branchParameter?.instances[0].attributes.default).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,12 @@ variable "default_dotfiles_uri" {
|
||||
}
|
||||
}
|
||||
|
||||
variable "default_dotfiles_branch" {
|
||||
type = string
|
||||
description = "The default dotfiles branch if the workspace user does not provide one"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "dotfiles_uri" {
|
||||
type = string
|
||||
description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
|
||||
@@ -61,6 +67,17 @@ variable "dotfiles_uri" {
|
||||
}
|
||||
}
|
||||
|
||||
variable "dotfiles_branch" {
|
||||
type = string
|
||||
description = "The branch to use for the dotfiles repository (optional, when set, the user isn't prompted for the branch)"
|
||||
default = null
|
||||
|
||||
validation {
|
||||
condition = var.dotfiles_branch == null || var.dotfiles_branch != ""
|
||||
error_message = "dotfiles_branch cannot be an empty string. Use null to prompt the user or provide a valid branch name."
|
||||
}
|
||||
}
|
||||
|
||||
variable "user" {
|
||||
type = string
|
||||
description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
|
||||
@@ -107,8 +124,21 @@ data "coder_parameter" "dotfiles_uri" {
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "dotfiles_branch" {
|
||||
count = var.dotfiles_branch == null ? 1 : 0
|
||||
type = "string"
|
||||
name = "dotfiles_branch"
|
||||
display_name = "Dotfiles Branch"
|
||||
order = var.coder_parameter_order
|
||||
default = var.default_dotfiles_branch
|
||||
description = "The branch to use for the dotfiles repository"
|
||||
mutable = true
|
||||
icon = "/icon/dotfiles.svg"
|
||||
}
|
||||
|
||||
locals {
|
||||
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
|
||||
dotfiles_branch = var.dotfiles_branch != null ? var.dotfiles_branch : data.coder_parameter.dotfiles_branch[0].value
|
||||
user = var.user != null ? var.user : ""
|
||||
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
|
||||
}
|
||||
@@ -118,6 +148,7 @@ resource "coder_script" "dotfiles" {
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
DOTFILES_URI : local.dotfiles_uri,
|
||||
DOTFILES_USER : local.user,
|
||||
DOTFILES_BRANCH : local.dotfiles_branch,
|
||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
||||
})
|
||||
display_name = "Dotfiles"
|
||||
@@ -136,6 +167,7 @@ resource "coder_app" "dotfiles" {
|
||||
command = templatefile("${path.module}/run.sh", {
|
||||
DOTFILES_URI : local.dotfiles_uri,
|
||||
DOTFILES_USER : local.user,
|
||||
DOTFILES_BRANCH : local.dotfiles_branch,
|
||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -15,7 +15,7 @@ Automatically installs [Node.js](https://github.com/nodejs/node) via [`nvm`](htt
|
||||
module "nodejs" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/thezoker/nodejs/coder"
|
||||
version = "1.0.13"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -28,17 +28,55 @@ This installs multiple versions of Node.js:
|
||||
module "nodejs" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/thezoker/nodejs/coder"
|
||||
version = "1.0.13"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
node_versions = [
|
||||
"18",
|
||||
"20",
|
||||
"node"
|
||||
]
|
||||
default_node_version = "1.0.13"
|
||||
default_node_version = "20"
|
||||
}
|
||||
```
|
||||
|
||||
## Pre and Post Install Scripts
|
||||
|
||||
Use `pre_install_script` and `post_install_script` to run custom scripts before and after Node.js installation.
|
||||
|
||||
```tf
|
||||
module "nodejs" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/thezoker/nodejs/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
|
||||
pre_install_script = "echo 'Setting up prerequisites...'"
|
||||
post_install_script = "npm install -g yarn pnpm"
|
||||
}
|
||||
```
|
||||
|
||||
## Cross-Module Dependency Ordering
|
||||
|
||||
This module uses `coder exp sync` to coordinate execution ordering with other modules. It exposes the following outputs for use with `coder exp sync want`:
|
||||
|
||||
- `install_script_name` — the sync name for the main Node.js installation script
|
||||
- `pre_install_script_name` — the sync name for the pre-install script
|
||||
- `post_install_script_name` — the sync name for the post-install script
|
||||
|
||||
For example, to ensure another module waits for Node.js to be fully installed:
|
||||
|
||||
```tf
|
||||
module "nodejs" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/thezoker/nodejs/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
# In another module's coder_script, wait for Node.js installation:
|
||||
# coder exp sync want my-script ${module.nodejs[0].install_script_name}
|
||||
```
|
||||
|
||||
## Full example
|
||||
|
||||
A example with all available options:
|
||||
@@ -47,15 +85,17 @@ A example with all available options:
|
||||
module "nodejs" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/thezoker/nodejs/coder"
|
||||
version = "1.0.13"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
nvm_version = "1.0.13"
|
||||
nvm_version = "v0.39.7"
|
||||
nvm_install_prefix = "/opt/nvm"
|
||||
node_versions = [
|
||||
"16",
|
||||
"18",
|
||||
"20",
|
||||
"node"
|
||||
]
|
||||
default_node_version = "1.0.13"
|
||||
default_node_version = "20"
|
||||
pre_install_script = "echo 'Pre-install setup'"
|
||||
post_install_script = "npm install -g typescript"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -38,15 +38,125 @@ variable "default_node_version" {
|
||||
default = "node"
|
||||
}
|
||||
|
||||
resource "coder_script" "nodejs" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "Node.js:"
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
NVM_VERSION : var.nvm_version,
|
||||
INSTALL_PREFIX : var.nvm_install_prefix,
|
||||
NODE_VERSIONS : join(",", var.node_versions),
|
||||
DEFAULT : var.default_node_version,
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing Node.js."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing Node.js."
|
||||
default = null
|
||||
}
|
||||
|
||||
locals {
|
||||
encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
|
||||
encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : ""
|
||||
|
||||
install_script = templatefile("${path.module}/run.sh", {
|
||||
NVM_VERSION = var.nvm_version,
|
||||
INSTALL_PREFIX = var.nvm_install_prefix,
|
||||
NODE_VERSIONS = join(",", var.node_versions),
|
||||
DEFAULT = var.default_node_version,
|
||||
})
|
||||
encoded_install_script = base64encode(local.install_script)
|
||||
|
||||
pre_install_script_name = "nodejs-pre_install_script"
|
||||
install_script_name = "nodejs-install_script"
|
||||
post_install_script_name = "nodejs-post_install_script"
|
||||
|
||||
module_dir_path = "$HOME/.nodejs-module"
|
||||
|
||||
pre_install_path = "${local.module_dir_path}/pre_install.sh"
|
||||
pre_install_log_path = "${local.module_dir_path}/pre_install.log"
|
||||
install_path = "${local.module_dir_path}/install.sh"
|
||||
install_log_path = "${local.module_dir_path}/install.log"
|
||||
post_install_path = "${local.module_dir_path}/post_install.sh"
|
||||
post_install_log_path = "${local.module_dir_path}/post_install.log"
|
||||
}
|
||||
|
||||
resource "coder_script" "pre_install_script" {
|
||||
count = var.pre_install_script == null ? 0 : 1
|
||||
agent_id = var.agent_id
|
||||
display_name = "Node.js: Pre-Install"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
mkdir -p ${local.module_dir_path}
|
||||
|
||||
trap 'coder exp sync complete ${local.pre_install_script_name}' EXIT
|
||||
coder exp sync start ${local.pre_install_script_name}
|
||||
|
||||
echo -n '${local.encoded_pre_install_script}' | base64 -d > ${local.pre_install_path}
|
||||
chmod +x ${local.pre_install_path}
|
||||
|
||||
${local.pre_install_path} 2>&1 | tee ${local.pre_install_log_path}
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_script" "nodejs" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "Node.js: Install"
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
mkdir -p ${local.module_dir_path}
|
||||
|
||||
trap 'coder exp sync complete ${local.install_script_name}' EXIT
|
||||
%{if var.pre_install_script != null~}
|
||||
coder exp sync want ${local.install_script_name} ${local.pre_install_script_name}
|
||||
%{endif~}
|
||||
coder exp sync start ${local.install_script_name}
|
||||
|
||||
echo -n '${local.encoded_install_script}' | base64 -d > ${local.install_path}
|
||||
chmod +x ${local.install_path}
|
||||
|
||||
${local.install_path} 2>&1 | tee ${local.install_log_path}
|
||||
EOT
|
||||
run_on_start = true
|
||||
start_blocks_login = true
|
||||
}
|
||||
|
||||
resource "coder_script" "post_install_script" {
|
||||
count = var.post_install_script != null ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
display_name = "Node.js: Post-Install"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
mkdir -p ${local.module_dir_path}
|
||||
|
||||
trap 'coder exp sync complete ${local.post_install_script_name}' EXIT
|
||||
coder exp sync want ${local.post_install_script_name} ${local.install_script_name}
|
||||
coder exp sync start ${local.post_install_script_name}
|
||||
|
||||
echo -n '${local.encoded_post_install_script}' | base64 -d > ${local.post_install_path}
|
||||
chmod +x ${local.post_install_path}
|
||||
|
||||
${local.post_install_path} 2>&1 | tee ${local.post_install_log_path}
|
||||
EOT
|
||||
}
|
||||
|
||||
output "pre_install_script_name" {
|
||||
description = "The name of the pre-install script for coder exp sync coordination."
|
||||
value = local.pre_install_script_name
|
||||
}
|
||||
|
||||
output "install_script_name" {
|
||||
description = "The name of the install script for coder exp sync coordination."
|
||||
value = local.install_script_name
|
||||
}
|
||||
|
||||
output "post_install_script_name" {
|
||||
description = "The name of the post-install script for coder exp sync coordination."
|
||||
value = local.post_install_script_name
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
run "test_nodejs_basic" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-123"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.agent_id == "test-agent-123"
|
||||
error_message = "Agent ID variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.nvm_version == "master"
|
||||
error_message = "nvm_version should default to master"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.default_node_version == "node"
|
||||
error_message = "default_node_version should default to node"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.pre_install_script == null
|
||||
error_message = "pre_install_script should default to null"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.post_install_script == null
|
||||
error_message = "post_install_script should default to null"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.install_script_name == "nodejs-install_script"
|
||||
error_message = "install_script_name output should be set"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_with_scripts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-scripts"
|
||||
pre_install_script = "echo 'Pre-install script'"
|
||||
post_install_script = "echo 'Post-install script'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.pre_install_script == "echo 'Pre-install script'"
|
||||
error_message = "Pre-install script should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.post_install_script == "echo 'Post-install script'"
|
||||
error_message = "Post-install script should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.pre_install_script_name == "nodejs-pre_install_script"
|
||||
error_message = "pre_install_script_name output should be set"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.post_install_script_name == "nodejs-post_install_script"
|
||||
error_message = "post_install_script_name output should be set"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_custom_options" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-custom"
|
||||
nvm_version = "v0.39.7"
|
||||
nvm_install_prefix = ".custom-nvm"
|
||||
node_versions = ["18", "20", "node"]
|
||||
default_node_version = "20"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.nvm_version == "v0.39.7"
|
||||
error_message = "nvm_version should be set to v0.39.7"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.nvm_install_prefix == ".custom-nvm"
|
||||
error_message = "nvm_install_prefix should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(var.node_versions) == 3
|
||||
error_message = "node_versions should have 3 entries"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.default_node_version == "20"
|
||||
error_message = "default_node_version should be set to 20"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_with_pre_install_only" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-pre"
|
||||
pre_install_script = "echo 'pre-install'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.pre_install_script != null
|
||||
error_message = "Pre-install script should be set"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.post_install_script == null
|
||||
error_message = "Post-install script should default to null"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_with_post_install_only" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-post"
|
||||
post_install_script = "echo 'post-install'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.pre_install_script == null
|
||||
error_message = "Pre-install script should default to null"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.post_install_script != null
|
||||
error_message = "Post-install script should be set"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user