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 @@ + + + copyparty_logo + + + + + + + + + + + + image/svg+xml + + copyparty_logo + github.com/9001/copyparty + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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
inside a element. + * - If found, calls `fillForm(form)` to process it. + * - If not found, logs a retry message and schedules another check after a delay. + * + * @returns {void} + */ + function pollForForm() { + const form = document.querySelector("web-client-form form"); + if (form) { + fillForm(form); + + // Start polling for top bar after form is filled + pollForSessionToolBar(); + } else { + log("Form not yet available. Retrying..."); + setTimeout(pollForForm, POLL_INTERVAL_MS); + } + } + + /** + * Continuously polls the DOM for a specific form element. + * - Searches for a element. + * - If found, adds another listener to session toolbar + * - If not found, logs a retry message and schedules another check after a delay. + * + * @returns {void} + */ + function pollForSessionToolBar() { + const sessionToolBar = document.querySelector("session-toolbar"); + if (sessionToolBar) { + log("Top bar detected. Proceeding with next steps..."); + attachCloseListener(sessionToolBar); + + // Automatically set checkboxes to improve user experience + setCheckbox("Unicode Keyboard Mode", true); + setCheckbox("Dynamic Resize", true); + } else { + log("Top bar not yet available. Retrying..."); + setTimeout(pollForSessionToolBar, POLL_INTERVAL_MS); + } + } + + /** + * Logs a message to the console with a standardized prefix. + * Format: [Devolutions Patch] $ + * + * @param {string} msg - The message to log. + * @returns {void} + */ + function log(msg) { + console.log(`[Devolutions Patch] $${msg}`); + } + + // 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(); + + log("Script loaded. Starting form detection..."); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", pollForForm); + } else { + pollForForm(); + } +})(); diff --git a/registry/coder/modules/windows-rdp/main.test.ts b/registry/coder/modules/windows-rdp/main.test.ts index 125b3b3b..80c09fd0 100644 --- a/registry/coder/modules/windows-rdp/main.test.ts +++ b/registry/coder/modules/windows-rdp/main.test.ts @@ -59,9 +59,11 @@ describe("Web RDP", async () => { expect(lines).toEqual( expect.arrayContaining([ '$moduleName = "DevolutionsGateway"', - // Devolutions does versioning in the format year.minor.patch - expect.stringMatching(/^\$moduleVersion = "\d{4}\.\d+\.\d+"$/), - "Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force", + // Default is "latest" to automatically get the newest version + '$moduleVersion = "latest"', + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12", + "Set-PSRepository -Name PSGallery -InstallationPolicy Trusted", + "Install-Module -Name $moduleName -Force", ]), ); }); @@ -86,7 +88,7 @@ describe("Web RDP", async () => { * @see {@link https://regex101.com/r/UMgQpv/2} */ const formEntryValuesRe = - /^const formFieldEntries = \{$.*?^\s+username: \{$.*?^\s*?querySelector.*?,$.*?^\s*value: "(?.+?)",$.*?password: \{$.*?^\s+querySelector: .*?,$.*?^\s*value: "(?.+?)",$.*?^};$/ms; + /username:\s*\{[\s\S]*?value:\s*"(?[^"]+)"[\s\S]*?password:\s*\{[\s\S]*?value:\s*"(?[^"]+)"/; // Test that things work with the default username/password const defaultState = await runTerraformApply( diff --git a/registry/coder/modules/windows-rdp/main.tf b/registry/coder/modules/windows-rdp/main.tf index c1b996dd..3c83d195 100644 --- a/registry/coder/modules/windows-rdp/main.tf +++ b/registry/coder/modules/windows-rdp/main.tf @@ -9,6 +9,24 @@ terraform { } } +variable "display_name" { + type = string + description = "The display name for the Web RDP application." + default = "Web RDP" +} + +variable "slug" { + type = string + description = "The slug for the Web RDP application." + default = "web-rdp" +} + +variable "icon" { + type = string + description = "The icon for the Web RDP application." + default = "/icon/desktop.svg" +} + 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)." @@ -48,8 +66,8 @@ variable "admin_password" { variable "devolutions_gateway_version" { type = string - default = "2025.2.2" - description = "Version of Devolutions Gateway to install. Defaults to the latest available version." + default = "latest" + description = "Version of Devolutions Gateway to install. Use 'latest' for the most recent version, or specify a version like '2025.3.2'." } resource "coder_script" "windows-rdp" { @@ -77,10 +95,10 @@ resource "coder_script" "windows-rdp" { resource "coder_app" "windows-rdp" { agent_id = var.agent_id share = var.share - slug = "web-rdp" - display_name = "Web RDP" + slug = var.slug + display_name = var.display_name url = "http://localhost:7171" - icon = "/icon/desktop.svg" + icon = var.icon subdomain = true order = var.order group = var.group diff --git a/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl b/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl index 27c45b45..1657b878 100644 --- a/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl +++ b/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl @@ -2,6 +2,9 @@ function Set-AdminPassword { param ( [string]$adminPassword ) + # Explicitly import LocalAccounts module + Import-Module Microsoft.PowerShell.LocalAccounts -ErrorAction SilentlyContinue + # Set admin password Get-LocalUser -Name "${admin_username}" | Set-LocalUser -Password (ConvertTo-SecureString -AsPlainText $adminPassword -Force) # Enable admin user @@ -28,23 +31,61 @@ function Install-DevolutionsGateway { $moduleName = "DevolutionsGateway" $moduleVersion = "${devolutions_gateway_version}" +# Ensure TLS 1.2 is enabled for PSGallery +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + # Install the module with the specified version for all users # This requires administrator privileges try { # Install-PackageProvider is required for AWS. Need to set command to # terminate on failure so that try/catch actually triggers Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction Stop - Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force + + # Set PSGallery as trusted after NuGet is installed + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + + if ($moduleVersion -eq "latest" -or [string]::IsNullOrWhiteSpace($moduleVersion)) { + Install-Module -Name $moduleName -Force + } else { + Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force + } } catch { # If the first command failed, assume that we're on GCP and run # Install-Module only - Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force + if ($moduleVersion -eq "latest" -or [string]::IsNullOrWhiteSpace($moduleVersion)) { + Install-Module -Name $moduleName -Force + } else { + Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force + } } # Construct the module path for system-wide installation -$moduleBasePath = "C:\Windows\system32\config\systemprofile\Documents\PowerShell\Modules\$moduleName\$moduleVersion" -$modulePath = Join-Path -Path $moduleBasePath -ChildPath "$moduleName.psd1" +$modulePath = $null # Declare outside the loop + +if ($moduleVersion -eq "latest" -or [string]::IsNullOrWhiteSpace($moduleVersion)) { + $installedModule = Get-InstalledModule -Name $moduleName -ErrorAction SilentlyContinue + if ($installedModule) { + $installedVersion = $installedModule.Version.ToString() + } +} else { + $installedVersion = $moduleVersion +} + +$paths = $env:PSModulePath -split ';' + +foreach ($path in $paths) { + $candidatePath = Join-Path -Path $path -ChildPath $moduleName + if ($installedVersion) { + $candidatePath = Join-Path -Path $candidatePath -ChildPath $installedVersion + } + + $psd1Path = Join-Path -Path $candidatePath -ChildPath "$moduleName.psd1" + if (Test-Path $psd1Path) { + $modulePath = $psd1Path + break + } +} # Import the module using the full path Import-Module $modulePath diff --git a/registry/coder/modules/zed/README.md b/registry/coder/modules/zed/README.md index 989df54d..132e489c 100644 --- a/registry/coder/modules/zed/README.md +++ b/registry/coder/modules/zed/README.md @@ -19,7 +19,7 @@ Zed is a high-performance, multiplayer code editor from the creators of Atom and module "zed" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/zed/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id } ``` @@ -32,7 +32,7 @@ module "zed" { module "zed" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/zed/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id folder = "/home/coder/project" } @@ -44,7 +44,7 @@ module "zed" { module "zed" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/zed/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id display_name = "Zed Editor" order = 1 @@ -57,7 +57,7 @@ module "zed" { module "zed" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/zed/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id agent_name = coder_agent.example.name } @@ -73,7 +73,7 @@ You can declaratively set/merge settings with the `settings` input. Provide a JS module "zed" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/zed/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id settings = jsonencode({ diff --git a/registry/coder/modules/zed/main.tf b/registry/coder/modules/zed/main.tf index 94ec69a6..0029672a 100644 --- a/registry/coder/modules/zed/main.tf +++ b/registry/coder/modules/zed/main.tf @@ -73,6 +73,7 @@ resource "coder_script" "zed_settings" { icon = "/icon/zed.svg" run_on_start = true script = <<-EOT + #!/bin/sh set -eu SETTINGS_JSON='${replace(var.settings, "\"", "\\\"")}' if [ -z "$${SETTINGS_JSON}" ] || [ "$${SETTINGS_JSON}" = "{}" ]; then diff --git a/registry/coder/templates/kubernetes-devcontainer/main.tf b/registry/coder/templates/kubernetes-devcontainer/main.tf index 5e36226d..d391c75a 100644 --- a/registry/coder/templates/kubernetes-devcontainer/main.tf +++ b/registry/coder/templates/kubernetes-devcontainer/main.tf @@ -264,7 +264,7 @@ resource "kubernetes_deployment" "main" { container { name = "dev" image = var.cache_repo == "" ? local.devcontainer_builder_image : envbuilder_cached_image.cached.0.image - image_pull_policy = "Always" + image_pull_policy = "IfNotPresent" security_context { privileged = true } @@ -455,4 +455,4 @@ resource "coder_metadata" "container_info" { key = "cache repo" value = var.cache_repo == "" ? "not enabled" : var.cache_repo } -} \ No newline at end of file +} diff --git a/registry/coder/templates/kubernetes-envbox/main.tf b/registry/coder/templates/kubernetes-envbox/main.tf index e70ad2a3..98543d9c 100644 --- a/registry/coder/templates/kubernetes-envbox/main.tf +++ b/registry/coder/templates/kubernetes-envbox/main.tf @@ -152,7 +152,7 @@ resource "kubernetes_pod" "main" { name = "dev" # We highly recommend pinning this to a specific release of envbox, as the latest tag may change. image = "ghcr.io/coder/envbox:latest" - image_pull_policy = "Always" + image_pull_policy = "IfNotPresent" command = ["/envbox", "docker"] security_context { @@ -310,4 +310,4 @@ resource "kubernetes_pod" "main" { } } } -} \ No newline at end of file +} diff --git a/registry/coder/templates/kubernetes/main.tf b/registry/coder/templates/kubernetes/main.tf index c72316ff..7d7c0aa8 100644 --- a/registry/coder/templates/kubernetes/main.tf +++ b/registry/coder/templates/kubernetes/main.tf @@ -287,7 +287,7 @@ resource "kubernetes_deployment" "main" { container { name = "dev" image = "codercom/enterprise-base:ubuntu" - image_pull_policy = "Always" + image_pull_policy = "IfNotPresent" command = ["sh", "-c", coder_agent.main.init_script] security_context { run_as_user = "1000" diff --git a/registry/djarbz/.images/avatar.png b/registry/djarbz/.images/avatar.png new file mode 100644 index 00000000..f6019203 Binary files /dev/null and b/registry/djarbz/.images/avatar.png differ diff --git a/registry/djarbz/.images/copyparty_screenshot.png b/registry/djarbz/.images/copyparty_screenshot.png new file mode 100644 index 00000000..690c716f Binary files /dev/null and b/registry/djarbz/.images/copyparty_screenshot.png differ diff --git a/registry/djarbz/README.md b/registry/djarbz/README.md new file mode 100644 index 00000000..2319441a --- /dev/null +++ b/registry/djarbz/README.md @@ -0,0 +1,11 @@ +--- +display_name: "Austin" +bio: "IT Pro by day, script kiddie at night." +avatar: "./.images/avatar.png" +github: "djarbz" +status: "community" +--- + +# Austin + +I like to program as a hobby. diff --git a/registry/djarbz/modules/copyparty/README.md b/registry/djarbz/modules/copyparty/README.md new file mode 100644 index 00000000..1209f852 --- /dev/null +++ b/registry/djarbz/modules/copyparty/README.md @@ -0,0 +1,68 @@ +--- +display_name: copyparty +description: A web based file explorer alternative to Filebrowser. +icon: ../../../../.icons/copyparty.svg +verified: false +tags: [files, filebrowser, web, copyparty] +--- + +# copyparty + + + +This module installs Copyparty, an alternative to Filebrowser. +[Copyparty](https://github.com/9001/copyparty) is a portable file server with accelerated resumable uploads, dedup, WebDAV, FTP, TFTP, zeroconf, media indexer, thumbnails++ all in one file, no deps + +```tf +module "copyparty" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/djarbz/copyparty/coder" + version = "1.0.1" +} +``` + + + +![copyparty-browser-fs8](../../.images/copyparty_screenshot.png) + +## Examples + +### Example 1 + +Some basic command line options: + +```tf +module "copyparty" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/djarbz/copyparty/coder" + version = "1.0.1" + agent_id = coder_agent.example.id + arguments = [ + "-v", "/home/coder/:/home:r", # Share home directory (read-only) + "-v", "${local.repo_dir}:/repo:rw", # Share project directory (read-write) + "-e2dsa", # Enables general file indexing" + ] +} +``` + +### Example 2 + +```tf +module "copyparty" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/djarbz/copyparty/coder" + version = "1.0.1" + agent_id = coder_agent.example.id + subdomain = true + arguments = [ + "-v", "/tmp:/tmp:r", # Share tmp directory (read-only) + "-v", "/home/coder/:/home:rw", # Share home directory (read-write) + "-v", "${local.root_dir}:/work:A:c,dotsrch", # Share work directory (All Perms) + "-e2dsa", # Enables general file indexing + "--re-maxage", "900", # Rescan filesystem for changes every SEC + "--see-dots", # Show dotfiles by default if user has correct permissions on volume + "--xff-src=lan", # List of trusted reverse-proxy CIDRs (comma-separated) or `lan` for private IPs. + "--rproxy", "1", # Which ip to associate clients with, index of X-FWD IP. + ] +} +``` diff --git a/registry/djarbz/modules/copyparty/copyparty.tftest.hcl b/registry/djarbz/modules/copyparty/copyparty.tftest.hcl new file mode 100644 index 00000000..a6cb66af --- /dev/null +++ b/registry/djarbz/modules/copyparty/copyparty.tftest.hcl @@ -0,0 +1,204 @@ +# --- Test Case 1: Required Variables --- +run "plan_with_required_vars" { + command = plan + + variables { + agent_id = "example-agent-id" + } +} + +# --- Test Case 2: Coder App URL uses custom port --- +run "app_url_uses_port" { + command = plan + + variables { + agent_id = "example-agent-id" + port = 19999 + } + + assert { + condition = resource.coder_app.copyparty.url == "http://localhost:19999" + error_message = "Expected copyparty app URL to include configured port" + } +} + +# --- Test Case 3: Default Values --- +run "test_defaults" { + # This run block applies the module with default values + # (except for the required 'agent_id' provided above). + + variables { + agent_id = "example-agent-id" + } + + # --- Asserts for coder_app "copyparty" --- + assert { + condition = resource.coder_app.copyparty.display_name == "copyparty" + error_message = "Default display_name is incorrect" + } + + assert { + condition = resource.coder_app.copyparty.slug == "copyparty" + error_message = "Default slug is incorrect" + } + + assert { + condition = resource.coder_app.copyparty.url == "http://localhost:3923" + error_message = "Default URL is incorrect, expected port 3923" + } + + assert { + condition = resource.coder_app.copyparty.subdomain == false + error_message = "Default subdomain should be false" + } + + assert { + condition = resource.coder_app.copyparty.share == "owner" + error_message = "Default share value should be 'owner'" + } + + assert { + condition = resource.coder_app.copyparty.open_in == "slim-window" + error_message = "Default open_in value should be 'slim-window'" + } + + # --- Asserts for coder_script "copyparty" --- + assert { + condition = coder_script.copyparty.display_name == "copyparty" + error_message = "Script display_name is incorrect" + } + + # Check rendered script content (this assumes your run.sh uses the variables) + assert { + condition = strcontains(coder_script.copyparty.script, "PORT=\"3923\"") + error_message = "Script content does not reflect default port" + } + + assert { + condition = strcontains(coder_script.copyparty.script, "LOG_PATH=\"/tmp/copyparty.log\"") + error_message = "Script content does not reflect default log_path" + } + + assert { + condition = strcontains(coder_script.copyparty.script, "ARGUMENTS=()") + error_message = "Script content does not reflect default empty arguments" + } +} + +# --- Test Case 4: Custom Values --- +run "test_custom_values" { + # Override default variables for this specific run + variables { + agent_id = "example-agent-id" + port = 8080 + slug = "my-custom-app" + display_name = "My Custom App" + share = "authenticated" + open_in = "tab" + pinned_version = "v1.2.3" + arguments = ["--verbose", "-v"] + log_path = "/var/log/custom.log" + } + + # --- Asserts for coder_app "copyparty" --- + assert { + condition = resource.coder_app.copyparty.display_name == "My Custom App" + error_message = "Custom display_name was not applied" + } + + assert { + condition = resource.coder_app.copyparty.slug == "my-custom-app" + error_message = "Custom slug was not applied" + } + + assert { + condition = resource.coder_app.copyparty.url == "http://localhost:8080" + error_message = "Custom port was not applied to URL" + } + + assert { + condition = resource.coder_app.copyparty.share == "authenticated" + error_message = "Custom share value was not applied" + } + + assert { + condition = resource.coder_app.copyparty.open_in == "tab" + error_message = "Custom open_in value was not applied" + } + + # --- Asserts for coder_script "copyparty" --- + assert { + condition = strcontains(coder_script.copyparty.script, "PORT=\"8080\"") + error_message = "Script content does not reflect custom port" + } + + assert { + condition = strcontains(coder_script.copyparty.script, "PINNED_VERSION=\"v1.2.3\"") + error_message = "Script content does not reflect custom pinned_version" + } + + assert { + condition = strcontains(coder_script.copyparty.script, "ARGUMENTS=(\"--verbose\" \"-v\")") + error_message = "Script content does not reflect custom arguments" + } + + assert { + condition = strcontains(coder_script.copyparty.script, "LOG_PATH=\"/var/log/custom.log\"") + error_message = "Script content does not reflect custom log_path" + } +} + +# --- Test Case 5: Validation Failure (open_in) --- +run "test_invalid_open_in" { + # This is a 'plan' test that expects a failure + command = plan + + variables { + agent_id = "example-agent-id" + open_in = "invalid-value" + } + + # Expect this plan to fail due to the validation rule in 'var.open_in' + expect_failures = [ + var.open_in, + ] +} + +# --- Test Case 6: Validation Failure (share) --- +run "test_invalid_share" { + # This is a 'plan' test that expects a failure + command = plan + + variables { + agent_id = "example-agent-id" + share = "everyone" # This is not 'owner', 'authenticated', or 'public' + } + + # Expect this plan to fail due to the validation rule in 'var.share' + expect_failures = [ + var.share, + ] +} + +# --- Test Case 7: Comma in Arguments [Readme Example 2] --- +run "test_comma_args" { + # Arguments containing commas + variables { + agent_id = "example-agent-id" + arguments = [ + "-v", "/tmp:/tmp:r", # Share tmp directory (read-only) + "-v", "/home/coder/:/home:rw", # Share home directory (read-write) + "-v", "/work:/work:A:c,dotsrch", # Share work directory (All Perms) + "-e2dsa", # Enables general file indexing + "--re-maxage", "900", # Rescan filesystem for changes every SEC + "--see-dots", # Show dotfiles by default if user has correct permissions on volume + "--xff-src=lan", # List of trusted reverse-proxy CIDRs (comma-separated) or `lan` for private IPs. + "--rproxy", "1", # Which ip to associate clients with, index of X-FWD IP. + ] + } + + assert { + condition = strcontains(coder_script.copyparty.script, "ARGUMENTS=(\"-v\" \"/tmp:/tmp:r\" \"-v\" \"/home/coder/:/home:rw\" \"-v\" \"/work:/work:A:c,dotsrch\" \"-e2dsa\" \"--re-maxage\" \"900\" \"--see-dots\" \"--xff-src=lan\" \"--rproxy\" \"1\")") + error_message = "Script content does not reflect Readme Example #2 arguments with commas" + } +} diff --git a/registry/djarbz/modules/copyparty/main.tf b/registry/djarbz/modules/copyparty/main.tf new file mode 100644 index 00000000..e9976da5 --- /dev/null +++ b/registry/djarbz/modules/copyparty/main.tf @@ -0,0 +1,174 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +locals { + # A built-in icon like "/icon/code.svg" or a full URL of icon + icon_url = "/icon/copyparty.svg" + # a map of all possible values + # options = { + # "Option 1" = { + # "name" = "Option 1", + # "value" = "1" + # "icon" = "/emojis/1.png" + # } + # "Option 2" = { + # "name" = "Option 2", + # "value" = "2" + # "icon" = "/emojis/2.png" + # } + # } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "log_path" { + type = string + description = "The path to log copyparty to." + default = "/tmp/copyparty.log" +} + +variable "port" { + type = number + description = "ports to listen on (comma/range); ignored for unix-sockets (default: 3923)" + default = 3923 +} + +variable "slug" { + type = string + description = "The slug for the copyparty application." + default = "copyparty" +} + +variable "display_name" { + type = string + description = "The display name for the copyparty application." + default = "copyparty" +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +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'." + } +} + +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 "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 "mutable" { +# type = bool +# description = "Whether the parameter is mutable." +# default = true +# } + +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 +} +# Add other variables here + +variable "pinned_version" { + type = string + description = "Install a specific version in semver format (v1.19.16)." + default = "" +} + +variable "arguments" { + type = list(string) + description = "A list of arguments to pass to the application." + default = [] +} + + +resource "coder_script" "copyparty" { + agent_id = var.agent_id + display_name = "copyparty" + icon = local.icon_url + script = templatefile("${path.module}/run.sh", { + LOG_PATH : var.log_path, + PORT : var.port, + PINNED_VERSION : var.pinned_version, + ARGUMENTS : join(" ", formatlist("\"%s\"", var.arguments)), + }) + run_on_start = true + run_on_stop = false +} + +resource "coder_app" "copyparty" { + agent_id = var.agent_id + slug = var.slug + display_name = var.display_name + url = "http://localhost:${var.port}" + icon = local.icon_url + subdomain = var.subdomain + share = var.share + order = var.order + group = var.group + open_in = var.open_in + + # Remove if the app does not have a healthcheck endpoint + healthcheck { + url = "http://localhost:${var.port}" + interval = 5 + threshold = 6 + } +} + +# data "coder_parameter" "copyparty" { +# type = "list(string)" +# name = "copyparty" +# display_name = "copyparty" +# icon = local.icon_url +# mutable = var.mutable +# default = local.options["Option 1"]["value"] + +# dynamic "option" { +# for_each = local.options +# content { +# icon = option.value.icon +# name = option.value.name +# value = option.value.value +# } +# } +# } diff --git a/registry/djarbz/modules/copyparty/run.sh b/registry/djarbz/modules/copyparty/run.sh new file mode 100755 index 00000000..99388759 --- /dev/null +++ b/registry/djarbz/modules/copyparty/run.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash + +# Convert templated variables to shell variables +# This variable is assigned to itself, so the assignment does nothing. +# shellcheck disable=SC2269 +LOG_PATH="${LOG_PATH}" + +# Ports to listen on (comma/range); ignored for unix-sockets (default: 3923) +PORT="${PORT}" +# Pinned version (e.g., v1.19.16); overrides latest release discovery if set +PINNED_VERSION="${PINNED_VERSION}" +# Custom CLI Arguments +# The variable from Terraform is a series of quoted and space separated strings. +# We need to parse it into a proper bash array. +ARGUMENTS=(${ARGUMENTS}) + +# VARIABLE appears unused. Verify use (or export if used externally). +# shellcheck disable=SC2034 +MODULE_NAME="Copyparty" + +printf '\e[1mInstalling %s ...\e[0m\n' "$${MODULE_NAME}" + +# Add code here +# Use variables from the templatefile function in main.tf +# e.g. LOG_PATH, PORT, etc. + +printf "🐍 Verifying Python 3 installation...\n" +if ! command -v python3 &> /dev/null; then + printf "❌ Python3 could not be found. Please install it to continue.\n" + exit 1 +fi +printf "✅ Python3 is installed.\n" + +RELEASE_TO_INSTALL="" +# Install provided version to pin, otherwise discover latest github release from `https://github.com/9001/copyparty`. +if [[ -n "$${PINNED_VERSION}" ]]; then + printf "📌 Pinned version specified: %s\n" "$${PINNED_VERSION}" + # Verify that it is in v#.#.# format + if [[ ! "$${PINNED_VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + printf "❌ Invalid format for PINNED_VERSION. Expected 'v#.#.#' (e.g., v1.19.16).\n" + exit 1 + fi + RELEASE_TO_INSTALL="$${PINNED_VERSION}" + printf "✅ Using pinned version %s.\n" "$${RELEASE_TO_INSTALL}" +else + printf "🔎 Discovering latest release from GitHub...\n" + # Use curl to get the latest release tag from the GitHub API and sed to parse it + LATEST_RELEASE=$(curl -fsSL https://api.github.com/repos/9001/copyparty/releases/latest | grep '"tag_name":' | sed -E 's/.*"(v[^"]+)".*/\1/') + if [[ -z "$${LATEST_RELEASE}" ]]; then + printf "❌ Could not determine the latest release. Please check your internet connection.\n" + exit 1 + fi + RELEASE_TO_INSTALL="$${LATEST_RELEASE}" + printf "🏷️ Latest release is %s.\n" "$${RELEASE_TO_INSTALL}" +fi + +# Download appropriate release version assets: `copyparty-sfx.py` and `helptext.html`. +printf "🚀 Downloading copyparty %s...\n" "$${RELEASE_TO_INSTALL}" +DOWNLOAD_URL="https://github.com/9001/copyparty/releases/download/$${RELEASE_TO_INSTALL}" + +printf "⏬ Downloading copyparty-sfx.py...\n" +if ! curl -fsSL -o /tmp/copyparty-sfx.py "$${DOWNLOAD_URL}/copyparty-sfx.py"; then + printf "❌ Failed to download copyparty-sfx.py.\n" + exit 1 +fi + +printf "⏬ Downloading helptext.html...\n" +if ! curl -fsSL -o /tmp/helptext.html "$${DOWNLOAD_URL}/helptext.html"; then + # This is not a fatal error, just a warning. + printf "⚠️ Could not download helptext.html. The application will still work.\n" +fi + +chmod +x /tmp/copyparty-sfx.py +printf "✅ Download complete.\n" + +printf "🥳 Installation complete!\n" + +# Build a clean, quoted string of the command for logging purposes only. +log_command="python3 /tmp/copyparty-sfx.py -p '$${PORT}'" +for arg in "$${ARGUMENTS[@]}"; do + # printf "DEBUG: ARG [$${arg}]\n" + log_command+=" '$${arg}'" +done + +# Dump the executing command to a tmp file for diagnostic review. +{ + printf "=== Starting copyparty at %s ===\n" "$(date)" + printf "EXECUTING: %s\n" "$${log_command}" +} > "/tmp/copyparty.cmd" + +printf "👷 Starting %s in background...\n" "$${MODULE_NAME}" + +# Execute the actual command using the robust array expansion. +# Then, capture its output (stdout and stderr) to the log file. +python3 /tmp/copyparty-sfx.py -p "$${PORT}" "$${ARGUMENTS[@]}" > "$${LOG_PATH}" 2>&1 & + +printf "✅ Service started. Check logs at %s\n" "$${LOG_PATH}" diff --git a/registry/ericpaulsen/templates/nfs-deployment/README.md b/registry/ericpaulsen/templates/nfs-deployment/README.md new file mode 100644 index 00000000..c2bdbdc6 --- /dev/null +++ b/registry/ericpaulsen/templates/nfs-deployment/README.md @@ -0,0 +1,70 @@ +--- +display_name: "NFS K8s Deployment" +description: "Mount an NFS share to a Coder K8s workspace" +icon: "../../../../.icons/folder.svg" +verified: false +tags: ["kubernetes", "shared-dir", "nfs"] +--- + +# NFS K8s Deployment + +This template provisions a Coder workspace as a Kubernetes Deployment, with an NFS share mounted +as a volume. The NFS share will synchronize the server-side files onto the client (Coder workspace) +When you stop the Coder workspace and rebuild, the NFS share will be re-mounted, and the changes persisted. + +Note the `volume` and `volume_mount` blocks in the deployment and container spec, +respectively: + +```terraform +resource "kubernetes_deployment" "main" { + spec { + template { + spec { + container { + volume_mount { + mount_path = data.coder_parameter.nfs_mount_path.value # mount path in the container + name = "nfs-share" + } + } + volume { + name = "nfs-share" + nfs { + path = data.coder_parameter.nfs_mount_path.value # path to be exported from the server + server = data.coder_parameter.nfs_server.value # server IP address + } + } + } + } + } +} +``` + +## server-side configuration + +1. Create an NFS mount on the server for the clients to access: + + ```console + export NFS_MNT_PATH=/mnt/nfs_share + # Create directory to shaare + sudo mkdir -p $NFS_MNT_PATH + # Assign UID & GIDs access + sudo chown -R uid:gid $NFS_MNT_PATH + sudo chmod 777 $NFS_MNT_PATH + ``` + +1. Grant access to the client by updating the `/etc/exports` file, which + controls the directories shared with remote clients. See + [Red Hat's docs for more information about the configuration options](https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/5/html/deployment_guide/s1-nfs-server-config-exports). + + ```console + # Provides read/write access to clients accessing the NFS from any IP address. + /mnt/nfs_share *(rw,sync,no_subtree_check) + ``` + +1. Export the NFS file share directory. You must do this every time you change + `/etc/exports`. + + ```console + sudo exportfs -a + sudo systemctl restart + ``` diff --git a/registry/ericpaulsen/templates/nfs-deployment/main.tf b/registry/ericpaulsen/templates/nfs-deployment/main.tf new file mode 100644 index 00000000..e8c395e6 --- /dev/null +++ b/registry/ericpaulsen/templates/nfs-deployment/main.tf @@ -0,0 +1,348 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + kubernetes = { + source = "hashicorp/kubernetes" + } + } +} + +provider "coder" { +} + +provider "kubernetes" { + config_path = var.use_kubeconfig == true ? "~/.kube/config" : null +} + +variable "use_kubeconfig" { + type = bool + description = <<-EOF + Use host kubeconfig? (true/false) + + Set this to false if the Coder host is itself running as a Pod on the same + Kubernetes cluster as you are deploying workspaces to. + + Set this to true if the Coder host is running outside the Kubernetes cluster + for workspaces. A valid "~/.kube/config" must be present on the Coder host. + EOF + default = false +} + +variable "namespace" { + type = string + description = "The Kubernetes namespace to create workspaces in (must exist prior to creating workspaces). If the Coder host is itself running as a Pod on the same Kubernetes cluster as you are deploying workspaces to, set this to the same namespace." +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "cpu" { + name = "cpu" + display_name = "CPU" + description = "The number of CPU cores" + default = "2" + icon = "/icon/memory.svg" + mutable = true + option { + name = "2 Cores" + value = "2" + } + option { + name = "4 Cores" + value = "4" + } + option { + name = "6 Cores" + value = "6" + } + option { + name = "8 Cores" + value = "8" + } +} + +data "coder_parameter" "memory" { + name = "memory" + display_name = "Memory" + description = "The amount of memory in GB" + default = "2" + icon = "/icon/memory.svg" + mutable = true + option { + name = "2 GB" + value = "2" + } + option { + name = "4 GB" + value = "4" + } + option { + name = "6 GB" + value = "6" + } + option { + name = "8 GB" + value = "8" + } +} + +data "coder_parameter" "home_disk_size" { + name = "home_disk_size" + display_name = "Home disk size" + description = "The size of the home disk in GB" + default = "10" + type = "number" + icon = "/emojis/1f4be.png" + mutable = false + validation { + min = 1 + max = 99999 + } +} + +data "coder_parameter" "nfs_server" { + name = "nfs_server" + type = "string" + display_name = "NFS Server IP" + description = "The NFS server IP address to use for the workspace" +} + +data "coder_parameter" "nfs_mount_path" { + name = "nfs_mount_path" + type = "string" + display_name = "NFS Mount Path" + description = "The path in your workspace container to mount the NFS share to" + default = "/mnt/nfs-share" + validation { + regex = "^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*$" + error = "NFS mount path must be a valid path in your workspace container" + } +} + +resource "coder_agent" "coder" { + os = "linux" + arch = "amd64" + + # The following metadata blocks are optional. They are used to display + # information about your workspace in the dashboard. You can remove them + # if you don't want to display any information. + # For basic resources, you can use the `coder stat` command. + # If you need more control, you can write your own script. + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Home Disk" + key = "3_home_disk" + script = "coder stat disk --path $${HOME}" + interval = 60 + timeout = 1 + } + + metadata { + display_name = "CPU Usage (Host)" + key = "4_cpu_usage_host" + script = "coder stat cpu --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Memory Usage (Host)" + key = "5_mem_usage_host" + script = "coder stat mem --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Load Average (Host)" + key = "6_load_host" + # get load avg scaled by number of cores + script = <