diff --git a/.github/scripts/version-bump.sh b/.github/scripts/version-bump.sh
index fc316619..6af3eca2 100755
--- a/.github/scripts/version-bump.sh
+++ b/.github/scripts/version-bump.sh
@@ -77,16 +77,19 @@ update_readme_version() {
in_target_module = 0
}
}
- /version.*=.*"/ {
+ /^[[:space:]]*version[[:space:]]*=/ {
if (in_target_module) {
- gsub(/version[[:space:]]*=[[:space:]]*"[^"]*"/, "version = \"" new_version "\"")
+ match($0, /^[[:space]]*/
+ indent = substr($0, 1, RLENGTH)
+ print indent "version = \"" new_version "\""
in_target_module = 0
+ next
}
}
{ print }
' "$readme_path" > "${readme_path}.tmp" && mv "${readme_path}.tmp" "$readme_path"
return 0
- elif grep -q 'version\s*=\s*"' "$readme_path"; then
+ elif grep -q '^[[:space:]]*version[[:space:]]*=' "$readme_path"; then
echo "⚠️ Found version references but no module source match for $namespace/$module_name"
return 1
fi
@@ -148,9 +151,9 @@ main() {
local current_version
if [ -z "$latest_tag" ]; then
- if [ -f "$readme_path" ] && grep -q 'version\s*=\s*"' "$readme_path"; then
+ if [ -f "$readme_path" ] && grep -q '^[[:space:]]*version[[:space:]]*=' "$readme_path"; then
local readme_version
- readme_version=$(grep 'version\s*=\s*"' "$readme_path" | head -1 | sed 's/.*version\s*=\s*"\([^"]*\)".*/\1/')
+ readme_version=$(awk '/^[[:space:]]*version[[:space:]]*=/ { match($0, /"[^"]*"/); print substr($0, RSTART+1, RLENGTH-2); exit }' "$readme_path")
echo "No git tag found, but README shows version: $readme_version"
if ! validate_version "$readme_version"; then
diff --git a/.github/typos.toml b/.github/typos.toml
index 600a39ba..7ebdacef 100644
--- a/.github/typos.toml
+++ b/.github/typos.toml
@@ -6,6 +6,7 @@ HashiCorp = "HashiCorp"
mavrickrishi = "mavrickrishi" # Username
mavrick = "mavrick" # Username
inh = "inh" # Option in setpriv command
+exportfs = "exportfs" # nfs related binary
[files]
extend-exclude = ["registry/coder/templates/aws-devcontainer/architecture.svg"] #False positive
\ No newline at end of file
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 502511c9..75e859ab 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -82,7 +82,7 @@ jobs:
- name: Validate formatting
run: bun fmt:ci
- name: Check for typos
- uses: crate-ci/typos@v1.38.1
+ uses: crate-ci/typos@v1.39.0
with:
config: .github/typos.toml
validate-readme-files:
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 29e7ef69..c275a7af 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -19,6 +19,6 @@ jobs:
with:
go-version: stable
- name: golangci-lint
- uses: golangci/golangci-lint-action@v8
+ uses: golangci/golangci-lint-action@v9
with:
version: v2.1
diff --git a/.icons/cmux.svg b/.icons/cmux.svg
new file mode 100644
index 00000000..95b56bb0
--- /dev/null
+++ b/.icons/cmux.svg
@@ -0,0 +1,47 @@
+
+
diff --git a/.icons/copyparty.svg b/.icons/copyparty.svg
new file mode 100644
index 00000000..2c4f0d04
--- /dev/null
+++ b/.icons/copyparty.svg
@@ -0,0 +1,210 @@
+
+
diff --git a/.icons/mux.svg b/.icons/mux.svg
new file mode 100644
index 00000000..95b56bb0
--- /dev/null
+++ b/.icons/mux.svg
@@ -0,0 +1,47 @@
+
+
diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md
index 9c749229..98062326 100644
--- a/registry/coder-labs/modules/codex/README.md
+++ b/registry/coder-labs/modules/codex/README.md
@@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
- version = "3.0.0"
+ version = "3.1.0"
agent_id = coder_agent.example.id
openai_api_key = var.openai_api_key
workdir = "/home/coder/project"
@@ -33,7 +33,7 @@ module "codex" {
module "codex" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder"
- version = "3.0.0"
+ version = "3.1.0"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
@@ -61,7 +61,7 @@ module "coder-login" {
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
- version = "3.0.0"
+ version = "3.1.0"
agent_id = coder_agent.example.id
openai_api_key = "..."
ai_prompt = data.coder_parameter.ai_prompt.value
@@ -84,6 +84,7 @@ module "codex" {
- **System Prompt**: If `codex_system_prompt` is set, writes the prompt to `AGENTS.md` in the `~/.codex/` directory
- **Start**: Launches Codex CLI in the specified directory, wrapped by AgentAPI
- **Configuration**: Sets `OPENAI_API_KEY` environment variable and passes `--model` flag to Codex CLI (if variables provided)
+- **Session Continuity**: When `continue = true` (default), the module automatically tracks task sessions in `~/.codex-module/.codex-task-session`. On workspace restart, it resumes the existing session with full conversation history. Set `continue = false` to always start fresh sessions.
## Configuration
@@ -107,7 +108,7 @@ For custom Codex configuration, use `base_config_toml` and/or `additional_mcp_se
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
- version = "3.0.0"
+ version = "3.1.0"
# ... other variables ...
# Override default configuration
diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts
index 7d34a9c4..2041e36e 100644
--- a/registry/coder-labs/modules/codex/main.test.ts
+++ b/registry/coder-labs/modules/codex/main.test.ts
@@ -368,4 +368,90 @@ describe("codex", async () => {
expect(prompt.exitCode).not.toBe(0);
expect(prompt.stderr).toContain("No such file or directory");
});
+
+ test("codex-continue-capture-new-session", async () => {
+ const { id } = await setup({
+ moduleVariables: {
+ continue: "true",
+ ai_prompt: "test task",
+ },
+ });
+
+ const workdir = "/home/coder";
+ const expectedSessionId = "019a1234-5678-9abc-def0-123456789012";
+ const sessionsDir = "/home/coder/.codex/sessions";
+ const sessionFile = `${sessionsDir}/${expectedSessionId}.jsonl`;
+
+ await execContainer(id, ["mkdir", "-p", sessionsDir]);
+ await execContainer(id, [
+ "bash",
+ "-c",
+ `echo '{"id":"${expectedSessionId}","cwd":"${workdir}","created":"2024-10-24T20:00:00Z","model":"gpt-4-turbo"}' > ${sessionFile}`,
+ ]);
+
+ await execModuleScript(id);
+
+ await expectAgentAPIStarted(id);
+
+ const trackingFile = "/home/coder/.codex-module/.codex-task-session";
+ const maxAttempts = 30;
+ let trackingFileContents = "";
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ const result = await execContainer(id, [
+ "bash",
+ "-c",
+ `cat ${trackingFile} 2>/dev/null || echo ""`,
+ ]);
+ if (result.stdout.trim().length > 0) {
+ trackingFileContents = result.stdout;
+ break;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+
+ expect(trackingFileContents).toContain(`${workdir}|${expectedSessionId}`);
+
+ const startLog = await readFileContainer(
+ id,
+ "/home/coder/.codex-module/agentapi-start.log",
+ );
+ expect(startLog).toContain("Capturing new session ID");
+ expect(startLog).toContain("Session tracked");
+ expect(startLog).toContain(expectedSessionId);
+ });
+
+ test("codex-continue-resume-existing-session", async () => {
+ const { id } = await setup({
+ moduleVariables: {
+ continue: "true",
+ ai_prompt: "test prompt",
+ },
+ });
+
+ const workdir = "/home/coder";
+ const mockSessionId = "019a1234-5678-9abc-def0-123456789012";
+ const trackingFile = "/home/coder/.codex-module/.codex-task-session";
+
+ await execContainer(id, ["mkdir", "-p", "/home/coder/.codex-module"]);
+ await execContainer(id, [
+ "bash",
+ "-c",
+ `echo "${workdir}|${mockSessionId}" > ${trackingFile}`,
+ ]);
+
+ await execModuleScript(id);
+
+ const startLog = await execContainer(id, [
+ "bash",
+ "-c",
+ "cat /home/coder/.codex-module/agentapi-start.log",
+ ]);
+ expect(startLog.stdout).toContain("Found existing task session");
+ expect(startLog.stdout).toContain(mockSessionId);
+ expect(startLog.stdout).toContain("Resuming existing session");
+ expect(startLog.stdout).toContain(
+ `Starting Codex with arguments: --model gpt-4-turbo resume ${mockSessionId}`,
+ );
+ expect(startLog.stdout).not.toContain("test prompt");
+ });
});
diff --git a/registry/coder-labs/modules/codex/main.tf b/registry/coder-labs/modules/codex/main.tf
index d181c10f..a68cd79f 100644
--- a/registry/coder-labs/modules/codex/main.tf
+++ b/registry/coder-labs/modules/codex/main.tf
@@ -137,6 +137,12 @@ variable "ai_prompt" {
default = ""
}
+variable "continue" {
+ type = bool
+ description = "Automatically continue existing sessions on workspace restart. When true, resumes existing conversation if found, otherwise runs prompt or starts new session. When false, always starts fresh (ignores existing sessions)."
+ default = true
+}
+
variable "codex_system_prompt" {
type = string
description = "System instructions written to AGENTS.md in the ~/.codex directory"
@@ -187,8 +193,9 @@ module "agentapi" {
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_CODEX_MODEL='${var.codex_model}' \
- ARG_CODEX_START_DIRECTORY='${var.workdir}' \
+ ARG_CODEX_START_DIRECTORY='${local.workdir}' \
ARG_CODEX_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
+ ARG_CONTINUE='${var.continue}' \
/tmp/start.sh
EOT
@@ -206,7 +213,7 @@ module "agentapi" {
ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \
ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
- ARG_CODEX_START_DIRECTORY='${var.workdir}' \
+ ARG_CODEX_START_DIRECTORY='${local.workdir}' \
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
/tmp/install.sh
EOT
diff --git a/registry/coder-labs/modules/codex/scripts/start.sh b/registry/coder-labs/modules/codex/scripts/start.sh
index be54d575..663e80e5 100644
--- a/registry/coder-labs/modules/codex/scripts/start.sh
+++ b/registry/coder-labs/modules/codex/scripts/start.sh
@@ -3,6 +3,7 @@
source "$HOME"/.bashrc
set -o errexit
set -o pipefail
+
command_exists() {
command -v "$1" > /dev/null 2>&1
}
@@ -16,6 +17,7 @@ fi
printf "Version: %s\n" "$(codex --version)"
set -o nounset
ARG_CODEX_TASK_PROMPT=$(echo -n "$ARG_CODEX_TASK_PROMPT" | base64 -d)
+ARG_CONTINUE=${ARG_CONTINUE:-true}
echo "=== Codex Launch Configuration ==="
printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")"
@@ -23,53 +25,187 @@ printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}"
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
printf "Has Task Prompt: %s\n" "$([ -n "$ARG_CODEX_TASK_PROMPT" ] && echo "Yes" || echo "No")"
printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS"
+printf "Continue Sessions: %s\n" "$ARG_CONTINUE"
echo "======================================"
set +o nounset
-CODEX_ARGS=()
-if command_exists codex; then
- printf "Codex is installed\n"
-else
- printf "Error: Codex is not installed. Please enable install_codex or install it manually\n"
- exit 1
-fi
+SESSION_TRACKING_FILE="$HOME/.codex-module/.codex-task-session"
-if [ -d "${ARG_CODEX_START_DIRECTORY}" ]; then
- printf "Directory '%s' exists. Changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
- cd "${ARG_CODEX_START_DIRECTORY}" || {
- printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
- exit 1
- }
-else
- printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
- mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
- printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
- exit 1
- }
- cd "${ARG_CODEX_START_DIRECTORY}" || {
- printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
- exit 1
- }
-fi
+find_session_for_directory() {
+ local target_dir="$1"
-if [ -n "$ARG_CODEX_MODEL" ]; then
- CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
-fi
-
-if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then
- printf "Running the task prompt %s\n" "$ARG_CODEX_TASK_PROMPT"
- if [ "${ARG_REPORT_TASKS}" == "true" ]; then
- PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
- else
- PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT"
+ if [ ! -f "$SESSION_TRACKING_FILE" ]; then
+ return 1
fi
- CODEX_ARGS+=("$PROMPT")
-else
- printf "No task prompt given.\n"
-fi
-# Terminal dimensions optimized for Coder Tasks UI sidebar:
-# - Width 67: fits comfortably in sidebar
-# - Height 1190: adjusted due to Codex terminal height bug
-printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
-agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}"
+ local session_id=$(grep "^$target_dir|" "$SESSION_TRACKING_FILE" | cut -d'|' -f2 | head -1)
+
+ if [ -n "$session_id" ]; then
+ echo "$session_id"
+ return 0
+ fi
+
+ return 1
+}
+
+store_session_mapping() {
+ local dir="$1"
+ local session_id="$2"
+
+ mkdir -p "$(dirname "$SESSION_TRACKING_FILE")"
+
+ if [ -f "$SESSION_TRACKING_FILE" ]; then
+ grep -v "^$dir|" "$SESSION_TRACKING_FILE" > "$SESSION_TRACKING_FILE.tmp" 2> /dev/null || true
+ mv "$SESSION_TRACKING_FILE.tmp" "$SESSION_TRACKING_FILE"
+ fi
+
+ echo "$dir|$session_id" >> "$SESSION_TRACKING_FILE"
+}
+
+find_recent_session_file() {
+ local target_dir="$1"
+ local sessions_dir="$HOME/.codex/sessions"
+
+ if [ ! -d "$sessions_dir" ]; then
+ return 1
+ fi
+
+ local latest_file=""
+ local latest_time=0
+
+ while IFS= read -r session_file; do
+ local file_time=$(stat -c %Y "$session_file" 2> /dev/null || stat -f %m "$session_file" 2> /dev/null || echo "0")
+ local first_line=$(head -n 1 "$session_file" 2> /dev/null)
+ local session_cwd=$(echo "$first_line" | grep -o '"cwd":"[^"]*"' | cut -d'"' -f4)
+
+ if [ "$session_cwd" = "$target_dir" ] && [ "$file_time" -gt "$latest_time" ]; then
+ latest_file="$session_file"
+ latest_time="$file_time"
+ fi
+ done < <(find "$sessions_dir" -type f -name "*.jsonl" 2> /dev/null)
+
+ if [ -n "$latest_file" ]; then
+ local first_line=$(head -n 1 "$latest_file")
+ local session_id=$(echo "$first_line" | grep -o '"id":"[^"]*"' | cut -d'"' -f4)
+ if [ -n "$session_id" ]; then
+ echo "$session_id"
+ return 0
+ fi
+ fi
+
+ return 1
+}
+
+wait_for_session_file() {
+ local target_dir="$1"
+ local max_attempts=20
+ local attempt=0
+
+ while [ $attempt -lt $max_attempts ]; do
+ local session_id=$(find_recent_session_file "$target_dir" 2> /dev/null || echo "")
+ if [ -n "$session_id" ]; then
+ echo "$session_id"
+ return 0
+ fi
+ sleep 0.5
+ attempt=$((attempt + 1))
+ done
+
+ return 1
+}
+
+validate_codex_installation() {
+ if command_exists codex; then
+ printf "Codex is installed\n"
+ else
+ printf "Error: Codex is not installed. Please enable install_codex or install it manually\n"
+ exit 1
+ fi
+}
+
+setup_workdir() {
+ if [ -d "${ARG_CODEX_START_DIRECTORY}" ]; then
+ printf "Directory '%s' exists. Changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
+ cd "${ARG_CODEX_START_DIRECTORY}" || {
+ printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
+ exit 1
+ }
+ else
+ printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
+ mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
+ printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
+ exit 1
+ }
+ cd "${ARG_CODEX_START_DIRECTORY}" || {
+ printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
+ exit 1
+ }
+ fi
+}
+
+build_codex_args() {
+ CODEX_ARGS=()
+
+ if [ -n "$ARG_CODEX_MODEL" ]; then
+ CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
+ fi
+
+ if [ "$ARG_CONTINUE" = "true" ]; then
+ existing_session=$(find_session_for_directory "$ARG_CODEX_START_DIRECTORY" 2> /dev/null || echo "")
+
+ if [ -n "$existing_session" ]; then
+ printf "Found existing task session for this directory: %s\n" "$existing_session"
+ printf "Resuming existing session...\n"
+ CODEX_ARGS+=("resume" "$existing_session")
+ else
+ printf "No existing task session found for this directory\n"
+ printf "Starting new task session...\n"
+
+ if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then
+ if [ "${ARG_REPORT_TASKS}" == "true" ]; then
+ PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
+ else
+ PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT"
+ fi
+ CODEX_ARGS+=("$PROMPT")
+ fi
+ fi
+ else
+ printf "Continue disabled, starting fresh session\n"
+
+ if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then
+ if [ "${ARG_REPORT_TASKS}" == "true" ]; then
+ PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
+ else
+ PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT"
+ fi
+ CODEX_ARGS+=("$PROMPT")
+ fi
+ fi
+}
+
+capture_session_id() {
+ if [ "$ARG_CONTINUE" = "true" ] && [ -z "$existing_session" ]; then
+ printf "Capturing new session ID...\n"
+ new_session=$(wait_for_session_file "$ARG_CODEX_START_DIRECTORY" || echo "")
+
+ if [ -n "$new_session" ]; then
+ store_session_mapping "$ARG_CODEX_START_DIRECTORY" "$new_session"
+ printf "✓ Session tracked: %s\n" "$new_session"
+ printf "This session will be automatically resumed on next restart\n"
+ else
+ printf "⚠ Could not capture session ID after 10s timeout\n"
+ fi
+ fi
+}
+
+start_codex() {
+ printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
+ agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
+ capture_session_id
+}
+
+validate_codex_installation
+setup_workdir
+build_codex_args
+start_codex
diff --git a/registry/coder-labs/modules/codex/testdata/codex-mock.sh b/registry/coder-labs/modules/codex/testdata/codex-mock.sh
index 8c1c7366..fe8f3806 100644
--- a/registry/coder-labs/modules/codex/testdata/codex-mock.sh
+++ b/registry/coder-labs/modules/codex/testdata/codex-mock.sh
@@ -1,5 +1,6 @@
#!/bin/bash
+# Handle --version flag
if [[ "$1" == "--version" ]]; then
echo "HELLO: $(bash -c env)"
echo "codex version v1.0.0"
@@ -8,7 +9,30 @@ fi
set -e
+SESSION_ID=""
+IS_RESUME=false
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ resume)
+ IS_RESUME=true
+ SESSION_ID="$2"
+ shift 2
+ ;;
+ *)
+ shift
+ ;;
+ esac
+done
+
+if [ "$IS_RESUME" = false ]; then
+ SESSION_ID="019a1234-5678-9abc-def0-123456789012"
+ echo "Created new session: $SESSION_ID"
+else
+ echo "Resuming session: $SESSION_ID"
+fi
+
while true; do
- echo "$(date) - codex-mock"
+ echo "$(date) - codex-mock (session: $SESSION_ID)"
sleep 15
done
diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md
index 83f59c7c..e0b520e0 100644
--- a/registry/coder-labs/modules/copilot/README.md
+++ b/registry/coder-labs/modules/copilot/README.md
@@ -13,7 +13,7 @@ Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-c
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
- version = "0.2.1"
+ version = "0.2.2"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
}
@@ -51,7 +51,7 @@ data "coder_parameter" "ai_prompt" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
- version = "0.2.1"
+ version = "0.2.2"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
@@ -71,12 +71,12 @@ Customize tool permissions, MCP servers, and Copilot settings:
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
- version = "0.2.1"
+ version = "0.2.2"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
- # Version pinning (defaults to "0.0.334", use "latest" for newest version)
- copilot_version = "latest"
+ # Version pinning (defaults to "latest", use specific version if desired)
+ copilot_version = "0.0.334"
# Tool permissions
allow_tools = ["shell(git)", "shell(npm)", "write"]
@@ -142,7 +142,7 @@ variable "github_token" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
- version = "0.2.1"
+ version = "0.2.2"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
github_token = var.github_token
@@ -156,7 +156,7 @@ Run Copilot as a command-line tool without task reporting or web interface. This
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
- version = "0.2.1"
+ version = "0.2.2"
agent_id = coder_agent.example.id
workdir = "/home/coder"
report_tasks = false
diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf
index eb9f78d4..41a83d53 100644
--- a/registry/coder-labs/modules/copilot/main.tf
+++ b/registry/coder-labs/modules/copilot/main.tf
@@ -104,7 +104,7 @@ variable "agentapi_version" {
variable "copilot_version" {
type = string
description = "The version of GitHub Copilot CLI to install. Use 'latest' for the latest version or specify a version like '0.0.334'."
- default = "0.0.334"
+ default = "latest"
}
variable "report_tasks" {
diff --git a/registry/coder-labs/modules/sourcegraph-amp/README.md b/registry/coder-labs/modules/sourcegraph-amp/README.md
index 5a5039f0..608defd6 100644
--- a/registry/coder-labs/modules/sourcegraph-amp/README.md
+++ b/registry/coder-labs/modules/sourcegraph-amp/README.md
@@ -13,7 +13,7 @@ Run [Amp CLI](https://ampcode.com/) in your workspace to access Sourcegraph's AI
```tf
module "amp-cli" {
source = "registry.coder.com/coder-labs/sourcegraph-amp/coder"
- version = "2.0.0"
+ version = "2.0.1"
agent_id = coder_agent.example.id
sourcegraph_amp_api_key = var.sourcegraph_amp_api_key
install_sourcegraph_amp = true
@@ -48,7 +48,7 @@ variable "amp_api_key" {
module "amp-cli" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/sourcegraph-amp/coder"
- amp_version = "2.0.0"
+ amp_version = "2.0.1"
agent_id = coder_agent.example.id
amp_api_key = var.amp_api_key # recommended for tasks usage
workdir = "/home/coder/project"
diff --git a/registry/coder-labs/modules/sourcegraph-amp/main.tf b/registry/coder-labs/modules/sourcegraph-amp/main.tf
index ddaa475d..fc36ea8d 100644
--- a/registry/coder-labs/modules/sourcegraph-amp/main.tf
+++ b/registry/coder-labs/modules/sourcegraph-amp/main.tf
@@ -6,7 +6,12 @@ terraform {
source = "coder/coder"
version = ">= 2.7"
}
+ external = {
+ source = "hashicorp/external"
+ version = "2.3.5"
+ }
}
+
}
variable "agent_id" {
diff --git a/registry/coder-labs/templates/tasks-docker/main.tf b/registry/coder-labs/templates/tasks-docker/main.tf
index c0a165fc..5fbea1af 100644
--- a/registry/coder-labs/templates/tasks-docker/main.tf
+++ b/registry/coder-labs/templates/tasks-docker/main.tf
@@ -1,7 +1,8 @@
terraform {
required_providers {
coder = {
- source = "coder/coder"
+ source = "coder/coder"
+ version = ">= 2.13"
}
docker = {
source = "kreuzwerker/docker"
@@ -12,22 +13,32 @@ terraform {
# This template requires a valid Docker socket
# However, you can reference our Kubernetes/VM
# example templates and adapt the Claude Code module
-#
-# see: https://registry.coder.com/templates
+#
+# see: https://registry.coder.com/templates
provider "docker" {}
+# A `coder_ai_task` resource enables Tasks and associates
+# the task with the coder_app that will act as an AI agent.
+resource "coder_ai_task" "task" {
+ count = data.coder_workspace.me.start_count
+ app_id = module.claude-code[count.index].task_app_id
+}
+
+# You can read the task prompt from the `coder_task` data source.
+data "coder_task" "me" {}
+
# The Claude Code module does the automatic task reporting
# Other agent modules: https://registry.coder.com/modules?search=agent
-# Or use a custom agent:
+# Or use a custom agent:
module "claude-code" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/claude-code/coder"
- version = "3.0.0"
+ version = "4.0.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/projects"
order = 999
claude_api_key = ""
- ai_prompt = data.coder_parameter.ai_prompt.value
+ ai_prompt = data.coder_task.me.prompt
system_prompt = data.coder_parameter.system_prompt.value
model = "sonnet"
permission_mode = "plan"
@@ -51,13 +62,13 @@ data "coder_workspace_preset" "default" {
(servers, dev watchers, GUI apps).
- Built-in tools - use for everything else:
(file operations, git commands, builds & installs, one-off shell commands)
-
+
Remember this decision rule:
- Stays running? → desktop-commander
- Finishes immediately? → built-in tools
-
+
-- Context --
- There is an existing app and tmux dev server running on port 8000. Be sure to read it's CLAUDE.md (./realworld-django-rest-framework-angular/CLAUDE.md) to learn more about it.
+ There is an existing app and tmux dev server running on port 8000. Be sure to read it's CLAUDE.md (./realworld-django-rest-framework-angular/CLAUDE.md) to learn more about it.
Since this app is for demo purposes and the user is previewing the homepage and subsequent pages, aim to make the first visual change/prototype very quickly so the user can preview it, then focus on backend or logic which can be a more involved, long-running architecture plan.
@@ -107,7 +118,7 @@ data "coder_workspace_preset" "default" {
# Pre-builds is a Coder Premium
# feature to speed up workspace creation
- #
+ #
# see https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces
# prebuilds {
# instances = 1
@@ -126,13 +137,6 @@ data "coder_parameter" "system_prompt" {
description = "System prompt for the agent with generalized instructions"
mutable = false
}
-data "coder_parameter" "ai_prompt" {
- type = "string"
- name = "AI Prompt"
- default = ""
- description = "Write a prompt for Claude Code"
- mutable = true
-}
data "coder_parameter" "setup_script" {
name = "setup_script"
display_name = "Setup Script"
@@ -373,4 +377,4 @@ resource "docker_container" "workspace" {
label = "coder.workspace_name"
value = data.coder_workspace.me.name
}
-}
\ No newline at end of file
+}
diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md
index d68af511..954db1ce 100644
--- a/registry/coder/modules/agentapi/README.md
+++ b/registry/coder/modules/agentapi/README.md
@@ -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 = "1.2.0"
+ version = "2.0.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf
index e73f45f6..5c3ab9c4 100644
--- a/registry/coder/modules/agentapi/main.tf
+++ b/registry/coder/modules/agentapi/main.tf
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
- version = ">= 2.7"
+ version = ">= 2.12"
}
}
}
@@ -239,8 +239,6 @@ resource "coder_app" "agentapi_cli" {
group = var.cli_app_group
}
-resource "coder_ai_task" "agentapi" {
- sidebar_app {
- id = coder_app.agentapi_web.id
- }
+output "task_app_id" {
+ value = coder_app.agentapi_web.id
}
diff --git a/registry/coder/modules/aider/README.md b/registry/coder/modules/aider/README.md
index c93fb89c..3ce9c88e 100644
--- a/registry/coder/modules/aider/README.md
+++ b/registry/coder/modules/aider/README.md
@@ -8,76 +8,58 @@ tags: [agent, ai, aider]
# Aider
-Run [Aider](https://aider.chat) AI pair programming in your workspace. This module installs Aider and provides a persistent session using screen or tmux.
+Run [Aider](https://aider.chat) AI pair programming in your workspace. This module installs Aider with AgentAPI for seamless Coder Tasks Support.
```tf
-module "aider" {
- source = "registry.coder.com/coder/aider/coder"
- version = "1.1.2"
- agent_id = coder_agent.example.id
-}
-```
-
-## Features
-
-- **Interactive Parameter Selection**: Choose your AI provider, model, and configuration options when creating the workspace
-- **Multiple AI Providers**: Supports Anthropic (Claude), OpenAI, DeepSeek, GROQ, and OpenRouter
-- **Persistent Sessions**: Uses screen (default) or tmux to keep Aider running in the background
-- **Optional Dependencies**: Install Playwright for web page scraping and PortAudio for voice coding
-- **Project Integration**: Works with any project directory, including Git repositories
-- **Browser UI**: Use Aider in your browser with a modern web interface instead of the terminal
-- **Non-Interactive Mode**: Automatically processes tasks when provided via the `task_prompt` variable
-
-## Module Parameters
-
-> [!NOTE]
-> The `use_screen` and `use_tmux` parameters cannot both be enabled at the same time. By default, `use_screen` is set to `true` and `use_tmux` is set to `false`.
-
-## Usage Examples
-
-### Basic setup with API key
-
-```tf
-variable "anthropic_api_key" {
+variable "api_key" {
type = string
- description = "Anthropic API key"
+ description = "API key"
sensitive = true
}
module "aider" {
- count = data.coder_workspace.me.start_count
- source = "registry.coder.com/coder/aider/coder"
- version = "1.1.2"
- agent_id = coder_agent.example.id
- ai_api_key = var.anthropic_api_key
-}
-```
-
-This basic setup will:
-
-- Install Aider in the workspace
-- Create a persistent screen session named "aider"
-- Configure Aider to use Anthropic Claude 3.7 Sonnet model
-- Enable task reporting (configures Aider to report tasks to Coder MCP)
-
-### Using OpenAI with tmux
-
-```tf
-variable "openai_api_key" {
- type = string
- description = "OpenAI API key"
- sensitive = true
-}
-
-module "aider" {
- count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
- version = "1.1.2"
+ version = "2.0.0"
agent_id = coder_agent.example.id
- use_tmux = true
- ai_provider = "openai"
- ai_model = "4o" # Uses Aider's built-in alias for gpt-4o
- ai_api_key = var.openai_api_key
+ api_key = var.api_key
+ ai_provider = "google"
+ model = "gemini"
+}
+```
+
+## Prerequisites
+
+- pipx is automatically installed if not already available
+
+## Usage Example
+
+```tf
+data "coder_parameter" "ai_prompt" {
+ name = "AI Prompt"
+ description = "Write an initial prompt for Aider to work on."
+ type = "string"
+ default = ""
+ mutable = true
+}
+
+variable "gemini_api_key" {
+ type = string
+ description = "Gemini API key"
+ sensitive = true
+}
+
+module "aider" {
+ source = "registry.coder.com/coder/aider/coder"
+ version = "2.0.0"
+ agent_id = coder_agent.example.id
+ api_key = var.gemini_api_key
+ install_aider = true
+ workdir = "/home/coder"
+ ai_provider = "google"
+ model = "gemini"
+ install_agentapi = true
+ ai_prompt = data.coder_parameter.ai_prompt.value
+ system_prompt = "..."
}
```
@@ -93,174 +75,16 @@ variable "custom_api_key" {
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
- version = "1.1.2"
+ version = "2.0.0"
agent_id = coder_agent.example.id
+ workdir = "/home/coder"
ai_provider = "custom"
custom_env_var_name = "MY_CUSTOM_API_KEY"
- ai_model = "custom-model"
- ai_api_key = var.custom_api_key
+ model = "custom-model"
+ api_key = var.custom_api_key
}
```
-### Adding Custom Extensions (Experimental)
-
-You can extend Aider's capabilities by adding custom extensions:
-
-```tf
-module "aider" {
- count = data.coder_workspace.me.start_count
- source = "registry.coder.com/coder/aider/coder"
- version = "1.1.2"
- agent_id = coder_agent.example.id
- ai_api_key = var.anthropic_api_key
-
- experiment_pre_install_script = <<-EOT
- pip install some-custom-dependency
- EOT
-
- experiment_additional_extensions = <<-EOT
- custom-extension:
- args: []
- cmd: custom-extension-command
- description: A custom extension for Aider
- enabled: true
- envs: {}
- name: custom-extension
- timeout: 300
- type: stdio
- EOT
-}
-```
-
-Note: The indentation in the heredoc is preserved, so you can write the YAML naturally.
-
-## Task Reporting (Experimental)
-
-> This functionality is in early access as of Coder v2.21 and is still evolving.
-> For now, we recommend testing it in a demo or staging environment,
-> rather than deploying to production
->
-> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents)
->
-> Join our [Discord channel](https://discord.gg/coder) or
-> [contact us](https://coder.com/contact) to get help or share feedback.
-
-Your workspace must have either `screen` or `tmux` installed to use this.
-
-Task reporting is **enabled by default** in this module, allowing you to:
-
-- Send an initial prompt to Aider during workspace creation
-- Monitor task progress in the Coder UI
-- Use the `coder_parameter` resource to collect prompts from users
-
-### Setting up Task Reporting
-
-To use task reporting effectively:
-
-1. Add the Coder Login module to your template
-2. Configure the necessary variables to pass the task prompt
-3. Optionally add a coder_parameter to collect prompts from users
-
-Here's a complete example:
-
-```tf
-module "coder-login" {
- count = data.coder_workspace.me.start_count
- source = "registry.coder.com/modules/coder-login/coder"
- version = "1.0.15"
- agent_id = coder_agent.example.id
-}
-
-variable "anthropic_api_key" {
- type = string
- description = "Anthropic API key"
- sensitive = true
-}
-
-data "coder_parameter" "ai_prompt" {
- type = "string"
- name = "AI Prompt"
- default = ""
- description = "Write a prompt for Aider"
- mutable = true
- ephemeral = true
-}
-
-module "aider" {
- count = data.coder_workspace.me.start_count
- source = "registry.coder.com/coder/aider/coder"
- version = "1.1.2"
- agent_id = coder_agent.example.id
- ai_api_key = var.anthropic_api_key
- task_prompt = data.coder_parameter.ai_prompt.value
-
- # Optionally customize the system prompt
- system_prompt = <<-EOT
-You are a helpful Coding assistant. Aim to autonomously investigate
-and solve issues the user gives you and test your work, whenever possible.
-Avoid shortcuts like mocking tests. When you get stuck, you can ask the user
-but opt for autonomy.
-YOU MUST REPORT ALL TASKS TO CODER.
-When reporting tasks, you MUST follow these EXACT instructions:
-- IMMEDIATELY report status after receiving ANY user message.
-- Be granular. If you are investigating with multiple steps, report each step to coder.
-Task state MUST be one of the following:
-- Use "state": "working" when actively processing WITHOUT needing additional user input.
-- Use "state": "complete" only when finished with a task.
-- Use "state": "failure" when you need ANY user input, lack sufficient details, or encounter blockers.
-Task summaries MUST:
-- Include specifics about what you're doing.
-- Include clear and actionable steps for the user.
-- Be less than 160 characters in length.
- EOT
-}
-```
-
-When a task prompt is provided via the `task_prompt` variable, the module automatically:
-
-1. Combines the system prompt with the task prompt into a single message in the format:
-
-```
-SYSTEM PROMPT:
-[system_prompt content]
-
-This is your current task: [task_prompt]
-```
-
-2. Executes the task during workspace creation using the `--message` and `--yes-always` flags
-3. Logs task output to `$HOME/.aider.log` for reference
-
-If you want to disable task reporting, set `experiment_report_tasks = false` in your module configuration.
-
-## Using Aider in Your Workspace
-
-After the workspace starts, Aider will be installed and configured according to your parameters. A persistent session will automatically be started during workspace creation.
-
-### Session Options
-
-You can run Aider in three different ways:
-
-1. **Direct Mode**: Aider starts directly in the specified folder when you click the app button
-
-- Simple setup without persistent context
-- Suitable for quick coding sessions
-
-2. **Screen Mode** (Default): Run Aider in a screen session that persists across connections
-
-- Session name: "aider" (or configured via `session_name`)
-
-3. **Tmux Mode**: Run Aider in a tmux session instead of screen
-
-- Set `use_tmux = true` to enable
-- Session name: "aider" (or configured via `session_name`)
-- Configures tmux with mouse support for shared sessions
-
-Persistent sessions (screen/tmux) allow you to:
-
-- Disconnect and reconnect without losing context
-- Run Aider in the background while doing other work
-- Switch between terminal and browser interfaces
-
### Available AI Providers and Models
Aider supports various providers and models, and this module integrates directly with Aider's built-in model aliases:
@@ -280,10 +104,12 @@ For a complete and up-to-date list of supported aliases and models, please refer
## Troubleshooting
-If you encounter issues:
+- If `aider` is not found, ensure `install_aider = true` and your API key is valid
+- Logs are written under `/home/coder/.aider-module/` (`install.log`, `agentapi-start.log`) for debugging
+- If AgentAPI fails to start, verify that your container has network access and executable permissions for the scripts
-1. **Screen/Tmux issues**: If you can't reconnect to your session, check if the session exists with `screen -list` or `tmux list-sessions`
-2. **API key issues**: Ensure you've entered the correct API key for your selected provider
-3. **Browser mode issues**: If the browser interface doesn't open, check that you're accessing it from a machine that can reach your Coder workspace
+## References
-For more information on using Aider, see the [Aider documentation](https://aider.chat/docs/).
+- [Aider Documentation](https://aider.chat/docs)
+- [AgentAPI Documentation](https://github.com/coder/agentapi)
+- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
diff --git a/registry/coder/modules/aider/main.test.ts b/registry/coder/modules/aider/main.test.ts
index c25513a5..c0aa51df 100644
--- a/registry/coder/modules/aider/main.test.ts
+++ b/registry/coder/modules/aider/main.test.ts
@@ -1,107 +1,138 @@
-import { describe, expect, it } from "bun:test";
import {
- findResourceInstance,
- runTerraformApply,
- runTerraformInit,
- testRequiredVariables,
-} from "~test";
+ test,
+ afterEach,
+ describe,
+ setDefaultTimeout,
+ beforeAll,
+ expect,
+} from "bun:test";
+import { execContainer, readFileContainer, runTerraformInit } from "~test";
+import {
+ loadTestFile,
+ writeExecutable,
+ setup as setupUtil,
+ execModuleScript,
+ expectAgentAPIStarted,
+} from "../../../coder/modules/agentapi/test-util";
-describe("aider", async () => {
- await runTerraformInit(import.meta.dir);
+let cleanupFunctions: (() => Promise)[] = [];
+const registerCleanup = (cleanup: () => Promise) => {
+ cleanupFunctions.push(cleanup);
+};
+afterEach(async () => {
+ const cleanupFnsCopy = cleanupFunctions.slice().reverse();
+ cleanupFunctions = [];
+ for (const cleanup of cleanupFnsCopy) {
+ try {
+ await cleanup();
+ } catch (error) {
+ console.error("Error during cleanup:", error);
+ }
+ }
+});
- testRequiredVariables(import.meta.dir, {
- agent_id: "foo",
+interface SetupProps {
+ skipAgentAPIMock?: boolean;
+ skipAiderMock?: boolean;
+ moduleVariables?: Record;
+ agentapiMockScript?: string;
+}
+
+const setup = async (props?: SetupProps): Promise<{ id: string }> => {
+ const projectDir = "/home/coder/project";
+ const { id } = await setupUtil({
+ moduleDir: import.meta.dir,
+ moduleVariables: {
+ install_aider: props?.skipAiderMock ? "true" : "false",
+ install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
+ aider_model: "test-model",
+ ...props?.moduleVariables,
+ },
+ registerCleanup,
+ projectDir,
+ skipAgentAPIMock: props?.skipAgentAPIMock,
+ agentapiMockScript: props?.agentapiMockScript,
});
- it("configures task prompt correctly", async () => {
- const testPrompt = "Add a hello world function";
- const state = await runTerraformApply(import.meta.dir, {
- agent_id: "foo",
- task_prompt: testPrompt,
+ // Place the Aider mock CLI binary inside the container
+ if (!props?.skipAiderMock) {
+ await writeExecutable({
+ containerId: id,
+ filePath: "/usr/bin/aider",
+ content: await loadTestFile(`${import.meta.dir}`, "aider-mock.sh"),
});
+ }
- const instance = findResourceInstance(state, "coder_script");
- expect(instance.script).toContain(
- `This is your current task: ${testPrompt}`,
- );
- expect(instance.script).toContain("aider --architect --yes-always");
+ return { id };
+};
+
+setDefaultTimeout(60 * 1000);
+
+describe("Aider", async () => {
+ beforeAll(async () => {
+ await runTerraformInit(import.meta.dir);
});
- it("handles custom system prompt", async () => {
- const customPrompt = "Report all tasks with state: working";
- const state = await runTerraformApply(import.meta.dir, {
- agent_id: "foo",
- system_prompt: customPrompt,
+ test("happy-path", async () => {
+ const { id } = await setup({
+ moduleVariables: {
+ model: "gemini",
+ },
});
-
- const instance = findResourceInstance(state, "coder_script");
- expect(instance.script).toContain(customPrompt);
+ await execModuleScript(id);
+ await expectAgentAPIStarted(id);
});
- it("handles pre and post install scripts", async () => {
- const state = await runTerraformApply(import.meta.dir, {
- agent_id: "foo",
- experiment_pre_install_script: "echo 'Pre-install script executed'",
- experiment_post_install_script: "echo 'Post-install script executed'",
+ test("api-key", async () => {
+ const apiKey = "test-api-key-123";
+ const { id } = await setup({
+ moduleVariables: {
+ api_key: apiKey,
+ model: "gemini",
+ },
});
-
- const instance = findResourceInstance(state, "coder_script");
-
- expect(instance.script).toContain("Running pre-install script");
- expect(instance.script).toContain("Running post-install script");
- expect(instance.script).toContain("base64 -d > /tmp/pre_install.sh");
- expect(instance.script).toContain("base64 -d > /tmp/post_install.sh");
+ await execModuleScript(id);
+ const resp = await readFileContainer(
+ id,
+ "/home/coder/.aider-module/agentapi-start.log",
+ );
+ expect(resp).toContain("API key provided!");
});
- it("validates that use_screen and use_tmux cannot both be true", async () => {
- const state = await runTerraformApply(import.meta.dir, {
- agent_id: "foo",
- use_screen: true,
- use_tmux: true,
+ test("custom-folder", async () => {
+ const workdir = "/tmp/aider-test";
+ const { id } = await setup({
+ moduleVariables: {
+ workdir,
+ model: "gemini",
+ },
});
-
- const instance = findResourceInstance(state, "coder_script");
-
- expect(instance.script).toContain(
- "Error: Both use_screen and use_tmux cannot be enabled at the same time",
+ await execModuleScript(id);
+ const resp = await readFileContainer(
+ id,
+ "/home/coder/.aider-module/install.log",
);
- expect(instance.script).toContain("exit 1");
+ expect(resp).toContain(workdir);
});
- it("configures Aider with known provider and model", async () => {
- const state = await runTerraformApply(import.meta.dir, {
- agent_id: "foo",
- ai_provider: "anthropic",
- ai_model: "sonnet",
- ai_api_key: "test-anthropic-key",
+ test("pre-post-install-scripts", async () => {
+ const { id } = await setup({
+ moduleVariables: {
+ pre_install_script: "#!/bin/bash\necho 'pre-install-script'",
+ post_install_script: "#!/bin/bash\necho 'post-install-script'",
+ model: "gemini",
+ },
});
-
- const instance = findResourceInstance(state, "coder_script");
- expect(instance.script).toContain(
- 'export ANTHROPIC_API_KEY=\\"test-anthropic-key\\"',
+ await execModuleScript(id);
+ const preLog = await readFileContainer(
+ id,
+ "/home/coder/.aider-module/pre_install.log",
);
- expect(instance.script).toContain("--model sonnet");
- expect(instance.script).toContain(
- "Starting Aider using anthropic provider and model: sonnet",
- );
- });
-
- it("handles custom provider with custom env var and API key", async () => {
- const state = await runTerraformApply(import.meta.dir, {
- agent_id: "foo",
- ai_provider: "custom",
- custom_env_var_name: "MY_CUSTOM_API_KEY",
- ai_model: "custom-model",
- ai_api_key: "test-custom-key",
- });
-
- const instance = findResourceInstance(state, "coder_script");
- expect(instance.script).toContain(
- 'export MY_CUSTOM_API_KEY=\\"test-custom-key\\"',
- );
- expect(instance.script).toContain("--model custom-model");
- expect(instance.script).toContain(
- "Starting Aider using custom provider and model: custom-model",
+ expect(preLog).toContain("pre-install-script");
+ const postLog = await readFileContainer(
+ id,
+ "/home/coder/.aider-module/post_install.log",
);
+ expect(postLog).toContain("post-install-script");
});
});
diff --git a/registry/coder/modules/aider/main.tf b/registry/coder/modules/aider/main.tf
index e1f2eccd..70274cb8 100644
--- a/registry/coder/modules/aider/main.tf
+++ b/registry/coder/modules/aider/main.tf
@@ -36,87 +36,84 @@ variable "icon" {
default = "/icon/aider.svg"
}
-variable "folder" {
+variable "workdir" {
type = string
description = "The folder to run Aider in."
default = "/home/coder"
}
+variable "report_tasks" {
+ type = bool
+ description = "Whether to enable task reporting to Coder UI via AgentAPI"
+ default = false
+}
+
+variable "subdomain" {
+ type = bool
+ description = "Whether to use a subdomain for AgentAPI."
+ default = false
+}
+
+variable "cli_app" {
+ type = bool
+ description = "Whether to create a CLI app for Aider"
+ default = false
+}
+
+variable "web_app_display_name" {
+ type = string
+ description = "Display name for the web app"
+ default = "Aider"
+}
+
+variable "cli_app_display_name" {
+ type = string
+ description = "Display name for the CLI app"
+ default = "Aider CLI"
+}
+
+variable "pre_install_script" {
+ type = string
+ description = "Custom script to run before installing Aider."
+ default = null
+}
+
+variable "post_install_script" {
+ type = string
+ description = "Custom script to run after installing Aider."
+ default = null
+}
+
+variable "install_agentapi" {
+ type = bool
+ description = "Whether to install AgentAPI."
+ default = true
+}
+
+variable "agentapi_version" {
+ type = string
+ description = "The version of AgentAPI to install."
+ default = "v0.10.0"
+}
+
+variable "ai_prompt" {
+ type = string
+ description = "Initial task prompt for Aider."
+ default = ""
+}
+
+# ---------------------------------------------
+
variable "install_aider" {
type = bool
description = "Whether to install Aider."
default = true
}
-variable "aider_version" {
- type = string
- description = "The version of Aider to install."
- default = "latest"
-}
-
-variable "use_screen" {
- type = bool
- description = "Whether to use screen for running Aider in the background"
- default = true
-}
-
-variable "use_tmux" {
- type = bool
- description = "Whether to use tmux instead of screen for running Aider in the background"
- default = false
-}
-
-variable "session_name" {
- type = string
- description = "Name for the persistent session (screen or tmux)"
- default = "aider"
-}
-
-variable "experiment_report_tasks" {
- type = bool
- description = "Whether to enable task reporting."
- default = true
-}
-
variable "system_prompt" {
type = string
description = "System prompt for instructing Aider on task reporting and behavior"
- default = <<-EOT
-You are a helpful Coding assistant. Aim to autonomously investigate
-and solve issues the user gives you and test your work, whenever possible.
-Avoid shortcuts like mocking tests. When you get stuck, you can ask the user
-but opt for autonomy.
-YOU MUST REPORT ALL TASKS TO CODER.
-When reporting tasks, you MUST follow these EXACT instructions:
-- IMMEDIATELY report status after receiving ANY user message.
-- Be granular. If you are investigating with multiple steps, report each step to coder.
-Task state MUST be one of the following:
-- Use "state": "working" when actively processing WITHOUT needing additional user input.
-- Use "state": "complete" only when finished with a task.
-- Use "state": "failure" when you need ANY user input, lack sufficient details, or encounter blockers.
-Task summaries MUST:
-- Include specifics about what you're doing.
-- Include clear and actionable steps for the user.
-- Be less than 160 characters in length.
-EOT
-}
-
-variable "task_prompt" {
- type = string
- description = "Task prompt to use with Aider"
- default = ""
-}
-
-variable "experiment_pre_install_script" {
- type = string
- description = "Custom script to run before installing Aider."
- default = null
-}
-
-variable "experiment_post_install_script" {
- type = string
- description = "Custom script to run after installing Aider."
- default = null
+ default = "You are a helpful coding assistant that helps developers write, debug, and understand code. Provide clear explanations, follow best practices, and help solve coding problems efficiently."
}
variable "experiment_additional_extensions" {
@@ -128,20 +125,19 @@ variable "experiment_additional_extensions" {
variable "ai_provider" {
type = string
description = "AI provider to use with Aider (openai, anthropic, azure, google, etc.)"
- default = "anthropic"
+ default = "google"
validation {
condition = contains(["openai", "anthropic", "azure", "google", "cohere", "mistral", "ollama", "custom"], var.ai_provider)
- error_message = "ai_provider must be one of: openai, anthropic, azure, google, cohere, mistral, ollama, custom"
+ error_message = "provider must be one of: openai, anthropic, azure, google, cohere, mistral, ollama, custom"
}
}
-variable "ai_model" {
+variable "model" {
type = string
description = "AI model to use with Aider. Can use Aider's built-in aliases like '4o' (gpt-4o), 'sonnet' (claude-3-7-sonnet), 'opus' (claude-3-opus), etc."
- default = "sonnet"
}
-variable "ai_api_key" {
+variable "api_key" {
type = string
description = "API key for the selected AI provider. This will be set as the appropriate environment variable based on the provider."
default = ""
@@ -154,55 +150,66 @@ variable "custom_env_var_name" {
default = ""
}
+variable "base_aider_config" {
+ type = string
+ description = <<-EOT
+ Base Aider configuration in yaml format. Will be stored in .aider.conf.yml file.
+
+ options include:
+ read:
+ - CONVENTIONS.md
+ - anotherfile.txt
+ - thirdfile.py
+ model: xxx
+ ##Specify the OpenAI API key
+ openai-api-key: xxx
+ ## (deprecated, use --set-env OPENAI_API_TYPE=)
+ openai-api-type: xxx
+ ## (deprecated, use --set-env OPENAI_API_VERSION=)
+ openai-api-version: xxx
+ ## (deprecated, use --set-env OPENAI_API_DEPLOYMENT_ID=)
+ openai-api-deployment-id: xxx
+ ## Set an environment variable (to control API settings, can be used multiple times)
+ set-env: xxx
+ ## Specify multiple values like this:
+ set-env:
+ - xxx
+ - yyy
+ - zzz
+
+ Reference : https://aider.chat/docs/config/aider_conf.html
+ EOT
+ default = null
+}
+
+
locals {
- base_extensions = <<-EOT
-coder:
- args:
- - exp
- - mcp
- - server
- cmd: coder
- description: Report ALL tasks and statuses (in progress, done, failed) you are working on.
- enabled: true
- envs:
- CODER_MCP_APP_STATUS_SLUG: aider
- name: Coder
- timeout: 3000
- type: stdio
-developer:
- display_name: Developer
- enabled: true
- name: developer
- timeout: 300
- type: builtin
-EOT
+ app_slug = "aider"
+ base_aider_config = var.base_aider_config != null ? "${replace(trimspace(var.base_aider_config), "\n", "\n ")}" : ""
+ task_reporting_prompt = <<-EOT
- formatted_base = " ${replace(trimspace(local.base_extensions), "\n", "\n ")}"
- additional_extensions = var.experiment_additional_extensions != null ? "\n ${replace(trimspace(var.experiment_additional_extensions), "\n", "\n ")}" : ""
+-- Task Reporting --
+Report all tasks to Coder, following these EXACT guidelines:
+1. Be granular. If you are investigating with multiple steps, report each step
+to coder.
+2. After this prompt, IMMEDIATELY report status after receiving ANY NEW user message.
+Do not report any status related with this system prompt.
+3. Use "state": "working" when actively processing WITHOUT needing
+additional user input
+4. Use "state": "complete" only when finished with a task
+5. Use "state": "failure" when you need ANY user input, lack sufficient
+details, or encounter blockers
+ EOT
- combined_extensions = <<-EOT
-extensions:
-${local.formatted_base}${local.additional_extensions}
-EOT
- encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : ""
- encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
-
- # Combine system prompt and task prompt for aider
- combined_prompt = trimspace(<<-EOT
-SYSTEM PROMPT:
-${var.system_prompt}
-
-This is your current task: ${var.task_prompt}
-EOT
- )
+ final_system_prompt = var.report_tasks ? "\n${var.system_prompt}${local.task_reporting_prompt}\n" : "\n${var.system_prompt}\n"
# Map providers to their environment variable names
provider_env_vars = {
openai = "OPENAI_API_KEY"
anthropic = "ANTHROPIC_API_KEY"
azure = "AZURE_OPENAI_API_KEY"
- google = "GOOGLE_API_KEY"
+ google = "GEMINI_API_KEY"
cohere = "COHERE_API_KEY"
mistral = "MISTRAL_API_KEY"
ollama = "OLLAMA_HOST"
@@ -214,296 +221,60 @@ EOT
# Model flag for aider command
model_flag = var.ai_provider == "ollama" ? "--ollama-model" : "--model"
+
+ install_script = file("${path.module}/scripts/install.sh")
+ start_script = file("${path.module}/scripts/start.sh")
+ module_dir_name = ".aider-module"
}
-# Install and Initialize Aider
-resource "coder_script" "aider" {
- agent_id = var.agent_id
- display_name = "Aider"
- icon = var.icon
- script = <<-EOT
+module "agentapi" {
+ source = "registry.coder.com/coder/agentapi/coder"
+ version = "1.2.0"
+
+ agent_id = var.agent_id
+ 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
+ agentapi_subdomain = var.subdomain
+ module_dir_name = local.module_dir_name
+ install_agentapi = var.install_agentapi
+ agentapi_version = var.agentapi_version
+ pre_install_script = var.pre_install_script
+ post_install_script = var.post_install_script
+ start_script = <<-EOT
#!/bin/bash
- set -e
+ set -o errexit
+ set -o pipefail
- command_exists() {
- command -v "$1" >/dev/null 2>&1
- }
-
- echo "Setting up Aider AI pair programming..."
-
- if [ "${var.use_screen}" = "true" ] && [ "${var.use_tmux}" = "true" ]; then
- echo "Error: Both use_screen and use_tmux cannot be enabled at the same time."
- exit 1
- fi
-
- mkdir -p "${var.folder}"
-
- if [ "$(uname)" = "Linux" ]; then
- echo "Checking dependencies for Linux..."
-
- if [ "${var.use_tmux}" = "true" ]; then
- if ! command_exists tmux; then
- echo "Installing tmux for persistent sessions..."
- if command -v apt-get >/dev/null 2>&1; then
- if command -v sudo >/dev/null 2>&1; then
- sudo apt-get update -qq
- sudo apt-get install -y -qq tmux
- else
- apt-get update -qq || echo "Warning: Cannot update package lists without sudo privileges"
- apt-get install -y -qq tmux || echo "Warning: Cannot install tmux without sudo privileges"
- fi
- elif command -v dnf >/dev/null 2>&1; then
- if command -v sudo >/dev/null 2>&1; then
- sudo dnf install -y -q tmux
- else
- dnf install -y -q tmux || echo "Warning: Cannot install tmux without sudo privileges"
- fi
- else
- echo "Warning: Unable to install tmux on this system. Neither apt-get nor dnf found."
- fi
- else
- echo "tmux is already installed, skipping installation."
- fi
- elif [ "${var.use_screen}" = "true" ]; then
- if ! command_exists screen; then
- echo "Installing screen for persistent sessions..."
- if command -v apt-get >/dev/null 2>&1; then
- if command -v sudo >/dev/null 2>&1; then
- sudo apt-get update -qq
- sudo apt-get install -y -qq screen
- else
- apt-get update -qq || echo "Warning: Cannot update package lists without sudo privileges"
- apt-get install -y -qq screen || echo "Warning: Cannot install screen without sudo privileges"
- fi
- elif command -v dnf >/dev/null 2>&1; then
- if command -v sudo >/dev/null 2>&1; then
- sudo dnf install -y -q screen
- else
- dnf install -y -q screen || echo "Warning: Cannot install screen without sudo privileges"
- fi
- else
- echo "Warning: Unable to install screen on this system. Neither apt-get nor dnf found."
- fi
- else
- echo "screen is already installed, skipping installation."
- fi
- fi
- else
- echo "This module currently only supports Linux workspaces."
- exit 1
- fi
-
- if [ -n "${local.encoded_pre_install_script}" ]; then
- echo "Running pre-install script..."
- echo "${local.encoded_pre_install_script}" | base64 -d > /tmp/pre_install.sh
- chmod +x /tmp/pre_install.sh
- /tmp/pre_install.sh
- fi
-
- if [ "${var.install_aider}" = "true" ]; then
- echo "Installing Aider..."
-
- if ! command_exists python3 || ! command_exists pip3; then
- echo "Installing Python dependencies required for Aider..."
- if command -v apt-get >/dev/null 2>&1; then
- if command -v sudo >/dev/null 2>&1; then
- sudo apt-get update -qq
- sudo apt-get install -y -qq python3-pip python3-venv
- else
- apt-get update -qq || echo "Warning: Cannot update package lists without sudo privileges"
- apt-get install -y -qq python3-pip python3-venv || echo "Warning: Cannot install Python packages without sudo privileges"
- fi
- elif command -v dnf >/dev/null 2>&1; then
- if command -v sudo >/dev/null 2>&1; then
- sudo dnf install -y -q python3-pip python3-virtualenv
- else
- dnf install -y -q python3-pip python3-virtualenv || echo "Warning: Cannot install Python packages without sudo privileges"
- fi
- else
- echo "Warning: Unable to install Python on this system. Neither apt-get nor dnf found."
- fi
- else
- echo "Python is already installed, skipping installation."
- fi
-
- if ! command_exists aider; then
- curl -LsSf https://aider.chat/install.sh | sh
- fi
-
- if [ -f "$HOME/.bashrc" ]; then
- if ! grep -q 'export PATH="$HOME/bin:$PATH"' "$HOME/.bashrc"; then
- echo 'export PATH="$HOME/bin:$PATH"' >> "$HOME/.bashrc"
- fi
- fi
-
- if [ -f "$HOME/.zshrc" ]; then
- if ! grep -q 'export PATH="$HOME/bin:$PATH"' "$HOME/.zshrc"; then
- echo 'export PATH="$HOME/bin:$PATH"' >> "$HOME/.zshrc"
- fi
- fi
-
- fi
-
- if [ -n "${local.encoded_post_install_script}" ]; then
- echo "Running post-install script..."
- echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh
- chmod +x /tmp/post_install.sh
- /tmp/post_install.sh
- fi
-
- if [ "${var.experiment_report_tasks}" = "true" ]; then
- echo "Configuring Aider to report tasks via Coder MCP..."
-
- mkdir -p "$HOME/.config/aider"
-
- cat > "$HOME/.config/aider/config.yml" << EOL
-${trimspace(local.combined_extensions)}
-EOL
- echo "Added Coder MCP extension to Aider config.yml"
- fi
-
- echo "Starting persistent Aider session..."
-
- touch "$HOME/.aider.log"
-
- export LANG=en_US.UTF-8
- export LC_ALL=en_US.UTF-8
-
- export PATH="$HOME/bin:$PATH"
-
- if [ "${var.use_tmux}" = "true" ]; then
- if [ -n "${var.task_prompt}" ]; then
- echo "Running Aider with message in tmux session..."
-
- # Configure tmux for shared sessions
- if [ ! -f "$HOME/.tmux.conf" ]; then
- echo "Creating ~/.tmux.conf with shared session settings..."
- echo "set -g mouse on" > "$HOME/.tmux.conf"
- fi
-
- if ! grep -q "^set -g mouse on$" "$HOME/.tmux.conf"; then
- echo "Adding 'set -g mouse on' to ~/.tmux.conf..."
- echo "set -g mouse on" >> "$HOME/.tmux.conf"
- fi
-
- echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
- tmux new-session -d -s ${var.session_name} -c ${var.folder} "export ${local.env_var_name}=\"${var.ai_api_key}\"; aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\""
- echo "Aider task started in tmux session '${var.session_name}'. Check the UI for progress."
- else
- # Configure tmux for shared sessions
- if [ ! -f "$HOME/.tmux.conf" ]; then
- echo "Creating ~/.tmux.conf with shared session settings..."
- echo "set -g mouse on" > "$HOME/.tmux.conf"
- fi
-
- if ! grep -q "^set -g mouse on$" "$HOME/.tmux.conf"; then
- echo "Adding 'set -g mouse on' to ~/.tmux.conf..."
- echo "set -g mouse on" >> "$HOME/.tmux.conf"
- fi
-
- echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
- tmux new-session -d -s ${var.session_name} -c ${var.folder} "export ${local.env_var_name}=\"${var.ai_api_key}\"; aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${var.system_prompt}\""
- echo "Tmux session '${var.session_name}' started. Access it by clicking the Aider button."
- fi
- else
- if [ -n "${var.task_prompt}" ]; then
- echo "Running Aider with message in screen session..."
-
- if [ ! -f "$HOME/.screenrc" ]; then
- echo "Creating ~/.screenrc and adding multiuser settings..."
- echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
- fi
-
- if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
- echo "Adding 'multiuser on' to ~/.screenrc..."
- echo "multiuser on" >> "$HOME/.screenrc"
- fi
-
- if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then
- echo "Adding 'acladd $(whoami)' to ~/.screenrc..."
- echo "acladd $(whoami)" >> "$HOME/.screenrc"
- fi
-
- echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
- screen -U -dmS ${var.session_name} bash -c "
- cd ${var.folder}
- export PATH=\"$HOME/bin:$HOME/.local/bin:$PATH\"
- export ${local.env_var_name}=\"${var.ai_api_key}\"
- aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\"
- /bin/bash
- "
-
- echo "Aider task started in screen session '${var.session_name}'. Check the UI for progress."
- else
-
- if [ ! -f "$HOME/.screenrc" ]; then
- echo "Creating ~/.screenrc and adding multiuser settings..."
- echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
- fi
-
- if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
- echo "Adding 'multiuser on' to ~/.screenrc..."
- echo "multiuser on" >> "$HOME/.screenrc"
- fi
-
- if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then
- echo "Adding 'acladd $(whoami)' to ~/.screenrc..."
- echo "acladd $(whoami)" >> "$HOME/.screenrc"
- fi
-
- echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
- screen -U -dmS ${var.session_name} bash -c "
- cd ${var.folder}
- export PATH=\"$HOME/bin:$HOME/.local/bin:$PATH\"
- export ${local.env_var_name}=\"${var.ai_api_key}\"
- aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\"
- /bin/bash
- "
- echo "Screen session '${var.session_name}' started. Access it by clicking the Aider button."
- fi
- fi
-
- echo "Aider setup complete!"
+ echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
+ chmod +x /tmp/start.sh
+ ARG_WORKDIR='${var.workdir}' \
+ ARG_API_KEY='${base64encode(var.api_key)}' \
+ ARG_MODEL='${var.model}' \
+ ARG_PROVIDER='${var.ai_provider}' \
+ ARG_ENV_API_NAME_HOLDER='${local.env_var_name}' \
+ ARG_SYSTEM_PROMPT='${base64encode(local.final_system_prompt)}' \
+ ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
+ /tmp/start.sh
EOT
- run_on_start = true
-}
-# Aider CLI app
-resource "coder_app" "aider_cli" {
- agent_id = var.agent_id
- slug = "aider"
- display_name = "Aider"
- icon = var.icon
- command = <<-EOT
+ install_script = <<-EOT
#!/bin/bash
- set -e
+ set -o errexit
+ set -o pipefail
- export PATH="$HOME/bin:$HOME/.local/bin:$PATH"
-
- export LANG=en_US.UTF-8
- export LC_ALL=en_US.UTF-8
-
- if [ "${var.use_tmux}" = "true" ]; then
- if tmux has-session -t ${var.session_name} 2>/dev/null; then
- echo "Attaching to existing Aider tmux session..."
- tmux attach-session -t ${var.session_name}
- else
- echo "Starting new Aider tmux session..."
- tmux new-session -s ${var.session_name} -c ${var.folder} "export ${local.env_var_name}=\"${var.ai_api_key}\"; aider ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\"; exec bash"
- fi
- elif [ "${var.use_screen}" = "true" ]; then
- if ! screen -list | grep -q "${var.session_name}"; then
- echo "Error: No existing Aider session found. Please wait for the script to start it."
- exit 1
- fi
- screen -xRR ${var.session_name}
- else
- cd "${var.folder}"
- echo "Starting Aider directly..."
- export ${local.env_var_name}="${var.ai_api_key}"
- aider ${local.model_flag} ${var.ai_model} --message "${local.combined_prompt}"
- fi
+ echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
+ chmod +x /tmp/install.sh
+ ARG_WORKDIR='${var.workdir}' \
+ ARG_INSTALL_AIDER='${var.install_aider}' \
+ ARG_REPORT_TASKS='${var.report_tasks}' \
+ ARG_AIDER_CONFIG="$(echo -n '${base64encode(local.base_aider_config)}' | base64 -d)" \
+ /tmp/install.sh
EOT
- order = var.order
- group = var.group
}
+
diff --git a/registry/coder/modules/aider/main.tftest.hcl b/registry/coder/modules/aider/main.tftest.hcl
new file mode 100644
index 00000000..281bde86
--- /dev/null
+++ b/registry/coder/modules/aider/main.tftest.hcl
@@ -0,0 +1,149 @@
+run "test_aider_basic" {
+ command = plan
+
+ variables {
+ agent_id = "test-agent-123"
+ workdir = "/home/coder"
+ model = "gemini"
+ }
+
+ assert {
+ condition = var.workdir == "/home/coder"
+ error_message = "Workdir variable should default to /home/coder"
+ }
+
+ assert {
+ condition = var.agent_id == "test-agent-123"
+ error_message = "Agent ID variable should be set correctly"
+ }
+
+ assert {
+ condition = var.install_aider == true
+ error_message = "install_aider should default to true"
+ }
+
+ assert {
+ condition = var.install_agentapi == true
+ error_message = "install_agentapi should default to true"
+ }
+
+ assert {
+ condition = var.report_tasks == false
+ error_message = "report_tasks should default to false"
+ }
+}
+
+run "test_with_api_key" {
+ command = plan
+
+ variables {
+ agent_id = "test-agent-456"
+ workdir = "/home/coder/workspace"
+ api_key = "test-api-key-123"
+ model = "gemini"
+ }
+
+ assert {
+ condition = var.api_key == "test-api-key-123"
+ error_message = "API key value should match the input"
+ }
+}
+
+run "test_custom_options" {
+ command = plan
+
+ variables {
+ agent_id = "test-agent-789"
+ workdir = "/home/coder/custom"
+ order = 5
+ group = "development"
+ icon = "/icon/custom.svg"
+ model = "4o"
+ ai_prompt = "Help me write better code"
+ install_aider = false
+ install_agentapi = false
+ agentapi_version = "v0.10.0"
+ api_key = ""
+ base_aider_config = "read:\n - CONVENTIONS.md"
+ }
+
+ assert {
+ condition = var.order == 5
+ error_message = "Order variable should be set to 5"
+ }
+
+ assert {
+ condition = var.group == "development"
+ error_message = "Group variable should be set to 'development'"
+ }
+
+ assert {
+ condition = var.icon == "/icon/custom.svg"
+ error_message = "Icon variable should be set to custom icon"
+ }
+
+ assert {
+ condition = var.model == "4o"
+ error_message = "Model variable should be set to '4o'"
+ }
+
+ assert {
+ condition = var.ai_prompt == "Help me write better code"
+ error_message = "AI prompt variable should be set correctly"
+ }
+
+ assert {
+ condition = var.install_aider == false
+ error_message = "install_aider should be set to false"
+ }
+
+ assert {
+ condition = var.install_agentapi == false
+ error_message = "install_agentapi should be set to false"
+ }
+
+ assert {
+ condition = var.agentapi_version == "v0.10.0"
+ error_message = "AgentAPI version should be set to 'v0.10.0'"
+ }
+}
+
+run "test_with_scripts" {
+ command = plan
+
+ variables {
+ agent_id = "test-agent-scripts"
+ workdir = "/home/coder/scripts"
+ model = "gemini"
+ 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"
+ }
+}
+
+run "test_ai_provider_env_mapping" {
+ command = plan
+
+ variables {
+ agent_id = "test-agent-provider"
+ workdir = "/home/coder/test"
+ ai_provider = "google"
+ model = "gemini"
+ custom_env_var_name = ""
+ }
+
+ # Ensure provider -> env var mapping works as expected (based on locals.provider_env_vars)
+ assert {
+ condition = var.ai_provider == "google"
+ error_message = "AI provider should be set to 'google' for this test"
+ }
+}
diff --git a/registry/coder/modules/aider/scripts/install.sh b/registry/coder/modules/aider/scripts/install.sh
new file mode 100644
index 00000000..b2244aa0
--- /dev/null
+++ b/registry/coder/modules/aider/scripts/install.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+set -euo pipefail
+
+# Function to check if a command exists
+command_exists() {
+ command -v "$1" > /dev/null 2>&1
+}
+
+# Inputs
+ARG_WORKDIR=${ARG_WORKDIR:-/home/coder}
+ARG_INSTALL_AIDER=${ARG_INSTALL_AIDER:-true}
+ARG_AIDER_CONFIG=${ARG_AIDER_CONFIG:-}
+
+echo "--------------------------------"
+echo "Install flag: $ARG_INSTALL_AIDER"
+echo "Workspace: $ARG_WORKDIR"
+echo "--------------------------------"
+
+function install_aider() {
+ echo "pipx installing..."
+ sudo apt-get install -y pipx
+ echo "pipx installed!"
+ pipx ensurepath
+ mkdir -p "$ARG_WORKDIR/.local/bin"
+ export PATH="$HOME/.local/bin:$ARG_WORKDIR/.local/bin:$PATH"
+
+ if ! command_exists aider; then
+ echo "Installing Aider via pipx..."
+ pipx install --force aider-install
+ aider-install
+ fi
+ echo "Aider installed: $(aider --version || echo 'Aider installation check failed')"
+}
+
+function configure_aider_settings() {
+ if [ -n "${ARG_AIDER_CONFIG}" ]; then
+ echo "Configuring Aider environment variables and model"
+
+ mkdir -p "$HOME/.config/aider"
+
+ echo "$ARG_AIDER_CONFIG" > "$HOME/.config/aider/.aider.conf.yml"
+ echo "Aider config created at $HOME/.config/aider/.aider.conf.yml"
+ else
+ printf "No Aider environment variables or model configured\n"
+ fi
+}
+
+install_aider
+configure_aider_settings
diff --git a/registry/coder/modules/aider/scripts/start.sh b/registry/coder/modules/aider/scripts/start.sh
new file mode 100644
index 00000000..1bd18ffa
--- /dev/null
+++ b/registry/coder/modules/aider/scripts/start.sh
@@ -0,0 +1,55 @@
+#!/bin/bash
+set -euo pipefail
+
+# Ensure pipx-installed apps are in PATH
+export PATH="$HOME/.local/bin:$PATH"
+
+ARG_WORKDIR=${ARG_WORKDIR:-/home/coder}
+ARG_API_KEY=$(echo -n "${ARG_API_KEY:-}" | base64 -d)
+ARG_SYSTEM_PROMPT=$(echo -n "${ARG_SYSTEM_PROMPT:-}" | base64 -d 2> /dev/null || echo "")
+ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d 2> /dev/null || echo "")
+ARG_MODEL=${ARG_MODEL:-}
+ARG_PROVIDER=${ARG_PROVIDER:-}
+ARG_ENV_API_NAME_HOLDER=${ARG_ENV_API_NAME_HOLDER:-}
+
+echo "--------------------------------"
+echo "Provider: $ARG_PROVIDER"
+echo "Model: $ARG_MODEL"
+echo "--------------------------------"
+
+if [ -n "$ARG_API_KEY" ]; then
+ printf "API key provided!\n"
+ export $ARG_ENV_API_NAME_HOLDER=$ARG_API_KEY
+else
+ printf "API key not provided.\n"
+fi
+
+build_initial_prompt() {
+ local initial_prompt=""
+ if [ -n "$ARG_AI_PROMPT" ]; then
+ if [ -n "$ARG_SYSTEM_PROMPT" ]; then
+ initial_prompt="$ARG_SYSTEM_PROMPT $ARG_AI_PROMPT"
+ else
+ initial_prompt="$ARG_AI_PROMPT"
+ fi
+ fi
+ echo "$initial_prompt"
+}
+
+start_agentapi() {
+ echo "Starting in directory: $ARG_WORKDIR"
+ cd "$ARG_WORKDIR"
+
+ local initial_prompt
+ initial_prompt=$(build_initial_prompt)
+ if [ -n "$initial_prompt" ]; then
+ echo "Starting agentapi with initial prompt"
+ agentapi server -I="$initial_prompt" --type aider --term-width=67 --term-height=1190 -- aider --model $ARG_MODEL --yes-always
+ else
+ agentapi server --term-width=67 --term-height=1190 -- aider --model $ARG_MODEL --yes-always
+ fi
+}
+
+# TODO: Implement MCP server for coder when Aider support MCP servers.
+
+start_agentapi
diff --git a/registry/coder/modules/aider/testdata/aider-mock.sh b/registry/coder/modules/aider/testdata/aider-mock.sh
new file mode 100644
index 00000000..e021b2d2
--- /dev/null
+++ b/registry/coder/modules/aider/testdata/aider-mock.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+if [[ "$1" == "--version" ]]; then
+ echo "HELLO: $(bash -c env)"
+ echo "aider version v0.86.0"
+ exit 0
+fi
+
+set -e
+
+while true; do
+ echo "$(date) - aider-agent-mock"
+ sleep 15
+done
diff --git a/registry/coder/modules/amazon-q/README.md b/registry/coder/modules/amazon-q/README.md
index 3146f01e..71444a65 100644
--- a/registry/coder/modules/amazon-q/README.md
+++ b/registry/coder/modules/amazon-q/README.md
@@ -13,7 +13,7 @@ Run [Amazon Q](https://aws.amazon.com/q/) in your workspace to access Amazon's A
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
- version = "2.1.1"
+ version = "3.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder"
@@ -102,7 +102,7 @@ data "coder_parameter" "ai_prompt" {
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
- version = "2.1.1"
+ version = "3.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -228,7 +228,7 @@ If no custom `agent_config` is provided, the default agent name "agent" is used.
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
- version = "2.1.1"
+ version = "3.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -258,7 +258,7 @@ This example will:
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
- version = "2.1.1"
+ version = "3.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -279,7 +279,7 @@ module "amazon-q" {
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
- version = "2.1.1"
+ version = "3.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -305,7 +305,7 @@ module "amazon-q" {
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
- version = "2.1.1"
+ version = "3.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -319,7 +319,7 @@ module "amazon-q" {
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
- version = "2.1.1"
+ version = "3.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -340,7 +340,7 @@ module "amazon-q" {
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
- version = "2.1.1"
+ version = "3.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -358,7 +358,7 @@ For environments without direct internet access, you can host Amazon Q installat
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
- version = "2.1.1"
+ version = "3.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
diff --git a/registry/coder/modules/amazon-q/main.tf b/registry/coder/modules/amazon-q/main.tf
index 84ac3c03..1fec87da 100644
--- a/registry/coder/modules/amazon-q/main.tf
+++ b/registry/coder/modules/amazon-q/main.tf
@@ -6,7 +6,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
- version = ">= 2.7"
+ version = ">= 2.12"
}
}
}
@@ -214,7 +214,7 @@ locals {
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
- version = "1.2.0"
+ version = "2.0.0"
agent_id = var.agent_id
folder = local.workdir
@@ -268,3 +268,7 @@ module "agentapi" {
/tmp/install.sh
EOT
}
+
+output "task_app_id" {
+ value = module.agentapi.task_app_id
+}
diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md
index 3e690ce2..d2a92aff 100644
--- a/registry/coder/modules/claude-code/README.md
+++ b/registry/coder/modules/claude-code/README.md
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
- version = "3.2.1"
+ version = "4.0.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
@@ -32,6 +32,10 @@ module "claude-code" {
- You can get the API key from the [Anthropic Console](https://console.anthropic.com/dashboard).
- You can get the Session Token using the `claude setup-token` command. This is a long-lived authentication token (requires Claude subscription)
+### Session Resumption Behavior
+
+By default, Claude Code automatically resumes existing conversations when your workspace restarts. Sessions are tracked per workspace directory, so conversations continue where you left off. If no session exists (first start), your `ai_prompt` will run normally. To disable this behavior and always start fresh, set `continue = false`
+
## Examples
### Usage with Agent Boundaries
@@ -47,7 +51,7 @@ module "claude-code" {
boundary_log_level = "WARN"
boundary_additional_allowed_urls = ["GET *google.com"]
boundary_proxy_port = "8087"
- version = "3.2.1"
+ version = "3.4.3"
}
```
@@ -66,7 +70,7 @@ data "coder_parameter" "ai_prompt" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
- version = "3.2.1"
+ version = "4.0.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
@@ -102,7 +106,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 = "3.2.1"
+ version = "4.0.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
install_claude_code = true
@@ -125,7 +129,7 @@ variable "claude_code_oauth_token" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
- version = "3.2.1"
+ version = "4.0.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
@@ -198,7 +202,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
- version = "3.2.1"
+ version = "4.0.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -255,7 +259,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
- version = "3.2.1"
+ version = "4.0.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts
index 9c132f1a..94fcb391 100644
--- a/registry/coder/modules/claude-code/main.test.ts
+++ b/registry/coder/modules/claude-code/main.test.ts
@@ -167,7 +167,7 @@ describe("claude-code", async () => {
const { id } = await setup({
moduleVariables: {
permission_mode: mode,
- task_prompt: "test prompt",
+ ai_prompt: "test prompt",
},
});
await execModuleScript(id);
@@ -185,7 +185,7 @@ describe("claude-code", async () => {
const { id } = await setup({
moduleVariables: {
model: model,
- task_prompt: "test prompt",
+ ai_prompt: "test prompt",
},
});
await execModuleScript(id);
@@ -198,13 +198,63 @@ describe("claude-code", async () => {
expect(startLog.stdout).toContain(`--model ${model}`);
});
- test("claude-continue-previous-conversation", async () => {
+ test("claude-continue-resume-task-session", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
- task_prompt: "test prompt",
+ report_tasks: "true",
+ ai_prompt: "test prompt",
},
});
+
+ // Create a mock task session file with the hardcoded task session ID
+ const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2";
+ const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
+ await execContainer(id, ["mkdir", "-p", sessionDir]);
+ await execContainer(id, [
+ "bash",
+ "-c",
+ `touch ${sessionDir}/session-${taskSessionId}.jsonl`,
+ ]);
+
+ await execModuleScript(id);
+
+ const startLog = await execContainer(id, [
+ "bash",
+ "-c",
+ "cat /home/coder/.claude-module/agentapi-start.log",
+ ]);
+ expect(startLog.stdout).toContain("--resume");
+ expect(startLog.stdout).toContain(taskSessionId);
+ expect(startLog.stdout).toContain("Resuming existing task session");
+ expect(startLog.stdout).toContain("--dangerously-skip-permissions");
+ });
+
+ test("claude-continue-resume-standalone-session", async () => {
+ const { id } = await setup({
+ moduleVariables: {
+ continue: "true",
+ report_tasks: "false",
+ ai_prompt: "test prompt",
+ },
+ });
+
+ const sessionId = "some-random-session-id";
+ const workdir = "/home/coder/project";
+ const claudeJson = {
+ projects: {
+ [workdir]: {
+ lastSessionId: sessionId,
+ },
+ },
+ };
+
+ await execContainer(id, [
+ "bash",
+ "-c",
+ `echo '${JSON.stringify(claudeJson)}' > /home/coder/.claude.json`,
+ ]);
+
await execModuleScript(id);
const startLog = await execContainer(id, [
@@ -213,6 +263,7 @@ describe("claude-code", async () => {
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(startLog.stdout).toContain("--continue");
+ expect(startLog.stdout).toContain("Resuming existing session");
});
test("pre-post-install-scripts", async () => {
diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf
index df3eaaa5..9c1816ad 100644
--- a/registry/coder/modules/claude-code/main.tf
+++ b/registry/coder/modules/claude-code/main.tf
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
- version = ">= 2.7"
+ version = ">= 2.12"
}
}
}
@@ -134,8 +134,8 @@ variable "resume_session_id" {
variable "continue" {
type = bool
- description = "Load the most recent conversation in the current directory. Task will fail in a new workspace with no conversation/session to continue"
- default = false
+ description = "Automatically continue existing sessions on workspace restart. When true, resumes existing conversation if found, otherwise runs prompt or starts new session. When false, always starts fresh (ignores existing sessions)."
+ default = true
}
variable "dangerously_skip_permissions" {
@@ -228,6 +228,18 @@ variable "boundary_proxy_port" {
default = "8087"
}
+variable "enable_boundary_pprof" {
+ type = bool
+ description = "Whether to enable coder boundary pprof server"
+ default = false
+}
+
+variable "boundary_pprof_port" {
+ type = string
+ description = "Port for pprof server used by Boundary"
+ default = "6067"
+}
+
resource "coder_env" "claude_code_md_path" {
count = var.claude_md_path == "" ? 0 : 1
@@ -258,7 +270,7 @@ resource "coder_env" "claude_api_key" {
locals {
# we have to trim the slash because otherwise coder exp mcp will
- # set up an invalid claude config
+ # set up an invalid claude config
workdir = trimsuffix(var.workdir, "/")
app_slug = "ccw"
install_script = file("${path.module}/scripts/install.sh")
@@ -301,9 +313,8 @@ locals {
}
module "agentapi" {
-
source = "registry.coder.com/coder/agentapi/coder"
- version = "1.2.0"
+ version = "2.0.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
@@ -337,12 +348,15 @@ module "agentapi" {
ARG_PERMISSION_MODE='${var.permission_mode}' \
ARG_WORKDIR='${local.workdir}' \
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
+ ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
ARG_BOUNDARY_LOG_DIR='${var.boundary_log_dir}' \
ARG_BOUNDARY_LOG_LEVEL='${var.boundary_log_level}' \
- ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS='${join(" ", var.boundary_additional_allowed_urls)}' \
+ ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS='${join("|", var.boundary_additional_allowed_urls)}' \
ARG_BOUNDARY_PROXY_PORT='${var.boundary_proxy_port}' \
+ ARG_ENABLE_BOUNDARY_PPROF='${var.enable_boundary_pprof}' \
+ ARG_BOUNDARY_PPROF_PORT='${var.boundary_pprof_port}' \
ARG_CODER_HOST='${local.coder_host}' \
/tmp/start.sh
EOT
@@ -365,3 +379,7 @@ module "agentapi" {
/tmp/install.sh
EOT
}
+
+output "task_app_id" {
+ value = module.agentapi.task_app_id
+}
diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl
index 6994caf2..adfca6d2 100644
--- a/registry/coder/modules/claude-code/main.tftest.hcl
+++ b/registry/coder/modules/claude-code/main.tftest.hcl
@@ -57,7 +57,7 @@ run "test_claude_code_with_custom_options" {
group = "development"
icon = "/icon/custom.svg"
model = "opus"
- task_prompt = "Help me write better code"
+ ai_prompt = "Help me write better code"
permission_mode = "plan"
continue = true
install_claude_code = false
@@ -88,8 +88,8 @@ run "test_claude_code_with_custom_options" {
}
assert {
- condition = var.task_prompt == "Help me write better code"
- error_message = "Task prompt variable should be set correctly"
+ condition = var.ai_prompt == "Help me write better code"
+ error_message = "AI prompt variable should be set correctly"
}
assert {
diff --git a/registry/coder/modules/claude-code/scripts/install.sh b/registry/coder/modules/claude-code/scripts/install.sh
index 1285df90..80f84e6d 100644
--- a/registry/coder/modules/claude-code/scripts/install.sh
+++ b/registry/coder/modules/claude-code/scripts/install.sh
@@ -1,10 +1,12 @@
#!/bin/bash
-set -euo pipefail
if [ -f "$HOME/.bashrc" ]; then
source "$HOME"/.bashrc
fi
+# Set strict error handling AFTER sourcing bashrc to avoid unbound variable errors from user dotfiles
+set -euo pipefail
+
BOLD='\033[0;1m'
command_exists() {
@@ -91,11 +93,6 @@ function report_tasks() {
export CODER_MCP_APP_STATUS_SLUG="$ARG_MCP_APP_STATUS_SLUG"
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
coder exp mcp configure claude-code "$ARG_WORKDIR"
- else
- export CODER_MCP_APP_STATUS_SLUG=""
- export CODER_MCP_AI_AGENTAPI_URL=""
- echo "Configuring Claude Code with Coder MCP..."
- coder exp mcp configure claude-code "$ARG_WORKDIR"
fi
}
diff --git a/registry/coder/modules/claude-code/scripts/remove-last-session-id.sh b/registry/coder/modules/claude-code/scripts/remove-last-session-id.sh
index dac86a03..d72369fa 100755
--- a/registry/coder/modules/claude-code/scripts/remove-last-session-id.sh
+++ b/registry/coder/modules/claude-code/scripts/remove-last-session-id.sh
@@ -26,15 +26,19 @@ echo ".claude.json path $claude_json_path"
# Check if .claude.json exists
if [ ! -f "$claude_json_path" ]; then
echo "No .claude.json file found"
- exit 0
+ exit 1
fi
# Use jq to check if lastSessionId exists for the working directory and remove it
if jq -e ".projects[\"$working_dir\"].lastSessionId" "$claude_json_path" > /dev/null 2>&1; then
# Remove lastSessionId and update the file
- jq "del(.projects[\"$working_dir\"].lastSessionId)" "$claude_json_path" > "${claude_json_path}.tmp" && mv "${claude_json_path}.tmp" "$claude_json_path"
- echo "Removed lastSessionId from .claude.json"
+ if jq "del(.projects[\"$working_dir\"].lastSessionId)" "$claude_json_path" > "${claude_json_path}.tmp" && mv "${claude_json_path}.tmp" "$claude_json_path"; then
+ echo "Removed lastSessionId from .claude.json"
+ exit 0
+ else
+ echo "Failed to remove lastSessionId from .claude.json"
+ fi
else
echo "No lastSessionId found in .claude.json - nothing to do"
fi
diff --git a/registry/coder/modules/claude-code/scripts/start.sh b/registry/coder/modules/claude-code/scripts/start.sh
index daef71a3..fb5bafcc 100644
--- a/registry/coder/modules/claude-code/scripts/start.sh
+++ b/registry/coder/modules/claude-code/scripts/start.sh
@@ -1,9 +1,12 @@
#!/bin/bash
-set -euo pipefail
if [ -f "$HOME/.bashrc" ]; then
source "$HOME"/.bashrc
fi
+
+# Set strict error handling AFTER sourcing bashrc to avoid unbound variable errors from user dotfiles
+set -euo pipefail
+
export PATH="$HOME/.local/bin:$PATH"
command_exists() {
@@ -17,11 +20,14 @@ ARG_DANGEROUSLY_SKIP_PERMISSIONS=${ARG_DANGEROUSLY_SKIP_PERMISSIONS:-}
ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-}
ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d)
+ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true}
ARG_ENABLE_BOUNDARY=${ARG_ENABLE_BOUNDARY:-false}
ARG_BOUNDARY_VERSION=${ARG_BOUNDARY_VERSION:-"main"}
ARG_BOUNDARY_LOG_DIR=${ARG_BOUNDARY_LOG_DIR:-"/tmp/boundary_logs"}
ARG_BOUNDARY_LOG_LEVEL=${ARG_BOUNDARY_LOG_LEVEL:-"WARN"}
ARG_BOUNDARY_PROXY_PORT=${ARG_BOUNDARY_PROXY_PORT:-"8087"}
+ARG_ENABLE_BOUNDARY_PPROF=${ARG_ENABLE_BOUNDARY_PPROF:-false}
+ARG_BOUNDARY_PPROF_PORT=${ARG_BOUNDARY_PPROF_PORT:-"6067"}
ARG_CODER_HOST=${ARG_CODER_HOST:-}
echo "--------------------------------"
@@ -33,6 +39,7 @@ printf "ARG_DANGEROUSLY_SKIP_PERMISSIONS: %s\n" "$ARG_DANGEROUSLY_SKIP_PERMISSIO
printf "ARG_PERMISSION_MODE: %s\n" "$ARG_PERMISSION_MODE"
printf "ARG_AI_PROMPT: %s\n" "$ARG_AI_PROMPT"
printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR"
+printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS"
printf "ARG_ENABLE_BOUNDARY: %s\n" "$ARG_ENABLE_BOUNDARY"
printf "ARG_BOUNDARY_VERSION: %s\n" "$ARG_BOUNDARY_VERSION"
printf "ARG_BOUNDARY_LOG_DIR: %s\n" "$ARG_BOUNDARY_LOG_DIR"
@@ -42,10 +49,18 @@ printf "ARG_CODER_HOST: %s\n" "$ARG_CODER_HOST"
echo "--------------------------------"
-# see the remove-last-session-id.sh script for details
-# about why we need it
-# avoid exiting if the script fails
-bash "/tmp/remove-last-session-id.sh" "$(pwd)" 2> /dev/null || true
+# Clean up stale session data (see remove-last-session-id.sh for details)
+CAN_CONTINUE_CONVERSATION=false
+set +e
+bash "/tmp/remove-last-session-id.sh" "$(pwd)" 2> /dev/null
+session_cleanup_exit_code=$?
+set -e
+
+case $session_cleanup_exit_code in
+ 0)
+ CAN_CONTINUE_CONVERSATION=true
+ ;;
+esac
function install_boundary() {
# Install boundary from public github repo
@@ -64,37 +79,102 @@ function validate_claude_installation() {
fi
}
+# Hardcoded task session ID for Coder task reporting
+# This ensures all task sessions use a consistent, predictable ID
+TASK_SESSION_ID="cd32e253-ca16-4fd3-9825-d837e74ae3c2"
+
+task_session_exists() {
+ local workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-')
+ local project_dir="$HOME/.claude/projects/${workdir_normalized}"
+
+ if [ -d "$project_dir" ] && find "$project_dir" -type f -name "*${TASK_SESSION_ID}*" 2> /dev/null | grep -q .; then
+ return 0
+ else
+ return 1
+ fi
+}
+
ARGS=()
-function build_claude_args() {
+function start_agentapi() {
+ # For Task reporting
+ export CODER_MCP_ALLOWED_TOOLS="coder_report_task"
+
+ mkdir -p "$ARG_WORKDIR"
+ cd "$ARG_WORKDIR"
+
if [ -n "$ARG_MODEL" ]; then
ARGS+=(--model "$ARG_MODEL")
fi
- if [ -n "$ARG_RESUME_SESSION_ID" ]; then
- ARGS+=(--resume "$ARG_RESUME_SESSION_ID")
- fi
-
- if [ "$ARG_CONTINUE" = "true" ]; then
- ARGS+=(--continue)
- fi
-
if [ -n "$ARG_PERMISSION_MODE" ]; then
ARGS+=(--permission-mode "$ARG_PERMISSION_MODE")
fi
-}
-
-function start_agentapi() {
- mkdir -p "$ARG_WORKDIR"
- cd "$ARG_WORKDIR"
- if [ -n "$ARG_AI_PROMPT" ]; then
- ARGS+=(--dangerously-skip-permissions "$ARG_AI_PROMPT")
- else
- if [ -n "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" ]; then
+ if [ -n "$ARG_RESUME_SESSION_ID" ]; then
+ echo "Resuming task session by ID: $ARG_RESUME_SESSION_ID"
+ ARGS+=(--resume "$ARG_RESUME_SESSION_ID")
+ if [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ]; then
ARGS+=(--dangerously-skip-permissions)
fi
+ elif [ "$ARG_CONTINUE" = "true" ]; then
+ if [ "$ARG_REPORT_TASKS" = "true" ] && task_session_exists; then
+ echo "Task session detected (ID: $TASK_SESSION_ID)"
+ ARGS+=(--resume "$TASK_SESSION_ID")
+ ARGS+=(--dangerously-skip-permissions)
+ echo "Resuming existing task session"
+ elif [ "$ARG_REPORT_TASKS" = "false" ] && [ "$CAN_CONTINUE_CONVERSATION" = true ]; then
+ echo "Previous session exists"
+ ARGS+=(--continue)
+ if [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ]; then
+ ARGS+=(--dangerously-skip-permissions)
+ fi
+ echo "Resuming existing session"
+ else
+ echo "No existing session found"
+ if [ "$ARG_REPORT_TASKS" = "true" ]; then
+ ARGS+=(--session-id "$TASK_SESSION_ID")
+ fi
+ if [ -n "$ARG_AI_PROMPT" ]; then
+ if [ "$ARG_REPORT_TASKS" = "true" ]; then
+ ARGS+=(--dangerously-skip-permissions -- "$ARG_AI_PROMPT")
+ else
+ if [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ]; then
+ ARGS+=(--dangerously-skip-permissions)
+ fi
+ ARGS+=(-- "$ARG_AI_PROMPT")
+ fi
+ echo "Starting new session with prompt"
+ else
+ if [ "$ARG_REPORT_TASKS" = "true" ] || [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ]; then
+ ARGS+=(--dangerously-skip-permissions)
+ fi
+ echo "Starting new session"
+ fi
+ fi
+ else
+ echo "Continue disabled, starting fresh session"
+ if [ "$ARG_REPORT_TASKS" = "true" ]; then
+ ARGS+=(--session-id "$TASK_SESSION_ID")
+ fi
+ if [ -n "$ARG_AI_PROMPT" ]; then
+ if [ "$ARG_REPORT_TASKS" = "true" ]; then
+ ARGS+=(--dangerously-skip-permissions -- "$ARG_AI_PROMPT")
+ else
+ if [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ]; then
+ ARGS+=(--dangerously-skip-permissions)
+ fi
+ ARGS+=(-- "$ARG_AI_PROMPT")
+ fi
+ echo "Starting new session with prompt"
+ else
+ if [ "$ARG_REPORT_TASKS" = "true" ] || [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ]; then
+ ARGS+=(--dangerously-skip-permissions)
+ fi
+ echo "Starting claude code session"
+ fi
fi
+
printf "Running claude code with args: %s\n" "$(printf '%q ' "${ARGS[@]}")"
if [ "${ARG_ENABLE_BOUNDARY:-false}" = "true" ]; then
@@ -106,12 +186,13 @@ function start_agentapi() {
# Build boundary args with conditional --unprivileged flag
BOUNDARY_ARGS=(--log-dir "$ARG_BOUNDARY_LOG_DIR")
# Add default allowed URLs
- BOUNDARY_ARGS+=(--allow "*anthropic.com" --allow "registry.npmjs.org" --allow "*sentry.io" --allow "claude.ai" --allow "$ARG_CODER_HOST")
+ BOUNDARY_ARGS+=(--allow "domain=anthropic.com" --allow "domain=registry.npmjs.org" --allow "domain=sentry.io" --allow "domain=claude.ai" --allow "domain=$ARG_CODER_HOST")
# Add any additional allowed URLs from the variable
if [ -n "$ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS" ]; then
- IFS=' ' read -ra ADDITIONAL_URLS <<< "$ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS"
+ IFS='|' read -ra ADDITIONAL_URLS <<< "$ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS"
for url in "${ADDITIONAL_URLS[@]}"; do
+ # Quote the URL to preserve spaces within the allow rule
BOUNDARY_ARGS+=(--allow "$url")
done
fi
@@ -122,23 +203,20 @@ function start_agentapi() {
# Set log level for boundary
BOUNDARY_ARGS+=(--log-level $ARG_BOUNDARY_LOG_LEVEL)
- # Remove --dangerously-skip-permissions from ARGS when using boundary (it doesn't work with elevated permissions)
- # Create a new array without the dangerous permissions flag
- CLAUDE_ARGS=()
- for arg in "${ARGS[@]}"; do
- if [ "$arg" != "--dangerously-skip-permissions" ]; then
- CLAUDE_ARGS+=("$arg")
- fi
- done
+ if [ "${ARG_ENABLE_BOUNDARY_PPROF:-false}" = "true" ]; then
+ # Enable boundary pprof server on specified port
+ BOUNDARY_ARGS+=(--pprof)
+ BOUNDARY_ARGS+=(--pprof-port ${ARG_BOUNDARY_PPROF_PORT})
+ fi
agentapi server --allowed-hosts="*" --type claude --term-width 67 --term-height 1190 -- \
- sudo -E env PATH=$PATH setpriv --inh-caps=+net_admin --ambient-caps=+net_admin --bounding-set=+net_admin boundary "${BOUNDARY_ARGS[@]}" -- \
- claude "${CLAUDE_ARGS[@]}"
+ sudo -E env PATH=$PATH setpriv --reuid=$(id -u) --regid=$(id -g) --clear-groups \
+ --inh-caps=+net_admin --ambient-caps=+net_admin --bounding-set=+net_admin boundary "${BOUNDARY_ARGS[@]}" -- \
+ claude "${ARGS[@]}"
else
agentapi server --type claude --term-width 67 --term-height 1190 -- claude "${ARGS[@]}"
fi
}
validate_claude_installation
-build_claude_args
start_agentapi
diff --git a/registry/coder/modules/code-server/README.md b/registry/coder/modules/code-server/README.md
index 13fb7b72..b9ed6b72 100644
--- a/registry/coder/modules/code-server/README.md
+++ b/registry/coder/modules/code-server/README.md
@@ -14,7 +14,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
- version = "1.3.1"
+ version = "1.4.0"
agent_id = coder_agent.example.id
}
```
@@ -29,7 +29,7 @@ module "code-server" {
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
- version = "1.3.1"
+ version = "1.4.0"
agent_id = coder_agent.example.id
install_version = "4.8.3"
}
@@ -43,7 +43,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
- version = "1.3.1"
+ version = "1.4.0"
agent_id = coder_agent.example.id
extensions = [
"dracula-theme.theme-dracula"
@@ -61,7 +61,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
- version = "1.3.1"
+ version = "1.4.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -78,12 +78,26 @@ Just run code-server in the background, don't fetch it from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
- version = "1.3.1"
+ version = "1.4.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
}
```
+### Pass Additional Arguments
+
+You can pass additional command-line arguments to code-server using the `additional_args` variable. For example, to disable workspace trust:
+
+```tf
+module "code-server" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/coder/code-server/coder"
+ version = "1.4.0"
+ agent_id = coder_agent.example.id
+ additional_args = "--disable-workspace-trust"
+}
+```
+
### Offline and Use Cached Modes
By default the module looks for code-server at `/tmp/code-server` but this can be changed with `install_prefix`.
@@ -94,7 +108,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
- version = "1.3.1"
+ version = "1.4.0"
agent_id = coder_agent.example.id
use_cached = true
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
@@ -107,7 +121,7 @@ Just run code-server in the background, don't fetch it from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
- version = "1.3.1"
+ version = "1.4.0"
agent_id = coder_agent.example.id
offline = true
}
diff --git a/registry/coder/modules/code-server/main.tf b/registry/coder/modules/code-server/main.tf
index 650829f6..f5651353 100644
--- a/registry/coder/modules/code-server/main.tf
+++ b/registry/coder/modules/code-server/main.tf
@@ -148,6 +148,12 @@ variable "open_in" {
}
}
+variable "additional_args" {
+ type = string
+ description = "Additional command-line arguments to pass to code-server (e.g., '--disable-workspace-trust')."
+ default = ""
+}
+
resource "coder_script" "code-server" {
agent_id = var.agent_id
display_name = "code-server"
@@ -168,6 +174,7 @@ resource "coder_script" "code-server" {
EXTENSIONS_DIR : var.extensions_dir,
FOLDER : var.folder,
AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
+ ADDITIONAL_ARGS : var.additional_args,
})
run_on_start = true
diff --git a/registry/coder/modules/code-server/run.sh b/registry/coder/modules/code-server/run.sh
index 73bcd689..55918fa4 100644
--- a/registry/coder/modules/code-server/run.sh
+++ b/registry/coder/modules/code-server/run.sh
@@ -16,7 +16,7 @@ fi
function run_code_server() {
echo "👷 Running code-server in the background..."
echo "Check logs at ${LOG_PATH}!"
- $CODE_SERVER "$EXTENSION_ARG" --auth none --port "${PORT}" --app-name "${APP_NAME}" > "${LOG_PATH}" 2>&1 &
+ $CODE_SERVER "$EXTENSION_ARG" --auth none --port "${PORT}" --app-name "${APP_NAME}" ${ADDITIONAL_ARGS} > "${LOG_PATH}" 2>&1 &
}
# Check if the settings file exists...
diff --git a/registry/coder/modules/goose/README.md b/registry/coder/modules/goose/README.md
index f4f91ab5..89fd7280 100644
--- a/registry/coder/modules/goose/README.md
+++ b/registry/coder/modules/goose/README.md
@@ -13,7 +13,7 @@ Run the [Goose](https://block.github.io/goose/) agent in your workspace to gener
```tf
module "goose" {
source = "registry.coder.com/coder/goose/coder"
- version = "2.2.1"
+ version = "3.0.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
@@ -79,7 +79,7 @@ resource "coder_agent" "main" {
module "goose" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/goose/coder"
- version = "2.2.1"
+ version = "3.0.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
diff --git a/registry/coder/modules/goose/main.tf b/registry/coder/modules/goose/main.tf
index 51f8b6d6..b7db4f99 100644
--- a/registry/coder/modules/goose/main.tf
+++ b/registry/coder/modules/goose/main.tf
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
- version = ">= 2.7"
+ version = ">= 2.12"
}
}
}
@@ -140,7 +140,7 @@ EOT
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
- version = "1.2.0"
+ version = "2.0.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
@@ -174,3 +174,7 @@ module "agentapi" {
/tmp/install.sh
EOT
}
+
+output "task_app_id" {
+ value = module.agentapi.task_app_id
+}
diff --git a/registry/coder/modules/jetbrains/README.md b/registry/coder/modules/jetbrains/README.md
index ef19ec20..7b55232c 100644
--- a/registry/coder/modules/jetbrains/README.md
+++ b/registry/coder/modules/jetbrains/README.md
@@ -14,7 +14,7 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
- version = "1.1.0"
+ version = "1.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
# tooltip = "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button." # Optional
@@ -40,7 +40,7 @@ When `default` contains IDE codes, those IDEs are created directly without user
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
- version = "1.1.0"
+ version = "1.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA
@@ -53,7 +53,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
- version = "1.1.0"
+ version = "1.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
# Show parameter with limited options
@@ -67,7 +67,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
- version = "1.1.0"
+ version = "1.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
default = ["IU", "PY"]
@@ -82,7 +82,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
- version = "1.1.0"
+ version = "1.2.0"
agent_id = coder_agent.example.id
folder = "/workspace/project"
@@ -108,7 +108,7 @@ module "jetbrains" {
module "jetbrains_pycharm" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
- version = "1.1.0"
+ version = "1.2.0"
agent_id = coder_agent.example.id
folder = "/workspace/project"
@@ -128,7 +128,7 @@ Add helpful tooltip text that appears when users hover over the IDE app buttons:
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
- version = "1.1.0"
+ version = "1.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
default = ["IU", "PY"]
@@ -136,6 +136,26 @@ module "jetbrains" {
}
```
+### Accessing the IDE Metadata
+
+You can now reference the output `ide_metadata` as a map.
+
+```tf
+# Add metadata to the container showing the installed IDEs and their build versions.
+resource "coder_metadata" "container_info" {
+ count = data.coder_workspace.me.start_count
+ resource_id = one(docker_container.workspace).id
+
+ dynamic "item" {
+ for_each = length(module.jetbrains) > 0 ? one(module.jetbrains).ide_metadata : {}
+ content {
+ key = item.value.build
+ value = "${item.value.name} [${item.key}]"
+ }
+ }
+}
+```
+
## Behavior
### Parameter vs Direct Apps
diff --git a/registry/coder/modules/jetbrains/jetbrains.tftest.hcl b/registry/coder/modules/jetbrains/jetbrains.tftest.hcl
index 7676c34f..21726c25 100644
--- a/registry/coder/modules/jetbrains/jetbrains.tftest.hcl
+++ b/registry/coder/modules/jetbrains/jetbrains.tftest.hcl
@@ -1,3 +1,53 @@
+variables {
+ # Default IDE config, mirrored from main.tf for test assertions.
+ # If main.tf defaults change, update this map to match.
+ expected_ide_config = {
+ "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "251.26927.39" },
+ "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "251.26927.50" },
+ "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "251.26927.53" },
+ "PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "251.26927.60" },
+ "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "251.26927.74" },
+ "RD" = { name = "Rider", icon = "/icon/rider.svg", build = "251.26927.67" },
+ "RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "251.26927.47" },
+ "RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "251.26927.79" },
+ "WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "251.26927.40" }
+ }
+}
+
+run "validate_test_config_matches_defaults" {
+ command = plan
+
+ variables {
+ # Provide minimal vars to allow plan to read module variables
+ agent_id = "foo"
+ folder = "/home/coder"
+ }
+
+ assert {
+ condition = length(var.ide_config) == length(var.expected_ide_config)
+ error_message = "Test configuration mismatch: 'var.ide_config' in main.tf has ${length(var.ide_config)} items, but 'var.expected_ide_config' in the test file has ${length(var.expected_ide_config)} items. Please update the test file's global variables block."
+ }
+
+ assert {
+ # Check that all keys in the test local are present in the module's default
+ condition = alltrue([
+ for key in keys(var.expected_ide_config) :
+ can(var.ide_config[key])
+ ])
+ error_message = "Test configuration mismatch: Keys in 'var.expected_ide_config' are out of sync with 'var.ide_config' defaults. Please update the test file's global variables block."
+ }
+
+ assert {
+ # Check if all build numbers in the test local match the module's defaults
+ # This relies on the previous two assertions passing (same length, same keys)
+ condition = alltrue([
+ for key, config in var.expected_ide_config :
+ var.ide_config[key].build == config.build
+ ])
+ error_message = "Test configuration mismatch: One or more build numbers in 'var.expected_ide_config' do not match the defaults in 'var.ide_config'. Please update the test file's global variables block."
+ }
+}
+
run "requires_agent_and_folder" {
command = plan
@@ -160,3 +210,87 @@ run "tooltip_null_when_not_provided" {
error_message = "Expected coder_app tooltip to be null when not provided"
}
}
+
+run "output_empty_when_default_empty" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ folder = "/home/coder"
+ # var.default is empty
+ }
+
+ assert {
+ condition = length(output.ide_metadata) == 0
+ error_message = "Expected ide_metadata output to be empty when var.default is not set"
+ }
+}
+
+run "output_single_ide_uses_fallback_build" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ folder = "/home/coder"
+ default = ["GO"]
+ # Force HTTP data source to fail to test fallback logic
+ releases_base_link = "https://coder.com"
+ }
+
+ assert {
+ condition = length(output.ide_metadata) == 1
+ error_message = "Expected ide_metadata output to have 1 item"
+ }
+
+ assert {
+ condition = can(output.ide_metadata["GO"])
+ error_message = "Expected ide_metadata output to have key 'GO'"
+ }
+
+ assert {
+ condition = output.ide_metadata["GO"].name == var.expected_ide_config["GO"].name
+ error_message = "Expected ide_metadata['GO'].name to be '${var.expected_ide_config["GO"].name}'"
+ }
+
+ assert {
+ condition = output.ide_metadata["GO"].build == var.expected_ide_config["GO"].build
+ error_message = "Expected ide_metadata['GO'].build to use the fallback '${var.expected_ide_config["GO"].build}'"
+ }
+
+ assert {
+ condition = output.ide_metadata["GO"].icon == var.expected_ide_config["GO"].icon
+ error_message = "Expected ide_metadata['GO'].icon to be '${var.expected_ide_config["GO"].icon}'"
+ }
+}
+
+run "output_multiple_ides" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ folder = "/home/coder"
+ default = ["IU", "PY"]
+ # Force HTTP data source to fail to test fallback logic
+ releases_base_link = "https://coder.com"
+ }
+
+ assert {
+ condition = length(output.ide_metadata) == 2
+ error_message = "Expected ide_metadata output to have 2 items"
+ }
+
+ assert {
+ condition = can(output.ide_metadata["IU"]) && can(output.ide_metadata["PY"])
+ error_message = "Expected ide_metadata output to have keys 'IU' and 'PY'"
+ }
+
+ assert {
+ condition = output.ide_metadata["PY"].name == var.expected_ide_config["PY"].name
+ error_message = "Expected ide_metadata['PY'].name to be '${var.expected_ide_config["PY"].name}'"
+ }
+
+ assert {
+ condition = output.ide_metadata["PY"].build == var.expected_ide_config["PY"].build
+ error_message = "Expected ide_metadata['PY'].build to be the fallback '${var.expected_ide_config["PY"].build}'"
+ }
+}
diff --git a/registry/coder/modules/jetbrains/main.tf b/registry/coder/modules/jetbrains/main.tf
index d33fc6b2..51f7c816 100644
--- a/registry/coder/modules/jetbrains/main.tf
+++ b/registry/coder/modules/jetbrains/main.tf
@@ -1,5 +1,5 @@
terraform {
- required_version = ">= 1.0"
+ required_version = ">= 1.9"
required_providers {
coder = {
@@ -163,7 +163,8 @@ variable "ide_config" {
condition = length(var.ide_config) > 0
error_message = "The ide_config must not be empty."
}
- # ide_config must be a superset of var.. options
+ # ide_config must be a superset of var.options
+ # Requires Terraform 1.9+ for cross-variable validation references
validation {
condition = alltrue([
for code in var.options : contains(keys(var.ide_config), code)
@@ -256,4 +257,13 @@ resource "coder_app" "jetbrains" {
local.options_metadata[each.key].build,
var.agent_name != null ? "&agent_name=${var.agent_name}" : "",
])
-}
\ No newline at end of file
+}
+
+output "ide_metadata" {
+ description = "A map of the metadata for each selected JetBrains IDE."
+ value = {
+ # We iterate directly over the selected_ides map.
+ # 'key' will be the IDE key (e.g., "IC", "PY")
+ for key, val in local.selected_ides : key => local.options_metadata[key]
+ }
+}
diff --git a/registry/coder/modules/kasmvnc/README.md b/registry/coder/modules/kasmvnc/README.md
index 2bc862d4..7f01b45b 100644
--- a/registry/coder/modules/kasmvnc/README.md
+++ b/registry/coder/modules/kasmvnc/README.md
@@ -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.4"
+ version = "1.2.5"
agent_id = coder_agent.example.id
desktop_environment = "xfce"
subdomain = true
diff --git a/registry/coder/modules/kasmvnc/main.tf b/registry/coder/modules/kasmvnc/main.tf
index ca7315ec..5a5b449b 100644
--- a/registry/coder/modules/kasmvnc/main.tf
+++ b/registry/coder/modules/kasmvnc/main.tf
@@ -23,7 +23,7 @@ variable "port" {
variable "kasm_version" {
type = string
description = "Version of KasmVNC to install."
- default = "1.3.2"
+ default = "1.4.0"
}
variable "desktop_environment" {
diff --git a/registry/coder/modules/kasmvnc/run.sh b/registry/coder/modules/kasmvnc/run.sh
index 04b8b9ee..089dce3e 100644
--- a/registry/coder/modules/kasmvnc/run.sh
+++ b/registry/coder/modules/kasmvnc/run.sh
@@ -8,10 +8,10 @@ error() {
exit 1
}
-# Function to check if vncserver is already installed
+# Function to check if KasmVNC is already installed
check_installed() {
- if command -v vncserver &> /dev/null; then
- echo "vncserver is already installed."
+ if command -v kasmvncserver &> /dev/null; then
+ echo "KasmVNC is already installed."
return 0 # Don't exit, just indicate it's installed
else
return 1 # Indicates not installed
@@ -158,7 +158,7 @@ case "$arch" in
;;
esac
-# Check if vncserver is installed, and install if not
+# Check if KasmVNC is installed, and install if not
if ! check_installed; then
# Check for NOPASSWD sudo (required)
if ! command -v sudo &> /dev/null || ! sudo -n true 2> /dev/null; then
@@ -188,7 +188,7 @@ if ! check_installed; then
;;
esac
else
- echo "vncserver already installed. Skipping installation."
+ echo "KasmVNC already installed. Skipping installation."
fi
if command -v sudo &> /dev/null && sudo -n true 2> /dev/null; then
@@ -227,7 +227,7 @@ EOF
# This password is not used since we start the server without auth.
# The server is protected via the Coder session token / tunnel
# and does not listen publicly
-echo -e "password\npassword\n" | vncpasswd -wo -u "$USER"
+echo -e "password\npassword\n" | kasmvncpasswd -wo -u "$USER"
get_http_dir() {
# determine the served file path
@@ -290,7 +290,7 @@ VNC_LOG="/tmp/kasmvncserver.log"
printf "🚀 Starting KasmVNC server...\n"
set +e
-vncserver -select-de "${DESKTOP_ENVIRONMENT}" -disableBasicAuth > "$VNC_LOG" 2>&1
+kasmvncserver -select-de "${DESKTOP_ENVIRONMENT}" -disableBasicAuth > "$VNC_LOG" 2>&1
RETVAL=$?
set -e
diff --git a/registry/coder/modules/kiro/kiro.tftest.hcl b/registry/coder/modules/kiro/kiro.tftest.hcl
new file mode 100644
index 00000000..b132551a
--- /dev/null
+++ b/registry/coder/modules/kiro/kiro.tftest.hcl
@@ -0,0 +1,124 @@
+run "required_vars" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ }
+}
+
+run "default_output" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ }
+
+ assert {
+ condition = output.kiro_url == "kiro://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN"
+ error_message = "Default kiro_url must match expected value"
+ }
+
+ assert {
+ condition = coder_app.kiro.order == null
+ error_message = "coder_app order must be null by default"
+ }
+}
+
+run "adds_folder" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ folder = "/foo/bar"
+ }
+
+ assert {
+ condition = output.kiro_url == "kiro://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN"
+ error_message = "URL must include folder parameter"
+ }
+}
+
+run "folder_and_open_recent" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ folder = "/foo/bar"
+ open_recent = true
+ }
+
+ assert {
+ condition = output.kiro_url == "kiro://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN"
+ error_message = "URL must include folder and openRecent parameters"
+ }
+}
+
+run "custom_slug_display_name" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ slug = "kiro-ai"
+ display_name = "Kiro AI IDE"
+ }
+
+ assert {
+ condition = coder_app.kiro.slug == "kiro-ai"
+ error_message = "coder_app slug must be set to kiro-ai"
+ }
+
+ assert {
+ condition = coder_app.kiro.display_name == "Kiro AI IDE"
+ error_message = "coder_app display_name must be set to Kiro AI IDE"
+ }
+}
+
+run "sets_order" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ order = 5
+ }
+
+ assert {
+ condition = coder_app.kiro.order == 5
+ error_message = "coder_app order must be set to 5"
+ }
+}
+
+run "sets_group" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ group = "AI IDEs"
+ }
+
+ assert {
+ condition = coder_app.kiro.group == "AI IDEs"
+ error_message = "coder_app group must be set to AI IDEs"
+ }
+}
+
+run "writes_mcp_json" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ mcp = jsonencode({
+ servers = {
+ demo = { url = "http://localhost:1234" }
+ }
+ })
+ }
+
+ assert {
+ condition = strcontains(coder_script.kiro_mcp[0].script, base64encode(jsonencode({
+ servers = {
+ demo = { url = "http://localhost:1234" }
+ }
+ })))
+ error_message = "coder_script must contain base64-encoded MCP JSON"
+ }
+}
\ No newline at end of file
diff --git a/registry/coder/modules/mux/README.md b/registry/coder/modules/mux/README.md
new file mode 100644
index 00000000..9bd85e20
--- /dev/null
+++ b/registry/coder/modules/mux/README.md
@@ -0,0 +1,104 @@
+---
+display_name: mux
+description: Coding Agent Multiplexer - Run multiple AI agents in parallel
+icon: ../../../../.icons/mux.svg
+verified: false
+tags: [ai, agents, development, multiplexer]
+---
+
+# mux
+
+Automatically install and run mux in a Coder workspace. By default, the module installs `mux@next` from npm (with a fallback to downloading the npm tarball if npm is unavailable). 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.0.0"
+ agent_id = coder_agent.example.id
+}
+```
+
+## Features
+
+- **Parallel Agent Execution**: Run multiple AI agents simultaneously on different tasks
+- **Mux Workspace Isolation**: Each agent works in its own isolated environment
+- **Git Divergence Visualization**: Track changes across different mux agent workspaces
+- **Long-Running Processes**: Resume AI work after interruptions
+- **Cost Tracking**: Monitor API usage across agents
+
+## Examples
+
+### Basic Usage
+
+```tf
+module "mux" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/coder/mux/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.example.id
+}
+```
+
+### Pin Version
+
+```tf
+module "mux" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/coder/mux/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.example.id
+ # Default is "latest"; set to a specific version to pin
+ install_version = "0.4.0"
+}
+```
+
+### Custom Port
+
+```tf
+module "mux" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/coder/mux/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.example.id
+ port = 8080
+}
+```
+
+### Use Cached Installation
+
+Run an existing copy of mux if found, otherwise install from npm:
+
+```tf
+module "mux" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/coder/mux/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.example.id
+ use_cached = true
+}
+```
+
+### Skip Install
+
+Run without installing from the network (requires mux to be pre-installed):
+
+```tf
+module "mux" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/coder/mux/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.example.id
+ install = false
+}
+```
+
+## Supported Platforms
+
+- Linux (x86_64, aarch64)
+
+## Notes
+
+- mux is currently in preview and you may encounter bugs
+- Requires internet connectivity for agent operations (unless `install` is set to false)
+- Installs `mux@next` from npm by default (falls back to the npm tarball if npm is unavailable)
diff --git a/registry/coder/modules/mux/main.test.ts b/registry/coder/modules/mux/main.test.ts
new file mode 100644
index 00000000..efc00460
--- /dev/null
+++ b/registry/coder/modules/mux/main.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "bun:test";
+import {
+ executeScriptInContainer,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "~test";
+
+describe("mux", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ });
+
+ it("runs with default", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ });
+
+ const output = await executeScriptInContainer(
+ state,
+ "alpine/curl",
+ "sh",
+ "apk add --no-cache bash tar gzip ca-certificates findutils nodejs && update-ca-certificates",
+ );
+ if (output.exitCode !== 0) {
+ console.log("STDOUT:\n" + output.stdout.join("\n"));
+ console.log("STDERR:\n" + output.stderr.join("\n"));
+ }
+ expect(output.exitCode).toBe(0);
+ const expectedLines = [
+ "📥 npm not found; downloading tarball from npm registry...",
+ "🥳 mux has been installed in /tmp/mux",
+ "🚀 Starting mux server on port 4000...",
+ "Check logs at /tmp/mux.log!",
+ ];
+ for (const line of expectedLines) {
+ expect(output.stdout).toContain(line);
+ }
+ }, 60000);
+
+ it("runs with npm present", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ });
+
+ const output = await executeScriptInContainer(
+ state,
+ "node:20-alpine",
+ "sh",
+ "apk add bash",
+ );
+
+ expect(output.exitCode).toBe(0);
+ const expectedLines = [
+ "📦 Installing mux via npm into /tmp/mux...",
+ "🥳 mux has been installed in /tmp/mux",
+ "🚀 Starting mux server on port 4000...",
+ "Check logs at /tmp/mux.log!",
+ ];
+ for (const line of expectedLines) {
+ expect(output.stdout).toContain(line);
+ }
+ }, 60000);
+});
diff --git a/registry/coder/modules/mux/main.tf b/registry/coder/modules/mux/main.tf
new file mode 100644
index 00000000..08c70aab
--- /dev/null
+++ b/registry/coder/modules/mux/main.tf
@@ -0,0 +1,158 @@
+terraform {
+ # Requires Terraform 1.9+ for cross-variable validation references
+ required_version = ">= 1.9"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 2.5"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "port" {
+ type = number
+ description = "The port to run mux on."
+ default = 4000
+}
+
+variable "display_name" {
+ type = string
+ description = "The display name for the mux application."
+ default = "mux"
+}
+
+variable "slug" {
+ type = string
+ description = "The slug for the mux application."
+ default = "mux"
+}
+
+variable "install_prefix" {
+ type = string
+ description = "The prefix to install mux to."
+ default = "/tmp/mux"
+}
+
+variable "log_path" {
+ type = string
+ description = "The path for mux logs."
+ default = "/tmp/mux.log"
+}
+
+variable "add-project" {
+ type = string
+ description = "Path to add/open as a project in mux (idempotent)."
+ default = ""
+}
+
+variable "install_version" {
+ type = string
+ description = "The version or dist-tag of mux to install."
+ default = "next"
+}
+
+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'."
+ }
+}
+
+variable "order" {
+ type = number
+ description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+variable "group" {
+ type = string
+ description = "The name of a group that this app belongs to."
+ default = null
+}
+
+variable "install" {
+ type = bool
+ description = "Install mux from the network (npm or tarball). If false, run without installing (requires a pre-installed mux)."
+ default = true
+}
+
+variable "use_cached" {
+ type = bool
+ description = "Use cached copy of mux if present; otherwise install from npm"
+ default = false
+}
+
+variable "subdomain" {
+ type = bool
+ description = <<-EOT
+ Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
+ If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
+ EOT
+ default = false
+}
+
+variable "open_in" {
+ type = string
+ description = <<-EOT
+ Determines where the app will be opened. Valid values are `"tab"` and `"slim-window" (default)`.
+ `"tab"` opens in a new tab in the same browser window.
+ `"slim-window"` opens a new browser window without navigation controls.
+ EOT
+ default = "slim-window"
+ validation {
+ condition = contains(["tab", "slim-window"], var.open_in)
+ error_message = "The 'open_in' variable must be one of: 'tab', 'slim-window'."
+ }
+}
+
+resource "coder_script" "mux" {
+ agent_id = var.agent_id
+ display_name = "mux"
+ icon = "/icon/mux.svg"
+ script = templatefile("${path.module}/run.sh", {
+ VERSION : var.install_version,
+ PORT : var.port,
+ LOG_PATH : var.log_path,
+ ADD_PROJECT : var.add-project,
+ INSTALL_PREFIX : var.install_prefix,
+ OFFLINE : !var.install,
+ USE_CACHED : var.use_cached,
+ })
+ run_on_start = true
+
+ lifecycle {
+ precondition {
+ condition = var.install || !var.use_cached
+ error_message = "Cannot use 'use_cached' when 'install' is false"
+ }
+ }
+}
+
+resource "coder_app" "mux" {
+ agent_id = var.agent_id
+ slug = var.slug
+ display_name = var.display_name
+ url = "http://localhost:${var.port}"
+ icon = "/icon/mux.svg"
+ subdomain = var.subdomain
+ share = var.share
+ order = var.order
+ group = var.group
+ open_in = var.open_in
+
+ healthcheck {
+ url = "http://localhost:${var.port}/health"
+ interval = 5
+ threshold = 6
+ }
+}
+
+
diff --git a/registry/coder/modules/mux/mux.tftest.hcl b/registry/coder/modules/mux/mux.tftest.hcl
new file mode 100644
index 00000000..c403d377
--- /dev/null
+++ b/registry/coder/modules/mux/mux.tftest.hcl
@@ -0,0 +1,66 @@
+run "required_vars" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ }
+}
+
+run "install_false_and_use_cached_conflict" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ use_cached = true
+ install = false
+ }
+
+ expect_failures = [
+ resource.coder_script.mux
+ ]
+}
+
+run "custom_port" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ port = 8080
+ }
+
+ assert {
+ condition = resource.coder_app.mux.url == "http://localhost:8080"
+ error_message = "coder_app URL must use the configured port"
+ }
+}
+
+run "custom_version" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ install_version = "0.3.0"
+ }
+}
+
+# install=false should succeed
+run "install_false_only_success" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ install = false
+ }
+}
+
+# use_cached-only should succeed
+run "use_cached_only_success" {
+ command = plan
+
+ variables {
+ agent_id = "foo"
+ use_cached = true
+ }
+}
+
+
diff --git a/registry/coder/modules/mux/run.sh b/registry/coder/modules/mux/run.sh
new file mode 100644
index 00000000..c202a9ee
--- /dev/null
+++ b/registry/coder/modules/mux/run.sh
@@ -0,0 +1,195 @@
+#!/usr/bin/env bash
+
+BOLD='\033[0;1m'
+RESET='\033[0m'
+MUX_BINARY="${INSTALL_PREFIX}/mux"
+
+function run_mux() {
+ local port_value
+ port_value="${PORT}"
+ if [ -z "$port_value" ]; then
+ port_value="4000"
+ fi
+ # Build args for mux (POSIX-compatible, avoid bash arrays)
+ set -- server --port "$port_value"
+ if [ -n "${ADD_PROJECT}" ]; then
+ set -- "$@" --add-project "${ADD_PROJECT}"
+ fi
+ echo "🚀 Starting mux server on port $port_value..."
+ echo "Check logs at ${LOG_PATH}!"
+ PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
+}
+
+# Check if mux is already installed for offline mode
+if [ "${OFFLINE}" = true ]; then
+ if [ -f "$MUX_BINARY" ]; then
+ echo "🥳 Found a copy of mux"
+ run_mux
+ exit 0
+ fi
+ echo "❌ Failed to find a copy of mux"
+ exit 1
+fi
+
+# If there is no cached install OR we don't want to use a cached install
+if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
+ printf "$${BOLD}Installing mux from npm...\n"
+
+ # Clean up from other install (in case install prefix changed).
+ if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/mux" ]; then
+ rm "$CODER_SCRIPT_BIN_DIR/mux"
+ fi
+
+ mkdir -p "$(dirname "$MUX_BINARY")"
+
+ if command -v npm > /dev/null 2>&1; then
+ echo "📦 Installing mux via npm into ${INSTALL_PREFIX}..."
+ NPM_WORKDIR="${INSTALL_PREFIX}/npm"
+ mkdir -p "$NPM_WORKDIR"
+ cd "$NPM_WORKDIR" || exit 1
+ if [ ! -f package.json ]; then
+ echo '{}' > package.json
+ fi
+ PKG="mux"
+ if [ -z "${VERSION}" ] || [ "${VERSION}" = "latest" ]; then
+ PKG_SPEC="$PKG@latest"
+ else
+ PKG_SPEC="$PKG@${VERSION}"
+ fi
+ if ! npm install --no-audit --no-fund --omit=dev "$PKG_SPEC"; then
+ echo "❌ Failed to install mux via npm"
+ exit 1
+ fi
+ # Determine the installed binary path
+ BIN_DIR="$NPM_WORKDIR/node_modules/.bin"
+ CANDIDATE="$BIN_DIR/mux"
+ if [ ! -f "$CANDIDATE" ]; then
+ echo "❌ Could not locate mux binary after npm install"
+ exit 1
+ fi
+ chmod +x "$CANDIDATE" || true
+ ln -sf "$CANDIDATE" "$MUX_BINARY"
+ else
+ echo "📥 npm not found; downloading tarball from npm registry..."
+ VERSION_TO_USE="${VERSION}"
+ if [ -z "$VERSION_TO_USE" ]; then
+ VERSION_TO_USE="next"
+ fi
+ META_URL="https://registry.npmjs.org/mux/$VERSION_TO_USE"
+ META_JSON="$(curl -fsSL "$META_URL" || true)"
+ if [ -z "$META_JSON" ]; then
+ echo "❌ Failed to fetch npm metadata: $META_URL"
+ exit 1
+ fi
+ # Normalize JSON to a single line for robust pattern matching across environments
+ META_ONE_LINE="$(printf "%s" "$META_JSON" | tr -d '\n' || true)"
+ if [ -z "$META_ONE_LINE" ]; then
+ META_ONE_LINE="$META_JSON"
+ fi
+ # Try to extract tarball URL directly from metadata (prefer Node if available for robust JSON parsing)
+ TARBALL_URL=""
+ if command -v node > /dev/null 2>&1; then
+ TARBALL_URL="$(printf "%s" "$META_JSON" | node -e 'try{const fs=require("fs");const data=JSON.parse(fs.readFileSync(0,"utf8"));if(data&&data.dist&&data.dist.tarball){console.log(data.dist.tarball);}}catch(e){}')"
+ fi
+ # sed-based fallback
+ if [ -z "$TARBALL_URL" ]; then
+ TARBALL_URL="$(printf "%s" "$META_ONE_LINE" | sed -n 's/.*\"tarball\":\"\\([^\"]*\\)\".*/\\1/p' | head -n1)"
+ fi
+ # Fallback: resolve version then construct tarball URL
+ if [ -z "$TARBALL_URL" ]; then
+ RESOLVED_VERSION=""
+ if command -v node > /dev/null 2>&1; then
+ RESOLVED_VERSION="$(printf "%s" "$META_JSON" | node -e 'try{const fs=require("fs");const data=JSON.parse(fs.readFileSync(0,"utf8"));if(data&&data.version){console.log(data.version);}}catch(e){}')"
+ fi
+ if [ -z "$RESOLVED_VERSION" ]; then
+ RESOLVED_VERSION="$(printf "%s" "$META_ONE_LINE" | sed -n 's/.*\"version\":\"\\([^\"]*\\)\".*/\\1/p' | head -n1)"
+ fi
+ if [ -z "$RESOLVED_VERSION" ]; then
+ RESOLVED_VERSION="$(printf "%s" "$META_ONE_LINE" | grep -o '\"version\":\"[^\"]*\"' | head -n1 | cut -d '\"' -f4)"
+ fi
+ if [ -n "$RESOLVED_VERSION" ]; then
+ VERSION_TO_USE="$RESOLVED_VERSION"
+ fi
+ if [ -z "$VERSION_TO_USE" ]; then
+ echo "❌ Could not determine version for mux"
+ exit 1
+ fi
+ TARBALL_URL="https://registry.npmjs.org/mux/-/mux-$VERSION_TO_USE.tgz"
+ fi
+ TMP_DIR="$(mktemp -d)"
+ TAR_PATH="$TMP_DIR/mux.tgz"
+ if ! curl -fsSL "$TARBALL_URL" -o "$TAR_PATH"; then
+ echo "❌ Failed to download tarball: $TARBALL_URL"
+ rm -rf "$TMP_DIR"
+ exit 1
+ fi
+ if ! tar -xzf "$TAR_PATH" -C "$TMP_DIR"; then
+ echo "❌ Failed to extract tarball"
+ rm -rf "$TMP_DIR"
+ exit 1
+ fi
+ CANDIDATE=""
+ BIN_PATH=""
+ # Prefer reading bin path from package.json
+ if [ -f "$TMP_DIR/package/package.json" ]; then
+ if command -v node > /dev/null 2>&1; then
+ BIN_PATH="$(node -e 'try{const fs=require("fs");const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));let bp=typeof p.bin==="string"?p.bin:(p.bin&&p.bin.mux);if(bp){console.log(bp)}}catch(e){}' "$TMP_DIR/package/package.json")"
+ fi
+ if [ -z "$BIN_PATH" ]; then
+ # sed fallbacks (handle both string and object forms)
+ BIN_PATH=$(sed -n 's/.*\"bin\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' "$TMP_DIR/package/package.json" | head -n1)
+ if [ -z "$BIN_PATH" ]; then
+ BIN_PATH=$(sed -n '/\"bin\"[[:space:]]*:[[:space:]]*{/,/}/p' "$TMP_DIR/package/package.json" | sed -n 's/.*\"mux\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' | head -n1)
+ fi
+ fi
+ if [ -n "$BIN_PATH" ] && [ -f "$TMP_DIR/package/$BIN_PATH" ]; then
+ CANDIDATE="$TMP_DIR/package/$BIN_PATH"
+ fi
+ fi
+ # Fallback: check common locations
+ if [ -z "$CANDIDATE" ]; then
+ if [ -f "$TMP_DIR/package/bin/mux" ]; then
+ CANDIDATE="$TMP_DIR/package/bin/mux"
+ elif [ -f "$TMP_DIR/package/bin/mux.js" ]; then
+ CANDIDATE="$TMP_DIR/package/bin/mux.js"
+ elif [ -f "$TMP_DIR/package/bin/mux.mjs" ]; then
+ CANDIDATE="$TMP_DIR/package/bin/mux.mjs"
+ fi
+ fi
+ # Fallback: search for plausible filenames
+ if [ -z "$CANDIDATE" ] || [ ! -f "$CANDIDATE" ]; then
+ CANDIDATE=$(find "$TMP_DIR/package" -maxdepth 4 -type f \( -name "mux" -o -name "mux.js" -o -name "mux.mjs" -o -name "mux.cjs" -o -name "main.js" \) | head -n1)
+ fi
+ if [ -z "$CANDIDATE" ] || [ ! -f "$CANDIDATE" ]; then
+ echo "❌ Could not locate mux binary in tarball"
+ rm -rf "$TMP_DIR"
+ exit 1
+ fi
+ # Copy entire package to installation directory to preserve relative imports
+ DEST_DIR="${INSTALL_PREFIX}/.mux-package"
+ rm -rf "$DEST_DIR"
+ mkdir -p "$DEST_DIR"
+ cp -R "$TMP_DIR/package/." "$DEST_DIR/"
+ # Create/refresh launcher symlink
+ if [ -n "$BIN_PATH" ] && [ -f "$DEST_DIR/$BIN_PATH" ]; then
+ ln -sf "$DEST_DIR/$BIN_PATH" "$MUX_BINARY"
+ chmod +x "$DEST_DIR/$BIN_PATH" || true
+ else
+ ln -sf "$DEST_DIR/$(basename "$CANDIDATE")" "$MUX_BINARY"
+ chmod +x "$DEST_DIR/$(basename "$CANDIDATE")" || true
+ fi
+ rm -rf "$TMP_DIR"
+ fi
+
+ printf "🥳 mux has been installed in ${INSTALL_PREFIX}\n\n"
+fi
+
+# Make mux available in PATH if CODER_SCRIPT_BIN_DIR is set
+if [ -n "$CODER_SCRIPT_BIN_DIR" ]; then
+ if [ ! -e "$CODER_SCRIPT_BIN_DIR/mux" ]; then
+ ln -s "$MUX_BINARY" "$CODER_SCRIPT_BIN_DIR/mux"
+ fi
+fi
+
+# Start mux
+run_mux
diff --git a/registry/coder/modules/vault-token/README.md b/registry/coder/modules/vault-token/README.md
index e30abdc5..4561a170 100644
--- a/registry/coder/modules/vault-token/README.md
+++ b/registry/coder/modules/vault-token/README.md
@@ -19,7 +19,7 @@ variable "vault_token" {
module "vault" {
source = "registry.coder.com/coder/vault-token/coder"
- version = "1.2.1"
+ version = "1.2.2"
agent_id = coder_agent.example.id
vault_token = var.token # optional
vault_addr = "https://vault.example.com"
@@ -73,7 +73,7 @@ variable "vault_token" {
module "vault" {
source = "registry.coder.com/coder/vault-token/coder"
- version = "1.2.1"
+ version = "1.2.2"
agent_id = coder_agent.example.id
vault_addr = "https://vault.example.com"
vault_token = var.token
diff --git a/registry/coder/modules/vault-token/run.sh b/registry/coder/modules/vault-token/run.sh
index e1da6ee8..9b83f32f 100644
--- a/registry/coder/modules/vault-token/run.sh
+++ b/registry/coder/modules/vault-token/run.sh
@@ -68,7 +68,7 @@ install() {
else
printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "${INSTALL_VERSION}"
fi
- fetch vault.zip "https://releases.hashicorp.com/vault/$${INSTALL_VERSION}/vault_$${INSTALL_VERSION}_linux_amd64.zip"
+ fetch vault.zip "https://releases.hashicorp.com/vault/$${INSTALL_VERSION}/vault_$${INSTALL_VERSION}_linux_$${ARCH}.zip"
if [ $? -ne 0 ]; then
printf "Failed to download Vault.\n"
return 1
diff --git a/registry/coder/modules/windows-rdp/README.md b/registry/coder/modules/windows-rdp/README.md
index f19afc47..92c5ac17 100644
--- a/registry/coder/modules/windows-rdp/README.md
+++ b/registry/coder/modules/windows-rdp/README.md
@@ -15,7 +15,7 @@ Enable Remote Desktop + a web based client on Windows workspaces, powered by [de
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder"
- version = "1.2.3"
+ version = "1.3.0"
agent_id = resource.coder_agent.main.id
}
```
@@ -32,7 +32,7 @@ module "windows_rdp" {
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder"
- version = "1.2.3"
+ version = "1.3.0"
agent_id = resource.coder_agent.main.id
}
```
@@ -43,7 +43,7 @@ module "windows_rdp" {
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder"
- version = "1.2.3"
+ version = "1.3.0"
agent_id = resource.coder_agent.main.id
}
```
@@ -54,7 +54,7 @@ module "windows_rdp" {
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder"
- version = "1.2.3"
+ version = "1.3.0"
agent_id = resource.coder_agent.main.id
devolutions_gateway_version = "2025.2.2" # Specify a specific version
}
diff --git a/registry/coder/modules/windows-rdp/devolutions-patch.js b/registry/coder/modules/windows-rdp/devolutions-patch.js
index ef736452..1f231ca3 100644
--- a/registry/coder/modules/windows-rdp/devolutions-patch.js
+++ b/registry/coder/modules/windows-rdp/devolutions-patch.js
@@ -25,401 +25,426 @@
* @typedef {Readonly<{ querySelector: string; value: string; }>} FormFieldEntry
* @typedef {Readonly>} FormFieldEntries
*/
+(function () {
+ /**
+ * The communication protocol to set Devolutions to.
+ */
+ const PROTOCOL = "RDP";
-/**
- * The communication protocol to set Devolutions to.
- */
-const PROTOCOL = "RDP";
+ /**
+ * The hostname to use with Devolutions.
+ */
+ const HOSTNAME = "localhost";
-/**
- * The hostname to use with Devolutions.
- */
-const HOSTNAME = "localhost";
+ /**
+ * How often to poll the screen for the main Devolutions form.
+ */
+ const POLL_INTERVAL_MS = 500;
-/**
- * How often to poll the screen for the main Devolutions form.
- */
-const SCREEN_POLL_INTERVAL_MS = 500;
-
-/**
- * The fields in the Devolutions sign-in form that should be populated with
- * values from the Coder workspace.
- *
- * All properties should be defined as placeholder templates in the form
- * VALUE_NAME. The Coder module, when spun up, should then run some logic to
- * replace the template slots with actual values. These values should never
- * change from within JavaScript itself.
- *
- * @satisfies {FormFieldEntries}
- */
-const formFieldEntries = {
- /** @readonly */
- username: {
+ /**
+ * The fields in the Devolutions sign-in form that should be populated with
+ * values from the Coder workspace.
+ *
+ * All properties should be defined as placeholder templates in the form
+ * VALUE_NAME. The Coder module, when spun up, should then run some logic to
+ * replace the template slots with actual values. These values should never
+ * change from within JavaScript itself.
+ *
+ * @satisfies {FormFieldEntries}
+ */
+ const formFieldEntries = {
/** @readonly */
- querySelector: "web-client-username-control input",
+ username: {
+ /** @readonly */
+ querySelector: "web-client-username-control input",
+ /** @readonly */
+ value: "${CODER_USERNAME}",
+ },
/** @readonly */
- value: "${CODER_USERNAME}",
- },
+ password: {
+ /** @readonly */
+ querySelector: "web-client-password-control input",
- /** @readonly */
- password: {
- /** @readonly */
- querySelector: "web-client-password-control input",
-
- /** @readonly */
- value: "${CODER_PASSWORD}",
- },
-};
-
-/**
- * Handles typing in the values for the input form. All values are written
- * immediately, even though that would be physically impossible with a real
- * keyboard.
- *
- * Note: this code will never break, but you might get warnings in the console
- * from Angular about unexpected value changes. Angular patches over a lot of
- * the built-in browser APIs to support its component change detection system.
- * As part of that, it has validations for checking whether an input it
- * previously had control over changed without it doing anything.
- *
- * But the only way to simulate a keyboard input is by setting the input's
- * .value property, and then firing an input event. So basically, the inner
- * value will change, which Angular won't be happy about, but then the input
- * event will fire and sync everything back together.
- *
- * @param {HTMLInputElement} inputField
- * @param {string} inputText
- * @returns {Promise}
- */
-function setInputValue(inputField, inputText) {
- return new Promise((resolve, reject) => {
- // Adding timeout for input event, even though we'll be dispatching it
- // immediately, just in the off chance that something in the Angular app
- // intercepts it or stops it from propagating properly
- const timeoutId = window.setTimeout(() => {
- reject(new Error("Input event did not get processed correctly in time."));
- }, 3_000);
-
- const handleSuccessfulDispatch = () => {
- window.clearTimeout(timeoutId);
- inputField.removeEventListener("input", handleSuccessfulDispatch);
- resolve();
- };
-
- inputField.addEventListener("input", handleSuccessfulDispatch);
-
- // Code assumes that Angular will have an event handler in place to handle
- // the new event
- const inputEvent = new Event("input", {
- bubbles: true,
- cancelable: true,
- });
-
- inputField.value = inputText;
- inputField.dispatchEvent(inputEvent);
- });
-}
-
-/**
- * Takes a Devolutions remote session form, auto-fills it with data, and then
- * submits it.
- *
- * The logic here is more convoluted than it should be for two main reasons:
- * 1. Devolutions' HTML markup has errors. There are labels, but they aren't
- * bound to the inputs they're supposed to describe. This means no easy hooks
- * for selecting the elements, unfortunately.
- * 2. Trying to modify the .value properties on some of the inputs doesn't
- * work. Probably some combo of Angular data-binding and some inputs having
- * the readonly attribute. Have to simulate user input to get around this.
- *
- * @param {HTMLFormElement} myForm
- * @returns {Promise}
- */
-async function autoSubmitForm(myForm) {
- const setProtocolValue = () => {
- /** @type {HTMLDivElement | null} */
- const protocolDropdownTrigger = myForm.querySelector('div[role="button"]');
- if (protocolDropdownTrigger === null) {
- throw new Error("No clickable trigger for setting protocol value");
- }
-
- protocolDropdownTrigger.click();
-
- // Can't use form as container for querying the list of dropdown options,
- // because the elements don't actually exist inside the form. They're placed
- // in the top level of the HTML doc, and repositioned to make it look like
- // they're part of the form. Avoids CSS stacking context issues, maybe?
- /** @type {HTMLLIElement | null} */
- const protocolOption = document.querySelector(
- // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation
- 'p-dropdownitem[ng-reflect-label="' + PROTOCOL + '"] li',
- );
-
- if (protocolOption === null) {
- throw new Error(
- "Unable to find protocol option on screen that matches desired protocol",
- );
- }
-
- protocolOption.click();
+ /** @readonly */
+ value: "${CODER_PASSWORD}",
+ },
};
- const setHostname = () => {
- /** @type {HTMLInputElement | null} */
- const hostnameInput = myForm.querySelector("p-autocomplete#hostname input");
+ /**
+ * This ensures that the Devolutions login form (which by default, always shows
+ * up on screen when the app first launches) stays visually hidden from the user
+ * when they open Devolutions via the Coder module.
+ *
+ * The form will still be filled out automatically and submitted in the
+ * background via the rest of the logic in this file, so this function is mainly
+ * to help avoid screen flickering and make the overall experience feel a little
+ * more polished (even though it's just one giant hack).
+ *
+ * @returns {void}
+ */
+ function hideFormForInitialSubmission() {
+ const styleId = "coder-patch--styles-initial-submission";
+ const cssOpacityVariableName = "--coder-opacity-multiplier";
- if (hostnameInput === null) {
- throw new Error("Unable to find field for adding hostname");
+ /** @type {HTMLStyleElement | null} */
+ // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation
+ let styleContainer = document.querySelector("#" + styleId);
+ if (!styleContainer) {
+ styleContainer = document.createElement("style");
+ styleContainer.id = styleId;
+ styleContainer.innerHTML = `
+ /*
+ Have to use opacity instead of visibility, because the element still
+ needs to be interactive via the script so that it can be auto-filled.
+ */
+ :root {
+ /*
+ Can be 0 or 1. Start off invisible to avoid risks of UI flickering,
+ but the rest of the function should be in charge of making the form
+ container visible again if something goes wrong during setup.
+
+ Double dollar sign needed to avoid Terraform script false positives
+ */
+ $${cssOpacityVariableName}: 0;
+ }
+
+ /*
+ web-client-form is the container for the main session form, while
+ the div is for the dropdown that is used for selecting the protocol.
+ The dropdown is not inside of the form for CSS styling reasons, so we
+ need to select both.
+ */
+ web-client-form,
+ body > div.p-overlay {
+ /*
+ Double dollar sign needed to avoid Terraform script false positives
+ */
+ opacity: calc(100% * var($${cssOpacityVariableName})) !important;
+ }
+ `;
+
+ document.head.appendChild(styleContainer);
}
- return setInputValue(hostnameInput, HOSTNAME);
- };
-
- const setCoderFormFieldValues = async () => {
- // The RDP form will not appear on screen unless the dropdown is set to use
- // the RDP protocol
- const rdpSubsection = myForm.querySelector("rdp-form");
- if (rdpSubsection === null) {
- throw new Error(
- "Unable to find RDP subsection. Is the value of the protocol set to RDP?",
- );
- }
-
- for (const { value, querySelector } of Object.values(formFieldEntries)) {
- /** @type {HTMLInputElement | null} */
- const input = document.querySelector(querySelector);
-
- if (input === null) {
- throw new Error(
- // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation
- 'Unable to element that matches query "' + querySelector + '"',
- );
- }
-
- await setInputValue(input, value);
- }
- };
-
- const triggerSubmission = () => {
- /** @type {HTMLButtonElement | null} */
- const submitButton = myForm.querySelector(
- 'p-button[ng-reflect-type="submit"] button',
- );
-
- if (submitButton === null) {
- throw new Error("Unable to find submission button");
- }
-
- if (submitButton.disabled) {
- throw new Error(
- "Unable to submit form because submit button is disabled. Are all fields filled out correctly?",
- );
- }
-
- submitButton.click();
- };
-
- setProtocolValue();
- await setHostname();
- await setCoderFormFieldValues();
- triggerSubmission();
-}
-
-/**
- * Sets up logic for auto-populating the form data when the form appears on
- * screen.
- *
- * @returns {void}
- */
-function setupFormDetection() {
- /** @type {HTMLFormElement | null} */
- let formValueFromLastMutation = null;
-
- /** @returns {void} */
- const onDynamicTabMutation = () => {
- /** @type {HTMLFormElement | null} */
- const latestForm = document.querySelector("web-client-form > form");
-
- // Only try to auto-fill if we went from having no form on screen to
- // having a form on screen. That way, we don't accidentally override the
- // form if the user is trying to customize values, and this essentially
- // makes the script values function as default values
- const mounted = formValueFromLastMutation === null && latestForm !== null;
- if (mounted) {
- autoSubmitForm(latestForm);
- }
-
- formValueFromLastMutation = latestForm;
- };
-
- /** @type {number | undefined} */
- let pollingId = undefined;
-
- /** @returns {void} */
- const checkScreenForDynamicTab = () => {
- const dynamicTab = document.querySelector("web-client-dynamic-tab");
-
- // Keep polling until the main content container is on screen
- if (dynamicTab === null) {
+ // The root node being undefined should be physically impossible (if it's
+ // undefined, the browser itself is busted), but we need to do a type check
+ // here so that the rest of the function doesn't need to do type checks over
+ // and over.
+ const rootNode = document.querySelector(":root");
+ if (!(rootNode instanceof HTMLHtmlElement)) {
+ // Remove the container entirely because if the browser is busted, who knows
+ // if the CSS variables can be applied correctly. Better to have something
+ // be a bit more ugly/painful to use, than have it be impossible to use
+ styleContainer.remove();
return;
}
- window.clearInterval(pollingId);
+ // It's safe to make the form visible preemptively because Devolutions
+ // outputs the Windows view through an HTML canvas that it overlays on top
+ // of the rest of the app. Even if the form isn't hidden at the style level,
+ // it will still be covered up.
+ const restoreOpacity = () => {
+ rootNode.style.setProperty(cssOpacityVariableName, "1");
+ };
- // Call the mutation callback manually, to ensure it runs at least once
- onDynamicTabMutation();
+ // If this file gets more complicated, it might make sense to set up the
+ // timeout and event listener so that if one triggers, it cancels the other,
+ // but having restoreOpacity run more than once is a no-op for right now.
+ // Not a big deal if these don't get cleaned up.
- // Having the mutation observer is kind of an extra safety net that isn't
- // really expected to run that often. Most of the content in the dynamic
- // tab is being rendered through Canvas, which won't trigger any mutations
- // that the observer can detect
- const dynamicTabObserver = new MutationObserver(onDynamicTabMutation);
- dynamicTabObserver.observe(dynamicTab, {
- subtree: true,
- childList: true,
- });
- };
+ // Have the form automatically reappear no matter what, so that if something
+ // does break, the user isn't left out to dry
+ window.setTimeout(restoreOpacity, 5_000);
- pollingId = window.setInterval(
- checkScreenForDynamicTab,
- SCREEN_POLL_INTERVAL_MS,
- );
-}
-
-/**
- * Sets up custom styles for hiding default Devolutions elements that Coder
- * users shouldn't need to care about.
- *
- * @returns {void}
- */
-function setupAlwaysOnStyles() {
- const styleId = "coder-patch--styles-always-on";
- // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation
- const existingContainer = document.querySelector("#" + styleId);
- if (existingContainer) {
- return;
+ /** @type {HTMLFormElement | null} */
+ const form = document.querySelector("web-client-form > form");
+ form?.addEventListener(
+ "submit",
+ () => {
+ // Not restoring opacity right away just to give the HTML canvas a little
+ // bit of time to get spun up and cover up the main form
+ window.setTimeout(restoreOpacity, 1_000);
+ },
+ { once: true },
+ );
}
- const styleContainer = document.createElement("style");
- styleContainer.id = styleId;
- styleContainer.innerHTML = `
- /* app-menu corresponds to the sidebar of the default view. */
- app-menu {
- display: none !important;
+ /**
+ * Sets up custom styles for hiding default Devolutions elements that Coder
+ * users shouldn't need to care about.
+ *
+ * @returns {void}
+ */
+ function setupAlwaysOnStyles() {
+ const styleId = "coder-patch--styles-always-on";
+ // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation
+ const existingContainer = document.querySelector("#" + styleId);
+ if (existingContainer) {
+ return;
}
- `;
- document.head.appendChild(styleContainer);
-}
-
-/**
- * This ensures that the Devolutions login form (which by default, always shows
- * up on screen when the app first launches) stays visually hidden from the user
- * when they open Devolutions via the Coder module.
- *
- * The form will still be filled out automatically and submitted in the
- * background via the rest of the logic in this file, so this function is mainly
- * to help avoid screen flickering and make the overall experience feel a little
- * more polished (even though it's just one giant hack).
- *
- * @returns {void}
- */
-function hideFormForInitialSubmission() {
- const styleId = "coder-patch--styles-initial-submission";
- const cssOpacityVariableName = "--coder-opacity-multiplier";
-
- /** @type {HTMLStyleElement | null} */
- // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation
- let styleContainer = document.querySelector("#" + styleId);
- if (!styleContainer) {
- styleContainer = document.createElement("style");
+ const styleContainer = document.createElement("style");
styleContainer.id = styleId;
styleContainer.innerHTML = `
- /*
- Have to use opacity instead of visibility, because the element still
- needs to be interactive via the script so that it can be auto-filled.
- */
- :root {
- /*
- Can be 0 or 1. Start off invisible to avoid risks of UI flickering,
- but the rest of the function should be in charge of making the form
- container visible again if something goes wrong during setup.
-
- Double dollar sign needed to avoid Terraform script false positives
- */
- $${cssOpacityVariableName}: 0;
+ /* app-menu corresponds to the sidebar of the default view. */
+ app-menu {
+ display: none !important;
}
- /*
- web-client-form is the container for the main session form, while
- the div is for the dropdown that is used for selecting the protocol.
- The dropdown is not inside of the form for CSS styling reasons, so we
- need to select both.
- */
- web-client-form,
- body > div.p-overlay {
- /*
- Double dollar sign needed to avoid Terraform script false positives
- */
- opacity: calc(100% * var($${cssOpacityVariableName})) !important;
+ /* app-net-scan corresponds to the auto-discovery feature. */
+ app-net-scan {
+ display: none !important;
}
`;
document.head.appendChild(styleContainer);
}
- // The root node being undefined should be physically impossible (if it's
- // undefined, the browser itself is busted), but we need to do a type check
- // here so that the rest of the function doesn't need to do type checks over
- // and over.
- const rootNode = document.querySelector(":root");
- if (!(rootNode instanceof HTMLHtmlElement)) {
- // Remove the container entirely because if the browser is busted, who knows
- // if the CSS variables can be applied correctly. Better to have something
- // be a bit more ugly/painful to use, than have it be impossible to use
- styleContainer.remove();
- return;
+ /**
+ * Handles typing in the values for the input form. All values are written
+ * immediately, even though that would be physically impossible with a real
+ * keyboard.
+ *
+ * Note: this code will never break, but you might get warnings in the console
+ * from Angular about unexpected value changes. Angular patches over a lot of
+ * the built-in browser APIs to support its component change detection system.
+ * As part of that, it has validations for checking whether an input it
+ * previously had control over changed without it doing anything.
+ *
+ * But the only way to simulate a keyboard input is by setting the input's
+ * .value property, and then firing an input event. So basically, the inner
+ * value will change, which Angular won't be happy about, but then the input
+ * event will fire and sync everything back together.
+ *
+ * @param {HTMLInputElement} inputField
+ * @param {string} inputText
+ * @returns {Promise}
+ */
+ function setInputValue(inputField, inputText) {
+ return new Promise((resolve, reject) => {
+ // Adding timeout for input event, even though we'll be dispatching it
+ // immediately, just in the off chance that something in the Angular app
+ // intercepts it or stops it from propagating properly
+ const timeoutId = window.setTimeout(() => {
+ reject(
+ new Error("Input event did not get processed correctly in time."),
+ );
+ }, 3_000);
+
+ const handleSuccessfulDispatch = () => {
+ window.clearTimeout(timeoutId);
+ inputField.removeEventListener("input", handleSuccessfulDispatch);
+ resolve();
+ };
+
+ inputField.addEventListener("input", handleSuccessfulDispatch);
+
+ // Code assumes that Angular will have an event handler in place to handle
+ // the new event
+ const inputEvent = new Event("input", {
+ bubbles: true,
+ cancelable: true,
+ });
+
+ inputField.value = inputText;
+ inputField.dispatchEvent(inputEvent);
+ });
}
- // It's safe to make the form visible preemptively because Devolutions
- // outputs the Windows view through an HTML canvas that it overlays on top
- // of the rest of the app. Even if the form isn't hidden at the style level,
- // it will still be covered up.
- const restoreOpacity = () => {
- rootNode.style.setProperty(cssOpacityVariableName, "1");
- };
+ /**
+ * Takes a Devolutions remote session form, auto-fills it with data, and then
+ * submits it.
+ *
+ * The logic here is more convoluted than it should be for two main reasons:
+ * 1. Devolutions' HTML markup has errors. There are labels, but they aren't
+ * bound to the inputs they're supposed to describe. This means no easy hooks
+ * for selecting the elements, unfortunately.
+ * 2. Trying to modify the .value properties on some of the inputs doesn't
+ * work. Probably some combo of Angular data-binding and some inputs having
+ * the readonly attribute. Have to simulate user input to get around this.
+ *
+ * @param {HTMLFormElement} form
+ */
+ async function fillForm(form) {
+ try {
+ log("Form detected. Starting auto-fill...");
- // If this file gets more complicated, it might make sense to set up the
- // timeout and event listener so that if one triggers, it cancels the other,
- // but having restoreOpacity run more than once is a no-op for right now.
- // Not a big deal if these don't get cleaned up.
+ // By default, RDP is selected. Leaving this here if needed
+ // in the future.
+ const protocolTrigger = form.querySelector('p-dropdown[id="protocol"]');
+ if (protocolTrigger) {
+ protocolTrigger.click();
+ const protocolOption = document.querySelector(
+ `li[aria-label="$${PROTOCOL}"]`,
+ );
+ if (protocolOption) {
+ protocolOption.click();
+ log(`Protocol set to $${PROTOCOL}`);
+ } else {
+ log("Protocol option not found.");
+ }
+ } else {
+ log("Protocol dropdown trigger not found.");
+ }
- // Have the form automatically reappear no matter what, so that if something
- // does break, the user isn't left out to dry
- window.setTimeout(restoreOpacity, 5_000);
+ const hostnameInput = form.querySelector("p-autocomplete#hostname input");
+ if (hostnameInput) {
+ await setInputValue(hostnameInput, HOSTNAME);
+ log(`Hostname set to $${HOSTNAME}`);
+ } else {
+ log("Hostname input not found.");
+ }
- /** @type {HTMLFormElement | null} */
- const form = document.querySelector("web-client-form > form");
- form?.addEventListener(
- "submit",
- () => {
- // Not restoring opacity right away just to give the HTML canvas a little
- // bit of time to get spun up and cover up the main form
- window.setTimeout(restoreOpacity, 1_000);
- },
- { once: true },
- );
-}
+ for (const [key, { querySelector, value }] of Object.entries(
+ formFieldEntries,
+ )) {
+ const input = document.querySelector(querySelector);
+ if (input) {
+ await setInputValue(input, value);
+ log(`Set $${key} to $${value}`);
+ } else {
+ log(`Input for $${key} not found with selector: $${querySelector}`);
+ }
+ }
-// Always safe to call these immediately because even if the Angular app isn't
-// loaded by the time the function gets called, the CSS will always be globally
-// available for when Angular is finally ready
-setupAlwaysOnStyles();
-hideFormForInitialSubmission();
+ const submitButton = form.querySelector(
+ 'p-button[class="p-element"] button',
+ );
+ if (submitButton && !submitButton.disabled) {
+ submitButton.click();
+ log("Form submitted.");
+ } else {
+ log("Submit button not found or disabled.");
+ }
+ } catch (err) {
+ console.error("[Devolutions Patch] Error during form fill:", err);
+ }
+ }
-if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", setupFormDetection);
-} else {
- setupFormDetection();
-}
+ /**
+ * Attaches a click event listener to the "Close Session" button within the provided top bar element.
+ * When clicked, the listener triggers the window to close.
+ * Logs a message indicating whether the listener was successfully attached or if the button was not found.
+ *
+ * @param {HTMLElement} topBar - The container element that includes the "Close Session" button.
+ * @returns {void}
+ */
+ function attachCloseListener(topBar) {
+ const buttons = topBar.querySelectorAll("button");
+
+ const closeButton = Array.from(buttons).find((button) => {
+ const labelSpan = button.querySelector(".p-button-label");
+ return labelSpan && labelSpan.textContent.trim() === "Close Session";
+ });
+
+ if (closeButton) {
+ closeButton.parentElement.addEventListener("click", () => {
+ window.close();
+ });
+ log("Close listener attached.");
+ } else {
+ log("Close button not found in top bar.");
+ }
+ }
+
+ /**
+ * Sets the checked state of a checkbox based on its label text.
+ * Searches all components in the document and identifies the one
+ * whose label matches the provided `filterText`. Once found, it sets the checkbox
+ * to the specified `checked` state (true or false) and dispatches a change event
+ * to ensure any bound listeners (e.g., Angular change detection) are triggered.
+ * Logs the outcome of the operation for debugging or audit purposes.
+ *
+ * @param {string} filterText - The exact label text of the checkbox to target.
+ * @param {boolean} checked - The desired checked state (true to check, false to uncheck).
+ * @returns {void}
+ */
+ function setCheckbox(filterText, checked) {
+ const checkboxes = document.querySelectorAll("p-checkbox");
+
+ const targetCheckbox = Array.from(checkboxes).find((checkbox) => {
+ const label = checkbox.querySelector(".p-checkbox-label");
+ return label && label.textContent.trim() === filterText;
+ });
+
+ if (targetCheckbox) {
+ const input = targetCheckbox.querySelector('input[type="checkbox"]');
+ if (input) {
+ input.checked = checked;
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ log(`$${filterText} set to $${checked}.`);
+ } else {
+ log(`$${filterText} checkbox not found in top bar.`);
+ }
+ }
+
+ /**
+ * Continuously polls the DOM for a specific form element.
+ * - Searches for a