Compare commits

...

19 Commits

Author SHA1 Message Date
Jay Kumar 28a4d4a4e3 fix(agentapi): update tests for coder-utils pattern and rewrite README
- test-util.ts: find coder_script by run_on_start to pick the
  coder-utils install script instead of the shutdown script
- main.test.ts: mock coder CLI for coder exp sync calls
- README.md: rewrite in the style of claude-code and codex READMEs
  with feature list, examples, and troubleshooting section
2026-05-13 05:03:42 +00:00
Jay Kumar b8ae549102 refactor(agentapi): use coder-utils and tftpl for install script
Replace the inline coder_script resource with coder-utils module and
convert scripts/main.sh to scripts/install.sh.tftpl, matching the
pattern used by coder/claude-code and coder-labs/codex.

Changes:
- Replace coder_script.agentapi with module "coder_utils" (v0.0.1)
- Convert scripts/main.sh to scripts/install.sh.tftpl using
  templatefile() for variable injection instead of ARG_* env vars
- Keep coder_script.agentapi_shutdown as-is (coder-utils does not
  support run_on_stop)
- Keep coder_app resources (web and CLI) unchanged
- Add output "scripts" for downstream sync dependencies
- Simplify agentapi.tftest.hcl assertions (install script content is
  now base64-encoded inside the coder-utils wrapper)
2026-05-13 04:58:01 +00:00
Jay Kumar fafe8cca7b fix(agentapi): update README for module_dir_name → module_directory rename
- Replace module_dir_name with module_directory in example snippet.
- Remove deleted variables (pre_install_script, post_install_script,
  start_script, install_script) from example.
- Update state persistence docs to reference module_directory.
2026-04-19 10:31:08 +00:00
Jay Kumar 4517a0c4e0 fix(agentapi): update tests and scripts for module_dir_name → module_directory rename
- Replace all stale $module_path references in main.sh with
  ${MODULE_DIRECTORY}.
- Update main.test.ts: moduleDirName → moduleDirectory, pass
  module_directory instead of module_dir_name, fix shutdown test
  to use ARG_MODULE_DIRECTORY.
- Remove start_script from agentapi.tftest.hcl (deleted variable).
- Rename module_path → module_directory in testdata/agentapi-start.sh.
2026-04-19 10:04:32 +00:00
35C4n0r 3d778472e7 refactor(agentapi): rename module_dir_name to module_directory for consistency 2026-04-19 15:27:56 +05:30
35C4n0r 36701a3538 Merge branch 'main' into 35C4n0r/refactor-agentapi-decouple 2026-04-17 22:21:35 +05:30
Jay Kumar 0acbcb648e fix(agentapi): use double quotes for ARG_LIB_SCRIPT_PATH
The module_directory default contains $HOME which needs shell
expansion at runtime. Single quotes prevented expansion, causing
'No such file or directory' when sourcing the lib script.
2026-04-17 16:48:33 +00:00
35C4n0r b9d352e1ad chore: bun fmt 2026-04-17 19:43:31 +05:30
Jay Kumar 4d1814a191 fix(agentapi): remove references to deleted variables in main.sh and tests
- Remove PRE_INSTALL_SCRIPT, INSTALL_SCRIPT, START_SCRIPT,
  POST_INSTALL_SCRIPT from main.sh (variables removed from main.tf
  but still read under set -o nounset).
- Remove pre-install, install, and post-install execution blocks
  from main.sh. Consumer modules are now responsible for placing
  the start script at the expected path.
- Remove pre-post-install-scripts test (tests removed functionality).
- Write test start script directly to container instead of passing
  via Terraform variable.
- Pass ARG_LIB_SCRIPT_PATH in shutdown tests (required after lib
  sourcing was made configurable).
