Compare commits

..

2 Commits

Author SHA1 Message Date
Atif Ali 885c19e7bf fix(claude-code): default remote_control_name to workspace name 2026-03-11 17:38:56 +00:00
Atif Ali 1040aebd8b feat(claude-code): add optional remote control support
Add enable_remote_control and remote_control_name variables to the
claude-code module, allowing users to opt into Claude Code Remote
Control (https://code.claude.com/docs/en/remote-control.md).

When enabled, the module starts claude in remote-control mode instead
of the default interactive mode, allowing the session to be accessed
from claude.ai/code or the Claude mobile app.

Requires a Claude subscription (Pro, Max, Team, or Enterprise).
API keys are not supported.
2026-03-11 17:38:24 +00:00
50 changed files with 230 additions and 2705 deletions
-369
View File
@@ -1,369 +0,0 @@
---
name: coder-modules
description: Creates and updates Coder Registry modules with proper scaffolding, Terraform testing, README frontmatter, and version management
---
# Coder Modules
Coder Registry modules are reusable Terraform components that live under `registry/<namespace>/modules/<name>/` and are consumed by templates via `module` blocks.
## Before You Start
Before writing or modifying any code:
1. **Understand the request.** What tool, integration, or functionality is the module providing? What Coder resources does it need (`coder_script`, `coder_app`, `coder_env`, etc.)? Read the official documentation for the target tool or integration (installation steps, CLI flags, config files, environment variables, ports) so you can implement the module properly without guessing.
2. **Research existing modules.** Search the registry for similar modules. Read their `main.tf` to understand patterns, variable conventions, and how they solve similar problems. Avoid duplicating existing functionality.
3. **Check the Coder provider docs.** Verify that the resources and attributes you plan to use exist in the provider version you're targeting. Use the version-specific docs URL if needed.
4. **Clarify before building.** If the request is ambiguous (e.g. unclear which Coder resource to use, whether a `coder_app` vs `coder_script` is appropriate, what variables to expose, or which namespace to use), ask for clarification rather than guessing. Never assume a namespace; always confirm with the user.
5. **Plan the structure.** Decide on script organization (root `run.sh`, `scripts/` directory, or inline), what variables to expose, and what tests to write.
Always prefer the proper implementation over a simpler shortcut. Modules are infrastructure that users depend on. Doing less work is not the same as reducing complexity if it leaves the module incomplete or fragile.
## Documentation References
### Coder
- Coder docs (latest): <https://coder.com/docs>
- Version-specific Coder docs: `https://coder.com/docs/@v{MAJOR}.{MINOR}.{PATCH}` (e.g. <https://coder.com/docs/@v2.31.5>)
- Coder Registry: <https://registry.coder.com>
### Coder Terraform provider
- Provider docs (latest): <https://registry.terraform.io/providers/coder/coder/latest/docs>
- Version-specific provider docs: replace `latest` with a version number (e.g. <https://registry.terraform.io/providers/coder/coder/2.13.1/docs>)
Resources:
| Resource | Docs |
| ---------------- | ------------------------------------------------------------------------------------ |
| `coder_app` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/app> |
| `coder_script` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script> |
| `coder_env` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/env> |
| `coder_metadata` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/metadata> |
Data sources:
| Data Source | Docs |
| ----------------------- | ---------------------------------------------------------------------------------------------- |
| `coder_parameter` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/parameter> |
| `coder_workspace` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace> |
| `coder_workspace_owner` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace_owner> |
## Scaffolding a New Module
Only use this when creating a brand new module that does not yet exist. When updating an existing module, edit its files directly.
From repo root:
```bash
./scripts/new_module.sh namespace/module-name
```
Names must be lowercase alphanumeric with hyphens (e.g. `coder/my-tool`). Underscores are not allowed.
Creates `registry/<namespace>/modules/<module-name>/` with:
- `main.tf`: Terraform config with common resource patterns and variables — read this as the primary reference for module structure
- `README.md`: frontmatter and usage examples
- `MODULE_NAME.tftest.hcl`: Terraform native tests
- `run.sh`: install/start-up script template
If the namespace is new, the script also creates `registry/<namespace>/` with a README. New namespaces additionally need:
- `registry/<namespace>/.images/avatar.svg` (or `.png`): square image, 400x400px minimum
- The namespace README `avatar` field pointing to `./.images/avatar.svg`
The scaffolding script does not create the `.images/` directory or avatar file. When a new namespace is created, create `registry/<namespace>/.images/` and add a placeholder `avatar.svg` so the directory structure is ready for the user to replace with their real avatar.
The generated namespace README contains placeholder fields (`display_name`, `bio`, `status`, `github`, `avatar`, etc.) that the user must fill out. The `status` field is required and must be `official`, `partner`, or `community` (typically `community` for new contributors).
## Key Patterns
- Provider version constraints must reflect actual functionality requirements. Only raise the minimum `coder` provider version (e.g. `>= 2.5` to `>= 2.8`) when the module uses a resource, attribute, or behavior introduced in that version; check the provider changelog to confirm.
- Variable names MUST be `snake_case` (no hyphens; validation rejects them)
- New variables must have sensible defaults for backward compatibility
- Common variable: `agent_id` (string, required, no default)
- Common variable: `order` (number, default `null`, controls UI position)
- Use `locals {}` for computed values: URL normalization, base64 encoding, `file()` script content, config assembly
- Modules can consume other registry modules via `module` blocks (e.g. `cursor` uses `vscode-desktop-core`, CLI wrappers use `agentapi`). Before consuming a module, read its `main.tf` and `README.md` to understand the full interface: accepted variables, outputs, prerequisites, and runtime requirements. If you are inside the registry repo, read these files directly. Otherwise, read the module's page at `https://registry.coder.com/modules/<namespace>/<module-name>` which includes the full source, README, and variable definitions. Never pass arguments without confirming they exist.
- Most modules expose configuration via `variable` blocks, letting the template pass values. Use `coder_parameter` inside a module only when the module needs to present a UI choice directly to the workspace user (e.g. region selectors, IDE pickers).
- For parameter-only modules (region selectors, etc.), use `dynamic "option"` with `for_each` from a `locals` map and expose an `output` for the selected value.
- `coder_script` icons use the `/icon/<name>.svg` format. The `display_name` is typically the product name (e.g. "code-server", "Git Clone", "File Browser").
- Do not add comments that narrate what the code does or label sections. Only comment when explaining something non-obvious (e.g. why a workaround exists, a subtle constraint, or an unusual design choice).
## README.md
Required YAML frontmatter:
```yaml
---
display_name: My Tool
description: Short description of what this module does
icon: ../../../../.icons/tool.svg
verified: false
tags: [helper, ide]
---
```
Content rules:
- Single H1 heading matching `display_name`, directly below frontmatter
- When increasing header levels, increment by one each time (h1 -> h2 -> h3, not h1 -> h3)
- Usage snippet with `registry.coder.com/<ns>/<module>/coder` and pinned `version`
- Code fences labeled `tf` (NOT `hcl`)
- Relative icon paths (e.g. `../../../../.icons/`)
- **Do NOT include tables or lists that enumerate variables, parameters, or outputs.** The registry generates variable and output documentation automatically from the Terraform source. Describe what the module does and how to use it in prose, not by listing every configurable field.
- Usage examples are encouraged
- Use [GFM alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) for callouts: `> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`
```tf
module "my_tool" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/namespace/my-tool/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
}
```
## Icons
Modules reference icons in two places with different path systems:
- **README frontmatter** `icon:` uses a relative path to the repo's `.icons/` directory (e.g. `../../../../.icons/my-tool.svg`). Displayed on the registry website.
- **`coder_script` / `coder_app`** `icon =` uses an absolute `/icon/<name>.svg` path served by the Coder deployment from `site/static/icon/` in the `coder/coder` repo. Displayed in the workspace agent bar.
Workflow:
1. **Check what exists.** List the `.icons/` directory at the repo root for available SVGs. For `/icon/` paths, look at what similar modules already use.
2. **Use existing icons when they fit.** If the tool already has an icon in `.icons/` and `/icon/`, use those.
3. **When an icon doesn't exist,** reference the expected path anyway (e.g. `../../../../.icons/my-tool.svg` and `/icon/my-tool.svg`) so the structure is correct. Try to source the official SVG from the tool's branding page or repository. If you can obtain it, add it to `.icons/` in this repo.
4. **Don't substitute a generic icon.** If the tool has its own brand identity, use the correct name even if the file doesn't exist yet. Don't fall back to generic icons like `coder.svg` or `terminal.svg`.
5. **Track missing icons** so you can report them in your response.
## Scripts
Modules use three patterns for shell logic, depending on complexity:
### Root `run.sh` + `templatefile()` (simple modules)
A single `run.sh` at the module root, loaded via `templatefile()` to inject Terraform variables. Used by `code-server`, `vscode-web`, `git-clone`, `dotfiles`, `filebrowser`.
```tf
resource "coder_script" "my_tool" {
agent_id = var.agent_id
display_name = "My Tool"
icon = "/icon/my-tool.svg"
script = templatefile("${path.module}/run.sh", {
LOG_PATH : var.log_path,
})
run_on_start = true
}
```
Use `$${VAR}` (double dollar) in the shell script for Terraform `templatefile` escaping.
If a script sources external files (`$HOME/.bashrc`, `/etc/bashrc`, `/etc/os-release`), the `source` statement must come before `set -u`; CI enforces this ordering.
### `scripts/` directory + `file()` (complex modules)
Separate `scripts/install.sh` and `scripts/start.sh` loaded via `file()` into `locals`, then passed to a child module or encoded inline. Used by `coder/claude-code`, `coder-labs/copilot`, `coder-labs/codex`, `coder-labs/cursor-cli`, `coder/amazon-q` for example.
```tf
locals {
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
}
```
Use `file()` when scripts don't need Terraform variable interpolation. For config templates, use a `templates/` directory with `templatefile()` (e.g. `coder/amazon-q/templates/agent-config.json.tpl`).
### Inline heredoc (minimal modules)
For trivial logic, embed the script directly in the `coder_script` resource. Used by `cursor`, `zed`.
Modules that use a `scripts/` directory often also have a `testdata/` directory containing mock scripts for testing (e.g. `testdata/my-tool-mock.sh`).
## Testing
### .tftest.hcl (Required)
Every module must have Terraform native tests. The file can be named `main.tftest.hcl` or `<module-name>.tftest.hcl`. Use `command = plan` for most cases:
```hcl
run "plan_with_defaults" {
command = plan
variables {
agent_id = "test-agent-id"
}
assert {
condition = var.agent_id == "test-agent-id"
error_message = "agent_id should be set"
}
}
run "custom_port" {
command = plan
variables {
agent_id = "test-agent-id"
port = 8080
}
assert {
condition = resource.coder_app.my_tool.url == "http://localhost:8080"
error_message = "App URL should use configured port"
}
}
```
Advanced patterns:
- `override_data` to mock data sources like `coder_workspace` and `coder_workspace_owner`
- `command = apply` when testing outputs or computed values
- `expect_failures` to test validation rules
- `regexall()` / `startswith()` / `endswith()` for string assertions
- Assert on `coder_env`, `coder_script`, `coder_app` resource attributes
```hcl
run "with_mocked_workspace" {
command = apply
variables {
agent_id = "foo"
}
override_data {
target = data.coder_workspace.me
values = {
name = "test-workspace"
}
}
assert {
condition = output.url == "expected-value"
error_message = "URL should match expected format"
}
}
run "validation_rejects_conflict" {
command = plan
variables {
agent_id = "test"
option_a = true
option_b = true
}
expect_failures = [
var.option_a,
]
}
```
### main.test.ts (Optional)
For more complex testing (Docker containers, script execution, HTTP mocking).
Import from `~test` (mapped to `test/test.ts` via `tsconfig.json`):
```typescript
import { describe, expect, it } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
findResourceInstance,
} from "~test";
describe("my-tool", () => {
it("should init successfully", async () => {
await runTerraformInit(import.meta.dir);
});
testRequiredVariables(import.meta.dir, {
agent_id: "test-agent",
});
it("should apply with defaults", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent",
});
const app = findResourceInstance(state, "coder_app");
expect(app.slug).toBe("my-tool");
expect(app.display_name).toBe("My Tool");
});
});
```
### Test utility API (`~test`)
**Terraform helpers:**
- `runTerraformInit(dir)`: runs `terraform init`.
- `runTerraformApply(dir, vars, customEnv?)`: runs `terraform apply` with a random state file and returns `TerraformState`. Variables are passed as `TF_VAR_*`. Safe to run in parallel. `TerraformState` has `outputs: Record<string, TerraformOutput>` and `resources: TerraformStateResource[]`.
- `testRequiredVariables(dir, vars)`: auto-generates test cases (one success with all vars, plus one per var verifying apply fails without it). Pass `{}` if there are no required vars.
- `findResourceInstance(state, type, name?)`: finds the first resource instance by type. Throws if not found. Optionally filters by name.
**Docker helpers** (require `--network host`, Linux/Colima/OrbStack):
- `runContainer(image, init?)`: starts a detached container and returns its ID. Labeled `modules-test=true` for auto-cleanup.
- `removeContainer(id)`: force-removes a container.
- `execContainer(id, cmd[], args?[])`: runs a command in a container and returns `{ exitCode, stdout, stderr }`.
- `executeScriptInContainer(state, image, shell?, before?)`: finds `coder_script` in state, runs it in a container, and returns `{ exitCode, stdout: string[], stderr: string[] }`.
**File helpers:**
- `writeCoder(id, script)`: writes a mock `coder` CLI to `/usr/bin/coder` in the container.
- `writeFileContainer(id, path, content, { user? })`: writes a file to the container via base64.
- `readFileContainer(id, path)`: reads a file from the container as root.
**HTTP helpers:**
- `createJSONResponse(obj, statusCode?)`: creates a `Response` with a JSON body (defaults to 200).
Cleanup of `*.tfstate` files and `modules-test` Docker containers is handled automatically by `setup.ts` (preloaded via `bunfig.toml`).
## Commands
| Task | Command | Scope |
| ---------------- | ----------------------------------------------------- | ---------- |
| Format all | `bun run fmt` | Repo |
| Terraform tests | `bun run tftest` | Repo |
| TypeScript tests | `bun run tstest` | Repo |
| Single TF test | `terraform init -upgrade && terraform test -verbose` | Module dir |
| Single TS test | `bun test main.test.ts` | Module dir |
| Validate | `./scripts/terraform_validate.sh` | Repo |
| ShellCheck | `bun run shellcheck` | Repo |
| Version bump | `.github/scripts/version-bump.sh patch\|minor\|major` | Repo |
## Version Management
Bump version via `.github/scripts/version-bump.sh` when modifying modules:
- `patch`: bugfixes
- `minor`: new features, new variables with defaults
- `major`: breaking changes (removed inputs, changed defaults, new required variables)
The script automatically updates `version` references in README usage examples.
## Final Checks
Before considering the work complete, verify:
- Tests pass: `bun run tftest` and `bun run tstest`
- `bun run fmt` has been run
- `bun run shellcheck` passes if the module includes shell scripts
- New variables have sensible defaults for backward compatibility
- Breaking changes are documented if any inputs were removed, defaults changed, or new required variables added
- Shell scripts handle errors gracefully (`|| echo "Warning..."` for non-fatal failures)
- No hardcoded values that should be configurable via variables
- Asset and icon paths in frontmatter and Terraform must be relative (e.g. `../../../../.icons/`), not absolute. External hyperlinks to docs or other websites are fine.
## Response to the User
In your response, include:
- If a new namespace was created, remind the user to fill out the namespace README (`display_name`, `bio`, `status`, `github`, etc.) and replace the placeholder avatar. Note that this is only needed if they plan to contribute to the registry.
- If any icons were referenced but not found, list them and note they need to be sourced and added to both this repo's `.icons/` directory and the `coder/coder` repo at `site/static/icon/`.
- A note that to contribute the module to the public registry, they can open a pull request to <https://github.com/coder/registry>.
-321
View File
@@ -1,321 +0,0 @@
---
name: coder-templates
description: Creates and updates Coder Registry workspace templates with agent setup, infrastructure provisioning, and module consumption
---
# Coder Templates
Coder workspace templates are complete workspace definitions that live under `registry/<namespace>/templates/<name>/` and provision the infrastructure that workspaces run on.
## Before You Start
Before writing or modifying any code:
1. **Understand the request.** What platform is the template targeting (Docker, AWS, GCP, Azure, Kubernetes)? What kind of workspace (VM, container, devcontainer)?
2. **Research existing templates and modules.** Look under `registry/` in this repo for similar templates and modules first; if you are not in the repo or cannot find a match, browse <https://registry.coder.com>. Read `main.tf` to understand patterns for that platform, especially how they handle agent setup, persistent storage, and module consumption. Prefer platform-specific helper modules (e.g. region selectors) that provide ready-made `coder_parameter` blocks over hard-coding option lists.
3. **Check provider docs.** Verify the infrastructure provider resources you plan to use. Check both the Coder provider and the platform provider (AWS, Docker, etc.) version-specific docs if needed.
4. **Clarify before building.** If the request is ambiguous (e.g. unclear platform, whether to use devcontainers vs plain VMs, what parameters to expose, or which namespace to use), ask for clarification rather than guessing. Never assume a namespace; always confirm with the user.
5. **Plan the structure.** Decide on infrastructure resources, what `coder_parameter` options to expose, which registry modules to consume, and whether additional files like cloud-init configs are needed. When the user describes requirements in terms of their development needs rather than specific Terraform changes (e.g. "I need Node 20 + Postgres 16" or "make this template work for data science"), summarize what you plan to add or change before proceeding. Keep it brief: list the parameters, modules, and infrastructure changes. Skip this for straightforward requests where the action is clear (e.g. "add the code-server module" or "change the default region to us-west-2").
When updating an existing template, read and understand all of its current resources, parameters, and module consumption before making changes. If you observe patterns that deviate from the coder template standards (e.g. missing metadata blocks, hardcoded values that should be parameters, inline implementations that existing modules could replace, missing error handling in scripts), note these to the user as improvement opportunities in your response.
Always prefer the proper implementation over a simpler shortcut. Templates are infrastructure that users depend on. Doing less work is not the same as reducing complexity if it leaves the template incomplete or fragile.
Features marked as "Premium" in this skill require a Coder Premium license. When your implementation uses a Premium feature, note this in your response to the user so they can verify their deployment supports it.
## Documentation References
### Coder
- Platform docs (latest): <https://coder.com/docs>
- Version-specific docs: `https://coder.com/docs/@v{MAJOR}.{MINOR}.{PATCH}` (e.g. <https://coder.com/docs/@v2.31.5>)
- Creating templates: <https://coder.com/docs/admin/templates/creating-templates>
- Extending templates: <https://coder.com/docs/admin/templates/extending-templates>
- Template parameters: <https://coder.com/docs/admin/templates/extending-templates/parameters>
- Dynamic parameters: <https://coder.com/docs/admin/templates/extending-templates/dynamic-parameters>
- Workspace presets: <https://coder.com/docs/admin/templates/extending-templates/parameters#workspace-presets>
- Prebuilt workspaces: <https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces>
- Tasks: <https://coder.com/docs/ai-coder/tasks>
- Agent Boundaries: <https://coder.com/docs/ai-coder/agent-boundaries>
- Coder Registry: <https://registry.coder.com>
### Coder Terraform provider
- Provider docs (latest): <https://registry.terraform.io/providers/coder/coder/latest/docs>
- Version-specific provider docs: replace `latest` with a version number (e.g. <https://registry.terraform.io/providers/coder/coder/2.13.1/docs>)
Resources:
| Resource | Docs |
| ---------------- | ------------------------------------------------------------------------------------ |
| `coder_agent` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/agent> |
| `coder_app` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/app> |
| `coder_script` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script> |
| `coder_env` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/env> |
| `coder_metadata` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/metadata> |
| `coder_ai_task` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/ai_task> |
Data sources:
| Data Source | Docs |
| ------------------------ | ----------------------------------------------------------------------------------------------- |
| `coder_parameter` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/parameter> |
| `coder_workspace` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace> |
| `coder_workspace_owner` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace_owner> |
| `coder_provisioner` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/provisioner> |
| `coder_workspace_preset` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace_preset> |
| `coder_task` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/task> |
### Terraform providers commonly used in templates
All provider docs follow `https://registry.terraform.io/providers/ORG/NAME/latest/docs`:
| Provider | Source |
| ---------- | ---------------------- |
| Docker | `kreuzwerker/docker` |
| AWS | `hashicorp/aws` |
| Azure | `hashicorp/azurerm` |
| GCP | `hashicorp/google` |
| Kubernetes | `hashicorp/kubernetes` |
| Cloud-Init | `hashicorp/cloudinit` |
Browse all providers: <https://registry.terraform.io/browse/providers>
## Scaffolding a New Template
Only use this when creating a brand new template that does not yet exist. When updating an existing template, edit its files directly.
From repo root:
```bash
./scripts/new_template.sh namespace/template-name
```
Names must be lowercase alphanumeric with hyphens (e.g. `my-org/aws-ec2`). Underscores are not allowed.
Creates `registry/<namespace>/templates/<template-name>/` with:
- `main.tf`: full workspace Terraform config with common patterns — read this as the primary reference for template structure
- `README.md`: frontmatter and documentation
If the namespace is new, the script also creates `registry/<namespace>/` with a README. New namespaces additionally need:
- `registry/<namespace>/.images/avatar.svg` (or `.png`): square image, 400x400px minimum
- The namespace README `avatar` field pointing to `./.images/avatar.svg`
The scaffolding script does not create the `.images/` directory or avatar file. When a new namespace is created, create `registry/<namespace>/.images/` and add a placeholder `avatar.svg` so the directory structure is ready for the user to replace with their real avatar.
The generated namespace README contains placeholder fields (`display_name`, `bio`, `status`, `github`, `avatar`, etc.) that the user must fill out. The `status` field is required and must be `official`, `partner`, or `community` (typically `community` for new contributors).
## Key Patterns
- Provider version constraints must reflect actual functionality requirements. Only set a minimum `coder` provider version when the template uses a resource, attribute, or behavior introduced in that version. The same applies to infrastructure providers (Docker, AWS, etc.); check provider changelogs to confirm.
- Include `data.coder_workspace.me` and `data.coder_workspace_owner.me` for workspace and owner metadata. Include `data.coder_provisioner.me` only when you need the provisioner's `arch` or `os` for `coder_agent` (typical for Docker, Kubernetes, Incus); omit when the workspace OS/arch is fixed (e.g. cloud VMs with a known image).
- Use `locals {}` for computed values: username, environment variables, startup scripts, URL assembly
- Use `data.coder_workspace.me.start_count` as `count` on ephemeral resources
- Connect containers/VMs to the agent via `coder_agent.main.init_script` and `CODER_AGENT_TOKEN`
- Add `metadata` blocks for workspace dashboard stats (`coder stat cpu`, `coder stat mem`, etc.)
- Use `coder_metadata` on the primary compute resource to surface key details (region, instance type, image, disk size) in the workspace dashboard
- Optionally use `display_apps` block to hide specific built-in apps (defaults show all)
- Before implementing functionality from scratch, look for an existing module under `registry/*/modules/` in this repo; if you cannot find one or are not in the repo, search <https://registry.coder.com>. If a module already exists for what you need, consume it rather than reimplementing it. When multiple modules serve similar purposes, prefer the actively maintained one and check that you are not using a deprecated or superseded module.
- Before consuming a module, read its `main.tf` and `README.md` to understand the full interface: accepted variables, outputs, prerequisites, and runtime requirements. Prefer paths under `registry/<namespace>/modules/<name>/` in this workspace; otherwise use `https://registry.coder.com/modules/<namespace>/<module-name>`. Never pass arguments without confirming they exist.
- After identifying a module's prerequisites, verify the template's base image satisfies them. If it lacks a required tool, either switch to an image that includes it or ensure the prerequisite is installed before the module's script runs. These runtime issues are not caught by `terraform validate`; they only surface when the workspace starts.
- Module source URLs use `registry.coder.com/<namespace>/<module>/coder`. Older templates may use `registry.coder.com/modules/...`; prefer the shorter form when writing new modules or templates.
- Label infrastructure resources with `coder.owner` and `coder.workspace_id` for tracking orphans
- Use `lifecycle { ignore_changes = all }` on persistent volumes to prevent data loss
- Do not add comments that narrate what the code does or label sections. Only comment when explaining something non-obvious (e.g. why a workaround exists, a subtle constraint, or an unusual design choice).
### Additional files
Templates can include files beyond `main.tf` + `README.md`:
- `cloud-init/*.tftpl`: cloud-init configs for VM provisioning (AWS, Azure, GCP), loaded via `templatefile()`. Prefer this subdirectory over placing cloud-init files at the template root.
- `build/Dockerfile`: custom container images built by the template
- `.tftpl` files: any Terraform template files for scripts, configs, or cloud-init data
### Parameters
Use `data "coder_parameter"` for user-facing workspace options. Typical parameters: region/instance type/CPU/memory/disk for cloud VMs; container image or runtime version for Docker (pass as `build_arg` when using a local Dockerfile). Use same-platform templates in `registry/` as a starting reference, not a rigid pattern. Expose stated preferences as the parameter `default` with additional sensible `option` values unless the user explicitly restricts it.
- Prefer `dynamic "option"` blocks with `for_each` from a `locals` map over static `option` blocks. See the region selector modules (e.g. `coder/aws-region`) for the pattern.
- Use `form_type` for richer UI controls: `dropdown` (searchable), `multi-select` (for `list(string)`), `slider` (numeric), `radio`, `checkbox`, `textarea`.
- Conditional parameters: use `count` to show/hide a parameter based on another parameter's value.
- `mutable = false` for infrastructure that can't change after creation (region, disk); `mutable = true` for runtime config.
- `ephemeral = true` for one-shot build options that don't persist between starts.
- `validation {}` with `min`/`max`/`monotonic` for numbers, `regex`/`error` for strings.
- Dynamic parameter features require Coder provider `>= 2.4.0`.
### Presets
Workspace presets bundle commonly-used parameter combinations into selectable options. When a user creates a workspace, they can pick a preset to auto-fill multiple parameters at once. Define presets with `data "coder_workspace_preset"`:
```tf
data "coder_workspace_preset" "default" {
name = "Standard Dev Environment"
default = true
parameters = {
"region" = "us-east-1"
"cpu" = "4"
"memory" = "8"
"container_image" = "codercom/enterprise-base:ubuntu"
}
}
```
- The keys in `parameters` must match the `name` attribute of `coder_parameter` data sources in the same template.
- Set `default = true` on at most one preset to pre-select it in the UI.
- A template can define multiple presets for different use cases.
- Optional fields: `description` (context text in UI) and `icon` (e.g. `/emojis/1f680.png`).
### Prebuilds (Premium)
Prebuilds maintain an automatically-managed pool of pre-provisioned workspaces for a preset, reducing workspace creation time. This is a Premium feature. Prebuilds are configured as a nested block inside a preset:
```tf
data "coder_workspace_preset" "goland" {
name = "GoLand: Large"
parameters = {
"jetbrains_ide" = "GO"
"cpu" = "8"
"memory" = "16"
}
prebuilds {
instances = 3
expiration_policy {
ttl = 86400
}
scheduling {
timezone = "UTC"
schedule {
cron = "* 8-18 * * 1-5"
instances = 5
}
}
}
}
```
- `instances`: number of prebuilt workspaces to keep in the pool (base count when no schedule matches).
- `expiration_policy.ttl`: seconds before unclaimed prebuilds are cleaned up.
- `scheduling`: scale the pool up or down on a time-based cron schedule. The `cron` minute field must always be `*`.
- The preset must define all required parameters needed to build the workspace.
- When a prebuild is claimed, ownership transfers to the real user. Use `lifecycle { ignore_changes = [...] }` on resources that reference owner-specific values to prevent unnecessary recreation.
### Task-Oriented Templates
A template becomes task-capable by adding a `coder_ai_task` resource, which enables the Coder Tasks UI for AI agent workflows. Task templates require three additions on top of a regular template:
```tf
resource "coder_ai_task" "task" {
count = data.coder_workspace.me.start_count
app_id = module.claude-code[count.index].task_app_id
}
data "coder_task" "me" {}
module "claude-code" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/claude-code/coder"
version = "~> 4.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/projects"
ai_prompt = data.coder_task.me.prompt
system_prompt = data.coder_parameter.system_prompt.value
model = "sonnet"
permission_mode = "plan"
enable_boundary = true
}
```
- `coder_ai_task`: declares the template as task-capable. Its `app_id` must point to the agent module's `task_app_id` output.
- `data "coder_task"`: reads the user's task prompt. Pass it to the agent module via `ai_prompt`.
- Agent module: consume an AI agent module (`claude-code`, `codex`, etc.) with task-specific variables. Key variables include `ai_prompt`, `system_prompt`, `permission_mode`, and `enable_boundary`.
- Boundaries: set `enable_boundary = true` on the agent module to enable network-level filtering for the AI agent. See <https://coder.com/docs/ai-coder/agent-boundaries> for allowlist configuration.
- A `coder_app` with `slug = "preview"` gets special treatment in the Tasks UI navbar.
- Task templates heavily use presets to define scenarios (different repos, system prompts, setup scripts, container images).
- See `registry/coder-labs/templates/tasks-docker` as a reference implementation.
Docs: <https://coder.com/docs/ai-coder/tasks>
## README.md
Required YAML frontmatter:
```yaml
---
display_name: Docker Containers
description: Provision Docker containers with persistent home volumes as Coder workspaces
icon: ../../../../.icons/docker.svg
verified: false
tags: [docker, container]
---
```
Content rules:
- Single H1 heading matching `display_name`, directly below frontmatter
- When increasing header levels, increment by one each time (h1 -> h2 -> h3, not h1 -> h3)
- Opening paragraph describing what the template provisions. Be specific about the platform, compute type, and key capabilities (e.g. "Provision Kubernetes pods on an existing Amazon EKS cluster as Coder workspaces with persistent home volumes") rather than generic (e.g. "AWS Kubernetes template"). The frontmatter `description` field should follow the same principle.
- **Prerequisites** section (infrastructure requirements, provider credentials)
- **Architecture** section (what resources are created, what's ephemeral vs persistent)
- Code fences labeled `tf` (NOT `hcl`)
- Relative icon paths (e.g. `../../../../.icons/`)
- **Do NOT include tables or lists that enumerate variables, parameters, or outputs.** The registry generates variable and output documentation automatically from the Terraform source. Workspace parameter options are visible in the Coder UI. Describe what the template does and how to use it in prose, not by listing every configurable field.
- Use [GFM alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) for callouts: `> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`
## Icons
Templates reference icons in the README frontmatter `icon:` field using a relative path to the repo's `.icons/` directory (e.g. `../../../../.icons/aws.svg`). This icon is displayed on the registry website.
Workflow:
1. **Check what exists.** List the `.icons/` directory at the repo root for available SVGs.
2. **Use existing icons when they fit.** Most templates use a platform icon (aws, gcp, azure, docker, kubernetes) that already exists.
3. **When an icon doesn't exist,** reference the expected path anyway so the structure is correct. Try to source the official SVG from the platform's branding page or repository. If you can obtain it, add it to `.icons/` in this repo.
4. **Don't substitute a generic icon.** If the platform has its own brand identity, use the correct name even if the file doesn't exist yet.
5. **Track missing icons** so you can report them in your response.
## Testing
Templates do NOT require `.tftest.hcl` or `main.test.ts`. Testing is done by pushing the template to a Coder deployment.
## Commands
| Task | Command | Scope |
| ---------- | --------------------------------- | ----- |
| Format all | `bun run fmt` | Repo |
| Validate | `./scripts/terraform_validate.sh` | Repo |
| ShellCheck | `bun run shellcheck` | Repo |
## Final Checks
Before considering the work complete, verify:
- `terraform init && terraform validate` passes in the template directory
- `bun run fmt` has been run
- `bun run shellcheck` passes if the template includes shell scripts
- README documents prerequisites and architecture
- Shell scripts handle errors gracefully (`|| echo "Warning..."` for non-fatal failures). If a script sources external files (`$HOME/.bashrc`, `/etc/bashrc`, `/etc/os-release`), the `source` must come before `set -u`; CI enforces this ordering.
- No hardcoded values that should be configurable via variables or parameters
- Asset and icon paths in frontmatter and Terraform must be relative (e.g. `../../../../.icons/`), not absolute. External hyperlinks to docs or other websites are fine.
## Response to the User
In your response, include:
- A ready-to-run push command with real values filled in. Use `-d` to point at the template directory (so it works from the repo root), `-m` for a short description, and `-y` to skip interactive prompts:
```bash
coder templates push \
registry/ \
-m "Initial version: <brief description>" \
-y < template-name > -d < namespace > /templates/ < template-name > /
```
- If a new namespace was created, remind the user to fill out the namespace README (`display_name`, `bio`, `status`, `github`, etc.) and replace the placeholder avatar. Note that this is only needed if they plan to contribute to the registry.
- If any icons were referenced but not found, list them and note they need to be sourced and added to both this repo's `.icons/` directory and the `coder/coder` repo at `site/static/icon/`.
- A note that to contribute the template to the public registry, they can open a pull request to <https://github.com/coder/registry>.
-1
View File
@@ -1 +0,0 @@
../.agents/skills
+6 -6
View File
@@ -14,7 +14,7 @@ jobs:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Detect changed files
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
id: filter
with:
list-files: shell
@@ -37,9 +37,9 @@ jobs:
all:
- '**'
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@f7650296ceb9b020c79cd525ac7bd3c7f252ae1d # v2.31.6
uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2
with:
# We're using the latest version of Bun for now, but it might be worth
# reconsidering. They've pushed breaking changes in patch releases
@@ -82,12 +82,12 @@ jobs:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2
with:
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@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3
- name: Install dependencies
run: bun install
- name: Validate formatting
@@ -106,7 +106,7 @@ jobs:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
with:
go-version: "1.24.0"
- name: Validate contributors
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
with:
go-version: stable
- name: golangci-lint
+2 -2
View File
@@ -26,12 +26,12 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2
with:
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@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3
- name: Install dependencies
run: bun install
-1
View File
@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>1Password</title><circle cx="12" cy="12" r="12" fill="#0572EC"/><path fill="#fff" d="M11.105 4.864h1.788c.484 0 .729.002.914.096a.86.86 0 0 1 .377.377c.094.185.095.428.095.912v6.016c0 .12 0 .182-.015.238a.427.427 0 0 1-.067.137.923.923 0 0 1-.174.162l-.695.564c-.113.092-.17.138-.191.194a.216.216 0 0 0 0 .15c.02.055.078.101.191.193l.695.565c.094.076.14.115.174.162.03.042.053.087.067.137a.936.936 0 0 1 .015.238v2.746c0 .484-.001.727-.095.912a.86.86 0 0 1-.377.377c-.185.094-.43.096-.914.096h-1.788c-.484 0-.726-.002-.912-.096a.86.86 0 0 1-.377-.377c-.094-.185-.095-.428-.095-.912v-6.016c0-.12 0-.182.015-.238a.437.437 0 0 1 .067-.139c.034-.047.08-.083.174-.16l.695-.564c.113-.092.17-.138.191-.194a.216.216 0 0 0 0-.15c-.02-.055-.078-.101-.191-.193l-.695-.565a.92.92 0 0 1-.174-.162.437.437 0 0 1-.067-.139.92.92 0 0 1-.015-.236V6.25c0-.484.001-.727.095-.912a.86.86 0 0 1 .377-.377c.186-.094.428-.096.912-.096z"/></svg>

Before

Width:  |  Height:  |  Size: 999 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

-11
View File
@@ -1,11 +0,0 @@
---
display_name: Ben Potter
bio: Tinkerer and Product Manager at Coder
github: bpmct
avatar: ./.images/avatar.png
status: community
---
# Ben Potter
Tinkerer and Product Manager at Coder. Building modules to make dev environments better.
@@ -1,97 +0,0 @@
---
display_name: "1Password"
description: "Install the 1Password CLI and VS Code extension in your Coder workspace"
icon: ../../../../.icons/1password.svg
verified: false
tags: [integration, 1password, secrets]
---
# 1Password
Install the [1Password CLI](https://developer.1password.com/docs/cli/)
(`op`) in your Coder workspace and optionally authenticate with a service
account token. Can also install the
[1Password VS Code extension](https://marketplace.visualstudio.com/items?itemName=1Password.op-vscode)
for code-server and VS Code.
![1Password module in Coder](../../.images/onepassword-demo.png)
```tf
module "onepassword" {
source = "registry.coder.com/bpmct/onepassword/coder"
version = "1.0.2"
agent_id = coder_agent.main.id
service_account_token = var.op_service_account_token
}
```
## Authentication
### Service Account (recommended)
Create a [1Password service account](https://developer.1password.com/docs/service-accounts/get-started/)
and pass the token as a Terraform variable. The module sets
`OP_SERVICE_ACCOUNT_TOKEN` in the workspace so `op` commands work
immediately.
```tf
variable "op_service_account_token" {
type = string
sensitive = true
}
module "onepassword" {
source = "registry.coder.com/bpmct/onepassword/coder"
version = "1.0.2"
agent_id = coder_agent.main.id
service_account_token = var.op_service_account_token
}
```
### Personal Account
Pass your account details and the module will pre-register the account.
You'll be prompted for your password when you run `op signin` in the
terminal.
```tf
module "onepassword" {
source = "registry.coder.com/bpmct/onepassword/coder"
version = "1.0.2"
agent_id = coder_agent.main.id
account_address = "myteam.1password.com"
account_email = "you@example.com"
account_secret_key = var.op_secret_key
}
```
## VS Code Extension
Set `install_vscode_extension = true` to install the 1Password extension
for code-server and VS Code.
```tf
module "onepassword" {
source = "registry.coder.com/bpmct/onepassword/coder"
version = "1.0.2"
agent_id = coder_agent.main.id
service_account_token = var.op_service_account_token
install_vscode_extension = true
}
```
## Custom Scripts
Run custom logic before or after the CLI is installed.
```tf
module "onepassword" {
source = "registry.coder.com/bpmct/onepassword/coder"
version = "1.0.2"
agent_id = coder_agent.main.id
service_account_token = var.op_service_account_token
post_install_script = <<-EOT
op read "op://Vault/item/field" > ~/.secret
EOT
}
```
-112
View File
@@ -1,112 +0,0 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.17"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "service_account_token" {
type = string
description = "A 1Password service account token. If set, account-based sign-in is skipped."
default = ""
sensitive = true
}
variable "account_address" {
type = string
description = "The 1Password account sign-in address (e.g. myteam.1password.com)."
default = ""
}
variable "account_email" {
type = string
description = "The email address for the 1Password account."
default = ""
}
variable "account_secret_key" {
type = string
description = "The Secret Key for the 1Password account."
default = ""
sensitive = true
}
variable "install_dir" {
type = string
description = "The directory to install the 1Password CLI to."
default = "/usr/local/bin"
}
variable "op_cli_version" {
type = string
description = "The version of the 1Password CLI to install."
default = "latest"
validation {
condition = var.op_cli_version == "latest" || can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+$", var.op_cli_version))
error_message = "op_cli_version must be either 'latest' or a semantic version (e.g., '2.30.0')."
}
}
variable "install_vscode_extension" {
type = bool
description = "Install the 1Password VS Code extension for both VS Code and code-server."
default = false
}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing the 1Password CLI."
default = null
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing the 1Password CLI."
default = null
}
data "coder_parameter" "account_password" {
count = var.account_address != "" && var.service_account_token == "" ? 1 : 0
type = "string"
name = "op_account_password"
display_name = "1Password Account Password"
description = "Your 1Password account password. Used to sign in to the CLI."
mutable = true
default = ""
}
resource "coder_script" "onepassword" {
agent_id = var.agent_id
display_name = "1Password CLI"
icon = "/icon/1password.svg"
script = templatefile("${path.module}/run.sh", {
SERVICE_ACCOUNT_TOKEN = var.service_account_token
ACCOUNT_ADDRESS = var.account_address
ACCOUNT_EMAIL = var.account_email
ACCOUNT_SECRET_KEY = var.account_secret_key
ACCOUNT_PASSWORD = var.account_address != "" && var.service_account_token == "" ? data.coder_parameter.account_password[0].value : ""
INSTALL_DIR = var.install_dir
OP_CLI_VERSION = var.op_cli_version
INSTALL_VSCODE_EXTENSION = var.install_vscode_extension
PRE_INSTALL_SCRIPT = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
POST_INSTALL_SCRIPT = var.post_install_script != null ? base64encode(var.post_install_script) : ""
})
run_on_start = true
start_blocks_login = true
}
resource "coder_env" "op_service_account_token" {
count = var.service_account_token != "" ? 1 : 0
agent_id = var.agent_id
name = "OP_SERVICE_ACCOUNT_TOKEN"
value = var.service_account_token
}
-176
View File
@@ -1,176 +0,0 @@
#!/usr/bin/env bash
SERVICE_ACCOUNT_TOKEN="${SERVICE_ACCOUNT_TOKEN}"
ACCOUNT_ADDRESS="${ACCOUNT_ADDRESS}"
ACCOUNT_EMAIL="${ACCOUNT_EMAIL}"
ACCOUNT_SECRET_KEY="${ACCOUNT_SECRET_KEY}"
ACCOUNT_PASSWORD="${ACCOUNT_PASSWORD}"
INSTALL_DIR="${INSTALL_DIR}"
OP_CLI_VERSION="${OP_CLI_VERSION}"
INSTALL_VSCODE_EXTENSION="${INSTALL_VSCODE_EXTENSION}"
PRE_INSTALL_SCRIPT="${PRE_INSTALL_SCRIPT}"
POST_INSTALL_SCRIPT="${POST_INSTALL_SCRIPT}"
fetch() {
if command -v curl > /dev/null 2>&1; then
curl -sSL --fail "$1"
elif command -v wget > /dev/null 2>&1; then
wget -qO- "$1"
else
printf "curl or wget is not installed.\n" && return 1
fi
}
fetch_to_file() {
if command -v curl > /dev/null 2>&1; then
curl -sSL --fail "$2" -o "$1"
elif command -v wget > /dev/null 2>&1; then
wget -O "$1" "$2"
else
printf "curl or wget is not installed.\n" && return 1
fi
}
run_script() {
local ENCODED="$1" LABEL="$2"
if [ -n "$${ENCODED}" ]; then
printf "Running %s script...\n" "$${LABEL}"
SCRIPT_PATH=$(mktemp /tmp/op-"$${LABEL}"-XXXXXX.sh)
printf '%s' "$${ENCODED}" | base64 -d > "$${SCRIPT_PATH}"
chmod +x "$${SCRIPT_PATH}"
# shellcheck disable=SC2288
"$${SCRIPT_PATH}" || printf "WARNING: %s script failed.\n" "$${LABEL}"
rm -f "$${SCRIPT_PATH}"
fi
}
install() {
ARCH=$(uname -m)
if [ "$${ARCH}" = "x86_64" ]; then
ARCH="amd64"
elif [ "$${ARCH}" = "aarch64" ]; then
ARCH="arm64"
else
printf "Unsupported architecture: %s\n" "$${ARCH}" && return 1
fi
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
if [ "$${OS}" != "linux" ] && [ "$${OS}" != "darwin" ]; then
printf "Unsupported OS: %s\n" "$${OS}" && return 1
fi
if [ "$${OP_CLI_VERSION}" = "latest" ]; then
OP_CLI_VERSION=$(fetch "https://app-updates.agilebits.com/check/1/0/CLI2/en/2.0.0/N" \
| grep -oE '"version":"[^"]+"' | head -1 | cut -d'"' -f4) || true
if [ -z "$${OP_CLI_VERSION}" ]; then
printf "Failed to resolve latest version, falling back to 2.30.3.\n"
OP_CLI_VERSION="2.30.3"
fi
fi
printf "1Password CLI version: %s\n" "$${OP_CLI_VERSION}"
if command -v op > /dev/null 2>&1; then
CURRENT_VERSION=$(op --version 2> /dev/null || true)
if [ "$${CURRENT_VERSION}" = "$${OP_CLI_VERSION}" ]; then
printf "Already installed.\n"
return 0
fi
fi
DOWNLOAD_URL="https://cache.agilebits.com/dist/1P/op2/pkg/v$${OP_CLI_VERSION}/op_$${OS}_$${ARCH}_v$${OP_CLI_VERSION}.zip"
TEMP_DIR=$(mktemp -d)
cd "$${TEMP_DIR}" || return 1
if ! fetch_to_file op.zip "$${DOWNLOAD_URL}"; then
rm -rf "$${TEMP_DIR}" && return 1
fi
if command -v unzip > /dev/null 2>&1; then
unzip -o op.zip -d . > /dev/null
elif command -v busybox > /dev/null 2>&1; then
busybox unzip op.zip -d .
else
printf "unzip is not installed.\n"
rm -rf "$${TEMP_DIR}" && return 1
fi
chmod +x op
if [ -n "$${INSTALL_DIR}" ] && [ -w "$${INSTALL_DIR}" ]; then
mv op "$${INSTALL_DIR}/op"
elif [ -n "$${INSTALL_DIR}" ] && sudo mv op "$${INSTALL_DIR}/op" 2> /dev/null; then
true
else
mkdir -p ~/.local/bin && mv op ~/.local/bin/op
INSTALL_DIR=~/.local/bin
fi
printf "Installed to %s.\n" "$${INSTALL_DIR}"
rm -rf "$${TEMP_DIR}"
}
run_script "$${PRE_INSTALL_SCRIPT}" "pre-install"
if ! install; then
printf "Failed to install 1Password CLI.\n"
exit 1
fi
if [ -n "$${SERVICE_ACCOUNT_TOKEN}" ]; then
printf "Service account token configured.\n"
elif [ -n "$${ACCOUNT_ADDRESS}" ] && [ -n "$${ACCOUNT_EMAIL}" ]; then
ADD_ARGS="--address $${ACCOUNT_ADDRESS} --email $${ACCOUNT_EMAIL}"
if [ -n "$${ACCOUNT_SECRET_KEY}" ]; then
ADD_ARGS="$${ADD_ARGS} --secret-key $${ACCOUNT_SECRET_KEY}"
fi
if [ -n "$${ACCOUNT_PASSWORD}" ] && command -v expect > /dev/null 2>&1; then
OP_SESSION=$(expect -c "
log_user 0
spawn op account add $${ADD_ARGS} --raw
expect \"Enter the password*\"
send \"$${ACCOUNT_PASSWORD}\r\"
expect eof
catch wait result
set output \$expect_out(buffer)
puts -nonewline \$output
" 2>&1)
if op account list 2> /dev/null | grep -q "$${ACCOUNT_ADDRESS}"; then
printf "Signed in to %s.\n" "$${ACCOUNT_ADDRESS}"
if [ -n "$${OP_SESSION}" ]; then
mkdir -p "$${HOME}/.op"
SESSION_VAR="OP_SESSION_$(printf '%s' "$${ACCOUNT_ADDRESS}" | tr '.' '_' | tr '-' '_')"
printf 'export %s="%s"\n' "$${SESSION_VAR}" "$${OP_SESSION}" > "$${HOME}/.op/session"
chmod 600 "$${HOME}/.op/session"
for rc in "$${HOME}/.bashrc" "$${HOME}/.zshrc"; do
if [ -f "$${rc}" ] && ! grep -q ".op/session" "$${rc}" 2> /dev/null; then
printf '\n[ -f ~/.op/session ] && . ~/.op/session\n' >> "$${rc}"
fi
done
fi
else
printf "Sign-in failed. Run manually: op signin --account %s\n" "$${ACCOUNT_ADDRESS}"
fi
else
printf "To sign in, run in your terminal:\n"
printf " op account add %s\n" "$${ADD_ARGS}"
fi
fi
if [ "$${INSTALL_VSCODE_EXTENSION}" = "true" ]; then
EXTENSION_ID="1Password.op-vscode"
for _ in 1 2 3 4 5 6; do
command -v code-server > /dev/null 2>&1 || command -v code > /dev/null 2>&1 && break
sleep 5
done
if command -v code-server > /dev/null 2>&1; then
cd /tmp && code-server --install-extension "$${EXTENSION_ID}" --force 2>&1 || true
fi
if command -v code > /dev/null 2>&1; then
cd /tmp && code --install-extension "$${EXTENSION_ID}" --force 2>&1 || true
fi
fi
run_script "$${POST_INSTALL_SCRIPT}" "post-install"
+12 -27
View File
@@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.1"
version = "4.2.0"
agent_id = coder_agent.example.id
openai_api_key = var.openai_api_key
workdir = "/home/coder/project"
@@ -32,7 +32,7 @@ module "codex" {
module "codex" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.1"
version = "4.2.0"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
@@ -51,7 +51,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.1"
version = "4.2.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
enable_aibridge = true
@@ -60,16 +60,21 @@ module "codex" {
When `enable_aibridge = true`, the module:
- Configures Codex to use the aibridge model_provider with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token
- Configures Codex to use the AI Bridge profile with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token
```toml
model_provider = "aibridge"
profile = "aibridge" # sets the default profile to aibridge
[model_providers.aibridge]
name = "AI Bridge"
base_url = "https://example.coder.com/api/v2/aibridge/openai/v1"
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses"
[profiles.aibridge]
model_provider = "aibridge"
model = "<model>" # as configured in the module input
model_reasoning_effort = "<model_reasoning_effort>" # as configured in the module input
```
This allows Codex to route API requests through Coder's AI Bridge instead of directly to OpenAI's API.
@@ -89,7 +94,7 @@ data "coder_task" "me" {}
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.1"
version = "4.2.0"
agent_id = coder_agent.example.id
openai_api_key = "..."
ai_prompt = data.coder_task.me.prompt
@@ -100,26 +105,6 @@ module "codex" {
}
```
### Usage with Agent Boundaries
This example shows how to configure the Codex module to run the agent behind a process-level boundary that restricts its network access.
By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation.
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.1"
agent_id = coder_agent.main.id
openai_api_key = var.openai_api_key
workdir = "/home/coder/project"
enable_boundary = true
}
```
> [!NOTE]
> For developers: The module also supports installing boundary from a release version (`use_boundary_directly = true`) or compiling from source (`compile_boundary_from_source = true`). These are escape hatches for development and testing purposes.
### Advanced Configuration
This example shows additional configuration options for custom models, MCP servers, and base configuration.
@@ -127,7 +112,7 @@ This example shows additional configuration options for custom models, MCP serve
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.1"
version = "4.2.0"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
+3 -43
View File
@@ -468,49 +468,9 @@ describe("codex", async () => {
id,
"/home/coder/.codex/config.toml",
);
expect(configToml).toContain('model_provider = "aibridge"');
});
test("boundary-enabled", async () => {
const { id } = await setup({
moduleVariables: {
enable_boundary: "true",
boundary_config_path: "/tmp/test-boundary.yaml",
},
});
// Write boundary config
await execContainer(id, [
"bash",
"-c",
`cat > /tmp/test-boundary.yaml <<'EOF'
jail_type: landjail
proxy_port: 8087
log_level: warn
allowlist:
- "domain=api.openai.com"
EOF`,
]);
// Add mock coder binary for boundary setup
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: `#!/bin/bash
if [ "$1" = "boundary" ]; then
if [ "$2" = "--help" ]; then
echo "boundary help"
exit 0
fi
shift; shift; exec "$@"
fi
echo "mock coder"`,
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
// Verify boundary wrapper was used in start script
const startLog = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
expect(configToml).toContain(
"[profiles.aibridge]\n" + 'model_provider = "aibridge"',
);
expect(startLog).toContain("boundary");
expect(configToml).toContain('profile = "aibridge"');
});
});
+28 -60
View File
@@ -84,10 +84,10 @@ variable "enable_aibridge" {
variable "model_reasoning_effort" {
type = string
description = "The reasoning effort for the model. One of: none, low, medium, high. https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort"
default = ""
description = "The reasoning effort for the AI Bridge model. One of: none, low, medium, high. https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort"
default = "medium"
validation {
condition = contains(["", "none", "minimal", "low", "medium", "high", "xhigh"], var.model_reasoning_effort)
condition = contains(["none", "low", "medium", "high"], var.model_reasoning_effort)
error_message = "model_reasoning_effort must be one of: none, low, medium, high."
}
}
@@ -137,7 +137,7 @@ variable "agentapi_version" {
variable "codex_model" {
type = string
description = "The model for Codex to use. Defaults to gpt-5.3-codex."
default = "gpt-5.4"
default = "gpt-5.3-codex"
}
variable "pre_install_script" {
@@ -176,36 +176,6 @@ variable "codex_system_prompt" {
default = "You are a helpful coding assistant. Start every response with `Codex says:`"
}
variable "enable_boundary" {
type = bool
description = "Enable coder boundary for network filtering."
default = false
}
variable "boundary_config_path" {
type = string
description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var."
default = ""
}
variable "boundary_version" {
type = string
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release."
default = "latest"
}
variable "compile_boundary_from_source" {
type = bool
description = "Whether to compile boundary from source instead of using the official install script."
default = false
}
variable "use_boundary_directly" {
type = bool
description = "Whether to use boundary binary directly instead of coder boundary subcommand."
default = false
}
resource "coder_env" "openai_api_key" {
agent_id = var.agent_id
name = "OPENAI_API_KEY"
@@ -225,7 +195,7 @@ locals {
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".codex-module"
latest_codex_model = "gpt-5.4"
latest_codex_model = "gpt-5.3-codex"
aibridge_config = <<-EOF
[model_providers.aibridge]
name = "AI Bridge"
@@ -233,36 +203,35 @@ locals {
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses"
[profiles.aibridge]
model_provider = "aibridge"
model = "${var.codex_model}"
model_reasoning_effort = "${var.model_reasoning_effort}"
EOF
}
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.3.0"
version = "2.2.0"
agent_id = var.agent_id
folder = local.workdir
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_subdomain = var.subdomain
agentapi_version = var.agentapi_version
enable_state_persistence = var.enable_state_persistence
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
enable_boundary = var.enable_boundary
boundary_config_path = var.boundary_config_path
boundary_version = var.boundary_version
compile_boundary_from_source = var.compile_boundary_from_source
use_boundary_directly = var.use_boundary_directly
start_script = <<-EOT
agent_id = var.agent_id
folder = local.workdir
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_subdomain = var.subdomain
agentapi_version = var.agentapi_version
enable_state_persistence = var.enable_state_persistence
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
@@ -298,7 +267,6 @@ module "agentapi" {
ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
ARG_CODEX_START_DIRECTORY='${local.workdir}' \
ARG_MODEL_REASONING_EFFORT='${var.model_reasoning_effort}' \
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
/tmp/install.sh
EOT
@@ -93,14 +93,10 @@ function install_codex() {
write_minimal_default_config() {
local config_path="$1"
ARG_OPTIONAL_TOP_LEVEL_CONFIG=""
ARG_DEFAULT_PROFILE=""
if [[ "${ARG_ENABLE_AIBRIDGE}" = "true" ]]; then
ARG_OPTIONAL_TOP_LEVEL_CONFIG='model_provider = "aibridge"'
fi
if [[ "${ARG_MODEL_REASONING_EFFORT}" != "" ]]; then
ARG_OPTIONAL_TOP_LEVEL_CONFIG+=$'\n'"model_reasoning_effort = \"${ARG_MODEL_REASONING_EFFORT}\""
ARG_DEFAULT_PROFILE='profile = "aibridge"'
fi
cat << EOF > "$config_path"
@@ -108,17 +104,13 @@ write_minimal_default_config() {
sandbox_mode = "workspace-write"
approval_policy = "never"
preferred_auth_method = "apikey"
${ARG_OPTIONAL_TOP_LEVEL_CONFIG}
${ARG_DEFAULT_PROFILE}
[sandbox_workspace_write]
network_access = true
[notice.model_migrations]
"${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}"
[projects."${ARG_CODEX_START_DIRECTORY}"]
trust_level = "trusted"
EOF
}
@@ -155,7 +155,7 @@ setup_workdir() {
build_codex_args() {
CODEX_ARGS=()
if [[ -n "${ARG_CODEX_MODEL}" ]]; then
if [[ -n "${ARG_CODEX_MODEL}" ]] && [[ "${ARG_ENABLE_AIBRIDGE}" != "true" ]]; then
CODEX_ARGS+=("--model" "${ARG_CODEX_MODEL}")
fi
@@ -210,16 +210,7 @@ capture_session_id() {
start_codex() {
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh when
# enable_boundary=true. It points to a wrapper script that runs the command
# through coder boundary, sandboxing only the agent process.
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
printf "Starting with coder boundary enabled\n"
agentapi server --type codex --term-width 67 --term-height 1190 -- \
"${AGENTAPI_BOUNDARY_PREFIX}" codex "${CODEX_ARGS[@]}" &
else
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
fi
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
capture_session_id
}
@@ -13,7 +13,7 @@ Run [OpenCode](https://opencode.ai) AI coding assistant in your workspace for in
```tf
module "opencode" {
source = "registry.coder.com/coder-labs/opencode/coder"
version = "0.1.2"
version = "0.1.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
}
@@ -34,7 +34,7 @@ resource "coder_ai_task" "task" {
module "opencode" {
source = "registry.coder.com/coder-labs/opencode/coder"
version = "0.1.2"
version = "0.1.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
@@ -89,7 +89,7 @@ Run OpenCode as a command-line tool without web interface or task reporting:
```tf
module "opencode" {
source = "registry.coder.com/coder-labs/opencode/coder"
version = "0.1.2"
version = "0.1.1"
agent_id = coder_agent.main.id
workdir = "/home/coder"
report_tasks = false
@@ -39,7 +39,7 @@ install_opencode() {
if [ "$ARG_OPENCODE_VERSION" = "latest" ]; then
curl -fsSL https://opencode.ai/install | bash
else
curl -fsSL https://opencode.ai/install | VERSION="${ARG_OPENCODE_VERSION}" bash
VERSION=$ARG_OPENCODE_VERSION curl -fsSL https://opencode.ai/install | bash
fi
export PATH=/home/coder/.opencode/bin:$PATH
printf "Opencode location: %s\n" "$(which opencode)"
@@ -1,26 +1,26 @@
---
display_name: Coder Utils
display_name: Agent Helper
description: Building block for modules that need orchestrated script execution
icon: ../../../../.icons/coder.svg
verified: false
tags: [internal, library]
---
# Coder Utils
# Agent Helper
> [!CAUTION]
> We do not recommend using this module directly. It is intended primarily for internal use by Coder to create modules with orchestrated script execution.
The Coder Utils module is a building block for modules that need to run multiple scripts in a specific order. It uses `coder exp sync` for dependency management and is designed for orchestrating pre-install, install, post-install, and start scripts.
The Agent Helper module is a building block for modules that need to run multiple scripts in a specific order. It uses `coder exp sync` for dependency management and is designed for orchestrating pre-install, install, post-install, and start scripts.
> [!NOTE]
>
> - The `agent_name` should be the same as that of the agentapi module's `agent_name` if used together.
```tf
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "1.0.1"
module "agent_helper" {
source = "registry.coder.com/coder/agent-helper/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
agent_name = "myagent"
@@ -1,7 +1,7 @@
import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "~test";
describe("coder-utils", async () => {
describe("agent-helper", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
@@ -1,4 +1,4 @@
# Test for coder-utils module
# Test for agent-helper module
# Test with all scripts provided
run "test_with_all_scripts" {
+1 -1
View File
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
```tf
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.4.0"
version = "2.3.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
+1 -14
View File
@@ -53,12 +53,6 @@ variable "folder" {
default = "/home/coder"
}
variable "web_app" {
type = bool
description = "Whether to create the web workspace app. This is automatically enabled when using Coder Tasks, regardless of this setting."
default = true
}
variable "cli_app" {
type = bool
description = "Whether to create the CLI workspace app."
@@ -226,11 +220,6 @@ resource "coder_env" "boundary_config" {
}
locals {
# If this is a Task, always create the web app regardless of var.web_app
# since coder_ai_task requires the app to function.
is_task = try(data.coder_task.me.enabled, false)
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) : ""
@@ -316,8 +305,6 @@ resource "coder_script" "agentapi_shutdown" {
}
resource "coder_app" "agentapi_web" {
count = local.web_app ? 1 : 0
slug = var.web_app_slug
display_name = var.web_app_display_name
agent_id = var.agent_id
@@ -354,5 +341,5 @@ resource "coder_app" "agentapi_cli" {
}
output "task_app_id" {
value = local.web_app ? coder_app.agentapi_web[0].id : ""
value = coder_app.agentapi_web.id
}
+28 -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.8.0"
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.8.0"
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.8.0"
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.8.0"
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.8.0"
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.8.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
install_claude_code = true
@@ -211,13 +211,32 @@ variable "claude_code_oauth_token" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.0"
version = "4.8.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
}
```
### Usage with Remote Control
[Remote Control](https://code.claude.com/docs/en/remote-control.md) allows you to access your Claude Code session from [claude.ai/code](https://claude.ai/code) or the Claude mobile app. The session runs locally in your workspace while you interact with it from any browser or device.
> [!NOTE]
> Remote Control requires a Claude subscription (Pro, Max, Team, or Enterprise). API keys are not supported. You must authenticate using `claude_code_oauth_token`.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
enable_remote_control = true
# remote_control_name = "Custom Name" # Optional: defaults to workspace name
}
```
### Usage with AWS Bedrock
#### Prerequisites
@@ -284,7 +303,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.0"
version = "4.8.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -341,7 +360,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.0"
version = "4.8.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
+17 -10
View File
@@ -47,12 +47,6 @@ variable "report_tasks" {
default = true
}
variable "web_app" {
type = bool
description = "Whether to create the web app for Claude Code. When false, AgentAPI still runs but no web UI app icon is shown in the Coder dashboard. This is automatically enabled when using Coder Tasks, regardless of this setting."
default = true
}
variable "cli_app" {
type = bool
description = "Whether to create a CLI app for Claude Code"
@@ -273,6 +267,18 @@ variable "enable_state_persistence" {
default = true
}
variable "enable_remote_control" {
type = bool
description = "Enable Claude Code Remote Control, allowing the session to be accessed from claude.ai/code or the Claude mobile app. Requires a Claude subscription (Pro, Max, Team, or Enterprise). API keys are not supported. See https://code.claude.com/docs/en/remote-control.md"
default = false
}
variable "remote_control_name" {
type = string
description = "Custom session name for Remote Control, visible in the session list at claude.ai/code. Only used when enable_remote_control is true. Defaults to the workspace name."
default = ""
}
resource "coder_env" "claude_code_md_path" {
count = var.claude_md_path == "" ? 0 : 1
agent_id = var.agent_id
@@ -293,7 +299,7 @@ resource "coder_env" "claude_code_oauth_token" {
}
resource "coder_env" "claude_api_key" {
count = (var.enable_aibridge || (var.claude_api_key != "")) ? 1 : 0
count = local.claude_api_key != "" ? 1 : 0
agent_id = var.agent_id
name = "CLAUDE_API_KEY"
@@ -368,10 +374,9 @@ locals {
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.3.0"
version = "2.2.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_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
@@ -408,6 +413,8 @@ module "agentapi" {
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
ARG_CODER_HOST='${local.coder_host}' \
ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}' \
ARG_ENABLE_REMOTE_CONTROL='${var.enable_remote_control}' \
ARG_REMOTE_CONTROL_NAME='${var.remote_control_name != "" ? var.remote_control_name : data.coder_workspace.me.name}' \
/tmp/start.sh
EOT
@@ -416,6 +416,47 @@ run "test_disable_state_persistence" {
}
}
run "test_enable_remote_control_default" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.enable_remote_control == false
error_message = "enable_remote_control should default to false"
}
assert {
condition = var.remote_control_name == ""
error_message = "remote_control_name should default to empty string"
}
}
run "test_enable_remote_control" {
command = plan
variables {
agent_id = "test-agent-rc"
workdir = "/home/coder/project"
enable_remote_control = true
remote_control_name = "My Project"
}
assert {
condition = var.enable_remote_control == true
error_message = "enable_remote_control should be true when explicitly enabled"
}
assert {
condition = var.remote_control_name == "My Project"
error_message = "remote_control_name should be set to 'My Project'"
}
}
run "test_no_api_key_no_env" {
command = plan
@@ -430,18 +471,3 @@ run "test_no_api_key_no_env" {
error_message = "CLAUDE_API_KEY should not be created when no API key is provided and aibridge is disabled"
}
}
run "test_api_key_count_with_aibridge_no_override" {
command = plan
variables {
agent_id = "test-agent-count"
workdir = "/home/coder/test"
enable_aibridge = true
}
assert {
condition = length(coder_env.claude_api_key) == 1
error_message = "CLAUDE_API_KEY env should be created when aibridge is enabled, regardless of session_token value"
}
}
@@ -24,6 +24,8 @@ ARG_BOUNDARY_VERSION=${ARG_BOUNDARY_VERSION:-"latest"}
ARG_COMPILE_FROM_SOURCE=${ARG_COMPILE_FROM_SOURCE:-false}
ARG_USE_BOUNDARY_DIRECTLY=${ARG_USE_BOUNDARY_DIRECTLY:-false}
ARG_CODER_HOST=${ARG_CODER_HOST:-}
ARG_ENABLE_REMOTE_CONTROL=${ARG_ENABLE_REMOTE_CONTROL:-false}
ARG_REMOTE_CONTROL_NAME=${ARG_REMOTE_CONTROL_NAME:-}
echo "--------------------------------"
@@ -39,6 +41,8 @@ printf "ARG_BOUNDARY_VERSION: %s\n" "$ARG_BOUNDARY_VERSION"
printf "ARG_COMPILE_FROM_SOURCE: %s\n" "$ARG_COMPILE_FROM_SOURCE"
printf "ARG_USE_BOUNDARY_DIRECTLY: %s\n" "$ARG_USE_BOUNDARY_DIRECTLY"
printf "ARG_CODER_HOST: %s\n" "$ARG_CODER_HOST"
printf "ARG_ENABLE_REMOTE_CONTROL: %s\n" "$ARG_ENABLE_REMOTE_CONTROL"
printf "ARG_REMOTE_CONTROL_NAME: %s\n" "$ARG_REMOTE_CONTROL_NAME"
echo "--------------------------------"
@@ -88,7 +92,7 @@ TASK_SESSION_ID="cd32e253-ca16-4fd3-9825-d837e74ae3c2"
get_project_dir() {
local workdir_normalized
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/._' '-')
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-')
echo "$HOME/.claude/projects/${workdir_normalized}"
}
@@ -220,6 +224,15 @@ function start_agentapi() {
[ -n "$ARG_AI_PROMPT" ] && ARGS+=(-- "$ARG_AI_PROMPT")
fi
# Build the claude command - either regular or remote-control mode.
CLAUDE_CMD=("claude")
if [ "$ARG_ENABLE_REMOTE_CONTROL" = "true" ]; then
CLAUDE_CMD+=("remote-control")
if [ -n "$ARG_REMOTE_CONTROL_NAME" ]; then
CLAUDE_CMD+=("--name" "$ARG_REMOTE_CONTROL_NAME")
fi
fi
printf "Running claude code with args: %s\n" "$(printf '%q ' "${ARGS[@]}")"
if [ "$ARG_ENABLE_BOUNDARY" = "true" ]; then
@@ -246,9 +259,9 @@ function start_agentapi() {
agentapi server --type claude --term-width 67 --term-height 1190 -- \
"${BOUNDARY_CMD[@]}" "${BOUNDARY_ARGS[@]}" -- \
claude "${ARGS[@]}"
"${CLAUDE_CMD[@]}" "${ARGS[@]}"
else
agentapi server --type claude --term-width 67 --term-height 1190 -- claude "${ARGS[@]}"
agentapi server --type claude --term-width 67 --term-height 1190 -- "${CLAUDE_CMD[@]}" "${ARGS[@]}"
fi
}
+9 -9
View File
@@ -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.4.4"
version = "1.4.3"
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.4.4"
version = "1.4.3"
agent_id = coder_agent.example.id
install_version = "4.106.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.4.4"
version = "1.4.3"
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.4.4"
version = "1.4.3"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -72,13 +72,13 @@ module "code-server" {
### Install multiple extensions
Install multiple extensions from [OpenVSX](https://open-vsx.org/) by adding them to the `extensions` list:
Just run code-server in the background, don't fetch it from GitHub:
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.4.3"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
}
@@ -92,7 +92,7 @@ You can pass additional command-line arguments to code-server using the `additio
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.4.3"
agent_id = coder_agent.example.id
additional_args = "--disable-workspace-trust"
}
@@ -108,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.4.4"
version = "1.4.3"
agent_id = coder_agent.example.id
use_cached = true
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
@@ -121,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.4.4"
version = "1.4.3"
agent_id = coder_agent.example.id
offline = true
}
+6 -6
View File
@@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.3.2"
agent_id = coder_agent.example.id
}
```
@@ -31,7 +31,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.3.2"
agent_id = coder_agent.example.id
}
```
@@ -42,7 +42,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.3.2"
agent_id = coder_agent.example.id
user = "root"
}
@@ -54,14 +54,14 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.3.2"
agent_id = coder_agent.example.id
}
module "dotfiles-root" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.3.2"
agent_id = coder_agent.example.id
user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri
@@ -90,7 +90,7 @@ You can set a default dotfiles repository for all users by setting the `default_
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.3.2"
agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles"
}
+1 -50
View File
@@ -56,62 +56,13 @@ describe("dotfiles", async () => {
}
});
it("command uses bash for fish shell compatibility", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
manual_update: "true",
dotfiles_uri: "https://github.com/test/dotfiles",
});
const app = state.resources.find(
(r) => r.type === "coder_app" && r.name === "dotfiles",
);
expect(app).toBeDefined();
expect(app?.instances[0]?.attributes?.command).toContain("/bin/bash -c");
});
it("set custom order for coder_parameter", async () => {
const order = 99;
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
coder_parameter_order: order.toString(),
});
expect(state.resources).toHaveLength(3);
const parameters = state.resources.filter(
(r) => r.type === "coder_parameter",
);
for (const param of parameters) {
expect(param.instances[0].attributes.order).toBe(order);
}
});
it("set custom dotfiles_branch", async () => {
const branch = "develop";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
dotfiles_branch: branch,
});
expect(state.resources).toHaveLength(2);
const scriptResource = state.resources.find(
(r) => r.type === "coder_script",
);
expect(scriptResource?.instances[0].attributes.script).toContain(
`DOTFILES_BRANCH="${branch}"`,
);
});
it("default dotfiles_branch creates parameter", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
expect(state.resources).toHaveLength(3);
const branchParameter = state.resources.find(
(r) =>
r.type === "coder_parameter" &&
r.instances[0].attributes.name === "dotfiles_branch",
);
expect(branchParameter).toBeDefined();
expect(branchParameter?.instances[0].attributes.default).toBeNull();
expect(state.resources[0].instances[0].attributes.order).toBe(order);
});
});
+2 -34
View File
@@ -46,12 +46,6 @@ variable "default_dotfiles_uri" {
}
}
variable "default_dotfiles_branch" {
type = string
description = "The default dotfiles branch if the workspace user does not provide one"
default = ""
}
variable "dotfiles_uri" {
type = string
description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
@@ -67,17 +61,6 @@ variable "dotfiles_uri" {
}
}
variable "dotfiles_branch" {
type = string
description = "The branch to use for the dotfiles repository (optional, when set, the user isn't prompted for the branch)"
default = null
validation {
condition = var.dotfiles_branch == null || var.dotfiles_branch != ""
error_message = "dotfiles_branch cannot be an empty string. Use null to prompt the user or provide a valid branch name."
}
}
variable "user" {
type = string
description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
@@ -124,21 +107,8 @@ data "coder_parameter" "dotfiles_uri" {
}
}
data "coder_parameter" "dotfiles_branch" {
count = var.dotfiles_branch == null ? 1 : 0
type = "string"
name = "dotfiles_branch"
display_name = "Dotfiles Branch"
order = var.coder_parameter_order
default = var.default_dotfiles_branch
description = "The branch to use for the dotfiles repository"
mutable = true
icon = "/icon/dotfiles.svg"
}
locals {
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
dotfiles_branch = var.dotfiles_branch != null ? var.dotfiles_branch : data.coder_parameter.dotfiles_branch[0].value
user = var.user != null ? var.user : ""
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
}
@@ -148,7 +118,6 @@ resource "coder_script" "dotfiles" {
script = templatefile("${path.module}/run.sh", {
DOTFILES_URI : local.dotfiles_uri,
DOTFILES_USER : local.user,
DOTFILES_BRANCH : local.dotfiles_branch,
POST_CLONE_SCRIPT : local.encoded_post_clone_script
})
display_name = "Dotfiles"
@@ -164,12 +133,11 @@ resource "coder_app" "dotfiles" {
icon = "/icon/dotfiles.svg"
order = var.order
group = var.group
command = "/bin/bash -c \"$(echo ${base64encode(templatefile("${path.module}/run.sh", {
command = templatefile("${path.module}/run.sh", {
DOTFILES_URI : local.dotfiles_uri,
DOTFILES_USER : local.user,
DOTFILES_BRANCH : local.dotfiles_branch,
POST_CLONE_SCRIPT : local.encoded_post_clone_script
}))} | base64 -d)\""
})
}
output "dotfiles_uri" {
+3 -16
View File
@@ -4,7 +4,6 @@ set -euo pipefail
DOTFILES_URI="${DOTFILES_URI}"
DOTFILES_USER="${DOTFILES_USER}"
DOTFILES_BRANCH="${DOTFILES_BRANCH}"
# Validate DOTFILES_URI to prevent command injection (defense in depth)
if [ -n "$DOTFILES_URI" ]; then
@@ -25,18 +24,10 @@ if [ -n "$${DOTFILES_URI// }" ]; then
DOTFILES_USER="$USER"
fi
if [ -n "$DOTFILES_BRANCH" ]; then
echo "✨ Applying dotfiles for user $DOTFILES_USER from branch $DOTFILES_BRANCH"
else
echo "✨ Applying dotfiles for user $DOTFILES_USER"
fi
echo "✨ Applying dotfiles for user $DOTFILES_USER"
if [ "$DOTFILES_USER" = "$USER" ]; then
if [ -n "$DOTFILES_BRANCH" ]; then
coder dotfiles "$DOTFILES_URI" --branch "$DOTFILES_BRANCH" -y 2>&1 | tee ~/.dotfiles.log
else
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
fi
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
else
if command -v getent > /dev/null 2>&1; then
DOTFILES_USER_HOME=$(getent passwd "$DOTFILES_USER" | cut -d: -f6)
@@ -49,11 +40,7 @@ if [ -n "$${DOTFILES_URI// }" ]; then
fi
CODER_BIN=$(command -v coder)
if [ -n "$DOTFILES_BRANCH" ]; then
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" --branch "$DOTFILES_BRANCH" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
else
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
fi
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
fi
fi
+7 -7
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.3.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.3.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.3.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.3.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
default = ["IU", "PY"]
@@ -81,7 +81,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.3.0"
agent_id = coder_agent.main.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.3.1"
version = "1.3.0"
agent_id = coder_agent.main.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.3.1"
version = "1.3.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
default = ["IU", "PY"]
+9 -11
View File
@@ -125,7 +125,7 @@ variable "download_base_link" {
}
data "http" "jetbrains_ide_versions" {
for_each = local.selected_ides
for_each = length(var.default) == 0 ? var.options : var.default
url = "${var.releases_base_link}/products/releases?code=${each.key}&type=${var.channel}${var.major_version == "latest" ? "&latest=true" : ""}"
}
@@ -174,14 +174,9 @@ variable "ide_config" {
}
locals {
# 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
parsed_responses = {
for code in local.selected_ides : code => try(
for code in length(var.default) == 0 ? var.options : var.default : code => try(
jsondecode(data.http.jetbrains_ide_versions[code].response_body),
{} # Return empty object if API call fails
)
@@ -189,7 +184,7 @@ locals {
# Filter the parsed response for the requested major version if not "latest"
filtered_releases = {
for code in local.selected_ides : code => [
for code in length(var.default) == 0 ? var.options : var.default : code => [
for r in try(local.parsed_responses[code][keys(local.parsed_responses[code])[0]], []) :
r if var.major_version == "latest" || r.majorVersion == var.major_version
]
@@ -197,13 +192,13 @@ locals {
# Select the latest release for the requested major version (first item in the filtered list)
selected_releases = {
for code in local.selected_ides : code =>
for code in length(var.default) == 0 ? var.options : var.default : code =>
length(local.filtered_releases[code]) > 0 ? local.filtered_releases[code][0] : null
}
# Dynamically generate IDE configurations based on selected IDEs with fallback to ide_config
# Dynamically generate IDE configurations based on options with fallback to ide_config
options_metadata = {
for code in local.selected_ides : code => {
for code in length(var.default) == 0 ? var.options : var.default : code => {
icon = var.ide_config[code].icon
name = var.ide_config[code].name
identifier = code
@@ -216,6 +211,9 @@ locals {
json_data = local.selected_releases[code]
}
}
# Convert the parameter value to a set for for_each
selected_ides = length(var.default) == 0 ? toset(jsondecode(coalesce(data.coder_parameter.jetbrains_ides[0].value, "[]"))) : toset(var.default)
}
data "coder_parameter" "jetbrains_ides" {
+11 -29
View File
@@ -8,13 +8,13 @@ tags: [ai, agents, development, multiplexer]
# Mux
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The launcher keeps watching the mux process after startup, appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime, and can optionally wait a few seconds, remove the stale server lock, and restart Mux after any exit until an optional restart-attempt cap is reached. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The launcher now keeps watching the mux process after startup and appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.4.0"
agent_id = coder_agent.main.id
}
```
@@ -37,7 +37,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.4.0"
agent_id = coder_agent.main.id
}
```
@@ -48,7 +48,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.4.0"
agent_id = coder_agent.main.id
# Default is "latest"; set to a specific version to pin
install_version = "0.4.0"
@@ -63,7 +63,7 @@ Start Mux with `mux server --add-project /path/to/project`:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.4.0"
agent_id = coder_agent.main.id
add_project = "/path/to/project"
}
@@ -78,35 +78,19 @@ The module parses quoted values, so grouped arguments remain intact.
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.4.0"
agent_id = coder_agent.main.id
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
}
```
### Restart After Mux Exits
Enable automatic restarts after Mux exits, including clean exits and intentional shutdown signals such as `SIGTERM`. The launcher waits for `restart_delay_seconds`, removes `~/.mux/server.lock`, and starts Mux again. Set `max_restart_attempts` to a whole number to stop retrying after a fixed number of restarts, or leave it at `0` for unlimited retries.
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
agent_id = coder_agent.main.id
restart_on_kill = true
restart_delay_seconds = 3
max_restart_attempts = 5
}
```
### Custom Port
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.4.0"
agent_id = coder_agent.main.id
port = 8080
}
@@ -120,7 +104,7 @@ Force a specific package manager instead of auto-detection:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.4.0"
agent_id = coder_agent.main.id
package_manager = "pnpm" # or "npm", "bun"
}
@@ -134,7 +118,7 @@ Use a private or mirrored npm registry:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.4.0"
agent_id = coder_agent.main.id
registry_url = "https://npm.pkg.github.com"
}
@@ -148,7 +132,7 @@ Run an existing copy of Mux if found, otherwise install from npm:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.4.0"
agent_id = coder_agent.main.id
use_cached = true
}
@@ -162,7 +146,7 @@ Run without installing from the network (requires Mux to be pre-installed):
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.4.0"
agent_id = coder_agent.main.id
install = false
}
@@ -180,5 +164,3 @@ module "mux" {
- Installs `mux@next` from the npm registry by default; set `registry_url` to use a private or mirrored registry
- Falls back to a direct tarball download when no package manager is found
- Appends best-effort signal and external-kill diagnostics to `log_path` if the mux process dies after startup
- Set `restart_on_kill = true` to wait `restart_delay_seconds`, remove `~/.mux/server.lock`, and restart Mux after it exits
- Set `max_restart_attempts` to a whole-number cap on restart attempts, or leave it at `0` for unlimited retries
-137
View File
@@ -145,143 +145,6 @@ chmod +x /tmp/mux/mux`,
}
}, 60000);
it("restarts after a clean exit when enabled", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
restart_on_kill: true,
restart_delay_seconds: 1,
max_restart_attempts: 1,
});
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("alpine/curl");
try {
const setup = await execContainer(id, [
"sh",
"-c",
`apk add --no-cache bash >/dev/null
mkdir -p /tmp/mux
cat <<'EOF' > /tmp/mux/mux
#!/usr/bin/env sh
run_count_file="/tmp/mux-run-count"
run_count=0
if [ -f "$run_count_file" ]; then
run_count=$(cat "$run_count_file")
fi
run_count=$((run_count + 1))
printf '%s' "$run_count" > "$run_count_file"
echo "run=$run_count"
if [ "$run_count" -eq 1 ]; then
mkdir -p "$HOME/.mux"
touch "$HOME/.mux/server.lock"
exit 0
fi
if [ -f "$HOME/.mux/server.lock" ]; then
echo "lock=present"
else
echo "lock=cleaned"
fi
exit 0
EOF
chmod +x /tmp/mux/mux`,
]);
expect(setup.exitCode).toBe(0);
const output = await execContainer(id, ["sh", "-c", instance.script]);
if (output.exitCode !== 0) {
console.log("STDOUT:\n" + output.stdout);
console.log("STDERR:\n" + output.stderr);
}
expect(output.exitCode).toBe(0);
await execContainer(id, ["sh", "-c", "sleep 4"]);
const log = await readFileContainer(id, "/tmp/mux.log");
const runCount = await readFileContainer(id, "/tmp/mux-run-count");
expect(log).toContain("run=1");
expect(log).toContain("mux server exited cleanly.");
expect(log).toContain(
"Waiting 1 seconds before restarting mux after it exited.",
);
expect(log).toContain(
"Removing /root/.mux/server.lock before restarting mux.",
);
expect(log).toContain("run=2");
expect(log).toContain("lock=cleaned");
expect(log).toContain(
"Reached the max restart attempts limit (1); not restarting mux again.",
);
expect(runCount.trim()).toBe("2");
} finally {
await removeContainer(id);
}
}, 60000);
it("restarts after SIGTERM when enabled", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
restart_on_kill: true,
restart_delay_seconds: 1,
max_restart_attempts: 1,
});
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("alpine/curl");
try {
const setup = await execContainer(id, [
"sh",
"-c",
`apk add --no-cache bash >/dev/null
mkdir -p /tmp/mux
cat <<'EOF' > /tmp/mux/mux
#!/usr/bin/env sh
run_count_file="/tmp/mux-run-count"
run_count=0
if [ -f "$run_count_file" ]; then
run_count=$(cat "$run_count_file")
fi
run_count=$((run_count + 1))
printf '%s' "$run_count" > "$run_count_file"
echo "run=$run_count"
if [ "$run_count" -eq 1 ]; then
kill -TERM $$
fi
exit 0
EOF
chmod +x /tmp/mux/mux`,
]);
expect(setup.exitCode).toBe(0);
const output = await execContainer(id, ["sh", "-c", instance.script]);
if (output.exitCode !== 0) {
console.log("STDOUT:\n" + output.stdout);
console.log("STDERR:\n" + output.stderr);
}
expect(output.exitCode).toBe(0);
await execContainer(id, ["sh", "-c", "sleep 4"]);
const log = await readFileContainer(id, "/tmp/mux.log");
const runCount = await readFileContainer(id, "/tmp/mux-run-count");
expect(log).toContain("run=1");
expect(log).toContain("signal TERM (15); shell exit code 143.");
expect(log).toContain(
"Waiting 1 seconds before restarting mux after it exited.",
);
expect(log).toContain("run=2");
expect(log).toContain(
"Reached the max restart attempts limit (1); not restarting mux again.",
);
expect(runCount.trim()).toBe("2");
} finally {
await removeContainer(id);
}
}, 60000);
it("runs with npm present", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
-31
View File
@@ -49,34 +49,6 @@ variable "log_path" {
default = "/tmp/mux.log"
}
variable "restart_on_kill" {
type = bool
description = "Restart Mux after it exits by waiting briefly, removing the server lock, and launching it again."
default = false
}
variable "restart_delay_seconds" {
type = number
description = "How long to wait before restarting Mux after it exits when restart_on_kill is enabled."
default = 5
validation {
condition = var.restart_delay_seconds >= 0
error_message = "The 'restart_delay_seconds' variable must be greater than or equal to 0."
}
}
variable "max_restart_attempts" {
type = number
description = "Maximum whole-number restart attempts before giving up. Set to 0 for unlimited restarts when restart_on_kill is enabled."
default = 0
validation {
condition = var.max_restart_attempts >= 0 && floor(var.max_restart_attempts) == var.max_restart_attempts
error_message = "The 'max_restart_attempts' variable must be a whole number greater than or equal to 0."
}
}
variable "add_project" {
type = string
description = "Optional path to add/open as a project in Mux on startup."
@@ -199,9 +171,6 @@ resource "coder_script" "mux" {
OFFLINE : !var.install,
USE_CACHED : var.use_cached,
AUTH_TOKEN : local.mux_auth_token,
RESTART_ON_KILL : var.restart_on_kill,
RESTART_DELAY_SECONDS : var.restart_delay_seconds,
MAX_RESTART_ATTEMPTS : var.max_restart_attempts,
PACKAGE_MANAGER : var.package_manager,
REGISTRY_URL : local.registry_url,
})
-105
View File
@@ -111,111 +111,6 @@ run "launcher_logs_external_kills" {
}
}
run "restart_on_kill_enabled" {
command = plan
variables {
agent_id = "foo"
restart_on_kill = true
restart_delay_seconds = 7
}
assert {
condition = strcontains(resource.coder_script.mux.script, "restart_on_kill_value=\"true\"")
error_message = "mux launcher must receive the restart_on_kill setting"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "restart_delay_seconds_value=\"7\"")
error_message = "mux launcher must receive the configured restart delay"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "Waiting $${RESTART_DELAY_SECONDS_VALUE} seconds before restarting mux after it exited.")
error_message = "mux launcher must log the restart delay before relaunching"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "Removing $HOME/.mux/server.lock before restarting mux.")
error_message = "mux launcher must clean up the server lock before relaunching"
}
assert {
condition = !strcontains(resource.coder_script.mux.script, "\"$exit_code\" -le 128")
error_message = "mux launcher must no longer exclude non-signal exits from restart handling"
}
assert {
condition = !strcontains(resource.coder_script.mux.script, "1|2|15)")
error_message = "mux launcher must no longer exclude intentional signals from restart handling"
}
}
run "restart_on_kill_with_restart_cap" {
command = plan
variables {
agent_id = "foo"
restart_on_kill = true
restart_delay_seconds = 7
max_restart_attempts = 2
}
assert {
condition = strcontains(resource.coder_script.mux.script, "max_restart_attempts_value=\"2\"")
error_message = "mux launcher must receive the configured restart cap"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "Mux will stop restarting after $${max_restart_attempts_value} restart attempts.")
error_message = "mux launcher must describe the configured restart cap"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "Reached the max restart attempts limit ($MAX_RESTART_ATTEMPTS_VALUE); not restarting mux again.")
error_message = "mux launcher must log when it hits the restart cap"
}
}
run "invalid_max_restart_attempts" {
command = plan
variables {
agent_id = "foo"
max_restart_attempts = -1
}
expect_failures = [
var.max_restart_attempts
]
}
run "fractional_max_restart_attempts" {
command = plan
variables {
agent_id = "foo"
max_restart_attempts = 0.5
}
expect_failures = [
var.max_restart_attempts
]
}
run "invalid_restart_delay_seconds" {
command = plan
variables {
agent_id = "foo"
restart_delay_seconds = -1
}
expect_failures = [
var.restart_delay_seconds
]
}
run "custom_version" {
command = plan
+9 -82
View File
@@ -5,30 +5,17 @@ RESET='\033[0m'
MUX_BINARY="${INSTALL_PREFIX}/mux"
function run_mux() {
# Remove stale server lock if present
rm -f "$HOME/.mux/server.lock"
local port_value
local auth_token_value
local restart_on_kill_value
local restart_delay_seconds_value
local max_restart_attempts_value
port_value="${PORT}"
auth_token_value="${AUTH_TOKEN}"
restart_on_kill_value="${RESTART_ON_KILL}"
restart_delay_seconds_value="${RESTART_DELAY_SECONDS}"
max_restart_attempts_value="${MAX_RESTART_ATTEMPTS}"
if [ -z "$port_value" ]; then
port_value="4000"
fi
if [ -z "$restart_delay_seconds_value" ]; then
restart_delay_seconds_value="5"
fi
if [ -z "$max_restart_attempts_value" ]; then
max_restart_attempts_value="0"
fi
mkdir -p "$(dirname "${LOG_PATH}")"
# Build args for mux (POSIX-compatible, avoid bash arrays)
@@ -54,24 +41,13 @@ EOF_ARGS
echo "🚀 Starting mux server on port $port_value..."
echo "Check logs at ${LOG_PATH}!"
echo "Mux exit details will be appended to ${LOG_PATH} by the launcher."
if [ "$restart_on_kill_value" = true ]; then
echo "️ Auto-restart after mux exits is enabled with a $${restart_delay_seconds_value}-second delay."
if [ "$max_restart_attempts_value" = "0" ]; then
echo "️ Automatic restarts are unlimited for every mux exit."
else
echo "️ Mux will stop restarting after $${max_restart_attempts_value} restart attempts."
fi
fi
echo "Unexpected exits will be appended to ${LOG_PATH} by the launcher."
nohup env \
LOG_PATH="${LOG_PATH}" \
MUX_BINARY="$MUX_BINARY" \
AUTH_TOKEN="$auth_token_value" \
PORT_VALUE="$port_value" \
RESTART_ON_KILL_VALUE="$restart_on_kill_value" \
RESTART_DELAY_SECONDS_VALUE="$restart_delay_seconds_value" \
MAX_RESTART_ATTEMPTS_VALUE="$max_restart_attempts_value" \
bash -s -- "$@" > /dev/null 2>&1 << 'EOF_LAUNCHER' &
signal_name() {
local signal_number="$1"
@@ -106,14 +82,6 @@ append_kernel_kill_context() {
fi
}
cleanup_mux_lock() {
rm -f "$HOME/.mux/server.lock"
}
should_restart_mux() {
[ "$RESTART_ON_KILL_VALUE" = "true" ]
}
log_mux_exit() {
local mux_pid="$1"
local exit_code="$2"
@@ -146,52 +114,11 @@ log_mux_exit() {
echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself."
}
log_mux_restart_wait() {
local timestamp
timestamp="$(date -Iseconds 2> /dev/null || date)"
echo "[$timestamp] Waiting $${RESTART_DELAY_SECONDS_VALUE} seconds before restarting mux after it exited."
}
log_mux_restart_cleanup() {
local timestamp
timestamp="$(date -Iseconds 2> /dev/null || date)"
echo "[$timestamp] Removing $HOME/.mux/server.lock before restarting mux."
}
log_mux_restart_cap_reached() {
local timestamp
timestamp="$(date -Iseconds 2> /dev/null || date)"
echo "[$timestamp] Reached the max restart attempts limit ($MAX_RESTART_ATTEMPTS_VALUE); not restarting mux again."
}
restart_attempt_count=0
while true; do
cleanup_mux_lock
MUX_SERVER_AUTH_TOKEN="$AUTH_TOKEN" PORT="$PORT_VALUE" "$MUX_BINARY" "$@" >> "$LOG_PATH" 2>&1 &
mux_pid=$!
wait "$mux_pid"
exit_code=$?
log_mux_exit "$mux_pid" "$exit_code" >> "$LOG_PATH" 2>&1
if should_restart_mux; then
if [ "$MAX_RESTART_ATTEMPTS_VALUE" -gt 0 ] && [ "$restart_attempt_count" -ge "$MAX_RESTART_ATTEMPTS_VALUE" ]; then
log_mux_restart_cap_reached >> "$LOG_PATH" 2>&1
break
fi
restart_attempt_count=$((restart_attempt_count + 1))
log_mux_restart_wait >> "$LOG_PATH" 2>&1
sleep "$RESTART_DELAY_SECONDS_VALUE"
cleanup_mux_lock
log_mux_restart_cleanup >> "$LOG_PATH" 2>&1
continue
fi
break
done
MUX_SERVER_AUTH_TOKEN="$AUTH_TOKEN" PORT="$PORT_VALUE" "$MUX_BINARY" "$@" >> "$LOG_PATH" 2>&1 &
mux_pid=$!
wait "$mux_pid"
exit_code=$?
log_mux_exit "$mux_pid" "$exit_code" >> "$LOG_PATH" 2>&1
EOF_LAUNCHER
}
# Check if mux is already installed for offline mode
@@ -1,46 +0,0 @@
---
display_name: Portable Desktop
description: Install the portabledesktop binary for lightweight Linux desktop sessions.
icon: ../../../../.icons/desktop.svg
verified: true
tags: [desktop, vnc, ai]
---
# Portable Desktop
Install [portabledesktop](https://github.com/coder/portabledesktop) for lightweight Linux desktop sessions over VNC. The binary is stored in the agent's script data directory and is automatically available on PATH via `CODER_SCRIPT_BIN_DIR`.
```tf
module "portabledesktop" {
source = "registry.coder.com/coder/portabledesktop/coder"
version = "0.1.0"
agent_id = coder_agent.example.id
}
```
## Examples
### Custom download URL with checksum verification
```tf
module "portabledesktop" {
source = "registry.coder.com/coder/portabledesktop/coder"
version = "0.1.0"
agent_id = coder_agent.example.id
url = "https://example.com/portabledesktop-linux-x64"
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
```
### Additionally copy to a system path
Use `install_dir` to copy the binary to a system-wide directory in addition to the default script data directory:
```tf
module "portabledesktop" {
source = "registry.coder.com/coder/portabledesktop/coder"
version = "0.1.0"
agent_id = coder_agent.example.id
install_dir = "/usr/local/bin"
}
```
@@ -1,242 +0,0 @@
import { describe, expect, it } from "bun:test";
import {
execContainer,
findResourceInstance,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
type TerraformState,
} from "~test";
interface TestFixture {
state: TerraformState;
server: ReturnType<typeof Bun.serve>;
[Symbol.asyncDispose](): Promise<void>;
}
interface ContainerHandle {
id: string;
[Symbol.asyncDispose](): Promise<void>;
}
async function setupContainer(image: string): Promise<ContainerHandle> {
const id = await runContainer(image);
return {
id,
[Symbol.asyncDispose]: async () => {
await removeContainer(id);
},
};
}
const ENV_PREFIX =
'export CODER_SCRIPT_DATA_DIR=/tmp/coder-script-data && export CODER_SCRIPT_BIN_DIR=/tmp/coder-script-data/bin && mkdir -p "$CODER_SCRIPT_DATA_DIR" "$CODER_SCRIPT_BIN_DIR" && ';
async function setupFakeBinaryServer(
dir: string,
extraVars?: Record<string, string>,
): Promise<TestFixture> {
const fakeBinary = "#!/bin/sh\necho portabledesktop";
const server = Bun.serve({
port: 0,
fetch() {
return new Response(fakeBinary);
},
});
const state = await runTerraformApply(dir, {
agent_id: "foo",
url: `http://localhost:${server.port}/portabledesktop`,
...extraVars,
});
return {
state,
server,
[Symbol.asyncDispose]: async () => {
server.stop(true);
},
};
}
describe("portabledesktop", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
});
it("installs portabledesktop successfully", async () => {
await using fixture = await setupFakeBinaryServer(import.meta.dir);
await using container = await setupContainer("alpine/curl");
const script = findResourceInstance(fixture.state, "coder_script").script;
const resp = await execContainer(container.id, [
"sh",
"-c",
ENV_PREFIX + script,
]);
expect(resp.exitCode).toBe(0);
expect(resp.stdout).toContain("portabledesktop installed successfully");
// Check binary exists at CODER_SCRIPT_DATA_DIR.
const checkBinary = await execContainer(container.id, [
"test",
"-x",
"/tmp/coder-script-data/portabledesktop",
]);
expect(checkBinary.exitCode).toBe(0);
// Check symlink exists at CODER_SCRIPT_BIN_DIR.
const checkSymlink = await execContainer(container.id, [
"test",
"-L",
"/tmp/coder-script-data/bin/portabledesktop",
]);
expect(checkSymlink.exitCode).toBe(0);
}, 30000);
it("verifies checksum when sha256 is provided", async () => {
const fakeBinary = "#!/bin/sh\necho portabledesktop";
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(fakeBinary);
const sha256 = hasher.digest("hex");
await using fixture = await setupFakeBinaryServer(import.meta.dir, {
sha256,
});
await using container = await setupContainer("alpine/curl");
const script = findResourceInstance(fixture.state, "coder_script").script;
const resp = await execContainer(container.id, [
"sh",
"-c",
ENV_PREFIX + script,
]);
expect(resp.exitCode).toBe(0);
expect(resp.stdout).toContain("Checksum verified successfully");
expect(resp.stdout).toContain("portabledesktop installed successfully");
}, 30000);
it("fails when sha256 does not match", async () => {
const wrongSha256 =
"0000000000000000000000000000000000000000000000000000000000000000";
await using fixture = await setupFakeBinaryServer(import.meta.dir, {
sha256: wrongSha256,
});
await using container = await setupContainer("alpine/curl");
const script = findResourceInstance(fixture.state, "coder_script").script;
const resp = await execContainer(container.id, [
"sh",
"-c",
ENV_PREFIX + script,
]);
expect(resp.exitCode).toBe(1);
expect(resp.stdout).toContain("Checksum mismatch");
}, 30000);
it("skips checksum verification when sha256 is not set", async () => {
await using fixture = await setupFakeBinaryServer(import.meta.dir);
await using container = await setupContainer("alpine/curl");
const script = findResourceInstance(fixture.state, "coder_script").script;
const resp = await execContainer(container.id, [
"sh",
"-c",
ENV_PREFIX + script,
]);
expect(resp.exitCode).toBe(0);
expect(resp.stdout).not.toContain("Checksum verified");
expect(resp.stdout).toContain("portabledesktop installed successfully");
}, 30000);
it("falls back to sudo when install_dir is not writable", async () => {
await using fixture = await setupFakeBinaryServer(import.meta.dir, {
install_dir: "/usr/local/bin",
});
await using container = await setupContainer("alpine/curl");
await execContainer(container.id, [
"sh",
"-c",
"apk add sudo && " +
"adduser -D testuser && " +
"echo 'testuser ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers && " +
"mkdir -p /usr/local/bin",
]);
const script = findResourceInstance(fixture.state, "coder_script").script;
const resp = await execContainer(
container.id,
["sh", "-c", ENV_PREFIX + script],
["--user", "testuser"],
);
expect(resp.exitCode).toBe(0);
expect(resp.stdout).toContain("via sudo");
expect(resp.stdout).toContain("portabledesktop installed successfully");
// Verify the binary was copied to the install_dir.
const check = await execContainer(container.id, [
"test",
"-x",
"/usr/local/bin/portabledesktop",
]);
expect(check.exitCode).toBe(0);
}, 30000);
it("creates install_dir if it does not exist", async () => {
await using fixture = await setupFakeBinaryServer(import.meta.dir, {
install_dir: "/opt/custom/bin",
});
await using container = await setupContainer("alpine/curl");
const script = findResourceInstance(fixture.state, "coder_script").script;
const resp = await execContainer(container.id, [
"sh",
"-c",
ENV_PREFIX + script,
]);
expect(resp.exitCode).toBe(0);
expect(resp.stdout).toContain("portabledesktop installed successfully");
const check = await execContainer(container.id, [
"test",
"-x",
"/opt/custom/bin/portabledesktop",
]);
expect(check.exitCode).toBe(0);
}, 30000);
it("falls back to wget when curl is not available", async () => {
await using fixture = await setupFakeBinaryServer(import.meta.dir);
await using container = await setupContainer("alpine");
// Install wget but ensure curl is not present.
await execContainer(container.id, [
"sh",
"-c",
"apk add wget && ! command -v curl",
]);
const script = findResourceInstance(fixture.state, "coder_script").script;
const resp = await execContainer(container.id, [
"sh",
"-c",
ENV_PREFIX + script,
]);
expect(resp.exitCode).toBe(0);
expect(resp.stdout).toContain("via wget");
expect(resp.stdout).toContain("portabledesktop installed successfully");
}, 30000);
});
@@ -1,65 +0,0 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "install_dir" {
type = string
description = "Optional directory to copy the binary into (e.g. /usr/local/bin). The binary is always stored in the agent's script data directory and available on PATH via CODER_SCRIPT_BIN_DIR."
default = null
}
variable "url" {
type = string
description = "Custom download URL. Overrides the default GitHub latest release URL when set."
default = null
}
variable "sha256" {
type = string
description = "SHA256 checksum. When set, the downloaded binary is verified against it."
default = null
}
locals {
default_amd64_url = "https://github.com/coder/portabledesktop/releases/latest/download/portabledesktop-linux-x64"
default_arm64_url = "https://github.com/coder/portabledesktop/releases/latest/download/portabledesktop-linux-arm64"
using_custom_url = var.url != null
amd64_url = local.using_custom_url ? var.url : local.default_amd64_url
arm64_url = local.using_custom_url ? var.url : local.default_arm64_url
# Empty string signals "skip verification" to the shell script.
sha256 = var.sha256 != null ? var.sha256 : ""
install_dir = var.install_dir != null ? var.install_dir : ""
}
resource "coder_script" "portabledesktop" {
agent_id = var.agent_id
display_name = "Portable Desktop"
icon = "/icon/desktop.svg"
script = <<-EOT
#!/bin/sh
set -eu
echo -n '${base64encode(file("${path.module}/run.sh"))}' | base64 -d > /tmp/portabledesktop-install.sh
chmod +x /tmp/portabledesktop-install.sh
ARG_AMD64_URL="$(echo -n '${base64encode(local.amd64_url)}' | base64 -d)" \
ARG_ARM64_URL="$(echo -n '${base64encode(local.arm64_url)}' | base64 -d)" \
ARG_SHA256="$(echo -n '${base64encode(local.sha256)}' | base64 -d)" \
ARG_INSTALL_DIR="$(echo -n '${base64encode(local.install_dir)}' | base64 -d)" \
/tmp/portabledesktop-install.sh
EOT
run_on_start = true
}
@@ -1,36 +0,0 @@
run "plan_with_required_vars" {
command = plan
variables {
agent_id = "example-agent-id"
}
}
run "plan_with_custom_install_dir" {
command = plan
variables {
agent_id = "example-agent-id"
install_dir = "/opt/bin"
}
assert {
condition = resource.coder_script.portabledesktop.display_name == "Portable Desktop"
error_message = "Expected coder_script resource to have correct display name"
}
}
run "plan_with_custom_url" {
command = plan
variables {
agent_id = "example-agent-id"
url = "https://example.com/custom-portabledesktop"
sha256 = "abc123"
}
assert {
condition = resource.coder_script.portabledesktop.run_on_start == true
error_message = "Expected coder_script to run on start"
}
}
@@ -1,132 +0,0 @@
#!/usr/bin/env sh
# shellcheck disable=SC2292
# SC2292: We use [ ] instead of [[ ]] for POSIX sh compatibility.
set -eu
error() {
printf "ERROR: %s\n" "$@"
exit 1
}
# Check if portabledesktop is already in PATH.
if command -v portabledesktop > /dev/null 2>&1; then
printf "portabledesktop is already installed and in PATH.\n"
exit 0
fi
# Determine the storage path.
STORAGE_DIR="${CODER_SCRIPT_DATA_DIR}"
BINARY_PATH="${STORAGE_DIR}/portabledesktop"
mkdir -p "${STORAGE_DIR}"
# If the binary already exists and is executable, skip download.
if [ -x "${BINARY_PATH}" ]; then
printf "portabledesktop is already installed at %s, skipping download.\n" "${BINARY_PATH}"
else
# Detect architecture and select the appropriate download URL.
ARCH=$(uname -m)
case "${ARCH}" in
x86_64)
URL="${ARG_AMD64_URL}"
;;
aarch64)
URL="${ARG_ARM64_URL}"
;;
*)
error "Unsupported architecture: ${ARCH}"
;;
esac
# Select download tool.
if command -v curl > /dev/null 2>&1; then
DOWNLOAD_CMD="curl"
elif command -v wget > /dev/null 2>&1; then
DOWNLOAD_CMD="wget"
else
error "No download tool available (curl or wget required)."
fi
# Download with retry loop (3 attempts, 1s sleep between).
TMPFILE=$(mktemp)
MAX_ATTEMPTS=3
DOWNLOAD_SUCCESS=false
ATTEMPT=1
while [ "${ATTEMPT}" -le "${MAX_ATTEMPTS}" ]; do
printf "Downloading portabledesktop (attempt %s/%s) via %s...\n" "${ATTEMPT}" "${MAX_ATTEMPTS}" "${DOWNLOAD_CMD}"
DOWNLOAD_OK=false
if [ "${DOWNLOAD_CMD}" = "curl" ]; then
curl -fsSL "${URL}" -o "${TMPFILE}" && DOWNLOAD_OK=true
else
wget -qO "${TMPFILE}" "${URL}" && DOWNLOAD_OK=true
fi
if [ "${DOWNLOAD_OK}" = "true" ]; then
# Verify checksum when ARG_SHA256 is non-empty.
if [ -n "${ARG_SHA256}" ]; then
CHECKSUM_MATCH=false
if command -v sha256sum > /dev/null 2>&1; then
echo "${ARG_SHA256} ${TMPFILE}" | sha256sum -c - > /dev/null 2>&1 && CHECKSUM_MATCH=true
elif command -v shasum > /dev/null 2>&1; then
echo "${ARG_SHA256} ${TMPFILE}" | shasum -a 256 -c - > /dev/null 2>&1 && CHECKSUM_MATCH=true
else
rm -f "${TMPFILE}"
error "No SHA256 tool available (sha256sum or shasum required)."
fi
if [ "${CHECKSUM_MATCH}" != "true" ]; then
printf "WARNING: Checksum mismatch (attempt %s/%s): expected %s\n" \
"${ATTEMPT}" "${MAX_ATTEMPTS}" "${ARG_SHA256}"
rm -f "${TMPFILE}"
if [ "${ATTEMPT}" -lt "${MAX_ATTEMPTS}" ]; then
sleep 1
fi
ATTEMPT=$((ATTEMPT + 1))
continue
fi
printf "Checksum verified successfully.\n"
fi
DOWNLOAD_SUCCESS=true
break
else
printf "WARNING: Download failed (attempt %s/%s).\n" "${ATTEMPT}" "${MAX_ATTEMPTS}"
if [ "${ATTEMPT}" -lt "${MAX_ATTEMPTS}" ]; then
sleep 1
fi
fi
ATTEMPT=$((ATTEMPT + 1))
done
if [ "${DOWNLOAD_SUCCESS}" != "true" ]; then
rm -f "${TMPFILE}"
error "Failed to download portabledesktop after ${MAX_ATTEMPTS} attempts."
fi
# Make the binary executable and move to storage path.
chmod 755 "${TMPFILE}"
mv "${TMPFILE}" "${BINARY_PATH}"
fi
# Symlink into CODER_SCRIPT_BIN_DIR for PATH access.
if [ -n "${CODER_SCRIPT_BIN_DIR}" ] && [ ! -e "${CODER_SCRIPT_BIN_DIR}/portabledesktop" ]; then
ln -s "${CODER_SCRIPT_DATA_DIR}/portabledesktop" "${CODER_SCRIPT_BIN_DIR}/portabledesktop"
fi
# If ARG_INSTALL_DIR is set, copy the binary there with sudo fallback.
if [ -n "${ARG_INSTALL_DIR}" ]; then
if [ ! -d "${ARG_INSTALL_DIR}" ]; then
mkdir -p "${ARG_INSTALL_DIR}" 2> /dev/null || sudo mkdir -p "${ARG_INSTALL_DIR}" 2> /dev/null || true
fi
if cp "${CODER_SCRIPT_DATA_DIR}/portabledesktop" "${ARG_INSTALL_DIR}/portabledesktop" 2> /dev/null; then
printf "Copied portabledesktop to %s.\n" "${ARG_INSTALL_DIR}/portabledesktop"
elif sudo cp "${CODER_SCRIPT_DATA_DIR}/portabledesktop" "${ARG_INSTALL_DIR}/portabledesktop" 2> /dev/null; then
printf "Copied portabledesktop to %s (via sudo).\n" "${ARG_INSTALL_DIR}/portabledesktop"
else
error "Failed to copy portabledesktop to ${ARG_INSTALL_DIR}/portabledesktop."
fi
fi
printf "portabledesktop installed successfully.\n"
@@ -1,98 +0,0 @@
---
display_name: Docker RStudio
description: Provision Docker containers with RStudio, code-server, and RMarkdown
icon: ../../../../.icons/rstudio.svg
verified: true
tags: [docker, rstudio, r, rmarkdown, code-server]
---
# R Development on Docker Containers
Provision Docker containers pre-configured for R development as [Coder workspaces](https://coder.com/docs/workspaces) with this template.
Each workspace comes with:
- **RStudio Server** — full-featured R IDE in the browser.
- **code-server** — VS Code in the browser for general editing.
- **RMarkdown** — author reproducible documents, reports, and presentations.
The workspace is based on the [rocker/rstudio](https://rocker-project.org/) image, which ships R and RStudio Server pre-installed.
## Prerequisites
### Infrastructure
#### Running Coder inside Docker
If you installed Coder as a container within Docker, you will have to do the following things:
- Make the Docker socket available to the container
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
- You can get the GID by running `getent group docker` on the **host** machine
#### Running Coder outside of Docker
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
```sh
# Add coder user to Docker group
sudo adduser coder docker
# Restart Coder server
sudo systemctl restart coder
# Test Docker
sudo -u coder docker ps
```
## Architecture
This template provisions the following resources:
- Docker image (built from `build/Dockerfile`, extending `rocker/rstudio` with system dependencies)
- Docker container (ephemeral — destroyed on workspace stop)
- Docker volume (persistent on `/home/rstudio`)
When the workspace restarts, tools and files outside `/home/rstudio` are not persisted. The R library path defaults to a subdirectory of the home folder, so installed packages (including RMarkdown) survive restarts.
> [!NOTE]
> This template is designed to be a starting point! Edit the Terraform to extend it for your use case.
## Customization
### Changing the R version
Set the `rstudio_version` variable to any valid [rocker/rstudio tag](https://hub.docker.com/r/rocker/rstudio/tags) (for example `4.4.2`, `4.3`, or `latest`).
### Installing additional R packages
R packages are pre-installed via the `build/Dockerfile` so they are available immediately when the workspace starts. To add more packages, add `install.packages()` calls to the Dockerfile:
```dockerfile
RUN R -e "install.packages(c('tidyverse', 'shiny'))"
```
The image is pre-configured to use [Posit Package Manager](https://packagemanager.posit.co/) which provides pre-compiled binary packages for fast installation. Packages installed at build time avoid long startup delays from compiling from source on every workspace start.
### Adding system dependencies
The `build/Dockerfile` extends the `rocker/rstudio` base image with system packages required by modules (e.g. `curl` for code-server, `cmake` for R package compilation). If you add modules that need additional system-level tools, add them to the `Dockerfile`:
```dockerfile
RUN apt-get update \
&& apt-get install -y \
curl \
cmake \
your-package-here \
&& rm -rf /var/lib/apt/lists/*
```
### Adding LaTeX for PDF rendering
RMarkdown can render PDF output when LaTeX is available. Add the following to the startup script to install TinyTeX:
```sh
R --quiet -e "if (!require('tinytex', quietly = TRUE)) { install.packages('tinytex', repos = 'https://cloud.r-project.org'); tinytex::install_tinytex() }"
```
@@ -1,12 +0,0 @@
ARG RSTUDIO_VERSION=4
FROM rocker/rstudio:${RSTUDIO_VERSION}
RUN apt-get update \
&& apt-get install -y \
curl \
cmake \
&& rm -rf /var/lib/apt/lists/*
RUN R -e "install.packages('rmarkdown')"
RUN echo "auth-minimum-user-id=0" >>/etc/rstudio/rserver.conf
@@ -1,244 +0,0 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
}
}
}
locals {
username = data.coder_workspace_owner.me.name
}
variable "docker_socket" {
default = ""
description = "(Optional) Docker socket URI"
type = string
}
variable "rstudio_version" {
default = "4"
description = "The rocker/rstudio image tag to use (e.g. 4, 4.4, 4.4.2)"
type = string
}
provider "docker" {
# Defaulting to null if the variable is an empty string lets us
# have an optional variable without having to set our own default.
host = var.docker_socket != "" ? var.docker_socket : null
}
data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_agent" "main" {
arch = data.coder_provisioner.me.arch
os = "linux"
startup_script = <<-EOT
set -e
# Prepare user home with default files on first start.
if [ ! -f ~/.init_done ]; then
cp -rT /etc/skel ~ 2>/dev/null || true
touch ~/.init_done
fi
# Start RStudio Server. The rocker/rstudio image ships the
# server pre-installed. We disable authentication because
# the Coder proxy handles access control.
if command -v rserver > /dev/null 2>&1; then
sudo rserver \
--server-daemonize=0 \
--auth-none=1 \
--www-port=8787 \
--server-user=rstudio > /tmp/rserver.log 2>&1 &
elif [ -x /usr/lib/rstudio-server/bin/rserver ]; then
sudo /usr/lib/rstudio-server/bin/rserver \
--server-daemonize=0 \
--auth-none=1 \
--www-port=8787 \
--server-user=rstudio > /tmp/rserver.log 2>&1 &
fi
EOT
# These environment variables allow you to make Git commits
# right away after creating a workspace. They take precedence
# over configuration in ~/.gitconfig. Remove this block if
# you prefer to configure Git manually or via dotfiles.
env = {
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
}
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 average scaled by number of cores.
script = <<EOT
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
EOT
interval = 60
timeout = 1
}
metadata {
display_name = "Swap Usage (Host)"
key = "7_swap_host"
script = <<EOT
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
EOT
interval = 10
timeout = 1
}
}
# RStudio Server served through the Coder proxy so users can
# open the full RStudio IDE directly from the dashboard.
resource "coder_app" "rstudio" {
agent_id = coder_agent.main.id
slug = "rstudio"
display_name = "RStudio"
url = "http://localhost:8787"
icon = "/icon/rstudio.svg"
subdomain = true
share = "owner"
order = 1
}
# See https://registry.coder.com/modules/coder/code-server
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
# This ensures that the latest non-breaking version of the
# module gets downloaded. You can also pin the module version
# to prevent breaking changes in production.
version = "~> 1.0"
agent_id = coder_agent.main.id
order = 2
folder = "/home/rstudio"
}
resource "docker_image" "main" {
name = "coder-${data.coder_workspace.me.id}-rstudio"
build {
context = "./build"
build_args = {
RSTUDIO_VERSION = var.rstudio_version
}
}
}
resource "docker_volume" "home_volume" {
name = "coder-${data.coder_workspace.me.id}-home"
# Protect the volume from being deleted due to changes in
# attributes.
lifecycle {
ignore_changes = all
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
# This field becomes outdated if the workspace is renamed but
# can be useful for debugging or cleaning out dangling volumes.
labels {
label = "coder.workspace_name_at_creation"
value = data.coder_workspace.me.name
}
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = docker_image.main.image_id
# Uses lower() to avoid Docker restriction on container names.
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
# Hostname makes the shell more user friendly: rstudio@my-workspace:~$
hostname = data.coder_workspace.me.name
# Use the docker gateway if the access URL is 127.0.0.1.
entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
host {
host = "host.docker.internal"
ip = "host-gateway"
}
volumes {
container_path = "/home/rstudio"
volume_name = docker_volume.home_volume.name
read_only = false
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
labels {
label = "coder.workspace_name"
value = data.coder_workspace.me.name
}
}