2026-04-17 14:09:55 +00:00
Jay Kumar 968c1c1211 fix(agentapi): create module_directory before writing scripts
The coder_script resources write scripts to module_directory
(default $HOME/.coder-modules/coder/agentapi) but never create
the directory first, causing 'No such file or directory' in every
test. Also fix stale chmod targets that still referenced /tmp/.
2026-04-17 14:03:02 +00:00
Atif Ali b9f9fac9ee chore: update devcontainers icon (#850)
Updates the devcontainers icon to use the [Microsoft Fluent UI
`ic_fluent_cube_32_filled`](https://github.com/microsoft/fluentui-system-icons/blob/78c9587b995299d5bfc007a0077773556ecb0994/assets/Cube/SVG/ic_fluent_cube_32_filled.svg),
consistent with
[coder/coder#24478](https://github.com/coder/coder/pull/24478).

> 🤖 This PR was created with the help of Coder Agents, and needs a human
review. 🧑💻
2026-04-17 18:35:43 +05:00
35C4n0r ad25115a92 refactor(agentapi): decouple boundary logic and improve script sourcing 2026-04-17 18:59:02 +05:30
dependabot[bot] c724684589 chore(deps): bump the github-actions group with 2 updates (#841)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 12:48:07 +05:00
Atif Ali b76b544e78 feat(jetbrains): skip HTTP calls when ide_config is set (#836)
Fixes #835

## Problem

The `data "http"` resource always fires for every selected IDE, even
when the user has pinned versions via `ide_config`. In air-gapped or
caching scenarios, this causes:

- **30-second hangs** when `releases_base_link` is set to a dummy URL
like `https://localhost`
- **Fatal errors** with `https://localhost:1` (connection refused)
- The documented "air-gapped fallback" via `try()` never actually worked
— the `http` data source fails before `try()` can catch anything

## Fix

When `ide_config` is provided, the module now skips all HTTP calls and
uses the pinned build numbers directly.

| Scenario | `ide_config` | HTTP calls | Build source | On API failure |
|---|---|---|---|---|
| User wants latest | `null` (default) | Yes | JetBrains API | Terraform
error (fail loudly) |
| User pins versions | Set | **None** | `ide_config.build` | N/A |

### Changes

- `ide_config` default changed from a full map to `null`
- `name` and `icon` are now `optional(string)` in `ide_config` — falls
back to built-in metadata
- `data.http.jetbrains_ide_versions` `for_each` is empty when
`ide_config` is set
- Static `ide_metadata` local provides name/icon when `ide_config` is
null
- Removed `try()` fallback from `parsed_responses` — API errors are now
explicit instead of silently using stale builds
- Cross-variable validation rejects `major_version`, `channel`, and
`releases_base_link` when `ide_config` is set
- Validation for `ide_config ⊇ default` added (previously only
`ide_config ⊇ options` was checked)
- Version bumped `1.3.1` → `1.4.0`

### Usage

```tf
module "jetbrains" {
  source   = "registry.coder.com/coder/jetbrains/coder"
  version  = "1.4.0"
  agent_id = coder_agent.main.id
  folder   = "/home/coder/project"

  # Zero HTTP calls — only build is required.
  ide_config = {
    "GO" = { build = "261.22158.291" }
    "PY" = { build = "261.22158.340" }
  }
  options = ["GO", "PY"]
}
```

> 🤖 This PR was created with the help of Coder Agents, and needs a human
review. 🧑‍💻
2026-04-09 12:28:57 +05:00
Max Schwenk d3885a5047 feat: add auto permission mode to claude-code module (#830)
## Summary
- Add `auto` as a valid `permission_mode` for the claude-code module,
passing `--enable-auto-mode` to the CLI when selected
- Fix bypass permissions TOS prompt appearing interactively by
pre-seeding `bypassPermissionsModeAccepted` in `~/.claude.json` during
install (workaround for
https://github.com/anthropics/claude-code/issues/25503)
- Bump version `4.8.2` → `4.9.0`

## Test plan
- [x] All 19 terraform tests pass (`terraform test -verbose`)
- [x] Added `test_claude_code_auto_permission_mode` tftest
- [x] Added `claude-auto-permission-mode` TypeScript test verifying both
`--permission-mode auto` and `--enable-auto-mode` are passed
- [ ] Container test with auto mode (requires Linux/Colima)
- [ ] Verify bypass permissions TOS prompt no longer appears on task
startup

🤖 Generated with Claude Code using Claude Opus 4.6

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: DevCats <christofer@coder.com>
2026-04-07 13:59:36 -05:00
dependabot[bot] de7bd01021 chore(deps): bump the github-actions group with 2 updates (#834)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 22:10:34 +05:00
Atif Ali 494ad9bd48 fix(copilot): remove hardcoded model enum to allow any Copilot model (#833)
The `copilot_model` variable was restricted to a hardcoded enum of three
models (`claude-sonnet-4`, `claude-sonnet-4.5`, `gpt-5`). Models change
fast and this validation was blocking users from using newer models.

## Changes

- Remove `validation` block from `copilot_model` variable in `main.tf`
- Update variable description to indicate any Copilot-supported model
can be used
- Replace enum validation test with a test that verifies arbitrary model
strings are accepted
- Bump module version to `0.4.1` in README examples

Closes #832

> 🤖 This PR was created with the help of Coder Agents, and needs a human
review. 🧑‍💻
2026-04-05 00:42:33 +05:00
Phorcys 5ee68d04d1 feat: add mcp_config input variable to vscode-desktop-core module (#753)
## Description

Standardizes handling of `mcp` variables in VSCode Desktop-based
modules.
Made modular enough to pave the way for setting other config files than
`mcp_server.json` and `mcp.json`.

## Type of Change

- [ ] New module
- [ ] New template
- [ ] Bug fix
- [x] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Testing & Validation

- [x] Tests pass (`bun test`)
- [x] Code formatted (`bun fmt`)
- [x] Changes tested locally

---------

Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2026-04-03 13:29:46 -05:00
blinkagent[bot] 516a934694 feat(claude-code): wire web_app variable through to agentapi module (#831)
Follow-up to #764.

Now that the `agentapi` module `v2.4.0` is published with `web_app`
support, this PR completes the wiring:

## Changes

### `claude-code/main.tf`
- Bump agentapi dependency from `v2.3.0` → `v2.4.0`
- Replace `# TODO: pass web_app = var.web_app once agentapi module is
published with web_app support` with `web_app = var.web_app`

### `claude-code/README.md`
- Bump version references from `4.9.0` → `4.9.1`

## Result

Setting `web_app = false` on the `claude-code` module now correctly
passes through to the `agentapi` module, hiding the web UI app icon from
the Coder dashboard while still running AgentAPI. The task-safe behavior
(auto-enabling for `coder_ai_task`) is handled by the `agentapi` module.

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2026-04-03 12:19:05 -05:00
28 changed files with 718 additions and 822 deletions
+3 -3
View File
@@ -37,7 +37,7 @@ jobs:
all:
- '**'
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@f7650296ceb9b020c79cd525ac7bd3c7f252ae1d # v2.31.6
uses: coder/coder/.github/actions/setup-tf@2f5d21d1be7864b3e21d9c0b8e87d3ba229a1140 # v2.31.9
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
@@ -87,13 +87,13 @@ jobs:
bun-version: latest
# Need Terraform for its formatter
- name: Install Terraform
uses: coder/coder/.github/actions/setup-tf@f7650296ceb9b020c79cd525ac7bd3c7f252ae1d # v2.31.6
uses: coder/coder/.github/actions/setup-tf@2f5d21d1be7864b3e21d9c0b8e87d3ba229a1140 # v2.31.9
- name: Install dependencies
run: bun install
- name: Validate formatting
run: bun fmt:ci
- name: Check for typos
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0
uses: crate-ci/typos@02ea592e44b3a53c302f697cddca7641cd051c3d # v1.45.0
with:
config: .github/typos.toml
validate-readme-files:
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
bun-version: latest
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@f7650296ceb9b020c79cd525ac7bd3c7f252ae1d # v2.31.6
uses: coder/coder/.github/actions/setup-tf@2f5d21d1be7864b3e21d9c0b8e87d3ba229a1140 # v2.31.9
- name: Install dependencies
run: bun install
@@ -62,7 +62,7 @@ jobs:
- name: Comment on PR - Version bump required
if: failure()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
+3 -1
View File
@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" viewBox="0 0 32 32"><title>file_type_devcontainer</title><circle cx="16" cy="16" r="14" style="fill:#193e63"/><polygon points="10.777 22.742 9.343 21.348 12.729 17.865 9.346 14.417 10.774 13.017 15.525 17.859 10.777 22.742" style="fill:#add1ea"/><polygon points="21.42 19.101 22.854 17.706 19.468 14.224 22.851 10.776 21.423 9.376 16.672 14.218 21.42 19.101" style="fill:#add1ea"/></svg>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.8461 2.7571C15.2325 2.22387 16.7675 2.22387 18.1539 2.7571L28.0769 6.57367C29.2355 7.01927 30 8.13239 30 9.3737V22.6265C30 23.8678 29.2355 24.9809 28.0769 25.4265L18.1539 29.2431C16.7675 29.7763 15.2325 29.7763 13.8461 29.2431L3.92306 25.4265C2.76449 24.9809 2 23.8678 2 22.6265V9.3737C2 8.13239 2.76449 7.01927 3.92306 6.57367L13.8461 2.7571ZM9.39418 10.0809C8.88655 9.86331 8.29867 10.0985 8.08111 10.6061C7.86356 11.1137 8.09871 11.7016 8.60634 11.9192L15.0003 14.6594V21C15.0003 21.5523 15.448 22 16.0003 22C16.5525 22 17.0003 21.5523 17.0003 21V14.6594L23.3942 11.9192C23.9018 11.7016 24.137 11.1137 23.9194 10.6061C23.7018 10.0985 23.114 9.86331 22.6063 10.0809L16.0003 12.912L9.39418 10.0809Z" fill="#212121"/>
</svg>

Before

Width:  |  Height:  |  Size: 452 B

After

Width:  |  Height:  |  Size: 834 B

@@ -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.4.0"
version = "0.4.1"
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.4.0"
version = "0.4.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
@@ -71,7 +71,7 @@ Customize tool permissions, MCP servers, and Copilot settings:
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.0"
version = "0.4.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
@@ -142,7 +142,7 @@ variable "github_token" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.0"
version = "0.4.1"
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.4.0"
version = "0.4.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
report_tasks = false
@@ -179,7 +179,7 @@ module "aibridge-proxy" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.0"
version = "0.4.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/projects"
enable_aibridge_proxy = true
@@ -117,18 +117,23 @@ run "copilot_model_not_created_for_default" {
}
}
run "model_validation_accepts_valid_models" {
run "copilot_model_accepts_custom_model" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
copilot_model = "gpt-5"
copilot_model = "o3-pro"
}
assert {
condition = contains(["claude-sonnet-4", "claude-sonnet-4.5", "gpt-5"], var.copilot_model)
error_message = "Model should be one of the valid options"
condition = var.copilot_model == "o3-pro"
error_message = "copilot_model should accept any model string"
}
assert {
condition = length(resource.coder_env.copilot_model) == 1
error_message = "copilot_model env var should be created for non-default model"
}
}
+1 -5
View File
@@ -33,12 +33,8 @@ variable "github_token" {
variable "copilot_model" {
type = string
description = "Model to use. Supported values: claude-sonnet-4, claude-sonnet-4.5 (default), gpt-5."
description = "The model to use for Copilot. Any model supported by GitHub Copilot can be used."
default = "claude-sonnet-4.5"
validation {
condition = contains(["claude-sonnet-4", "claude-sonnet-4.5", "gpt-5"], var.copilot_model)
error_message = "copilot_model must be one of: claude-sonnet-4, claude-sonnet-4.5, gpt-5."
}
}
variable "copilot_config" {
+36 -73
View File
@@ -1,6 +1,6 @@
---
display_name: AgentAPI
description: Building block for modules that need to run an AgentAPI server
description: Building block for modules that need to run an AgentAPI server.
icon: ../../../../.icons/coder.svg
verified: true
tags: [internal, library]
@@ -11,65 +11,47 @@ tags: [internal, library]
> [!CAUTION]
> We do not recommend using this module directly. Instead, please consider using one of our [Tasks-compatible AI agent modules](https://registry.coder.com/modules?search=tag%3Atasks).
The AgentAPI module is a building block for modules that need to run an AgentAPI server. It is intended primarily for internal use by Coder to create modules compatible with Tasks.
The AgentAPI module is a building block for modules that need to run an [AgentAPI](https://github.com/coder/agentapi) server. It is intended primarily for internal use by Coder to create modules compatible with [Tasks](https://coder.com/docs/ai-coder/tasks).
```tf
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.4.0"
version = "2.5.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 = "Goose"
cli_app_slug = "goose-cli"
cli_app_display_name = "Goose CLI"
module_dir_name = local.module_dir_name
cli_app_slug = "goose-cli"
module_directory = local.module_directory
install_agentapi = var.install_agentapi
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = local.start_script
install_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
chmod +x /tmp/install.sh
ARG_PROVIDER='${var.goose_provider}' \
ARG_MODEL='${var.goose_model}' \
ARG_GOOSE_CONFIG="$(echo -n '${base64encode(local.combined_extensions)}' | base64 -d)" \
ARG_INSTALL='${var.install_goose}' \
ARG_GOOSE_VERSION='${var.goose_version}' \
/tmp/install.sh
EOT
}
```
## Task log snapshot
## Features
Captures the last 10 messages from AgentAPI when a task workspace stops. This allows viewing conversation history while the task is paused.
- **Web and CLI apps**: creates `coder_app` resources for browser-based chat and terminal attachment
- **Task log snapshot**: captures the last 10 conversation messages when a workspace stops, enabling offline viewing while the task is paused
- **State persistence**: optionally saves and restores AgentAPI conversation state across workspace restarts (requires agentapi >= v0.12.0)
- **Script orchestration**: uses [coder-utils](https://registry.coder.com/modules/coder/coder-utils) for `coder exp sync` based script ordering so downstream modules can serialize their own scripts behind this module
To enable for task workspaces:
## Examples
### Task log snapshot
Enabled by default. Captures the last 10 messages from AgentAPI when a task workspace stops.
```tf
module "agentapi" {
# ... other config
task_log_snapshot = true # default: true
task_log_snapshot = true # default
}
```
## State Persistence
### State persistence
AgentAPI can save and restore conversation state across workspace restarts.
This is disabled by default and requires agentapi binary >= v0.12.0.
State and PID files are stored in `$HOME/<module_dir_name>/` alongside other module files (e.g. `$HOME/.claude-module/agentapi-state.json`).
To enable:
Disabled by default. Requires agentapi >= v0.12.0.
```tf
module "agentapi" {
@@ -78,57 +60,38 @@ module "agentapi" {
}
```
To override file paths:
Custom file paths:
```tf
module "agentapi" {
# ... other config
state_file_path = "/custom/path/state.json"
pid_file_path = "/custom/path/agentapi.pid"
enable_state_persistence = true
state_file_path = "/custom/path/state.json"
pid_file_path = "/custom/path/agentapi.pid"
}
```
## Boundary (Network Filtering)
### Script serialization
The agentapi module supports optional [Agent Boundaries](https://coder.com/docs/ai-coder/agent-boundaries)
for network filtering. When enabled, the module sets up a `AGENTAPI_BOUNDARY_PREFIX` environment
variable that points to a wrapper script. Agent modules should use this prefix in their
start scripts to run the agent process through boundary.
Boundary requires a `config.yaml` file with your allowlist, jail type, proxy port, and log
level. See the [Agent Boundaries documentation](https://coder.com/docs/ai-coder/agent-boundaries)
for configuration details.
To enable:
The module outputs `scripts`, an ordered list of `coder exp sync` names. Downstream modules can use these to serialize their own `coder_script` resources behind the install pipeline:
```tf
module "agentapi" {
# ... other config
enable_boundary = true
boundary_config_path = "/home/coder/.config/coder_boundary/config.yaml"
source = "registry.coder.com/coder/agentapi/coder"
# ...
}
# Optional: install boundary binary instead of using coder subcommand
# use_boundary_directly        = true
# boundary_version              = "0.6.0"
# compile_boundary_from_source  = false
output "scripts" {
value = module.agentapi.scripts
}
```
### Contract for agent modules
When `enable_boundary = true`, the agentapi module exports `AGENTAPI_BOUNDARY_PREFIX`
as an environment variable pointing to a wrapper script. Agent module start scripts
should check for this variable and use it to prefix the agent command:
```bash
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
agentapi server -- "${AGENTAPI_BOUNDARY_PREFIX}" my-agent "${ARGS[@]}" &
else
agentapi server -- my-agent "${ARGS[@]}" &
fi
```
This ensures only the agent process is sandboxed while agentapi itself runs unrestricted.
## For module developers
For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
For a complete example of how to build a module on top of AgentAPI, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
## Troubleshooting
- Install logs are written to `~/.coder-modules/coder/agentapi/logs/install.log`
- AgentAPI server logs are written to `~/.coder-modules/coder/agentapi/agentapi-start.log`
- Check `agentapi --version` to verify the installed binary version
@@ -7,8 +7,6 @@ variables {
web_app_slug = "test"
cli_app_display_name = "Test CLI"
cli_app_slug = "test-cli"
start_script = "echo test"
module_dir_name = ".test-module"
}
run "default_values" {
@@ -29,33 +27,12 @@ run "default_values" {
error_message = "pid_file_path should default to empty string"
}
# Verify start script contains state persistence ARG_ vars.
assert {
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi.script))
error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE"
}
assert {
condition = can(regex("ARG_STATE_FILE_PATH", coder_script.agentapi.script))
error_message = "start script should contain ARG_STATE_FILE_PATH"
}
assert {
condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi.script))
error_message = "start script should contain ARG_PID_FILE_PATH"
}
# Verify shutdown script contains PID-related ARG_ vars.
assert {
condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain ARG_PID_FILE_PATH"
}
assert {
condition = can(regex("ARG_MODULE_DIR_NAME", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain ARG_MODULE_DIR_NAME"
}
assert {
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain ARG_ENABLE_STATE_PERSISTENCE"
@@ -74,11 +51,10 @@ run "state_persistence_disabled" {
error_message = "enable_state_persistence should be false"
}
# Even when disabled, the ARG_ vars should still be in the script
# (the shell script handles the conditional logic).
# Verify shutdown script contains the disabled flag.
assert {
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE='false'", coder_script.agentapi.script))
error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE='false'"
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE='false'", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain ARG_ENABLE_STATE_PERSISTENCE='false'"
}
}
@@ -90,19 +66,18 @@ run "custom_paths" {
pid_file_path = "/custom/agentapi.pid"
}
assert {
condition = can(regex("/custom/state.json", coder_script.agentapi.script))
error_message = "start script should contain custom state_file_path"
}
assert {
condition = can(regex("/custom/agentapi.pid", coder_script.agentapi.script))
error_message = "start script should contain custom pid_file_path"
}
# Verify custom paths also appear in shutdown script.
# Verify custom paths appear in shutdown script.
assert {
condition = can(regex("/custom/agentapi.pid", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain custom pid_file_path"
}
}
run "scripts_output" {
command = plan
assert {
condition = length(output.scripts) == 1 && output.scripts[0] == "coder-agentapi-install_script"
error_message = "scripts output should list the install script sync name"
}
}
+31 -147
View File
@@ -44,7 +44,7 @@ interface SetupProps {
moduleVariables?: Record<string, string>;
}
const moduleDirName = ".agentapi-module";
const moduleDirectory = "/home/coder/.agentapi-module";
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
const projectDir = "/home/coder/project";
@@ -58,8 +58,7 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => {
cli_app_display_name: "AgentAPI CLI",
cli_app_slug: "agentapi-cli",
agentapi_version: "latest",
module_dir_name: moduleDirName,
start_script: await loadTestFile(import.meta.dir, "agentapi-start.sh"),
module_directory: moduleDirectory,
folder: projectDir,
...props?.moduleVariables,
},
@@ -68,11 +67,31 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => {
skipAgentAPIMock: props?.skipAgentAPIMock,
moduleDir: import.meta.dir,
});
// Mock `coder` CLI so `coder exp sync` calls from coder-utils wrappers
// succeed without a real control plane.
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: "#!/bin/bash\nexit 0\n",
});
await writeExecutable({
containerId: id,
filePath: "/usr/bin/aiagent",
content: await loadTestFile(import.meta.dir, "ai-agent-mock.js"),
});
// Write the test start script directly to the module scripts dir,
// since start_script is no longer a Terraform variable.
const startScript = await loadTestFile(import.meta.dir, "agentapi-start.sh");
await execContainer(id, [
"bash",
"-c",
`mkdir -p ${moduleDirectory}/scripts`,
]);
await writeExecutable({
containerId: id,
filePath: `${moduleDirectory}/scripts/agentapi-start.sh`,
content: startScript,
});
return { id };
};
@@ -104,36 +123,6 @@ describe("agentapi", async () => {
await expectAgentAPIStarted(id, 3827);
});
test("pre-post-install-scripts", async () => {
const { id } = await setup({
moduleVariables: {
pre_install_script: `#!/bin/bash\necho "pre-install"`,
install_script: `#!/bin/bash\necho "install"`,
post_install_script: `#!/bin/bash\necho "post-install"`,
},
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
const preInstallLog = await readFileContainer(
id,
`/home/coder/${moduleDirName}/pre_install.log`,
);
const installLog = await readFileContainer(
id,
`/home/coder/${moduleDirName}/install.log`,
);
const postInstallLog = await readFileContainer(
id,
`/home/coder/${moduleDirName}/post_install.log`,
);
expect(preInstallLog).toContain("pre-install");
expect(installLog).toContain("install");
expect(postInstallLog).toContain("post-install");
});
test("install-agentapi", async () => {
const { id } = await setup({ skipAgentAPIMock: true });
@@ -313,10 +302,10 @@ describe("agentapi", async () => {
"/home/coder/agentapi-mock.log",
);
expect(mockLog).toContain(
`AGENTAPI_STATE_FILE: /home/coder/${moduleDirName}/agentapi-state.json`,
`AGENTAPI_STATE_FILE: ${moduleDirectory}/agentapi-state.json`,
);
expect(mockLog).toContain(
`AGENTAPI_PID_FILE: /home/coder/${moduleDirName}/agentapi.pid`,
`AGENTAPI_PID_FILE: ${moduleDirectory}/agentapi.pid`,
);
expect(mockLog).toContain("AGENTAPI_SAVE_STATE: true");
expect(mockLog).toContain("AGENTAPI_LOAD_STATE: true");
@@ -397,7 +386,7 @@ describe("agentapi", async () => {
return await execContainer(containerId, [
"bash",
"-c",
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 ARG_PID_FILE_PATH=${pidFilePath} ARG_ENABLE_STATE_PERSISTENCE=${enableStatePersistence} CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 ARG_PID_FILE_PATH=${pidFilePath} ARG_ENABLE_STATE_PERSISTENCE=${enableStatePersistence} ARG_LIB_SCRIPT_PATH=/tmp/agentapi-lib.sh CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
]);
};
@@ -542,15 +531,15 @@ describe("agentapi", async () => {
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
test("resolves default PID path from MODULE_DIR_NAME", async () => {
test("resolves default PID path from MODULE_DIRECTORY", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
// Start mock with PID file at the module_dir_name default location.
const defaultPidPath = `/home/coder/${moduleDirName}/agentapi.pid`;
// Start mock with PID file at the module_directory default location.
const defaultPidPath = `${moduleDirectory}/agentapi.pid`;
await setupMocks(id, "normal", 204, defaultPidPath);
// Don't pass pidFilePath - let shutdown script compute it from MODULE_DIR_NAME.
// Don't pass pidFilePath - let shutdown script compute it from MODULE_DIRECTORY.
const shutdownScript = await loadTestFile(
import.meta.dir,
"../scripts/agentapi-shutdown.sh",
@@ -572,7 +561,7 @@ describe("agentapi", async () => {
const result = await execContainer(id, [
"bash",
"-c",
`ARG_TASK_ID=test-task ARG_AGENTAPI_PORT=3284 ARG_MODULE_DIR_NAME=${moduleDirName} ARG_ENABLE_STATE_PERSISTENCE=true CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
`ARG_TASK_ID=test-task ARG_AGENTAPI_PORT=3284 ARG_MODULE_DIRECTORY=${moduleDirectory} ARG_ENABLE_STATE_PERSISTENCE=true ARG_LIB_SCRIPT_PATH=/tmp/agentapi-lib.sh CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
]);
expect(result.exitCode).toBe(0);
@@ -586,7 +575,7 @@ describe("agentapi", async () => {
skipAgentAPIMock: true,
});
await setupMocks(id, "normal", 204);
// No pidFilePath and no MODULE_DIR_NAME, so no PID file can be resolved.
// No pidFilePath and no MODULE_DIRECTORY, so no PID file can be resolved.
const result = await runShutdownScript(id, "test-task", "", "false");
expect(result.exitCode).toBe(0);
@@ -613,109 +602,4 @@ describe("agentapi", async () => {
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
});
describe("boundary", async () => {
test("boundary-disabled-by-default", async () => {
const { id } = await setup();
await execModuleScript(id);
await expectAgentAPIStarted(id);
// Config file should NOT exist when boundary is disabled
const configCheck = await execContainer(id, [
"bash",
"-c",
"test -f /home/coder/.config/coder_boundary/config.yaml && echo exists || echo missing",
]);
expect(configCheck.stdout.trim()).toBe("missing");
// AGENTAPI_BOUNDARY_PREFIX should NOT be in the mock log
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(mockLog).not.toContain("AGENTAPI_BOUNDARY_PREFIX:");
});
test("boundary-enabled", async () => {
const { id } = await setup({
moduleVariables: {
enable_boundary: "true",
boundary_config_path: "/tmp/test-boundary.yaml",
},
});
// Write boundary config to the path before running the module
await execContainer(id, [
"bash",
"-c",
`cat > /tmp/test-boundary.yaml <<'EOF'
jail_type: landjail
proxy_port: 8087
log_level: warn
allowlist:
- "domain=api.example.com"
EOF`,
]);
// Add mock coder binary for boundary setup
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: `#!/bin/bash
if [ "$1" = "boundary" ]; then
shift; shift; exec "$@"
fi
echo "mock coder"`,
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
// Verify the config file exists at the specified path
const config = await readFileContainer(id, "/tmp/test-boundary.yaml");
expect(config).toContain("jail_type: landjail");
expect(config).toContain("proxy_port: 8087");
expect(config).toContain("domain=api.example.com");
// AGENTAPI_BOUNDARY_PREFIX should be exported
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(mockLog).toContain("AGENTAPI_BOUNDARY_PREFIX:");
// E2E: start script should have used the wrapper
const startLog = await readFileContainer(
id,
"/home/coder/test-agentapi-start.log",
);
expect(startLog).toContain("Starting with boundary:");
});
test("boundary-enabled-no-coder-binary", async () => {
const { id } = await setup({
moduleVariables: {
enable_boundary: "true",
boundary_config_path: "/tmp/test-boundary.yaml",
},
});
// Write boundary config
await execContainer(id, [
"bash",
"-c",
`cat > /tmp/test-boundary.yaml <<'EOF'
jail_type: landjail
proxy_port: 8087
log_level: warn
EOF`,
]);
// Remove coder binary to simulate it not being available
await execContainer(
id,
[
"bash",
"-c",
"rm -f /usr/bin/coder /usr/local/bin/coder 2>/dev/null; hash -r",
],
["--user", "root"],
);
const resp = await execModuleScript(id);
// Script should fail because coder binary is required
expect(resp.exitCode).not.toBe(0);
const scriptLog = await readFileContainer(id, "/home/coder/script.log");
expect(scriptLog).toContain("Boundary cannot be enabled");
});
});
});
+46 -116
View File
@@ -93,29 +93,6 @@ variable "cli_app_slug" {
description = "The slug of the CLI workspace app."
}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing the agent used by AgentAPI."
default = null
}
variable "install_script" {
type = string
description = "Script to install the agent used by AgentAPI."
default = ""
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing the agent used by AgentAPI."
default = null
}
variable "start_script" {
type = string
description = "Script that starts AgentAPI."
}
variable "install_agentapi" {
type = bool
description = "Whether to install AgentAPI."
@@ -165,41 +142,6 @@ variable "agentapi_subdomain" {
}
}
variable "module_dir_name" {
type = string
description = "Name of the subdirectory in the home directory for module files."
}
variable "enable_boundary" {
type = bool
description = "Enable coder boundary for network filtering. Requires boundary_config to be set."
default = false
}
variable "boundary_config_path" {
type = string
description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var."
default = ""
}
variable "boundary_version" {
type = string
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release. When compile_boundary_from_source is true, a valid git reference should be provided (tag, commit, branch)."
default = "latest"
}
variable "compile_boundary_from_source" {
type = bool
description = "Whether to compile boundary from source instead of using the official install script."
default = false
}
variable "use_boundary_directly" {
type = bool
description = "Whether to use boundary binary directly instead of coder boundary subcommand. When false (default), uses coder boundary subcommand. When true, installs and uses boundary binary from release."
default = false
}
variable "enable_state_persistence" {
type = bool
description = "Enable AgentAPI conversation state persistence across restarts."
@@ -208,21 +150,20 @@ variable "enable_state_persistence" {
variable "state_file_path" {
type = string
description = "Path to the AgentAPI state file. Defaults to $HOME/<module_dir_name>/agentapi-state.json."
description = "Path to the AgentAPI state file. Defaults to <module_directory>/agentapi-state.json."
default = ""
}
variable "pid_file_path" {
type = string
description = "Path to the AgentAPI PID file. Defaults to $HOME/<module_dir_name>/agentapi.pid."
description = "Path to the AgentAPI PID file. Defaults to <module_directory>/agentapi.pid."
default = ""
}
resource "coder_env" "boundary_config" {
count = var.enable_boundary && var.boundary_config_path != "" ? 1 : 0
agent_id = var.agent_id
name = "BOUNDARY_CONFIG"
value = var.boundary_config_path
variable "module_directory" {
type = string
description = ""
default = "$HOME/.coder-modules/coder/agentapi"
}
locals {
@@ -232,12 +173,7 @@ locals {
web_app = var.web_app || local.is_task
# we always trim the slash for consistency
workdir = trimsuffix(var.folder, "/")
encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
encoded_install_script = var.install_script != null ? base64encode(var.install_script) : ""
encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : ""
agentapi_start_script_b64 = base64encode(var.start_script)
agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh"))
workdir = trimsuffix(var.folder, "/")
// Chat base path is only set if not using a subdomain.
// NOTE:
// - Initial support for --chat-base-path was added in v0.3.1 but configuration
@@ -245,51 +181,38 @@ locals {
// - As CODER_WORKSPACE_AGENT_NAME is a recent addition we use agent ID
// for backward compatibility.
agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat"
main_script = file("${path.module}/scripts/main.sh")
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
lib_script = file("${path.module}/scripts/lib.sh")
boundary_script = file("${path.module}/scripts/boundary.sh")
shutdown_script_destination = "${var.module_directory}/agentapi-shutdown.sh"
lib_script_destination = "${var.module_directory}/agentapi-lib.sh"
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
ARG_MODULE_DIRECTORY = var.module_directory
ARG_WORKDIR = local.workdir
ARG_INSTALL_AGENTAPI = tostring(var.install_agentapi)
ARG_AGENTAPI_VERSION = var.agentapi_version
ARG_WAIT_FOR_START_SCRIPT = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh"))
ARG_AGENTAPI_PORT = tostring(var.agentapi_port)
ARG_AGENTAPI_CHAT_BASE_PATH = local.agentapi_chat_base_path
ARG_TASK_ID = try(data.coder_task.me.id, "")
ARG_TASK_LOG_SNAPSHOT = tostring(var.task_log_snapshot)
ARG_ENABLE_STATE_PERSISTENCE = tostring(var.enable_state_persistence)
ARG_STATE_FILE_PATH = var.state_file_path
ARG_PID_FILE_PATH = var.pid_file_path
ARG_LIB_SCRIPT = base64encode(local.lib_script)
})
}
resource "coder_script" "agentapi" {
agent_id = var.agent_id
display_name = "Install and start AgentAPI"
icon = var.web_app_icon
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh
chmod +x /tmp/main.sh
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
echo -n '${base64encode(local.boundary_script)}' | base64 -d > /tmp/agentapi-boundary.sh
chmod +x /tmp/agentapi-boundary.sh
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \
ARG_PRE_INSTALL_SCRIPT="$(echo -n '${local.encoded_pre_install_script}' | base64 -d)" \
ARG_INSTALL_SCRIPT="$(echo -n '${local.encoded_install_script}' | base64 -d)" \
ARG_INSTALL_AGENTAPI='${var.install_agentapi}' \
ARG_AGENTAPI_VERSION='${var.agentapi_version}' \
ARG_START_SCRIPT="$(echo -n '${local.agentapi_start_script_b64}' | base64 -d)" \
ARG_WAIT_FOR_START_SCRIPT="$(echo -n '${local.agentapi_wait_for_start_script_b64}' | base64 -d)" \
ARG_POST_INSTALL_SCRIPT="$(echo -n '${local.encoded_post_install_script}' | base64 -d)" \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
ARG_COMPILE_BOUNDARY_FROM_SOURCE='${var.compile_boundary_from_source}' \
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
ARG_STATE_FILE_PATH='${var.state_file_path}' \
ARG_PID_FILE_PATH='${var.pid_file_path}' \
/tmp/main.sh
EOT
run_on_start = true
agent_id = var.agent_id
module_directory = var.module_directory
display_name_prefix = "AgentAPI"
icon = var.web_app_icon
install_script = local.install_script
}
resource "coder_script" "agentapi_shutdown" {
@@ -301,17 +224,19 @@ resource "coder_script" "agentapi_shutdown" {
#!/bin/bash
set -o pipefail
echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh
chmod +x /tmp/agentapi-shutdown.sh
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
mkdir -p "${var.module_directory}"
echo -n '${base64encode(local.shutdown_script)}' | base64 -d > "${local.shutdown_script_destination}"
chmod +x "${local.shutdown_script_destination}"
echo -n '${base64encode(local.lib_script)}' | base64 -d > "${local.lib_script_destination}"
ARG_MODULE_DIRECTORY='${var.module_directory}' \
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
ARG_PID_FILE_PATH='${var.pid_file_path}' \
/tmp/agentapi-shutdown.sh
ARG_LIB_SCRIPT_PATH="${local.lib_script_destination}" \
"${local.shutdown_script_destination}"
EOT
}
@@ -356,3 +281,8 @@ resource "coder_app" "agentapi_cli" {
output "task_app_id" {
value = local.web_app ? coder_app.agentapi_web[0].id : ""
}
output "scripts" {
description = "Ordered list of coder exp sync names for the coder_script resources this module creates, in run order. Scripts that were not configured are absent from the list."
value = module.coder_utils.scripts
}
@@ -12,12 +12,13 @@ readonly TASK_ID="${ARG_TASK_ID:-}"
readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}"
readonly ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
readonly MODULE_DIR_NAME="${ARG_MODULE_DIR_NAME:-}"
readonly PID_FILE_PATH="${ARG_PID_FILE_PATH:-${MODULE_DIR_NAME:+$HOME/$MODULE_DIR_NAME/agentapi.pid}}"
readonly MODULE_DIRECTORY="${ARG_MODULE_DIRECTORY:-}"
readonly PID_FILE_PATH="${ARG_PID_FILE_PATH:-${MODULE_DIRECTORY:+${MODULE_DIRECTORY}/agentapi.pid}}"
readonly LIB_SCRIPT_PATH="${ARG_LIB_SCRIPT_PATH}"
# Source shared utilities (written by the coder_script wrapper).
# shellcheck source=lib.sh
source /tmp/agentapi-lib.sh
source "${LIB_SCRIPT_PATH}"
# Runtime environment variables.
readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}"
@@ -1,95 +0,0 @@
#!/bin/bash
# boundary.sh - Boundary installation and setup for agentapi module.
# Sourced by main.sh when ENABLE_BOUNDARY=true.
# Exports AGENTAPI_BOUNDARY_PREFIX for use by module start scripts.
validate_boundary_subcommand() {
if command_exists coder; then
if coder boundary --help > /dev/null 2>&1; then
return 0
else
echo "Error: 'coder' command found but does not support 'boundary' subcommand. Please enable install_boundary."
exit 1
fi
else
echo "Error: ENABLE_BOUNDARY=true, but 'coder' command not found. Boundary cannot be enabled." >&2
exit 1
fi
}
# Install boundary binary if needed.
# Uses one of three strategies:
# 1. Compile from source (compile_boundary_from_source=true)
# 2. Install from release (use_boundary_directly=true)
# 3. Use coder boundary subcommand (default, no installation needed)
install_boundary() {
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]; then
echo "Compiling boundary from source (version: ${BOUNDARY_VERSION})"
# Remove existing boundary directory to allow re-running safely
if [ -d boundary ]; then
rm -rf boundary
fi
echo "Cloning boundary repository"
git clone https://github.com/coder/boundary.git
cd boundary || exit 1
git checkout "${BOUNDARY_VERSION}"
make build
sudo cp boundary /usr/local/bin/
sudo chmod +x /usr/local/bin/boundary
cd - || exit 1
elif [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
echo "Installing boundary using official install script (version: ${BOUNDARY_VERSION})"
curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "${BOUNDARY_VERSION}"
else
validate_boundary_subcommand
echo "Using coder boundary subcommand (provided by Coder)"
fi
}
# Set up boundary: install, write config, create wrapper script.
# Exports AGENTAPI_BOUNDARY_PREFIX pointing to the wrapper script.
setup_boundary() {
local module_path="$1"
echo "Setting up coder boundary..."
# Install boundary binary if needed
install_boundary
# Determine which boundary command to use and create wrapper script
BOUNDARY_WRAPPER_SCRIPT="$module_path/boundary-wrapper.sh"
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ] || [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
# Use boundary binary directly (from compilation or release installation)
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF'
#!/usr/bin/env bash
set -euo pipefail
exec boundary -- "$@"
WRAPPER_EOF
else
# Use coder boundary subcommand (default)
# Copy coder binary to strip CAP_NET_ADMIN capabilities.
# This is necessary because boundary doesn't work with privileged binaries
# (you can't launch privileged binaries inside network namespaces unless
# you have sys_admin).
CODER_NO_CAPS="$module_path/coder-no-caps"
if ! cp "$(which coder)" "$CODER_NO_CAPS"; then
echo "Error: Failed to copy coder binary to ${CODER_NO_CAPS}. Boundary cannot be enabled." >&2
exit 1
fi
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/coder-no-caps" boundary -- "$@"
WRAPPER_EOF
fi
chmod +x "${BOUNDARY_WRAPPER_SCRIPT}"
export AGENTAPI_BOUNDARY_PREFIX="${BOUNDARY_WRAPPER_SCRIPT}"
echo "Boundary wrapper configured: ${AGENTAPI_BOUNDARY_PREFIX}"
}
@@ -0,0 +1,110 @@
#!/bin/bash
set -e
set -x
set -o nounset
MODULE_DIRECTORY='${ARG_MODULE_DIRECTORY}'
WORKDIR='${ARG_WORKDIR}'
INSTALL_AGENTAPI='${ARG_INSTALL_AGENTAPI}'
AGENTAPI_VERSION='${ARG_AGENTAPI_VERSION}'
WAIT_FOR_START_SCRIPT=$(echo -n '${ARG_WAIT_FOR_START_SCRIPT}' | base64 -d)
AGENTAPI_PORT='${ARG_AGENTAPI_PORT}'
AGENTAPI_CHAT_BASE_PATH='${ARG_AGENTAPI_CHAT_BASE_PATH}'
TASK_ID='${ARG_TASK_ID}'
TASK_LOG_SNAPSHOT='${ARG_TASK_LOG_SNAPSHOT}'
ENABLE_STATE_PERSISTENCE='${ARG_ENABLE_STATE_PERSISTENCE}'
STATE_FILE_PATH='${ARG_STATE_FILE_PATH}'
PID_FILE_PATH='${ARG_PID_FILE_PATH}'
LIB_SCRIPT=$(echo -n '${ARG_LIB_SCRIPT}' | base64 -d)
set +o nounset
# Write and source lib.sh
LIB_SCRIPT_PATH="$${MODULE_DIRECTORY}/agentapi-lib.sh"
echo -n "$LIB_SCRIPT" > "$LIB_SCRIPT_PATH"
# shellcheck source=lib.sh
source "$LIB_SCRIPT_PATH"
command_exists() {
command -v "$1" > /dev/null 2>&1
}
mkdir -p "$${MODULE_DIRECTORY}/scripts"
# Check for jq dependency if task log snapshot is enabled.
if [[ $TASK_LOG_SNAPSHOT == true ]] && [[ -n $TASK_ID ]]; then
if ! command_exists jq; then
echo "Warning: jq is not installed. Task log snapshot requires jq to capture conversation history."
echo "Install jq to enable log snapshot functionality when the workspace stops."
fi
fi
if [ ! -d "$${WORKDIR}" ]; then
echo "Warning: The specified folder '$${WORKDIR}' does not exist."
echo "Creating the folder..."
mkdir -p "$${WORKDIR}"
echo "Folder created successfully."
fi
# Install AgentAPI if enabled
if [ "$${INSTALL_AGENTAPI}" = "true" ]; then
echo "Installing AgentAPI..."
arch=$(uname -m)
if [ "$arch" = "x86_64" ]; then
binary_name="agentapi-linux-amd64"
elif [ "$arch" = "aarch64" ]; then
binary_name="agentapi-linux-arm64"
else
echo "Error: Unsupported architecture: $arch"
exit 1
fi
if [ "$${AGENTAPI_VERSION}" = "latest" ]; then
# for the latest release the download URL pattern is different than for tagged releases
# https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases
download_url="https://github.com/coder/agentapi/releases/latest/download/$binary_name"
else
download_url="https://github.com/coder/agentapi/releases/download/$${AGENTAPI_VERSION}/$binary_name"
fi
curl \
--retry 5 \
--retry-delay 5 \
--fail \
--retry-all-errors \
-L \
-C - \
-o agentapi \
"$download_url"
chmod +x agentapi
sudo mv agentapi /usr/local/bin/agentapi
fi
if ! command_exists agentapi; then
echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually."
exit 1
fi
echo -n "$${WAIT_FOR_START_SCRIPT}" > "$${MODULE_DIRECTORY}/scripts/agentapi-wait-for-start.sh"
chmod +x "$${MODULE_DIRECTORY}/scripts/agentapi-wait-for-start.sh"
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
cd "$${WORKDIR}"
export AGENTAPI_CHAT_BASE_PATH="$${AGENTAPI_CHAT_BASE_PATH:-}"
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
export AGENTAPI_ALLOWED_HOSTS="*"
export AGENTAPI_PID_FILE="$${PID_FILE_PATH:-$${MODULE_DIRECTORY}/agentapi.pid}"
# Only set state env vars when persistence is enabled and the binary supports
# it. State persistence requires agentapi >= v0.12.0.
if [ "$${ENABLE_STATE_PERSISTENCE}" = "true" ]; then
actual_version=$(agentapi_version)
if version_at_least 0.12.0 "$actual_version"; then
export AGENTAPI_STATE_FILE="$${STATE_FILE_PATH:-$${MODULE_DIRECTORY}/agentapi-state.json}"
export AGENTAPI_SAVE_STATE="true"
export AGENTAPI_LOAD_STATE="true"
else
echo "Warning: State persistence requires agentapi >= v0.12.0 (current: $${actual_version:-unknown}), skipping."
fi
fi
nohup "$${MODULE_DIRECTORY}/scripts/agentapi-start.sh" true "$${AGENTAPI_PORT}" &> "$${MODULE_DIRECTORY}/agentapi-start.log" &
"$${MODULE_DIRECTORY}/scripts/agentapi-wait-for-start.sh" "$${AGENTAPI_PORT}"
@@ -1,142 +0,0 @@
#!/bin/bash
set -e
set -x
set -o nounset
MODULE_DIR_NAME="$ARG_MODULE_DIR_NAME"
WORKDIR="$ARG_WORKDIR"
PRE_INSTALL_SCRIPT="$ARG_PRE_INSTALL_SCRIPT"
INSTALL_SCRIPT="$ARG_INSTALL_SCRIPT"
INSTALL_AGENTAPI="$ARG_INSTALL_AGENTAPI"
AGENTAPI_VERSION="$ARG_AGENTAPI_VERSION"
START_SCRIPT="$ARG_START_SCRIPT"
WAIT_FOR_START_SCRIPT="$ARG_WAIT_FOR_START_SCRIPT"
POST_INSTALL_SCRIPT="$ARG_POST_INSTALL_SCRIPT"
AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
TASK_ID="${ARG_TASK_ID:-}"
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
ENABLE_BOUNDARY="${ARG_ENABLE_BOUNDARY:-false}"
BOUNDARY_VERSION="${ARG_BOUNDARY_VERSION:-latest}"
COMPILE_BOUNDARY_FROM_SOURCE="${ARG_COMPILE_BOUNDARY_FROM_SOURCE:-false}"
USE_BOUNDARY_DIRECTLY="${ARG_USE_BOUNDARY_DIRECTLY:-false}"
ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}"
PID_FILE_PATH="${ARG_PID_FILE_PATH:-}"
set +o nounset
# shellcheck source=lib.sh
source /tmp/agentapi-lib.sh
command_exists() {
command -v "$1" > /dev/null 2>&1
}
module_path="$HOME/${MODULE_DIR_NAME}"
mkdir -p "$module_path/scripts"
# Check for jq dependency if task log snapshot is enabled.
if [[ $TASK_LOG_SNAPSHOT == true ]] && [[ -n $TASK_ID ]]; then
if ! command_exists jq; then
echo "Warning: jq is not installed. Task log snapshot requires jq to capture conversation history."
echo "Install jq to enable log snapshot functionality when the workspace stops."
fi
fi
if [ ! -d "${WORKDIR}" ]; then
echo "Warning: The specified folder '${WORKDIR}' does not exist."
echo "Creating the folder..."
mkdir -p "${WORKDIR}"
echo "Folder created successfully."
fi
if [ -n "${PRE_INSTALL_SCRIPT}" ]; then
echo "Running pre-install script..."
echo -n "${PRE_INSTALL_SCRIPT}" > "$module_path/pre_install.sh"
chmod +x "$module_path/pre_install.sh"
"$module_path/pre_install.sh" 2>&1 | tee "$module_path/pre_install.log"
fi
echo "Running install script..."
echo -n "${INSTALL_SCRIPT}" > "$module_path/install.sh"
chmod +x "$module_path/install.sh"
"$module_path/install.sh" 2>&1 | tee "$module_path/install.log"
# Install AgentAPI if enabled
if [ "${INSTALL_AGENTAPI}" = "true" ]; then
echo "Installing AgentAPI..."
arch=$(uname -m)
if [ "$arch" = "x86_64" ]; then
binary_name="agentapi-linux-amd64"
elif [ "$arch" = "aarch64" ]; then
binary_name="agentapi-linux-arm64"
else
echo "Error: Unsupported architecture: $arch"
exit 1
fi
if [ "${AGENTAPI_VERSION}" = "latest" ]; then
# for the latest release the download URL pattern is different than for tagged releases
# https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases
download_url="https://github.com/coder/agentapi/releases/latest/download/$binary_name"
else
download_url="https://github.com/coder/agentapi/releases/download/${AGENTAPI_VERSION}/$binary_name"
fi
curl \
--retry 5 \
--retry-delay 5 \
--fail \
--retry-all-errors \
-L \
-C - \
-o agentapi \
"$download_url"
chmod +x agentapi
sudo mv agentapi /usr/local/bin/agentapi
fi
if ! command_exists agentapi; then
echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually."
exit 1
fi
echo -n "${START_SCRIPT}" > "$module_path/scripts/agentapi-start.sh"
echo -n "${WAIT_FOR_START_SCRIPT}" > "$module_path/scripts/agentapi-wait-for-start.sh"
chmod +x "$module_path/scripts/agentapi-start.sh"
chmod +x "$module_path/scripts/agentapi-wait-for-start.sh"
if [ -n "${POST_INSTALL_SCRIPT}" ]; then
echo "Running post-install script..."
echo -n "${POST_INSTALL_SCRIPT}" > "$module_path/post_install.sh"
chmod +x "$module_path/post_install.sh"
"$module_path/post_install.sh" 2>&1 | tee "$module_path/post_install.log"
fi
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
cd "${WORKDIR}"
# Set up boundary if enabled
export AGENTAPI_BOUNDARY_PREFIX=""
if [ "${ENABLE_BOUNDARY}" = "true" ]; then
# shellcheck source=boundary.sh
source /tmp/agentapi-boundary.sh
setup_boundary "$module_path"
fi
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
export AGENTAPI_ALLOWED_HOSTS="*"
export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}"
# Only set state env vars when persistence is enabled and the binary supports
# it. State persistence requires agentapi >= v0.12.0.
if [ "${ENABLE_STATE_PERSISTENCE}" = "true" ]; then
actual_version=$(agentapi_version)
if version_at_least 0.12.0 "$actual_version"; then
export AGENTAPI_STATE_FILE="${STATE_FILE_PATH:-$module_path/agentapi-state.json}"
export AGENTAPI_SAVE_STATE="true"
export AGENTAPI_LOAD_STATE="true"
else
echo "Warning: State persistence requires agentapi >= v0.12.0 (current: ${actual_version:-unknown}), skipping."
fi
fi
nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" &
"$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}"
+19 -1
View File
@@ -46,7 +46,25 @@ export const setupContainer = async ({
agent_id: "foo",
...vars,
});
const coderScript = findResourceInstance(state, "coder_script");
// Find the run_on_start script. With coder-utils the install script lives
// inside a module and the shutdown script is a separate resource, so we
// pick the first coder_script that has run_on_start = true.
let coderScript: { script: string; [k: string]: unknown } | undefined;
for (const resource of state.resources) {
if (resource.type !== "coder_script") continue;
for (const instance of resource.instances) {
const attrs = instance.attributes as Record<string, unknown>;
if (attrs.run_on_start === true) {
coderScript = attrs as { script: string; [k: string]: unknown };
break;
}
}
if (coderScript) break;
}
if (!coderScript) {
// Fallback to original behavior for backwards compatibility.
coderScript = findResourceInstance(state, "coder_script");
}
const coderEnvVars = extractCoderEnvVars(state);
const id = await runContainer(image ?? "codercom/enterprise-node:latest");
return {
@@ -31,16 +31,6 @@ for (const v of [
);
}
}
// Log boundary env vars.
for (const v of ["AGENTAPI_BOUNDARY_PREFIX"]) {
if (process.env[v]) {
fs.appendFileSync(
"/home/coder/agentapi-mock.log",
`\n${v}: ${process.env[v]}`,
);
}
}
// Write PID file for shutdown script.
if (process.env.AGENTAPI_PID_FILE) {
const path = require("path");
+5 -15
View File
@@ -5,8 +5,8 @@ set -o pipefail
use_prompt=${1:-false}
port=${2:-3284}
module_path="$HOME/.agentapi-module"
log_file_path="$module_path/agentapi.log"
module_directory="$HOME/.agentapi-module"
log_file_path="$module_directory/agentapi.log"
echo "using prompt: $use_prompt" >> /home/coder/test-agentapi-start.log
echo "using port: $port" >> /home/coder/test-agentapi-start.log
@@ -17,16 +17,6 @@ if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then
export AGENTAPI_CHAT_BASE_PATH
fi
# Use boundary wrapper if configured by agentapi module.
# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh
# and points to a wrapper script that runs the command through coder boundary.
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
echo "Starting with boundary: ${AGENTAPI_BOUNDARY_PREFIX}" >> /home/coder/test-agentapi-start.log
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
"${AGENTAPI_BOUNDARY_PREFIX}" bash -c aiagent \
> "$log_file_path" 2>&1
else
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
bash -c aiagent \
> "$log_file_path" 2>&1
fi
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
bash -c aiagent \
> "$log_file_path" 2>&1
+9 -9
View File
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.0"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
@@ -60,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.0"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_boundary = true
@@ -81,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.0"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_aibridge = true
@@ -110,7 +110,7 @@ data "coder_task" "me" {}
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.0"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
ai_prompt = data.coder_task.me.prompt
@@ -133,7 +133,7 @@ This example shows additional configuration options for version pinning, custom
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.0"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
@@ -189,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.0"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
install_claude_code = true
@@ -211,7 +211,7 @@ variable "claude_code_oauth_token" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.0"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
@@ -284,7 +284,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.0"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -341,7 +341,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.0"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
@@ -182,6 +182,24 @@ describe("claude-code", async () => {
expect(startLog.stdout).toContain(`--permission-mode ${mode}`);
});
test("claude-auto-permission-mode", async () => {
const mode = "auto";
const { id } = await setup({
moduleVariables: {
permission_mode: mode,
ai_prompt: "test prompt",
},
});
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(startLog.stdout).toContain(`--permission-mode ${mode}`);
});
test("claude-model", async () => {
const model = "opus";
const { coderEnvVars } = await setup({
+6 -5
View File
@@ -161,8 +161,8 @@ variable "permission_mode" {
description = "Permission mode for the cli, check https://docs.anthropic.com/en/docs/claude-code/iam#permission-modes"
default = ""
validation {
condition = contains(["", "default", "acceptEdits", "plan", "bypassPermissions"], var.permission_mode)
error_message = "interaction_mode must be one of: default, acceptEdits, plan, bypassPermissions."
condition = contains(["", "default", "acceptEdits", "plan", "auto", "bypassPermissions"], var.permission_mode)
error_message = "interaction_mode must be one of: default, acceptEdits, plan, auto, bypassPermissions."
}
}
@@ -368,10 +368,10 @@ locals {
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.3.0"
version = "2.4.0"
agent_id = var.agent_id
# TODO: pass web_app = var.web_app once agentapi module is published with web_app support
agent_id = var.agent_id
web_app = var.web_app
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
@@ -430,6 +430,7 @@ module "agentapi" {
ARG_MCP='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \
ARG_MCP_CONFIG_REMOTE_PATH='${base64encode(jsonencode(var.mcp_config_remote_path))}' \
ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
ARG_PERMISSION_MODE='${var.permission_mode}' \
/tmp/install.sh
EOT
}
@@ -183,11 +183,26 @@ run "test_claude_code_permission_mode_validation" {
}
assert {
condition = contains(["", "default", "acceptEdits", "plan", "bypassPermissions"], var.permission_mode)
condition = contains(["", "default", "acceptEdits", "plan", "auto", "bypassPermissions"], var.permission_mode)
error_message = "Permission mode should be one of the valid options"
}
}
run "test_claude_code_auto_permission_mode" {
command = plan
variables {
agent_id = "test-agent-auto"
workdir = "/home/coder/test"
permission_mode = "auto"
}
assert {
condition = var.permission_mode == "auto"
error_message = "Permission mode should be set to auto"
}
}
run "test_claude_code_with_boundary" {
command = plan
@@ -22,6 +22,7 @@ ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n "${ARG_MCP_CONFIG_REMOTE_PATH:-}" | base64
ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-}
ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-}
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-}
export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"
@@ -195,6 +196,7 @@ function configure_standalone_mode() {
jq --arg workdir "$ARG_WORKDIR" --arg apikey "${CLAUDE_API_KEY:-}" \
'.autoUpdaterStatus = "disabled" |
.autoModeAccepted = true |
.bypassPermissionsModeAccepted = true |
.hasAcknowledgedCostThreshold = true |
.hasCompletedOnboarding = true |
@@ -207,6 +209,7 @@ function configure_standalone_mode() {
cat > "$claude_config" << EOF
{
"autoUpdaterStatus": "disabled",
"autoModeAccepted": true,
"bypassPermissionsModeAccepted": true,
"hasAcknowledgedCostThreshold": true,
"hasCompletedOnboarding": true,
@@ -235,6 +238,28 @@ function report_tasks() {
fi
}
function accept_auto_mode() {
# Pre-accept the auto mode TOS prompt so it doesn't appear interactively.
# Claude Code shows a confirmation dialog for auto mode that blocks
# non-interactive/headless usage.
# Note: bypassPermissions acceptance is already handled by
# coder exp mcp configure (task mode) and configure_standalone_mode.
local claude_config="$HOME/.claude.json"
if [ -f "$claude_config" ]; then
jq '.autoModeAccepted = true' \
"$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config"
else
echo '{"autoModeAccepted": true}' > "$claude_config"
fi
echo "Pre-accepted auto mode prompt"
}
install_claude_code_cli
setup_claude_configurations
report_tasks
if [ "$ARG_PERMISSION_MODE" = "auto" ]; then
accept_auto_mode
fi
+31 -24
View File
@@ -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.3.1"
version = "1.4.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
@@ -39,7 +39,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.3.1"
version = "1.4.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
default = ["PY", "IU"] # Pre-configure PyCharm and IntelliJ IDEA
@@ -52,7 +52,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.3.1"
version = "1.4.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
# Show parameter with limited options
@@ -66,7 +66,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.3.1"
version = "1.4.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
default = ["IU", "PY"]
@@ -75,30 +75,37 @@ module "jetbrains" {
}
```
### Custom IDE Configuration
### Pinned Versions (Air-Gapped / Cached)
When `ide_config` is set, the module makes zero HTTP calls and uses the
provided build numbers directly. This is ideal for air-gapped environments
or when caching IDE installations.
> [!TIP]
> To find the latest build number for an IDE, query the JetBrains releases API:
>
> ```sh
> curl -s "https://data.services.jetbrains.com/products/releases?code=GO&type=release&latest=true" | jq 'to_entries[0].value[0] | {build, version}'
> ```
>
> Replace `GO` with the product code for the IDE you want (e.g. `IU`, `PY`, `CL`).
```tf
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.3.1"
version = "1.4.0"
agent_id = coder_agent.main.id
folder = "/workspace/project"
folder = "/home/coder/project"
# Custom IDE metadata (display names and icons)
# Only build is required. Name and icon fall back to built-in defaults.
ide_config = {
"IU" = {
name = "IntelliJ IDEA"
icon = "/custom/icons/intellij.svg"
build = "251.26927.53"
}
"PY" = {
name = "PyCharm"
icon = "/custom/icons/pycharm.svg"
build = "251.23774.211"
}
"GO" = { build = "261.22158.291" }
"PY" = { build = "261.22158.340" }
# Add entries for other IDEs as needed.
}
options = ["GO", "PY"] # Must match the keys in ide_config.
}
```
@@ -108,7 +115,7 @@ module "jetbrains" {
module "jetbrains_pycharm" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.3.1"
version = "1.4.0"
agent_id = coder_agent.main.id
folder = "/workspace/project"
@@ -128,7 +135,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.3.1"
version = "1.4.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
default = ["IU", "PY"]
@@ -165,9 +172,9 @@ resource "coder_metadata" "container_info" {
### Version Resolution
- Build numbers are fetched from the JetBrains API for the latest compatible versions when internet access is available
- If the API is unreachable (air-gapped environments), the module automatically falls back to build numbers from `ide_config`
- `major_version` and `channel` control which API endpoint is queried (when API access is available)
- **`ide_config` not set (default)**: Build numbers are fetched from the JetBrains releases API. If the API is unreachable, Terraform will return an error rather than silently using stale versions.
- **`ide_config` set**: The module skips all HTTP calls and uses the provided build numbers directly. No network access required. Ideal for air-gapped deployments or when caching IDE installations.
- `major_version` and `channel` control which API endpoint is queried (only when `ide_config` is not set).
## Supported IDEs
@@ -1,53 +1,3 @@
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 = "253.29346.141" },
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" },
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" },
"PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "253.29346.151" },
"PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "253.29346.142" },
"RD" = { name = "Rider", icon = "/icon/rider.svg", build = "253.29346.144" },
"RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "253.29346.140" },
"RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "253.29346.139" },
"WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "253.29346.143" }
}
}
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
@@ -259,15 +209,17 @@ run "output_empty_when_default_empty" {
}
}
run "output_single_ide_uses_fallback_build" {
run "uses_ide_config_when_set" {
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"
options = ["GO"]
ide_config = {
"GO" = { name = "GoLand Custom", icon = "/icon/goland.svg", build = "999.99999.999" }
}
}
assert {
@@ -281,30 +233,38 @@ run "output_single_ide_uses_fallback_build" {
}
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}'"
condition = output.ide_metadata["GO"].name == "GoLand Custom"
error_message = "Expected ide_metadata['GO'].name to be 'GoLand Custom'"
}
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}'"
condition = output.ide_metadata["GO"].build == "999.99999.999"
error_message = "Expected ide_metadata['GO'].build to use the pinned build '999.99999.999'"
}
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}'"
condition = output.ide_metadata["GO"].icon == "/icon/goland.svg"
error_message = "Expected ide_metadata['GO'].icon to be '/icon/goland.svg'"
}
assert {
condition = output.ide_metadata["GO"].json_data == null
error_message = "Expected ide_metadata['GO'].json_data to be null when using ide_config"
}
}
run "output_multiple_ides" {
run "uses_ide_config_for_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"
options = ["IU", "PY"]
ide_config = {
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "111.11111.111" }
"PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "222.22222.222" }
}
}
assert {
@@ -318,15 +278,50 @@ run "output_multiple_ides" {
}
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}'"
condition = output.ide_metadata["PY"].name == "PyCharm"
error_message = "Expected ide_metadata['PY'].name to be 'PyCharm'"
}
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}'"
condition = output.ide_metadata["PY"].build == "222.22222.222"
error_message = "Expected ide_metadata['PY'].build to be the pinned build '222.22222.222'"
}
assert {
condition = output.ide_metadata["IU"].build == "111.11111.111"
error_message = "Expected ide_metadata['IU'].build to be the pinned build '111.11111.111'"
}
assert {
condition = output.ide_metadata["IU"].json_data == null
error_message = "Expected ide_metadata['IU'].json_data to be null when using ide_config"
}
assert {
condition = output.ide_metadata["PY"].json_data == null
error_message = "Expected ide_metadata['PY'].json_data to be null when using ide_config"
}
}
run "ide_config_build_in_url" {
command = apply
variables {
agent_id = "test-agent-123"
folder = "/home/coder/project"
default = ["GO"]
options = ["GO"]
ide_config = {
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "999.99999.999" }
}
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_build_number=999.99999.999", app.url)) > 0])
error_message = "URL must include the pinned build number from ide_config"
}
}
run "validate_output_schema" {
command = plan
@@ -334,6 +329,10 @@ run "validate_output_schema" {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
options = ["GO"]
ide_config = {
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" }
}
}
assert {
@@ -351,3 +350,107 @@ run "validate_output_schema" {
error_message = "The ide_metadata output schema has changed. Please update the 'main.tf' and this test."
}
}
run "rejects_major_version_with_ide_config" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
options = ["GO"]
major_version = "2025.3"
ide_config = {
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.31033.129" }
}
}
expect_failures = [
var.ide_config,
]
}
run "rejects_default_not_in_ide_config" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO", "IU"]
options = ["GO", "IU"]
ide_config = {
"GO" = { build = "253.31033.129" }
}
}
expect_failures = [
var.ide_config,
]
}
run "ide_config_with_build_only" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
options = ["GO"]
ide_config = {
"GO" = { build = "999.99999.999" }
}
}
assert {
condition = output.ide_metadata["GO"].name == "GoLand"
error_message = "Expected name to fall back to ide_metadata when not set in ide_config"
}
assert {
condition = output.ide_metadata["GO"].icon == "/icon/goland.svg"
error_message = "Expected icon to fall back to ide_metadata when not set in ide_config"
}
assert {
condition = output.ide_metadata["GO"].build == "999.99999.999"
error_message = "Expected build to use ide_config value"
}
}
run "rejects_releases_base_link_with_ide_config" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
options = ["GO"]
releases_base_link = "https://internal.mirror.example.com"
ide_config = {
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.31033.129" }
}
}
expect_failures = [
var.ide_config,
]
}
run "rejects_channel_with_ide_config" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
options = ["GO"]
channel = "eap"
ide_config = {
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.31033.129" }
}
}
expect_failures = [
var.ide_config,
]
}
+74 -43
View File
@@ -125,95 +125,126 @@ variable "download_base_link" {
}
data "http" "jetbrains_ide_versions" {
for_each = local.selected_ides
for_each = var.ide_config == null ? local.selected_ides : toset([])
url = "${var.releases_base_link}/products/releases?code=${each.key}&type=${var.channel}${var.major_version == "latest" ? "&latest=true" : ""}"
}
variable "ide_config" {
description = <<-EOT
A map of JetBrains IDE configurations.
The key is the product code and the value is an object with the following properties:
- name: The name of the IDE.
- icon: The icon of the IDE.
- build: The build number of the IDE.
Optional map of JetBrains IDE configurations keyed by product code.
When null (default), the module fetches the latest build numbers from
the JetBrains API at plan time. When set, all HTTP calls are skipped
and the provided build numbers are used directly useful for
air-gapped environments or pinning specific versions.
Each value must contain:
- build: Full build number (e.g. "253.28294.337").
Optionally override the default display name or icon:
- name: Display name of the IDE (e.g. "GoLand").
- icon: Path or URL to the IDE icon (e.g. "/icon/goland.svg").
Example:
{
"CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" },
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" },
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" },
"GO" = { build = "261.22158.291" },
"IU" = { build = "261.22158.277" },
}
EOT
type = map(object({
name = string
icon = string
build = string
name = optional(string)
icon = optional(string)
}))
default = {
"CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" },
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" },
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" },
"PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "253.29346.151" },
"PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "253.29346.142" },
"RD" = { name = "Rider", icon = "/icon/rider.svg", build = "253.29346.144" },
"RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "253.29346.140" },
"RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "253.29346.139" },
"WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "253.29346.143" }
}
default = null
validation {
condition = length(var.ide_config) > 0
condition = var.ide_config == null || length(var.ide_config) > 0
error_message = "The ide_config must not be empty."
}
# ide_config must be a superset of var.options
# Requires Terraform 1.9+ for cross-variable validation references
validation {
condition = alltrue([
condition = var.ide_config == null || alltrue([
for code in var.options : contains(keys(var.ide_config), code)
])
error_message = "The ide_config must be a superset of var.options."
error_message = "The ide_config must contain entries for all IDE codes in var.options. Either add the missing entries to ide_config or narrow var.options to match."
}
# ide_config must also cover all codes in var.default to avoid
# key-not-found errors when building options_metadata.
validation {
condition = var.ide_config == null || alltrue([
for code in var.default : contains(keys(var.ide_config), code)
])
error_message = "The ide_config must contain entries for all IDE codes in var.default."
}
# major_version, channel, and releases_base_link only affect the
# HTTP call, which is skipped when ide_config is set. Reject
# non-default values to avoid silently ignoring user intent.
validation {
condition = var.ide_config == null || (
var.major_version == "latest" &&
var.channel == "release" &&
var.releases_base_link == "https://data.services.jetbrains.com"
)
error_message = "major_version, channel, and releases_base_link have no effect when ide_config is set. Remove them or unset ide_config."
}
}
locals {
# Static IDE metadata for name and icon lookups when ide_config is null.
ide_metadata = {
"CL" = { name = "CLion", icon = "/icon/clion.svg" }
"GO" = { name = "GoLand", icon = "/icon/goland.svg" }
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg" }
"PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg" }
"PY" = { name = "PyCharm", icon = "/icon/pycharm.svg" }
"RD" = { name = "Rider", icon = "/icon/rider.svg" }
"RM" = { name = "RubyMine", icon = "/icon/rubymine.svg" }
"RR" = { name = "RustRover", icon = "/icon/rustrover.svg" }
"WS" = { name = "WebStorm", icon = "/icon/webstorm.svg" }
}
# Determine the user's actual IDE selection.
# This is computed before the HTTP data source so that version lookups
# are only performed for IDEs the user chose not every option.
selected_ides = length(var.default) == 0 ? toset(jsondecode(coalesce(data.coder_parameter.jetbrains_ides[0].value, "[]"))) : toset(var.default)
# Parse HTTP responses once with error handling for air-gapped environments
# Parse HTTP responses. Only populated when ide_config is null
# and the module fetches versions from the JetBrains API.
# No try() fallback if the API is expected and fails, Terraform
# should error rather than silently using stale build numbers.
parsed_responses = {
for code in local.selected_ides : code => try(
jsondecode(data.http.jetbrains_ide_versions[code].response_body),
{} # Return empty object if API call fails
)
for code, response in data.http.jetbrains_ide_versions :
code => jsondecode(response.response_body)
}
# Filter the parsed response for the requested major version if not "latest"
filtered_releases = {
for code in local.selected_ides : code => [
for r in try(local.parsed_responses[code][keys(local.parsed_responses[code])[0]], []) :
for code, parsed in local.parsed_responses : code => [
for r in parsed[keys(parsed)[0]] :
r if var.major_version == "latest" || r.majorVersion == var.major_version
]
}
# Select the latest release for the requested major version (first item in the filtered list)
selected_releases = {
for code in local.selected_ides : code =>
length(local.filtered_releases[code]) > 0 ? local.filtered_releases[code][0] : null
for code, releases in local.filtered_releases :
code => length(releases) > 0 ? releases[0] : null
}
# Dynamically generate IDE configurations based on selected IDEs with fallback to ide_config
# Dynamically generate IDE configurations based on selected IDEs
options_metadata = {
for code in local.selected_ides : code => {
icon = var.ide_config[code].icon
name = var.ide_config[code].name
icon = var.ide_config != null ? coalesce(var.ide_config[code].icon, local.ide_metadata[code].icon) : local.ide_metadata[code].icon
name = var.ide_config != null ? coalesce(var.ide_config[code].name, local.ide_metadata[code].name) : local.ide_metadata[code].name
identifier = code
key = code
# Use API build number if available, otherwise fall back to ide_config build number
build = local.selected_releases[code] != null ? local.selected_releases[code].build : var.ide_config[code].build
# When ide_config is set, use the pinned build number directly.
# When fetching from API, use the API result (fails if unavailable).
build = var.ide_config != null ? var.ide_config[code].build : local.selected_releases[code].build
# Store API data for potential future use
json_data = local.selected_releases[code]
# API response data, null when using ide_config.
json_data = var.ide_config != null ? null : local.selected_releases[code]
}
}
}
@@ -233,8 +264,8 @@ data "coder_parameter" "jetbrains_ides" {
dynamic "option" {
for_each = var.options
content {
icon = var.ide_config[option.value].icon
name = var.ide_config[option.value].name
icon = var.ide_config != null ? coalesce(var.ide_config[option.value].icon, local.ide_metadata[option.value].icon) : local.ide_metadata[option.value].icon
name = var.ide_config != null ? coalesce(var.ide_config[option.value].name, local.ide_metadata[option.value].name) : local.ide_metadata[option.value].name
value = option.value
}
}
@@ -16,7 +16,7 @@ The VSCode Desktop Core module is a building block for modules that need to expo
```tf
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.2"
version = "1.1.0"
agent_id = var.agent_id
@@ -3,6 +3,11 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
runContainer,
execContainer,
removeContainer,
findResourceInstance,
readFileContainer,
} from "~test";
// hardcoded coder_app name in main.tf
@@ -16,6 +21,7 @@ const defaultVariables = {
coder_app_display_name: "VS Code Desktop",
protocol: "vscode",
config_dir: "$HOME/.vscode",
};
describe("vscode-desktop-core", async () => {
@@ -134,4 +140,41 @@ describe("vscode-desktop-core", async () => {
expect(coder_app?.instances[0].attributes.group).toBe("web-app-group");
});
});
it("writes mcp_config.json when mcp_config variable provided", async () => {
const id = await runContainer("alpine");
try {
const mcp_config = JSON.stringify({
servers: { demo: { url: "http://localhost:1234" } },
});
const state = await runTerraformApply(import.meta.dir, {
...defaultVariables,
mcp_config,
});
const script = findResourceInstance(
state,
"coder_script",
"vscode-desktop-mcp",
).script;
const resp = await execContainer(id, ["sh", "-c", script]);
if (resp.exitCode !== 0) {
console.log(resp.stdout);
console.log(resp.stderr);
}
expect(resp.exitCode).toBe(0);
const content = await readFileContainer(
id,
`${defaultVariables.config_dir.replace("$HOME", "/root")}/mcp_config.json`,
);
expect(content).toBe(mcp_config);
} finally {
await removeContainer(id);
}
}, 10000);
});
@@ -26,11 +26,22 @@ variable "open_recent" {
default = false
}
variable "mcp_config" {
type = map(any)
description = "MCP server configuration for the IDE. When set, writes mcp_config.json in var.config_dir."
default = null
}
variable "protocol" {
type = string
description = "The URI protocol the IDE."
}
variable "config_dir" {
type = string
description = "The path of the IDE's configuration folder."
}
variable "coder_app_icon" {
type = string
description = "The icon of the coder_app."
@@ -85,21 +96,36 @@ resource "coder_app" "vscode-desktop" {
data.coder_workspace.me.access_url,
"&token=$SESSION_TOKEN",
])
}
/*
url = join("", [
"vscode://coder.coder-remote/open",
"?owner=${data.coder_workspace_owner.me.name}",
"&workspace=${data.coder_workspace.me.name}",
var.folder != "" ? join("", ["&folder=", var.folder]) : "",
var.open_recent ? "&openRecent" : "",
"&url=${data.coder_workspace.me.access_url}",
"&token=$SESSION_TOKEN",
])
*/
resource "coder_script" "vscode-desktop-mcp" {
agent_id = var.agent_id
count = var.mcp_config != null ? 1 : 0
icon = var.coder_app_icon
display_name = "${var.coder_app_display_name} MCP"
run_on_start = true
start_blocks_login = false
script = <<-EOT
#!/bin/sh
set -euo pipefail
IDE_CONFIG_FOLDER="${var.config_dir}"
IDE_MCP_CONFIG_PATH="$IDE_CONFIG_FOLDER/mcp_config.json"
mkdir -p "$IDE_CONFIG_FOLDER"
echo -n "${base64encode(jsonencode(var.mcp_config))}" | base64 -d > "$IDE_MCP_CONFIG_PATH"
chmod 600 "$IDE_MCP_CONFIG_PATH"
# Cursor/Windsurf use this config instead, no need for chmod as symlinks do not have modes
ln -s "$IDE_MCP_CONFIG_PATH" "$IDE_CONFIG_FOLDER/mcp.json"
EOT
}
output "ide_uri" {
value = coder_app.vscode-desktop.url
description = "IDE URI."
}
}