Compare commits

..

15 Commits

Author SHA1 Message Date
DevelopmentCats c5f6a00851 chore: bun fmt 2026-02-24 15:35:12 -06:00
DevelopmentCats 05e6324e41 fix: refactor extension installation in VS Code Web to use VSIX downloads 2026-02-24 15:19:49 -06:00
DevelopmentCats fd6f980610 fix: enhance extension installation process in VS Code Web by utilizing remote CLI 2026-02-24 14:56:13 -06:00
DevelopmentCats 3447d31392 fix: enhance VS Code Web server readiness check to use log output 2026-02-24 14:32:52 -06:00
DevelopmentCats 618a9b8b5d fix: improve VS Code Web server readiness check before installing extensions
- Added a function to wait for the VS Code Web server to be ready before proceeding with the installation of extensions.
- Replaced sleep commands with a conditional wait to ensure extensions are only installed after the server is confirmed to be running.
2026-02-24 14:26:22 -06:00
DevelopmentCats 847c9491af fix: adjust VS Code Web CLI execution order to ensure extensions are installed after server starts
- Added a sleep command to allow the VS Code Web server to start before installing extensions.
- Modified the script to run the VS Code Web CLI first, followed by the installation of extensions.
2026-02-24 14:02:29 -06:00
DevCats 10142cbe1c Merge branch 'main' into vscode-web-cli 2026-02-24 13:26:43 -06:00
DevelopmentCats 8ec817e33c refactor: update VS Code Web settings handling to merge with existing settings
- Changed test description to reflect merging behavior of settings.
- Updated Terraform variable description to clarify merging of settings.
- Implemented a new function in the run script to merge settings using jq or Python3.
- Adjusted the settings file creation logic to merge new settings with existing ones.
- Updated README to reflect changes in settings configuration and merging requirements.
2026-02-24 13:26:19 -06:00
Atif Ali 08bd84c529 Merge branch 'main' into vscode-web-cli 2026-02-10 10:25:08 +05:00
blink-so[bot] c493bbd490 fix: resolve SC2155 shellcheck warning in vscode-web run.sh
Separate declaration and assignment for local variable to avoid masking return values.
2026-01-12 15:47:22 +00:00
Atif Ali b55d546f03 Merge branch 'main' into vscode-web-cli 2026-01-09 17:59:39 +05:00
Atif Ali f1d5947245 Merge branch 'main' into vscode-web-cli 2026-01-08 00:23:11 +05:00
Muhammad Atif Ali e1eda2ce65 bunfmt 2026-01-05 12:20:27 +05:00
Atif Ali a1eed799aa Merge branch 'main' into vscode-web-cli 2026-01-05 12:18:55 +05:00
Muhammad Atif Ali b52c0f9f63 refactor(vscode-web): migrate to VS Code CLI with code serve-web
- Replace code-server with official VS Code CLI
- Download CLI from code.visualstudio.com using cli-alpine-* URLs
- Add release_channel variable (stable/insiders)
- Add commit_id variable to pin specific VS Code versions
- Support offline mode with fallback to code-server or cached vscode-server
- Add comprehensive bun tests for settings, extensions, and CLI arguments
- Add Terraform tests for variable validation
2025-12-31 16:28:08 +05:00
62 changed files with 1430 additions and 3965 deletions
+7 -7
View File
@@ -1,7 +1,7 @@
name: CI
on:
pull_request:
branches: [main]
# Cancel in-progress runs for pull requests when developers push new changes
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -37,9 +37,9 @@ jobs:
all:
- '**'
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
- name: Set up Bun
uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # 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,18 +82,18 @@ jobs:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Bun
uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
with:
bun-version: latest
# Need Terraform for its formatter
- name: Install Terraform
uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
- name: Install dependencies
run: bun install
- name: Validate formatting
run: bun fmt:ci
- name: Check for typos
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0
uses: crate-ci/typos@65120634e79d8374d1aa2f27e54baa0c364fff5a # v1.42.1
with:
config: .github/typos.toml
validate-readme-files:
@@ -106,7 +106,7 @@ jobs:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # 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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # 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@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
with:
bun-version: latest
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
- name: Install dependencies
run: bun install
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
persist-credentials: false
- name: Run zizmor (blocking, HIGH only)
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
with:
advanced-security: false
annotations: true
@@ -49,7 +49,7 @@ jobs:
persist-credentials: false
- name: Run zizmor (SARIF)
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
with:
inputs: |
.github/workflows
-10
View File
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none">
<g fill="#40BE46">
<!-- Eye shape -->
<path d="M100 40C55 40 20 80 10 100c10 20 45 60 90 60s80-40 90-60c-10-20-45-60-90-60zm0 100c-35 0-63-28-75-40 12-12 40-40 75-40s63 28 75 40c-12 12-40 40-75 40z"/>
<!-- Inner circle (magnifying glass lens) -->
<path d="M100 72a28 28 0 1 0 0 56 28 28 0 0 0 0-56zm0 44a16 16 0 1 1 0-32 16 16 0 0 1 0 32z"/>
<!-- Horizontal line below -->
<rect x="25" y="170" width="150" height="12" rx="6"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 542 B

+1 -11
View File
@@ -1,11 +1 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_147_2)">
<path d="M162.358 73H257V182H162.358V73Z" fill="white"/>
<path d="M0 182V78.4618H26.039L27.0034 103.381L24.3033 102.221C25.7177 96.6843 27.8391 91.9835 30.6684 88.1202C33.6255 84.2569 37.1618 81.2949 41.2769 79.2343C45.3914 77.1742 49.8921 76.1439 54.7785 76.1439C63.3938 76.1439 70.3377 78.6552 75.6097 83.6773C81.0105 88.6998 84.4824 95.4606 86.0251 103.96L82.3606 104.153C83.518 98.1008 85.5112 93.0138 88.3401 88.8931C91.2976 84.6431 94.8978 81.4883 99.1411 79.4277C103.385 77.2387 108.143 76.1439 113.415 76.1439C120.615 76.1439 126.788 77.6249 131.931 80.5869C137.075 83.5488 141.061 87.9913 143.89 93.9152C146.719 99.7102 148.133 106.858 148.133 115.357V182H119.201V123.47C119.201 115.357 117.98 109.305 115.536 105.312C113.093 101.191 109.107 99.1311 103.577 99.1311C100.106 99.1311 97.1484 100.097 94.7052 102.029C92.262 103.96 90.3332 106.793 88.9188 110.528C87.6326 114.134 86.9895 118.577 86.9895 123.857V182H60.9506V123.857C60.9506 115.872 59.7936 109.755 57.4787 105.505C55.1642 101.256 51.1779 99.1311 45.5202 99.1311C42.0482 99.1311 39.0263 100.097 36.4548 102.029C34.0117 103.96 32.1472 106.793 30.861 110.528C29.5753 114.262 28.9322 118.705 28.9322 123.857V182H0Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_147_2">
<rect width="256" height="256" fill="white"/>
</clipPath>
</defs>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="135" height="62" fill="none" viewBox="0 0 135 62"><path fill="#fff" d="M3.168 48V22.272H9.648L9.888 28.464L9.216 28.176C9.568 26.8 10.096 25.632 10.8 24.672C11.536 23.712 12.416 22.976 13.44 22.464C14.464 21.952 15.584 21.696 16.8 21.696C18.944 21.696 20.672 22.32 21.984 23.568C23.328 24.816 24.192 26.496 24.576 28.608L23.664 28.656C23.952 27.152 24.448 25.888 25.152 24.864C25.888 23.808 26.784 23.024 27.84 22.512C28.896 21.968 30.08 21.696 31.392 21.696C33.184 21.696 34.72 22.064 36 22.8C37.28 23.536 38.272 24.64 38.976 26.112C39.68 27.552 40.032 29.328 40.032 31.44V48H32.832V33.456C32.832 31.44 32.528 29.936 31.92 28.944C31.312 27.92 30.32 27.408 28.944 27.408C28.08 27.408 27.344 27.648 26.736 28.128C26.128 28.608 25.648 29.312 25.296 30.24C24.976 31.136 24.816 32.24 24.816 33.552V48H18.336V33.552C18.336 31.568 18.048 30.048 17.472 28.992C16.896 27.936 15.904 27.408 14.496 27.408C13.632 27.408 12.88 27.648 12.24 28.128C11.632 28.608 11.168 29.312 10.848 30.24C10.528 31.168 10.368 32.272 10.368 33.552V48H3.168ZM54.2254 48.576C51.5694 48.576 49.4894 47.728 47.9854 46.032C46.5134 44.304 45.7774 41.904 45.7774 38.832V22.272H52.9774V37.152C52.9774 39.136 53.2814 40.592 53.8894 41.52C54.4974 42.416 55.4574 42.864 56.7694 42.864C58.2414 42.864 59.3774 42.368 60.1774 41.376C61.0094 40.352 61.4254 38.832 61.4254 36.816V22.272H68.6254V48H62.0494L61.8574 40.608L62.7694 40.8C62.3854 43.36 61.4734 45.296 60.0334 46.608C58.5934 47.92 56.6574 48.576 54.2254 48.576ZM72.8486 48L82.0166 34.944L73.0886 22.272H80.7206L86.2406 30.528L91.5686 22.272H99.3926L90.5126 34.992L99.6326 48H92.0006L86.3366 39.264L80.6246 48H72.8486Z"/><rect width="26" height="35" x="109" y="13" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M37.3333 213.333C33.0666 213.333 29.3333 211.733 26.1333 208.533C22.9333 205.333 21.3333 201.6 21.3333 197.333V58.6667C21.3333 54.4001 22.9333 50.6667 26.1333 47.4667C29.3333 44.2667 33.0666 42.6667 37.3333 42.6667H218.667C222.933 42.6667 226.667 44.2667 229.867 47.4667C233.067 50.6667 234.667 54.4001 234.667 58.6667V197.333C234.667 201.6 233.067 205.333 229.867 208.533C226.667 211.733 222.933 213.333 218.667 213.333H37.3333ZM37.3333 197.333H218.667V81.0668H37.3333V197.333ZM80 178.133L68.8 166.933L96.2666 139.2L68.5333 111.467L80 100.267L118.933 139.2L80 178.133ZM130.667 179.2V163.2H189.333V179.2H130.667Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 745 B

-2
View File
@@ -28,8 +28,6 @@ bun test main.test.ts # Run single TS test (from
- Use semantic versioning; bump version via script when modifying modules
- Docker tests require Linux or Colima/OrbStack (not Docker Desktop)
- Use `tf` (not `hcl`) for code blocks in README; use relative icon paths (e.g., `../../../../.icons/`)
- **Do NOT include input/output variable tables in module or template READMEs.** The registry automatically generates these from the Terraform source (e.g., variable and output blocks in `main.tf`). Adding them to the README is redundant and creates maintenance drift.
- Usage examples (e.g., a `module "..." { }` block) are encouraged, but not tables enumerating inputs/outputs.
## PR Review Checklist
+7 -20
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.2.0"
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = var.openai_api_key
workdir = "/home/coder/project"
@@ -32,7 +32,7 @@ module "codex" {
module "codex" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.2.0"
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
@@ -51,7 +51,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.2.0"
version = "4.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
enable_aibridge = true
@@ -63,8 +63,6 @@ When `enable_aibridge = true`, the module:
- 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
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"
@@ -77,6 +75,8 @@ model = "<model>" # as configured in the module input
model_reasoning_effort = "<model_reasoning_effort>" # as configured in the module input
```
Codex then runs with `--profile aibridge`
This allows Codex to route API requests through Coder's AI Bridge instead of directly to OpenAI's API.
Template build will fail if `openai_api_key` is provided alongside `enable_aibridge = true`.
@@ -94,7 +94,7 @@ data "coder_task" "me" {}
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.2.0"
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
ai_prompt = data.coder_task.me.prompt
@@ -112,7 +112,7 @@ This example shows additional configuration options for custom models, MCP serve
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.2.0"
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
@@ -148,19 +148,6 @@ module "codex" {
- **Configuration**: Sets `OPENAI_API_KEY` environment variable and passes `--model` flag to Codex CLI (if variables provided)
- **Session Continuity**: When `continue = true` (default), the module automatically tracks task sessions in `~/.codex-module/.codex-task-session`. On workspace restart, it resumes the existing session with full conversation history. Set `continue = false` to always start fresh sessions.
## State Persistence
AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Codex CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning).
To disable:
```tf
module "codex" {
# ... other config
enable_state_persistence = false
}
```
## Configuration
### Default Configuration
+10 -1
View File
@@ -464,13 +464,22 @@ describe("codex", async () => {
});
await execModuleScript(id);
const startLog = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
expect(startLog).toContain("AI Bridge is enabled, using profile aibridge");
expect(startLog).toContain(
"Starting Codex with arguments: --profile aibridge",
);
expect(configToml).toContain(
"[profiles.aibridge]\n" + 'model_provider = "aibridge"',
);
expect(configToml).toContain('profile = "aibridge"');
});
});
+27 -37
View File
@@ -131,13 +131,13 @@ variable "install_agentapi" {
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.12.1"
default = "v0.11.8"
}
variable "codex_model" {
type = string
description = "The model for Codex to use. Defaults to gpt-5.3-codex."
default = "gpt-5.3-codex"
description = "The model for Codex to use. Defaults to gpt-5.2-codex."
default = "gpt-5.2-codex"
}
variable "pre_install_script" {
@@ -164,12 +164,6 @@ variable "continue" {
default = true
}
variable "enable_state_persistence" {
type = bool
description = "Enable AgentAPI conversation state persistence across restarts."
default = true
}
variable "codex_system_prompt" {
type = string
description = "System instructions written to AGENTS.md in the ~/.codex directory"
@@ -190,13 +184,12 @@ resource "coder_env" "coder_aibridge_session_token" {
}
locals {
workdir = trimsuffix(var.workdir, "/")
app_slug = "codex"
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.3-codex"
aibridge_config = <<-EOF
workdir = trimsuffix(var.workdir, "/")
app_slug = "codex"
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".codex-module"
aibridge_config = <<-EOF
[model_providers.aibridge]
name = "AI Bridge"
base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
@@ -212,26 +205,25 @@ locals {
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.2.0"
version = "2.0.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
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
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
@@ -257,8 +249,6 @@ module "agentapi" {
chmod +x /tmp/install.sh
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_CODEX_MODEL='${var.codex_model}' \
ARG_LATEST_CODEX_MODEL='${local.latest_codex_model}' \
ARG_INSTALL='${var.install_codex}' \
ARG_CODEX_VERSION='${var.codex_version}' \
ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \
@@ -1,187 +0,0 @@
run "test_codex_basic" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
}
assert {
condition = var.agent_id == "test-agent"
error_message = "Agent ID should be set correctly"
}
assert {
condition = var.workdir == "/home/coder"
error_message = "Workdir should be set correctly"
}
assert {
condition = var.install_codex == true
error_message = "install_codex should default to true"
}
assert {
condition = var.install_agentapi == true
error_message = "install_agentapi should default to true"
}
assert {
condition = var.report_tasks == true
error_message = "report_tasks should default to true"
}
assert {
condition = var.continue == true
error_message = "continue should default to true"
}
}
run "test_enable_state_persistence_default" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
}
assert {
condition = var.enable_state_persistence == true
error_message = "enable_state_persistence should default to true"
}
}
run "test_disable_state_persistence" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
enable_state_persistence = false
}
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should be false when explicitly disabled"
}
}
run "test_codex_with_aibridge" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_aibridge = true
}
assert {
condition = var.enable_aibridge == true
error_message = "enable_aibridge should be set to true"
}
}
run "test_aibridge_disabled_with_api_key" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
enable_aibridge = false
}
assert {
condition = var.enable_aibridge == false
error_message = "enable_aibridge should be false"
}
assert {
condition = coder_env.openai_api_key.value == "test-key"
error_message = "OpenAI API key should be set correctly"
}
}
run "test_custom_options" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder/project"
openai_api_key = "test-key"
order = 5
group = "ai-tools"
icon = "/icon/custom.svg"
web_app_display_name = "Custom Codex"
cli_app = true
cli_app_display_name = "Codex Terminal"
subdomain = true
report_tasks = false
continue = false
codex_model = "gpt-4o"
codex_version = "0.1.0"
agentapi_version = "v0.12.0"
}
assert {
condition = var.order == 5
error_message = "Order should be set to 5"
}
assert {
condition = var.group == "ai-tools"
error_message = "Group should be set to 'ai-tools'"
}
assert {
condition = var.icon == "/icon/custom.svg"
error_message = "Icon should be set to custom icon"
}
assert {
condition = var.cli_app == true
error_message = "cli_app should be enabled"
}
assert {
condition = var.subdomain == true
error_message = "subdomain should be enabled"
}
assert {
condition = var.report_tasks == false
error_message = "report_tasks should be disabled"
}
assert {
condition = var.continue == false
error_message = "continue should be disabled"
}
assert {
condition = var.codex_model == "gpt-4o"
error_message = "codex_model should be set to 'gpt-4o'"
}
}
run "test_no_api_key_no_aibridge" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.openai_api_key == ""
error_message = "openai_api_key should be empty when not provided"
}
assert {
condition = var.enable_aibridge == false
error_message = "enable_aibridge should default to false"
}
}
@@ -20,8 +20,6 @@ echo "=== Codex Module Configuration ==="
printf "Install Codex: %s\n" "$ARG_INSTALL"
printf "Codex Version: %s\n" "$ARG_CODEX_VERSION"
printf "App Slug: %s\n" "$ARG_CODER_MCP_APP_STATUS_SLUG"
printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}"
printf "Latest Codex Model: %s\n" "${ARG_LATEST_CODEX_MODEL}"
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
printf "Has Base Config: %s\n" "$([ -n "$ARG_BASE_CONFIG_TOML" ] && echo "Yes" || echo "No")"
printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && echo "Yes" || echo "No")"
@@ -92,25 +90,15 @@ function install_codex() {
write_minimal_default_config() {
local config_path="$1"
ARG_DEFAULT_PROFILE=""
if [[ "${ARG_ENABLE_AIBRIDGE}" = "true" ]]; then
ARG_DEFAULT_PROFILE='profile = "aibridge"'
fi
cat << EOF > "$config_path"
# Minimal Default Codex Configuration
sandbox_mode = "workspace-write"
approval_policy = "never"
preferred_auth_method = "apikey"
${ARG_DEFAULT_PROFILE}
[sandbox_workspace_write]
network_access = true
[notice.model_migrations]
"${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}"
EOF
}
@@ -155,8 +155,11 @@ setup_workdir() {
build_codex_args() {
CODEX_ARGS=()
if [[ -n "${ARG_CODEX_MODEL}" ]] && [[ "${ARG_ENABLE_AIBRIDGE}" != "true" ]]; then
CODEX_ARGS+=("--model" "${ARG_CODEX_MODEL}")
if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then
printf "AI Bridge is enabled, using profile aibridge\n"
CODEX_ARGS+=("--profile" "aibridge")
elif [ -n "$ARG_CODEX_MODEL" ]; then
CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
fi
if [ "$ARG_CONTINUE" = "true" ]; then
@@ -210,7 +213,7 @@ capture_session_id() {
start_codex() {
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
capture_session_id
}
+6 -39
View File
@@ -3,7 +3,7 @@ display_name: Copilot CLI
description: GitHub Copilot CLI agent for AI-powered terminal assistance
icon: ../../../../.icons/github.svg
verified: false
tags: [agent, copilot, ai, github, tasks, aibridge]
tags: [agent, copilot, ai, github, tasks]
---
# Copilot
@@ -13,7 +13,7 @@ Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-c
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.0"
version = "0.3.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
}
@@ -51,7 +51,7 @@ data "coder_parameter" "ai_prompt" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.0"
version = "0.3.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
@@ -71,7 +71,7 @@ Customize tool permissions, MCP servers, and Copilot settings:
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.0"
version = "0.3.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
@@ -142,7 +142,7 @@ variable "github_token" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.0"
version = "0.3.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
github_token = var.github_token
@@ -156,7 +156,7 @@ Run Copilot as a command-line tool without task reporting or web interface. This
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.0"
version = "0.3.0"
agent_id = coder_agent.example.id
workdir = "/home/coder"
report_tasks = false
@@ -164,39 +164,6 @@ module "copilot" {
}
```
### Usage with AI Bridge Proxy
[AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy) routes Copilot traffic through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) for centralized LLM management and governance.
The proxy environment variables are scoped to the Copilot process only and do not affect other workspace traffic.
```tf
module "aibridge-proxy" {
source = "registry.coder.com/coder/aibridge-proxy/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
proxy_url = "https://aiproxy.example.com"
}
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/projects"
enable_aibridge_proxy = true
aibridge_proxy_auth_url = module.aibridge-proxy.proxy_auth_url
aibridge_proxy_cert_path = module.aibridge-proxy.cert_path
}
```
> [!NOTE]
> AI Bridge Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance).
> See the [AI Bridge Proxy setup guide](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup) for details on configuring the proxy on your Coder deployment.
> GitHub authentication is still required for Copilot as the proxy authenticates with AI Bridge using the Coder session token, but does not replace GitHub authentication.
> [!IMPORTANT]
> When using AI Bridge Proxy, enable [startup coordination](https://coder.com/docs/admin/templates/startup-coordination) by setting `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment.
> This ensures the Copilot module waits for the `aibridge-proxy` module to complete before starting. Without it, the Copilot start script may fail if the AI Bridge Proxy setup has not completed in time.
## Authentication
The module supports multiple authentication methods (in priority order):
@@ -234,116 +234,3 @@ run "app_slug_is_consistent" {
error_message = "module_dir_name should be '.copilot-module'"
}
}
run "aibridge_proxy_defaults" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.enable_aibridge_proxy == false
error_message = "enable_aibridge_proxy should default to false"
}
assert {
condition = var.aibridge_proxy_auth_url == null
error_message = "aibridge_proxy_auth_url should default to null"
}
assert {
condition = var.aibridge_proxy_cert_path == null
error_message = "aibridge_proxy_cert_path should default to null"
}
}
run "aibridge_proxy_enabled" {
command = plan
variables {
agent_id = "test-agent-aibridge-proxy"
workdir = "/home/coder"
enable_aibridge_proxy = true
aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com"
aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem"
}
assert {
condition = var.enable_aibridge_proxy == true
error_message = "AI Bridge Proxy should be enabled"
}
assert {
condition = var.aibridge_proxy_auth_url == "https://coder:mock-token@aiproxy.example.com"
error_message = "AI Bridge Proxy auth URL should match the input variable"
}
assert {
condition = var.aibridge_proxy_cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
error_message = "AI Bridge Proxy cert path should match the input variable"
}
}
run "aibridge_proxy_validation_missing_proxy_auth_url" {
command = plan
variables {
agent_id = "test-agent-validation"
workdir = "/home/coder"
enable_aibridge_proxy = true
aibridge_proxy_auth_url = ""
aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem"
}
expect_failures = [
var.enable_aibridge_proxy,
]
}
run "aibridge_proxy_validation_missing_cert_path" {
command = plan
variables {
agent_id = "test-agent-validation"
workdir = "/home/coder"
enable_aibridge_proxy = true
aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com"
aibridge_proxy_cert_path = ""
}
expect_failures = [
var.enable_aibridge_proxy,
]
}
run "aibridge_proxy_with_copilot_config" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
copilot_model = "gpt-5"
github_token = "ghp_test123"
allow_all_tools = true
enable_aibridge_proxy = true
aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com"
aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem"
}
assert {
condition = var.enable_aibridge_proxy == true
error_message = "AI Bridge Proxy should be enabled"
}
assert {
condition = length(resource.coder_env.github_token) == 1
error_message = "github_token environment variable should be set alongside proxy"
}
assert {
condition = length(resource.coder_env.copilot_model) == 1
error_message = "copilot_model environment variable should be set alongside proxy"
}
}
+1 -33
View File
@@ -1,5 +1,5 @@
terraform {
required_version = ">= 1.9"
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
@@ -173,35 +173,6 @@ variable "post_install_script" {
default = null
}
variable "enable_aibridge_proxy" {
type = bool
description = "Route Copilot traffic through AI Bridge Proxy. See https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy"
default = false
validation {
condition = !var.enable_aibridge_proxy || (var.aibridge_proxy_auth_url != null && length(var.aibridge_proxy_auth_url) > 0)
error_message = "aibridge_proxy_auth_url is required when enable_aibridge_proxy is true."
}
validation {
condition = !var.enable_aibridge_proxy || (var.aibridge_proxy_cert_path != null && length(var.aibridge_proxy_cert_path) > 0)
error_message = "aibridge_proxy_cert_path is required when enable_aibridge_proxy is true."
}
}
variable "aibridge_proxy_auth_url" {
type = string
description = "AI Bridge Proxy URL with authentication. Use the proxy_auth_url output from the aibridge-proxy module."
default = null
sensitive = true
}
variable "aibridge_proxy_cert_path" {
type = string
description = "Path to the AI Bridge Proxy CA certificate. Use the cert_path output from the aibridge-proxy module."
default = null
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
@@ -308,9 +279,6 @@ module "agentapi" {
ARG_TRUSTED_DIRECTORIES='${join(",", var.trusted_directories)}' \
ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \
ARG_RESUME_SESSION='${var.resume_session}' \
ARG_ENABLE_AIBRIDGE_PROXY='${var.enable_aibridge_proxy}' \
ARG_AIBRIDGE_PROXY_AUTH_URL='${var.aibridge_proxy_auth_url != null ? var.aibridge_proxy_auth_url : ""}' \
ARG_AIBRIDGE_PROXY_CERT_PATH='${var.aibridge_proxy_cert_path != null ? var.aibridge_proxy_cert_path : ""}' \
/tmp/start.sh
EOT
@@ -22,9 +22,6 @@ ARG_DENY_TOOLS=${ARG_DENY_TOOLS:-}
ARG_TRUSTED_DIRECTORIES=${ARG_TRUSTED_DIRECTORIES:-}
ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github}
ARG_RESUME_SESSION=${ARG_RESUME_SESSION:-true}
ARG_ENABLE_AIBRIDGE_PROXY=${ARG_ENABLE_AIBRIDGE_PROXY:-false}
ARG_AIBRIDGE_PROXY_AUTH_URL=${ARG_AIBRIDGE_PROXY_AUTH_URL:-}
ARG_AIBRIDGE_PROXY_CERT_PATH=${ARG_AIBRIDGE_PROXY_CERT_PATH:-}
validate_copilot_installation() {
if ! command_exists copilot; then
@@ -121,48 +118,6 @@ setup_github_authentication() {
return 0
}
setup_aibridge_proxy() {
if [ "$ARG_ENABLE_AIBRIDGE_PROXY" != "true" ]; then
return 0
fi
echo "Setting up AI Bridge Proxy..."
# Wait for the aibridge-proxy module to finish.
# Uses startup coordination to block until aibridge-proxy-setup signals completion.
if command -v coder > /dev/null 2>&1; then
coder exp sync want "copilot-aibridge" "aibridge-proxy-setup" > /dev/null 2>&1 || true
coder exp sync start "copilot-aibridge" > /dev/null 2>&1 || true
trap 'coder exp sync complete "copilot-aibridge" > /dev/null 2>&1 || true' EXIT
fi
if [ -z "$ARG_AIBRIDGE_PROXY_AUTH_URL" ]; then
echo "ERROR: AI Bridge Proxy is enabled but no proxy auth URL provided."
exit 1
fi
if [ -z "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then
echo "ERROR: AI Bridge Proxy is enabled but no certificate path provided."
exit 1
fi
if [ ! -f "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then
echo "ERROR: AI Bridge Proxy certificate not found at $ARG_AIBRIDGE_PROXY_CERT_PATH."
echo " Ensure the aibridge-proxy module has successfully completed setup."
exit 1
fi
# Set proxy environment variables scoped to this process tree only.
# These are inherited by the agentapi/copilot process below,
# but do not affect other workspace processes, avoiding routing
# unnecessary traffic through the proxy.
export HTTPS_PROXY="$ARG_AIBRIDGE_PROXY_AUTH_URL"
export NODE_EXTRA_CA_CERTS="$ARG_AIBRIDGE_PROXY_CERT_PATH"
echo "✓ AI Bridge Proxy configured"
echo " CA certificate: $ARG_AIBRIDGE_PROXY_CERT_PATH"
}
start_agentapi() {
echo "Starting in directory: $ARG_WORKDIR"
cd "$ARG_WORKDIR"
@@ -202,6 +157,5 @@ start_agentapi() {
}
setup_github_authentication
setup_aibridge_proxy
validate_copilot_installation
start_agentapi
@@ -1,57 +0,0 @@
---
display_name: ttyd
description: Share a terminal command over the web via a Coder app
icon: ../../../../.icons/terminal.svg
verified: true
tags: [terminal, web, ttyd]
---
# ttyd
Run any command and expose it as a web-based terminal via [ttyd](https://github.com/tsl0922/ttyd). Each connection spawns a new process for the configured command. The terminal is accessible as a Coder app in the workspace UI.
```tf
module "ttyd" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/ttyd/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
command = "bash"
}
```
## Examples
### Custom command
```tf
module "ttyd" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/ttyd/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
display_name = "Shared Terminal"
command = "tmux new-session -A -s main"
share = "authenticated"
}
```
### Readonly with custom ttyd options
```tf
module "ttyd" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/ttyd/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
command = "tail -f /var/log/app.log"
writable = false
additional_args = "-t fontSize=18"
}
```
## Session Behavior
By default, each browser tab that opens the ttyd app spawns a **new process** for the configured command. Closing the tab kills that process.
To get a **persistent, shared session** that survives tab closes and allows multiple viewers, use tmux as the command (see example above). This requires tmux to be installed in the workspace image.
@@ -1,112 +0,0 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
type scriptOutput,
testRequiredVariables,
} from "~test";
function testBaseLine(output: scriptOutput) {
expect(output.exitCode).toBe(0);
const stdout = output.stdout.join("\n");
expect(stdout).toContain("Installing ttyd");
expect(stdout).toContain("Installation complete!");
expect(stdout).toContain("Starting ttyd in background...");
}
describe("ttyd", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
command: "bash",
});
it("runs with bash", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "bash",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
}, 30000);
it("runs with custom command", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "htop",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
expect(output.stdout.join("\n")).toContain("htop");
}, 30000);
it("runs with writable=false", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "bash",
writable: "false",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
}, 30000);
it("runs with subdomain=false", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "bash",
agent_name: "main",
subdomain: "false",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
}, 30000);
it("runs with additional_args", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "bash",
additional_args: "-t fontSize=18",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
expect(output.stdout.join("\n")).toContain("fontSize=18");
}, 30000);
});
-165
View File
@@ -1,165 +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."
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
variable "agent_name" {
type = string
description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)"
default = null
}
variable "slug" {
type = string
description = "The slug of the coder_app resource."
default = "ttyd"
}
variable "display_name" {
type = string
description = "The display name for the ttyd application."
default = "Web Terminal"
}
variable "port" {
type = number
description = "The port to run ttyd on."
default = 7681
}
variable "command" {
type = string
description = "The command for ttyd to run (e.g., bash, fish, htop)."
}
variable "writable" {
type = bool
description = "Allow clients to write to the terminal."
default = true
}
variable "max_clients" {
type = number
description = "Maximum number of concurrent clients (0 for unlimited)."
default = 0
}
variable "additional_args" {
type = string
description = "Additional arguments to pass to ttyd."
default = ""
}
variable "log_path" {
type = string
description = "The path to log ttyd output to. Defaults to ~/.local/state/ttyd/ttyd.log (XDG-compliant)."
default = ""
}
variable "ttyd_version" {
type = string
description = "The version of ttyd to install."
default = "1.7.7"
}
variable "share" {
type = string
description = "Who can access the app: 'owner' (workspace owner only), 'authenticated' (logged-in users), or 'public' (anyone)."
default = "owner"
validation {
condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
}
}
variable "subdomain" {
type = bool
description = <<-EOT
Determines whether the app will be accessed via its own subdomain or whether it will be accessed via a path on Coder.
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
EOT
default = true
}
variable "order" {
type = number
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "open_in" {
type = string
description = <<-EOT
Determines where the app will be opened. Valid values are "tab" and "slim-window" (default).
"tab" opens in a new tab in the same browser window.
"slim-window" opens a new browser window without navigation controls.
EOT
default = "slim-window"
validation {
condition = contains(["tab", "slim-window"], var.open_in)
error_message = "The 'open_in' variable must be one of: 'tab', 'slim-window'."
}
}
resource "coder_script" "ttyd" {
agent_id = var.agent_id
display_name = var.display_name
icon = "/icon/terminal.svg"
script = templatefile("${path.module}/run.sh", {
PORT = var.port,
COMMAND = var.command,
WRITABLE = var.writable,
MAX_CLIENTS = var.max_clients,
ADDITIONAL_ARGS = var.additional_args,
LOG_PATH = local.log_path,
VERSION = var.ttyd_version,
BASE_PATH = local.base_path,
})
run_on_start = true
}
resource "coder_app" "ttyd" {
count = var.command != "" ? 1 : 0
agent_id = var.agent_id
slug = var.slug
display_name = var.display_name
url = "http://localhost:${var.port}${local.base_path}/"
icon = "/icon/terminal.svg"
subdomain = var.subdomain
share = var.share
order = var.order
group = var.group
open_in = var.open_in
healthcheck {
url = "http://localhost:${var.port}${local.base_path}/token"
interval = 5
threshold = 6
}
}
locals {
base_path = var.subdomain ? "" : format("/@%s/%s%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name != null ? ".${var.agent_name}" : "", var.slug)
log_path = var.log_path != "" ? var.log_path : "~/.local/state/ttyd/ttyd.log"
}
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BOLD='\033[[0;1m'
if command -v ttyd &> /dev/null; then
printf "%sFound existing ttyd installation\n\n" "$${BOLD}"
else
printf "%sInstalling ttyd %s\n\n" "$${BOLD}" "${VERSION}"
ARCH=$(uname -m)
# shellcheck disable=SC2195
case "$${ARCH}" in
x86_64) BINARY="ttyd.x86_64" ;;
aarch64) BINARY="ttyd.aarch64" ;;
armv7l) BINARY="ttyd.armhf" ;;
armv6l) BINARY="ttyd.arm" ;;
*)
echo "ERROR: Unsupported architecture: $${ARCH}" >&2
exit 1
;;
esac
BIN_DIR="$${HOME}/.local/bin"
mkdir -p "$${BIN_DIR}"
export PATH="$${BIN_DIR}:$${PATH}"
TTYD_BIN="$${BIN_DIR}/ttyd"
LOCK_DIR="/tmp/ttyd-install.lock"
if [[ ! -f "$${TTYD_BIN}" ]]; then
if mkdir "$${LOCK_DIR}" 2> /dev/null; then
if [[ ! -f "$${TTYD_BIN}" ]]; then
DOWNLOAD_URL="https://github.com/tsl0922/ttyd/releases/download/${VERSION}/$${BINARY}"
printf "Downloading ttyd from %s\n" "$${DOWNLOAD_URL}"
curl -fsSL "$${DOWNLOAD_URL}" -o "$${TTYD_BIN}.tmp"
chmod +x "$${TTYD_BIN}.tmp"
mv "$${TTYD_BIN}.tmp" "$${TTYD_BIN}"
fi
rmdir "$${LOCK_DIR}" 2> /dev/null || true
else
printf "Waiting for ttyd installation to complete...\n"
while [[ -d "$${LOCK_DIR}" ]] && [[ ! -f "$${TTYD_BIN}" ]]; do
sleep 0.5
done
fi
fi
printf "Installation complete!\n\n"
fi
if [[ -z "${COMMAND}" ]]; then
printf "No command specified, skipping ttyd startup.\n"
exit 0
fi
ARGS="-p ${PORT}"
if [[ "${WRITABLE}" = "true" ]]; then
ARGS="$${ARGS} -W"
fi
if [[ "${MAX_CLIENTS}" -gt 0 ]] 2> /dev/null; then
ARGS="$${ARGS} -m ${MAX_CLIENTS}"
fi
if [[ -n "${BASE_PATH}" ]]; then
ARGS="$${ARGS} -b ${BASE_PATH}"
fi
if [[ -n "${ADDITIONAL_ARGS}" ]]; then
ARGS="$${ARGS} ${ADDITIONAL_ARGS}"
fi
TTYD_LOG_PATH="${LOG_PATH}"
TTYD_LOG_PATH="$${TTYD_LOG_PATH/#\~/$${HOME}}"
TTYD_LOG_DIR="$${TTYD_LOG_PATH%/*}"
mkdir -p "$${TTYD_LOG_DIR}"
printf "Starting ttyd in background...\n"
printf "Running: ttyd %s -- %s\n\n" "$${ARGS}" "${COMMAND}"
# shellcheck disable=SC2086
ttyd $${ARGS} -- ${COMMAND} >> "$${TTYD_LOG_PATH}" 2>&1 &
printf "Logs at %s\n" "$${TTYD_LOG_PATH}"
+1 -68
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.3.0"
version = "2.1.1"
agent_id = var.agent_id
web_app_slug = local.app_slug
@@ -62,73 +62,6 @@ module "agentapi" {
}
```
## State Persistence
AgentAPI can save and restore conversation state across workspace restarts.
This is disabled by default and requires agentapi binary >= v0.12.0.
State and PID files are stored in `$HOME/<module_dir_name>/` alongside other module files (e.g. `$HOME/.claude-module/agentapi-state.json`).
To enable:
```tf
module "agentapi" {
# ... other config
enable_state_persistence = true
}
```
To override file paths:
```tf
module "agentapi" {
# ... other config
state_file_path = "/custom/path/state.json"
pid_file_path = "/custom/path/agentapi.pid"
}
```
## Boundary (Network Filtering)
The agentapi module supports optional [Agent Boundaries](https://coder.com/docs/ai-coder/agent-boundaries)
for network filtering. When enabled, the module sets up a `AGENTAPI_BOUNDARY_PREFIX` environment
variable that points to a wrapper script. Agent modules should use this prefix in their
start scripts to run the agent process through boundary.
Boundary requires a `config.yaml` file with your allowlist, jail type, proxy port, and log
level. See the [Agent Boundaries documentation](https://coder.com/docs/ai-coder/agent-boundaries)
for configuration details.
To enable:
```tf
module "agentapi" {
# ... other config
enable_boundary = true
boundary_config_path = "/home/coder/.config/coder_boundary/config.yaml"
# Optional: install boundary binary instead of using coder subcommand
# use_boundary_directly        = true
# boundary_version              = "0.6.0"
# compile_boundary_from_source  = false
}
```
### Contract for agent modules
When `enable_boundary = true`, the agentapi module exports `AGENTAPI_BOUNDARY_PREFIX`
as an environment variable pointing to a wrapper script. Agent module start scripts
should check for this variable and use it to prefix the agent command:
```bash
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
agentapi server -- "${AGENTAPI_BOUNDARY_PREFIX}" my-agent "${ARGS[@]}" &
else
agentapi server -- my-agent "${ARGS[@]}" &
fi
```
This ensures only the agent process is sandboxed while agentapi itself runs unrestricted.
## For module developers
For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
@@ -1,108 +0,0 @@
mock_provider "coder" {}
variables {
agent_id = "test-agent"
web_app_icon = "/icon/test.svg"
web_app_display_name = "Test"
web_app_slug = "test"
cli_app_display_name = "Test CLI"
cli_app_slug = "test-cli"
start_script = "echo test"
module_dir_name = ".test-module"
}
run "default_values" {
command = plan
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should default to false"
}
assert {
condition = var.state_file_path == ""
error_message = "state_file_path should default to empty string"
}
assert {
condition = var.pid_file_path == ""
error_message = "pid_file_path should default to empty string"
}
# Verify start script contains state persistence ARG_ vars.
assert {
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi.script))
error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE"
}
assert {
condition = can(regex("ARG_STATE_FILE_PATH", coder_script.agentapi.script))
error_message = "start script should contain ARG_STATE_FILE_PATH"
}
assert {
condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi.script))
error_message = "start script should contain ARG_PID_FILE_PATH"
}
# Verify shutdown script contains PID-related ARG_ vars.
assert {
condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain ARG_PID_FILE_PATH"
}
assert {
condition = can(regex("ARG_MODULE_DIR_NAME", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain ARG_MODULE_DIR_NAME"
}
assert {
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain ARG_ENABLE_STATE_PERSISTENCE"
}
}
run "state_persistence_disabled" {
command = plan
variables {
enable_state_persistence = false
}
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should be false"
}
# Even when disabled, the ARG_ vars should still be in the script
# (the shell script handles the conditional logic).
assert {
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE='false'", coder_script.agentapi.script))
error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE='false'"
}
}
run "custom_paths" {
command = plan
variables {
state_file_path = "/custom/state.json"
pid_file_path = "/custom/agentapi.pid"
}
assert {
condition = can(regex("/custom/state.json", coder_script.agentapi.script))
error_message = "start script should contain custom state_file_path"
}
assert {
condition = can(regex("/custom/agentapi.pid", coder_script.agentapi.script))
error_message = "start script should contain custom pid_file_path"
}
# Verify custom paths also appear in shutdown script.
assert {
condition = can(regex("/custom/agentapi.pid", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain custom pid_file_path"
}
}
+2 -310
View File
@@ -258,76 +258,11 @@ describe("agentapi", async () => {
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
});
test("state-persistence-disabled", async () => {
const { id } = await setup({
moduleVariables: {
enable_state_persistence: "false",
},
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
// PID file should always be exported
expect(mockLog).toContain("AGENTAPI_PID_FILE:");
// State vars should NOT be present when disabled
expect(mockLog).not.toContain("AGENTAPI_STATE_FILE:");
expect(mockLog).not.toContain("AGENTAPI_SAVE_STATE:");
expect(mockLog).not.toContain("AGENTAPI_LOAD_STATE:");
});
test("state-persistence-custom-paths", async () => {
const { id } = await setup({
moduleVariables: {
enable_state_persistence: "true",
state_file_path: "/home/coder/custom/state.json",
pid_file_path: "/home/coder/custom/agentapi.pid",
},
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(mockLog).toContain(
"AGENTAPI_STATE_FILE: /home/coder/custom/state.json",
);
expect(mockLog).toContain(
"AGENTAPI_PID_FILE: /home/coder/custom/agentapi.pid",
);
});
test("state-persistence-default-paths", async () => {
const { id } = await setup({
moduleVariables: {
enable_state_persistence: "true",
},
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(mockLog).toContain(
`AGENTAPI_STATE_FILE: /home/coder/${moduleDirName}/agentapi-state.json`,
);
expect(mockLog).toContain(
`AGENTAPI_PID_FILE: /home/coder/${moduleDirName}/agentapi.pid`,
);
expect(mockLog).toContain("AGENTAPI_SAVE_STATE: true");
expect(mockLog).toContain("AGENTAPI_LOAD_STATE: true");
});
describe("shutdown script", async () => {
const setupMocks = async (
containerId: string,
agentapiPreset: string,
httpCode: number = 204,
pidFilePath: string = "",
) => {
const agentapiMock = await loadTestFile(
import.meta.dir,
@@ -350,11 +285,10 @@ describe("agentapi", async () => {
content: coderMock,
});
const pidFileEnv = pidFilePath ? `AGENTAPI_PID_FILE=${pidFilePath}` : "";
await execContainer(containerId, [
"bash",
"-c",
`PRESET=${agentapiPreset} ${pidFileEnv} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
`PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
]);
await execContainer(containerId, [
@@ -369,25 +303,12 @@ describe("agentapi", async () => {
const runShutdownScript = async (
containerId: string,
taskId: string = "test-task",
pidFilePath: string = "",
enableStatePersistence: string = "false",
) => {
const shutdownScript = await loadTestFile(
import.meta.dir,
"../scripts/agentapi-shutdown.sh",
);
const libScript = await loadTestFile(
import.meta.dir,
"../scripts/lib.sh",
);
await writeExecutable({
containerId,
filePath: "/tmp/agentapi-lib.sh",
content: libScript,
});
await writeExecutable({
containerId,
filePath: "/tmp/shutdown.sh",
@@ -397,7 +318,7 @@ describe("agentapi", async () => {
return await execContainer(containerId, [
"bash",
"-c",
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 ARG_PID_FILE_PATH=${pidFilePath} ARG_ENABLE_STATE_PERSISTENCE=${enableStatePersistence} CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
]);
};
@@ -413,7 +334,6 @@ describe("agentapi", async () => {
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Retrieved 5 messages for log snapshot");
expect(result.stdout).toContain("Log snapshot posted successfully");
expect(result.stdout).not.toContain("Log snapshot capture failed");
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
@@ -489,233 +409,5 @@ describe("agentapi", async () => {
"Log snapshot endpoint not supported by this Coder version",
);
});
test("sends SIGUSR1 before shutdown", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const pidFile = "/tmp/agentapi-test.pid";
await setupMocks(id, "normal", 204, pidFile);
const result = await runShutdownScript(id, "test-task", pidFile, "true");
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI");
const sigusr1Log = await readFileContainer(id, "/tmp/sigusr1-received");
expect(sigusr1Log).toContain("SIGUSR1 received");
});
test("handles missing PID file gracefully", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "normal");
// Pass a non-existent PID file path with persistence enabled to
// exercise the SIGUSR1 path with a missing PID.
const result = await runShutdownScript(
id,
"test-task",
"/tmp/nonexistent.pid",
"true",
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Shutdown complete");
});
test("sends SIGTERM even when snapshot fails", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const pidFile = "/tmp/agentapi-test.pid";
// HTTP 500 will cause snapshot to fail
await setupMocks(id, "normal", 500, pidFile);
const result = await runShutdownScript(id, "test-task", pidFile, "true");
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"Log snapshot capture failed, continuing shutdown",
);
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
test("resolves default PID path from MODULE_DIR_NAME", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
// Start mock with PID file at the module_dir_name default location.
const defaultPidPath = `/home/coder/${moduleDirName}/agentapi.pid`;
await setupMocks(id, "normal", 204, defaultPidPath);
// Don't pass pidFilePath - let shutdown script compute it from MODULE_DIR_NAME.
const shutdownScript = await loadTestFile(
import.meta.dir,
"../scripts/agentapi-shutdown.sh",
);
const libScript = await loadTestFile(
import.meta.dir,
"../scripts/lib.sh",
);
await writeExecutable({
containerId: id,
filePath: "/tmp/agentapi-lib.sh",
content: libScript,
});
await writeExecutable({
containerId: id,
filePath: "/tmp/shutdown.sh",
content: shutdownScript,
});
const result = await execContainer(id, [
"bash",
"-c",
`ARG_TASK_ID=test-task ARG_AGENTAPI_PORT=3284 ARG_MODULE_DIR_NAME=${moduleDirName} ARG_ENABLE_STATE_PERSISTENCE=true CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI");
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
test("skips SIGUSR1 when no PID file available", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "normal", 204);
// No pidFilePath and no MODULE_DIR_NAME, so no PID file can be resolved.
const result = await runShutdownScript(id, "test-task", "", "false");
expect(result.exitCode).toBe(0);
// Should not send SIGUSR1 or SIGTERM (no PID to signal).
expect(result.stdout).not.toContain("Sending SIGUSR1");
expect(result.stdout).not.toContain("Sending SIGTERM");
expect(result.stdout).toContain("Shutdown complete");
});
test("skips SIGUSR1 when state persistence disabled", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const pidFile = "/tmp/agentapi-test.pid";
await setupMocks(id, "normal", 204, pidFile);
// PID file exists but state persistence is disabled.
const result = await runShutdownScript(id, "test-task", pidFile, "false");
expect(result.exitCode).toBe(0);
// Should NOT send SIGUSR1 (persistence disabled).
expect(result.stdout).not.toContain("Sending SIGUSR1");
// Should still send SIGTERM (graceful shutdown always happens).
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
});
describe("boundary", async () => {
test("boundary-disabled-by-default", async () => {
const { id } = await setup();
await execModuleScript(id);
await expectAgentAPIStarted(id);
// Config file should NOT exist when boundary is disabled
const configCheck = await execContainer(id, [
"bash",
"-c",
"test -f /home/coder/.config/coder_boundary/config.yaml && echo exists || echo missing",
]);
expect(configCheck.stdout.trim()).toBe("missing");
// AGENTAPI_BOUNDARY_PREFIX should NOT be in the mock log
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(mockLog).not.toContain("AGENTAPI_BOUNDARY_PREFIX:");
});
test("boundary-enabled", async () => {
const { id } = await setup({
moduleVariables: {
enable_boundary: "true",
boundary_config_path: "/tmp/test-boundary.yaml",
},
});
// Write boundary config to the path before running the module
await execContainer(id, [
"bash",
"-c",
`cat > /tmp/test-boundary.yaml <<'EOF'
jail_type: landjail
proxy_port: 8087
log_level: warn
allowlist:
- "domain=api.example.com"
EOF`,
]);
// Add mock coder binary for boundary setup
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: `#!/bin/bash
if [ "$1" = "boundary" ]; then
shift; shift; exec "$@"
fi
echo "mock coder"`,
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
// Verify the config file exists at the specified path
const config = await readFileContainer(id, "/tmp/test-boundary.yaml");
expect(config).toContain("jail_type: landjail");
expect(config).toContain("proxy_port: 8087");
expect(config).toContain("domain=api.example.com");
// AGENTAPI_BOUNDARY_PREFIX should be exported
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(mockLog).toContain("AGENTAPI_BOUNDARY_PREFIX:");
// E2E: start script should have used the wrapper
const startLog = await readFileContainer(
id,
"/home/coder/test-agentapi-start.log",
);
expect(startLog).toContain("Starting with boundary:");
});
test("boundary-enabled-no-coder-binary", async () => {
const { id } = await setup({
moduleVariables: {
enable_boundary: "true",
boundary_config_path: "/tmp/test-boundary.yaml",
},
});
// Write boundary config
await execContainer(id, [
"bash",
"-c",
`cat > /tmp/test-boundary.yaml <<'EOF'
jail_type: landjail
proxy_port: 8087
log_level: warn
EOF`,
]);
// Remove coder binary to simulate it not being available
await execContainer(
id,
[
"bash",
"-c",
"rm -f /usr/bin/coder /usr/local/bin/coder 2>/dev/null; hash -r",
],
["--user", "root"],
);
const resp = await execModuleScript(id);
// Script should fail because coder binary is required
expect(resp.exitCode).not.toBe(0);
const scriptLog = await readFileContainer(id, "/home/coder/script.log");
expect(scriptLog).toContain("Boundary cannot be enabled");
});
});
});
-71
View File
@@ -164,60 +164,6 @@ variable "module_dir_name" {
description = "Name of the subdirectory in the home directory for module files."
}
variable "enable_boundary" {
type = bool
description = "Enable coder boundary for network filtering. Requires boundary_config to be set."
default = false
}
variable "boundary_config_path" {
type = string
description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var."
default = ""
}
variable "boundary_version" {
type = string
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release. When compile_boundary_from_source is true, a valid git reference should be provided (tag, commit, branch)."
default = "latest"
}
variable "compile_boundary_from_source" {
type = bool
description = "Whether to compile boundary from source instead of using the official install script."
default = false
}
variable "use_boundary_directly" {
type = bool
description = "Whether to use boundary binary directly instead of coder boundary subcommand. When false (default), uses coder boundary subcommand. When true, installs and uses boundary binary from release."
default = false
}
variable "enable_state_persistence" {
type = bool
description = "Enable AgentAPI conversation state persistence across restarts."
default = false
}
variable "state_file_path" {
type = string
description = "Path to the AgentAPI state file. Defaults to $HOME/<module_dir_name>/agentapi-state.json."
default = ""
}
variable "pid_file_path" {
type = string
description = "Path to the AgentAPI PID file. Defaults to $HOME/<module_dir_name>/agentapi.pid."
default = ""
}
resource "coder_env" "boundary_config" {
count = var.enable_boundary && var.boundary_config_path != "" ? 1 : 0
agent_id = var.agent_id
name = "BOUNDARY_CONFIG"
value = var.boundary_config_path
}
locals {
# we always trim the slash for consistency
@@ -236,8 +182,6 @@ locals {
agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat"
main_script = file("${path.module}/scripts/main.sh")
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
lib_script = file("${path.module}/scripts/lib.sh")
boundary_script = file("${path.module}/scripts/boundary.sh")
}
resource "coder_script" "agentapi" {
@@ -251,10 +195,6 @@ resource "coder_script" "agentapi" {
echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh
chmod +x /tmp/main.sh
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
echo -n '${base64encode(local.boundary_script)}' | base64 -d > /tmp/agentapi-boundary.sh
chmod +x /tmp/agentapi-boundary.sh
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \
@@ -269,13 +209,6 @@ resource "coder_script" "agentapi" {
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
ARG_COMPILE_BOUNDARY_FROM_SOURCE='${var.compile_boundary_from_source}' \
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
ARG_STATE_FILE_PATH='${var.state_file_path}' \
ARG_PID_FILE_PATH='${var.pid_file_path}' \
/tmp/main.sh
EOT
run_on_start = true
@@ -292,14 +225,10 @@ resource "coder_script" "agentapi_shutdown" {
echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh
chmod +x /tmp/agentapi-shutdown.sh
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
ARG_PID_FILE_PATH='${var.pid_file_path}' \
/tmp/agentapi-shutdown.sh
EOT
}
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
# AgentAPI shutdown script.
#
# Performs a graceful shutdown of AgentAPI: sends SIGUSR1 to trigger state save,
# captures the last 10 messages as a log snapshot posted to the Coder instance,
# then sends SIGTERM for graceful termination.
# Captures the last 10 messages from AgentAPI and posts them to Coder instance
# as a snapshot. This script is called during workspace shutdown to access
# conversation history for paused tasks.
set -euo pipefail
@@ -11,13 +11,6 @@ set -euo pipefail
readonly TASK_ID="${ARG_TASK_ID:-}"
readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}"
readonly ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
readonly MODULE_DIR_NAME="${ARG_MODULE_DIR_NAME:-}"
readonly PID_FILE_PATH="${ARG_PID_FILE_PATH:-${MODULE_DIR_NAME:+$HOME/$MODULE_DIR_NAME/agentapi.pid}}"
# Source shared utilities (written by the coder_script wrapper).
# shellcheck source=lib.sh
source /tmp/agentapi-lib.sh
# Runtime environment variables.
readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}"
@@ -27,7 +20,7 @@ readonly CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN:-}"
readonly MAX_PAYLOAD_SIZE=65536 # 64KB
readonly MAX_MESSAGE_CONTENT=57344 # 56KB
readonly MAX_MESSAGES=10
readonly FETCH_TIMEOUT=10
readonly FETCH_TIMEOUT=5
readonly POST_TIMEOUT=10
log() {
@@ -145,45 +138,44 @@ post_task_log_snapshot() {
capture_task_log_snapshot() {
if [[ -z $TASK_ID ]]; then
log "No task ID, skipping log snapshot"
return 0
exit 0
fi
if [[ -z $CODER_AGENT_URL ]]; then
error "CODER_AGENT_URL not set, cannot capture log snapshot"
return 1
exit 1
fi
if [[ -z $CODER_AGENT_TOKEN ]]; then
error "CODER_AGENT_TOKEN not set, cannot capture log snapshot"
return 1
exit 1
fi
if ! command -v jq > /dev/null 2>&1; then
error "jq not found, cannot capture log snapshot"
return 1
exit 1
fi
if ! command -v curl > /dev/null 2>&1; then
error "curl not found, cannot capture log snapshot"
return 1
exit 1
fi
# Not local, must be visible to the EXIT trap after the function returns.
tmpdir=$(mktemp -d)
trap 'trap - EXIT; rm -rf "$tmpdir"' EXIT
trap 'rm -rf "$tmpdir"' EXIT
local payload_file="${tmpdir}/payload.json"
if ! fetch_and_build_messages_payload "$payload_file"; then
error "Cannot capture log snapshot without messages"
return 1
exit 1
fi
local message_count
message_count=$(jq '.messages | length' < "$payload_file")
if ((message_count == 0)); then
log "No messages for log snapshot"
return 0
exit 0
fi
log "Retrieved $message_count messages for log snapshot"
@@ -191,7 +183,7 @@ capture_task_log_snapshot() {
# Ensure payload fits within size limit.
if ! truncate_messages_payload_to_size "$payload_file" "$MAX_PAYLOAD_SIZE"; then
error "Failed to truncate payload to size limit"
return 1
exit 1
fi
local final_size final_count
@@ -201,60 +193,19 @@ capture_task_log_snapshot() {
if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then
error "Log snapshot capture failed"
return 1
exit 1
fi
}
main() {
log "Shutting down AgentAPI"
local agentapi_pid=
if [[ -n $PID_FILE_PATH ]]; then
agentapi_pid=$(cat "$PID_FILE_PATH" 2> /dev/null || echo "")
fi
# State persistence is only enabled when the binary supports it (>= v0.12.0).
# The default SIGUSR1 disposition on Linux is terminate, so sending it to an
# older binary would kill the process.
local state_persistence=0
if [[ $ENABLE_STATE_PERSISTENCE == true ]] && version_at_least 0.12.0 "$(agentapi_version)"; then
state_persistence=1
fi
# Trigger state save via SIGUSR1 (saves without exiting).
if ((state_persistence)) && [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then
log "Sending SIGUSR1 to AgentAPI (pid $agentapi_pid) to save state"
kill -USR1 "$agentapi_pid" || true
# Allow time for state save to complete before proceeding.
sleep 1
fi
# Capture log snapshot for task history.
if [[ $TASK_LOG_SNAPSHOT == true ]]; then
# Subshell scopes the EXIT trap (tmpdir cleanup) inside
# capture_task_log_snapshot and preserves set -e, which
# || would otherwise disable for the function body.
(capture_task_log_snapshot) || log "Log snapshot capture failed, continuing shutdown"
capture_task_log_snapshot
else
log "Log snapshot disabled, skipping"
fi
# Graceful termination.
if [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then
log "Sending SIGTERM to AgentAPI (pid $agentapi_pid)"
kill -TERM "$agentapi_pid" 2> /dev/null || true
# Wait for process to exit to guarantee a clean shutdown.
local elapsed=0
while kill -0 "$agentapi_pid" 2> /dev/null; do
sleep 1
((elapsed++)) || true
if ((elapsed % 5 == 0)); then
log "Warning: AgentAPI (pid $agentapi_pid) still running after ${elapsed}s"
fi
done
fi
log "Shutdown complete"
}
@@ -1,95 +0,0 @@
#!/bin/bash
# boundary.sh - Boundary installation and setup for agentapi module.
# Sourced by main.sh when ENABLE_BOUNDARY=true.
# Exports AGENTAPI_BOUNDARY_PREFIX for use by module start scripts.
validate_boundary_subcommand() {
if command_exists coder; then
if coder boundary --help > /dev/null 2>&1; then
return 0
else
echo "Error: 'coder' command found but does not support 'boundary' subcommand. Please enable install_boundary."
exit 1
fi
else
echo "Error: ENABLE_BOUNDARY=true, but 'coder' command not found. Boundary cannot be enabled." >&2
exit 1
fi
}
# Install boundary binary if needed.
# Uses one of three strategies:
# 1. Compile from source (compile_boundary_from_source=true)
# 2. Install from release (use_boundary_directly=true)
# 3. Use coder boundary subcommand (default, no installation needed)
install_boundary() {
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]; then
echo "Compiling boundary from source (version: ${BOUNDARY_VERSION})"
# Remove existing boundary directory to allow re-running safely
if [ -d boundary ]; then
rm -rf boundary
fi
echo "Cloning boundary repository"
git clone https://github.com/coder/boundary.git
cd boundary || exit 1
git checkout "${BOUNDARY_VERSION}"
make build
sudo cp boundary /usr/local/bin/
sudo chmod +x /usr/local/bin/boundary
cd - || exit 1
elif [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
echo "Installing boundary using official install script (version: ${BOUNDARY_VERSION})"
curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "${BOUNDARY_VERSION}"
else
validate_boundary_subcommand
echo "Using coder boundary subcommand (provided by Coder)"
fi
}
# Set up boundary: install, write config, create wrapper script.
# Exports AGENTAPI_BOUNDARY_PREFIX pointing to the wrapper script.
setup_boundary() {
local module_path="$1"
echo "Setting up coder boundary..."
# Install boundary binary if needed
install_boundary
# Determine which boundary command to use and create wrapper script
BOUNDARY_WRAPPER_SCRIPT="$module_path/boundary-wrapper.sh"
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ] || [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
# Use boundary binary directly (from compilation or release installation)
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF'
#!/usr/bin/env bash
set -euo pipefail
exec boundary -- "$@"
WRAPPER_EOF
else
# Use coder boundary subcommand (default)
# Copy coder binary to strip CAP_NET_ADMIN capabilities.
# This is necessary because boundary doesn't work with privileged binaries
# (you can't launch privileged binaries inside network namespaces unless
# you have sys_admin).
CODER_NO_CAPS="$module_path/coder-no-caps"
if ! cp "$(which coder)" "$CODER_NO_CAPS"; then
echo "Error: Failed to copy coder binary to ${CODER_NO_CAPS}. Boundary cannot be enabled." >&2
exit 1
fi
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/coder-no-caps" boundary -- "$@"
WRAPPER_EOF
fi
chmod +x "${BOUNDARY_WRAPPER_SCRIPT}"
export AGENTAPI_BOUNDARY_PREFIX="${BOUNDARY_WRAPPER_SCRIPT}"
echo "Boundary wrapper configured: ${AGENTAPI_BOUNDARY_PREFIX}"
}
@@ -1,45 +0,0 @@
#!/usr/bin/env bash
# Shared utility functions for agentapi module scripts.
# version_at_least checks if an actual version meets a minimum requirement.
# Non-semver strings (e.g. "latest", custom builds) always pass.
# Usage: version_at_least <minimum> <actual>
# version_at_least v0.12.0 v0.10.0 # returns 1 (false)
# version_at_least v0.12.0 v0.12.0 # returns 0 (true)
# version_at_least v0.12.0 latest # returns 0 (true)
version_at_least() {
local min="${1#v}"
local actual="${2#v}"
# Non-semver versions pass through (e.g. "latest", custom builds).
if ! [[ $actual =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
return 0
fi
local act_major="${BASH_REMATCH[1]}"
local act_minor="${BASH_REMATCH[2]}"
local act_patch="${BASH_REMATCH[3]}"
[[ $min =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 0
local min_major="${BASH_REMATCH[1]}"
local min_minor="${BASH_REMATCH[2]}"
local min_patch="${BASH_REMATCH[3]}"
# Arithmetic expressions set exit status: 0 (true) if non-zero, 1 (false) if zero.
if ((act_major != min_major)); then
((act_major > min_major))
return
fi
if ((act_minor != min_minor)); then
((act_minor > min_minor))
return
fi
((act_patch >= min_patch))
}
# agentapi_version returns the installed agentapi binary version (e.g. "0.11.8").
# Returns empty string if the binary is missing or doesn't support --version.
agentapi_version() {
agentapi --version 2> /dev/null | awk '{print $NF}'
}
@@ -16,18 +16,8 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
TASK_ID="${ARG_TASK_ID:-}"
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
ENABLE_BOUNDARY="${ARG_ENABLE_BOUNDARY:-false}"
BOUNDARY_VERSION="${ARG_BOUNDARY_VERSION:-latest}"
COMPILE_BOUNDARY_FROM_SOURCE="${ARG_COMPILE_BOUNDARY_FROM_SOURCE:-false}"
USE_BOUNDARY_DIRECTLY="${ARG_USE_BOUNDARY_DIRECTLY:-false}"
ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}"
PID_FILE_PATH="${ARG_PID_FILE_PATH:-}"
set +o nounset
# shellcheck source=lib.sh
source /tmp/agentapi-lib.sh
command_exists() {
command -v "$1" > /dev/null 2>&1
}
@@ -113,30 +103,8 @@ export LC_ALL=en_US.UTF-8
cd "${WORKDIR}"
# Set up boundary if enabled
export AGENTAPI_BOUNDARY_PREFIX=""
if [ "${ENABLE_BOUNDARY}" = "true" ]; then
# shellcheck source=boundary.sh
source /tmp/agentapi-boundary.sh
setup_boundary "$module_path"
fi
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
export AGENTAPI_ALLOWED_HOSTS="*"
export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}"
# Only set state env vars when persistence is enabled and the binary supports
# it. State persistence requires agentapi >= v0.12.0.
if [ "${ENABLE_STATE_PERSISTENCE}" = "true" ]; then
actual_version=$(agentapi_version)
if version_at_least 0.12.0 "$actual_version"; then
export AGENTAPI_STATE_FILE="${STATE_FILE_PATH:-$module_path/agentapi-state.json}"
export AGENTAPI_SAVE_STATE="true"
export AGENTAPI_LOAD_STATE="true"
else
echo "Warning: State persistence requires agentapi >= v0.12.0 (current: ${actual_version:-unknown}), skipping."
fi
fi
nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" &
"$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}"
@@ -3,26 +3,8 @@
// Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port]
const http = require("http");
const fs = require("fs");
const port = process.argv[2] || 3284;
// Write PID file for shutdown script.
if (process.env.AGENTAPI_PID_FILE) {
const path = require("path");
fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), {
recursive: true,
});
fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid));
}
// Handle SIGUSR1 (state save signal from shutdown script).
process.on("SIGUSR1", () => {
fs.writeFileSync(
"/tmp/sigusr1-received",
`SIGUSR1 received at ${Date.now()}\n`,
);
});
// Parse messages from environment or use default
let messages = [];
if (process.env.MESSAGES) {
@@ -6,50 +6,12 @@ const args = process.argv.slice(2);
const portIdx = args.findIndex((arg) => arg === "--port") + 1;
const port = portIdx ? args[portIdx] : 3284;
if (args.includes("--version")) {
console.log("agentapi version 99.99.99");
process.exit(0);
}
console.log(`starting server on port ${port}`);
fs.writeFileSync(
"/home/coder/agentapi-mock.log",
`AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`,
);
// Log state persistence env vars.
for (const v of [
"AGENTAPI_STATE_FILE",
"AGENTAPI_PID_FILE",
"AGENTAPI_SAVE_STATE",
"AGENTAPI_LOAD_STATE",
]) {
if (process.env[v]) {
fs.appendFileSync(
"/home/coder/agentapi-mock.log",
`\n${v}: ${process.env[v]}`,
);
}
}
// Log boundary env vars.
for (const v of ["AGENTAPI_BOUNDARY_PREFIX"]) {
if (process.env[v]) {
fs.appendFileSync(
"/home/coder/agentapi-mock.log",
`\n${v}: ${process.env[v]}`,
);
}
}
// Write PID file for shutdown script.
if (process.env.AGENTAPI_PID_FILE) {
const path = require("path");
fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), {
recursive: true,
});
fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid));
}
http
.createServer(function (_request, response) {
response.writeHead(200);
+3 -13
View File
@@ -17,16 +17,6 @@ if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then
export AGENTAPI_CHAT_BASE_PATH
fi
# Use boundary wrapper if configured by agentapi module.
# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh
# and points to a wrapper script that runs the command through coder boundary.
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
echo "Starting with boundary: ${AGENTAPI_BOUNDARY_PREFIX}" >> /home/coder/test-agentapi-start.log
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
"${AGENTAPI_BOUNDARY_PREFIX}" bash -c aiagent \
> "$log_file_path" 2>&1
else
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
bash -c aiagent \
> "$log_file_path" 2>&1
fi
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
bash -c aiagent \
> "$log_file_path" 2>&1
@@ -1,89 +0,0 @@
---
display_name: AI Bridge Proxy
description: Configure a workspace to route AI tool traffic through AI Bridge via AI Bridge Proxy.
icon: ../../../../.icons/coder.svg
verified: true
tags: [helper, aibridge]
---
# AI Bridge Proxy
This module configures a Coder workspace to use [AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy).
It downloads the proxy's CA certificate from the Coder deployment and provides Terraform outputs (`proxy_auth_url` and `cert_path`) that tool-specific modules can use to route their traffic through the proxy.
```tf
module "aibridge-proxy" {
source = "registry.coder.com/coder/aibridge-proxy/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
proxy_url = "https://aiproxy.example.com"
}
```
> [!NOTE]
> AI Bridge Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance).
> See the [AI Bridge Proxy setup guide](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup) for details on configuring the proxy on your Coder deployment.
## How it works
[AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy) is an HTTP proxy that intercepts traffic to AI providers and forwards it through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge), enabling centralized LLM management, governance, and cost tracking.
Any process with the proxy environment variables set will route **all** its traffic through the proxy.
This module **does not** set proxy environment variables globally on the workspace.
Instead, it provides Terraform outputs (`proxy_auth_url` and `cert_path`) that tool-specific modules consume to configure proxy routing.
See the [Copilot module](https://registry.coder.com/modules/coder-labs/copilot) for a working integration example.
It is recommended that tool modules scope the proxy environment variables to their own process rather than setting them globally on the workspace, to avoid routing unnecessary traffic through the proxy.
> [!WARNING]
> If the setup script fails (e.g. the proxy is unreachable), the workspace will still start but the agent will report a startup script error.
> Tools that depend on the proxy will not work until the issue is resolved. Check the workspace build logs for details.
## Startup Coordination
When used with tool-specific modules (e.g. [Copilot](https://registry.coder.com/modules/coder-labs/copilot)),
the setup script signals completion via [`coder exp sync`](https://coder.com/docs/admin/templates/startup-coordination) so dependent modules can wait for the `aibridge-proxy` module to complete before starting.
Dependent modules are unblocked once the setup script finishes, regardless of success or failure.
If the setup fails, dependent modules are expected to detect the failure and handle the error accordingly.
To enable startup coordination, set `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment:
```hcl
env = [
"CODER_AGENT_TOKEN=${coder_agent.main.token}",
"CODER_AGENT_SOCKET_SERVER_ENABLED=true",
]
```
> [!NOTE]
> [Startup coordination](https://coder.com/docs/admin/templates/startup-coordination) requires Coder >= v2.30.
> Without it, the sync calls are skipped gracefully but dependent modules may fail to start if the `aibridge-proxy` setup has not completed in time.
## Examples
### Custom certificate path
```tf
module "aibridge-proxy" {
source = "registry.coder.com/coder/aibridge-proxy/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
proxy_url = "https://aiproxy.example.com"
cert_path = "/home/coder/.certs/aibridge-proxy-ca.pem"
}
```
### Proxy with custom port
For deployments where the proxy is accessed directly on a configured port.
See [security considerations](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup#security-considerations) for network access guidelines.
```tf
module "aibridge-proxy" {
source = "registry.coder.com/coder/aibridge-proxy/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
proxy_url = "http://internal-proxy:8888"
}
```
@@ -1,254 +0,0 @@
import { serve } from "bun";
import {
afterEach,
beforeAll,
describe,
expect,
it,
setDefaultTimeout,
} from "bun:test";
import {
execContainer,
findResourceInstance,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => {
cleanupFunctions.push(cleanup);
};
afterEach(async () => {
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
cleanupFunctions = [];
for (const cleanup of cleanupFnsCopy) {
try {
await cleanup();
} catch (error) {
console.error("Error during cleanup:", error);
}
}
});
const FAKE_CERT =
"-----BEGIN CERTIFICATE-----\nMIIBfakecert\n-----END CERTIFICATE-----\n";
// Runs terraform apply to render the setup script, then starts a Docker
// container where we can execute it against a mock server.
const setupContainer = async (vars: Record<string, string> = {}) => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
proxy_url: "https://aiproxy.example.com",
...vars,
});
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("lorello/alpine-bash");
registerCleanup(async () => {
await removeContainer(id);
});
return { id, instance };
};
// Starts a mock HTTP server that simulates the Coder API certificate endpoint.
// Returns the server and its base URL.
const setupServer = (handler: (req: Request) => Response) => {
const server = serve({
fetch: handler,
port: 0,
});
registerCleanup(async () => {
server.stop();
});
return {
server,
// Base URL without trailing slash
url: server.url.toString().slice(0, -1),
};
};
setDefaultTimeout(30 * 1000);
describe("aibridge-proxy", () => {
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
// Verify that agent_id and proxy_url are required.
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
proxy_url: "https://aiproxy.example.com",
});
it("downloads the CA certificate successfully", async () => {
let receivedToken = "";
const { url } = setupServer((req) => {
const reqUrl = new URL(req.url);
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
receivedToken = req.headers.get("Coder-Session-Token") || "";
return new Response(FAKE_CERT, {
status: 200,
headers: { "Content-Type": "application/x-pem-file" },
});
}
return new Response("not found", { status: 404 });
});
const { id, instance } = await setupContainer();
// Override ACCESS_URL and SESSION_TOKEN at runtime to point at the mock server.
const exec = await execContainer(id, [
"env",
`ACCESS_URL=${url}`,
"SESSION_TOKEN=test-session-token-123",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy CA certificate saved to /tmp/aibridge-proxy/ca-cert.pem",
);
// Verify the cert was written to the default path.
const certContent = await execContainer(id, [
"cat",
"/tmp/aibridge-proxy/ca-cert.pem",
]);
expect(certContent.stdout).toContain("BEGIN CERTIFICATE");
// Verify the session token was sent in the request header.
expect(receivedToken).toBe("test-session-token-123");
});
it("fails when the server is unreachable", async () => {
const { id, instance } = await setupContainer();
// Port 9999 has nothing listening, so curl will fail to connect.
const exec = await execContainer(id, [
"env",
"ACCESS_URL=http://localhost:9999",
"SESSION_TOKEN=mock-token",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).not.toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy setup failed: could not connect to",
);
});
it("fails when the server returns a non-200 status", async () => {
const { url } = setupServer(() => {
return new Response("not found", { status: 404 });
});
const { id, instance } = await setupContainer();
const exec = await execContainer(id, [
"env",
`ACCESS_URL=${url}`,
"SESSION_TOKEN=mock-token",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).not.toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy setup failed: unexpected response",
);
});
it("fails when the server returns an empty response", async () => {
const { url } = setupServer((req) => {
const reqUrl = new URL(req.url);
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
return new Response("", { status: 200 });
}
return new Response("not found", { status: 404 });
});
const { id, instance } = await setupContainer();
const exec = await execContainer(id, [
"env",
`ACCESS_URL=${url}`,
"SESSION_TOKEN=mock-token",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).not.toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy setup failed: downloaded certificate is empty.",
);
});
it("saves the certificate to a custom path", async () => {
const { url } = setupServer((req) => {
const reqUrl = new URL(req.url);
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
return new Response(FAKE_CERT, {
status: 200,
headers: { "Content-Type": "application/x-pem-file" },
});
}
return new Response("not found", { status: 404 });
});
// Pass a custom cert_path to terraform apply so the script uses it.
const { id, instance } = await setupContainer({
cert_path: "/tmp/custom/certs/proxy-ca.pem",
});
const exec = await execContainer(id, [
"env",
`ACCESS_URL=${url}`,
"SESSION_TOKEN=mock-token",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy CA certificate saved to /tmp/custom/certs/proxy-ca.pem",
);
const certContent = await execContainer(id, [
"cat",
"/tmp/custom/certs/proxy-ca.pem",
]);
expect(certContent.stdout).toContain("BEGIN CERTIFICATE");
});
it("does not create global proxy env vars via coder_env", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
proxy_url: "https://aiproxy.example.com",
});
// Proxy env vars should NOT be set globally via coder_env.
// They are intended to be scoped to specific tool processes.
const proxyEnvVarNames = [
"HTTP_PROXY",
"HTTPS_PROXY",
"NODE_EXTRA_CA_CERTS",
"SSL_CERT_FILE",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
];
const proxyEnvVars = state.resources.filter(
(r) =>
r.type === "coder_env" &&
r.instances.some((i) =>
proxyEnvVarNames.includes(i.attributes.name as string),
),
);
expect(proxyEnvVars.length).toBe(0);
});
});
@@ -1,81 +0,0 @@
terraform {
required_version = ">= 1.9"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.12"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "proxy_url" {
type = string
description = "The full URL of the AI Bridge Proxy. Include the port if not using standard ports (e.g. https://aiproxy.example.com or http://internal-proxy:8888)."
validation {
condition = can(regex("^https?://", var.proxy_url))
error_message = "proxy_url must start with http:// or https://."
}
}
variable "cert_path" {
type = string
description = "Absolute path where the AI Bridge Proxy CA certificate will be saved."
default = "/tmp/aibridge-proxy/ca-cert.pem"
validation {
condition = startswith(var.cert_path, "/")
error_message = "cert_path must be an absolute path."
}
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
locals {
# Build the proxy URL with Coder authentication embedded.
# AI Bridge Proxy expects the Coder session token as the password
# in basic auth: http://coder:<token>@host:port
proxy_auth_url = replace(
var.proxy_url,
"://",
"://coder:${data.coder_workspace_owner.me.session_token}@"
)
}
# These outputs are intended to be consumed by tool-specific modules,
# to set proxy environment variables scoped to their process, rather than globally.
output "proxy_auth_url" {
description = "The AI Bridge Proxy URL with Coder authentication embedded (http://coder:<token>@host:port)."
value = local.proxy_auth_url
sensitive = true
}
output "cert_path" {
description = "Path to the downloaded AI Bridge Proxy CA certificate."
value = var.cert_path
}
# Downloads the CA certificate from the Coder deployment.
# This runs on workspace start but does not block login, if the script
# fails, the workspace remains usable and the error is visible in the build logs.
# Tools that depend on the proxy will fail until the certificate is available.
resource "coder_script" "aibridge_proxy_setup" {
agent_id = var.agent_id
display_name = "AI Bridge Proxy Setup"
icon = "/icon/coder.svg"
run_on_start = true
start_blocks_login = false
script = templatefile("${path.module}/scripts/setup.sh", {
CERT_PATH = var.cert_path,
ACCESS_URL = data.coder_workspace.me.access_url,
SESSION_TOKEN = data.coder_workspace_owner.me.session_token,
})
}
@@ -1,210 +0,0 @@
run "test_aibridge_proxy_basic" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
}
assert {
condition = var.agent_id == "test-agent-id"
error_message = "Agent ID should match the input variable"
}
assert {
condition = var.proxy_url == "https://aiproxy.example.com"
error_message = "Proxy URL should match the input variable"
}
assert {
condition = var.cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
error_message = "cert_path should default to /tmp/aibridge-proxy/ca-cert.pem"
}
}
run "test_aibridge_proxy_empty_url_validation" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = ""
}
expect_failures = [
var.proxy_url,
]
}
run "test_aibridge_proxy_invalid_url_validation" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "aiproxy.example.com"
}
expect_failures = [
var.proxy_url,
]
}
run "test_aibridge_proxy_url_formats" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
}
assert {
condition = can(regex("^https?://", var.proxy_url))
error_message = "Proxy URL should be a valid URL with scheme"
}
}
run "test_aibridge_proxy_https_with_port" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com:8443"
}
assert {
condition = can(regex("^https?://", var.proxy_url))
error_message = "Proxy URL should support HTTPS with custom port"
}
}
run "test_aibridge_proxy_http_with_port" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "http://internal-proxy:8888"
}
assert {
condition = can(regex("^https?://", var.proxy_url))
error_message = "Proxy URL should support HTTP with custom port"
}
}
run "test_aibridge_proxy_empty_cert_path_validation" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
cert_path = ""
}
expect_failures = [
var.cert_path,
]
}
run "test_aibridge_proxy_relative_cert_path_validation" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
cert_path = "relative/path/ca-cert.pem"
}
expect_failures = [
var.cert_path,
]
}
run "test_aibridge_proxy_custom_cert_path" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
cert_path = "/home/coder/.certs/ca-cert.pem"
}
assert {
condition = var.cert_path == "/home/coder/.certs/ca-cert.pem"
error_message = "cert_path should match the input variable"
}
}
run "test_aibridge_proxy_script" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
}
assert {
condition = coder_script.aibridge_proxy_setup.run_on_start == true
error_message = "Script should run on start"
}
assert {
condition = coder_script.aibridge_proxy_setup.start_blocks_login == false
error_message = "Script should not block login"
}
assert {
condition = coder_script.aibridge_proxy_setup.display_name == "AI Bridge Proxy Setup"
error_message = "Script display name should be 'AI Bridge Proxy Setup'"
}
}
run "test_aibridge_proxy_auth_url_https" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
}
override_data {
target = data.coder_workspace_owner.me
values = {
session_token = "mock-session-token"
}
}
assert {
condition = output.proxy_auth_url == "https://coder:mock-session-token@aiproxy.example.com"
error_message = "proxy_auth_url should contain the mocked session token"
}
assert {
condition = output.cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
error_message = "cert_path output should match the default"
}
}
run "test_aibridge_proxy_auth_url_http_with_port" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "http://internal-proxy:8888"
}
override_data {
target = data.coder_workspace_owner.me
values = {
session_token = "mock-session-token"
}
}
assert {
condition = output.proxy_auth_url == "http://coder:mock-session-token@internal-proxy:8888"
error_message = "proxy_auth_url should preserve the port"
}
assert {
condition = output.cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
error_message = "cert_path output should match the default"
}
}
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
if [ -z "$CERT_PATH" ]; then
CERT_PATH="${CERT_PATH}"
fi
if [ -z "$ACCESS_URL" ]; then
ACCESS_URL="${ACCESS_URL}"
fi
if [ -z "$SESSION_TOKEN" ]; then
SESSION_TOKEN="${SESSION_TOKEN}"
fi
set -euo pipefail
# Signal startup coordination.
# The trap ensures 'complete' is always called (even on failure) so dependent
# scripts unblock promptly and can check for the certificate themselves.
if command -v coder > /dev/null 2>&1; then
coder exp sync start "aibridge-proxy-setup" > /dev/null 2>&1 || true
trap 'coder exp sync complete "aibridge-proxy-setup" > /dev/null 2>&1 || true' EXIT
fi
if [ -z "$ACCESS_URL" ]; then
echo "Error: Coder access URL is not set."
exit 1
fi
if [ -z "$SESSION_TOKEN" ]; then
echo "Error: Coder session token is not set."
exit 1
fi
if ! command -v curl > /dev/null; then
echo "Error: curl is not installed."
exit 1
fi
echo "--------------------------------"
echo "AI Bridge Proxy Setup"
printf "Certificate path: %s\n" "$CERT_PATH"
printf "Access URL: %s\n" "$ACCESS_URL"
echo "--------------------------------"
CERT_DIR=$(dirname "$CERT_PATH")
mkdir -p "$CERT_DIR"
CERT_URL="$ACCESS_URL/api/v2/aibridge/proxy/ca-cert.pem"
echo "Downloading AI Bridge Proxy CA certificate from $CERT_URL..."
# Download the certificate with a 5s connection timeout and 10s total timeout
# to avoid the script hanging indefinitely.
if ! HTTP_STATUS=$(curl -s -o "$CERT_PATH" -w "%%{http_code}" \
--connect-timeout 5 \
--max-time 10 \
-H "Coder-Session-Token: $SESSION_TOKEN" \
"$CERT_URL"); then
echo "❌ AI Bridge Proxy setup failed: could not connect to $CERT_URL."
echo "Ensure AI Bridge Proxy is enabled and reachable from the workspace."
rm -f "$CERT_PATH"
exit 1
fi
if [ "$HTTP_STATUS" -ne 200 ]; then
echo "❌ AI Bridge Proxy setup failed: unexpected response (HTTP $HTTP_STATUS)."
echo "Ensure AI Bridge Proxy is enabled and reachable from the workspace."
rm -f "$CERT_PATH"
exit 1
fi
if [ ! -s "$CERT_PATH" ]; then
echo "❌ AI Bridge Proxy setup failed: downloaded certificate is empty."
rm -f "$CERT_PATH"
exit 1
fi
echo "AI Bridge Proxy CA certificate saved to $CERT_PATH"
echo "✅ AI Bridge Proxy setup complete."
+9 -22
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.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
@@ -36,19 +36,6 @@ module "claude-code" {
By default, Claude Code automatically resumes existing conversations when your workspace restarts. Sessions are tracked per workspace directory, so conversations continue where you left off. If no session exists (first start), your `ai_prompt` will run normally. To disable this behavior and always start fresh, set `continue = false`
## State Persistence
AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Claude CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning).
To disable:
```tf
module "claude-code" {
# ... other config
enable_state_persistence = false
}
```
## Examples
### Usage with Agent Boundaries
@@ -60,7 +47,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.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_boundary = true
@@ -81,7 +68,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.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_aibridge = true
@@ -110,7 +97,7 @@ data "coder_task" "me" {}
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
ai_prompt = data.coder_task.me.prompt
@@ -133,7 +120,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.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
@@ -189,7 +176,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.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
install_claude_code = true
@@ -211,7 +198,7 @@ variable "claude_code_oauth_token" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
@@ -284,7 +271,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -341,7 +328,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
+19 -26
View File
@@ -67,7 +67,7 @@ variable "cli_app_display_name" {
variable "pre_install_script" {
type = string
description = "Custom script to run before installing Claude Code. Can be used for dependency ordering between modules (e.g., waiting for git-clone to complete before Claude Code initialization)."
description = "Custom script to run before installing Claude Code."
default = null
}
@@ -261,12 +261,6 @@ variable "enable_aibridge" {
}
}
variable "enable_state_persistence" {
type = bool
description = "Enable AgentAPI conversation state persistence across restarts."
default = true
}
resource "coder_env" "claude_code_md_path" {
count = var.claude_md_path == "" ? 0 : 1
agent_id = var.agent_id
@@ -362,26 +356,25 @@ locals {
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.2.0"
version = "2.0.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
folder = local.workdir
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
agentapi_subdomain = var.subdomain
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_version = var.agentapi_version
enable_state_persistence = var.enable_state_persistence
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = <<-EOT
agent_id = var.agent_id
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
folder = local.workdir
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
agentapi_subdomain = var.subdomain
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_version = var.agentapi_version
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
@@ -387,36 +387,6 @@ run "test_aibridge_disabled_with_api_key" {
}
}
run "test_enable_state_persistence_default" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.enable_state_persistence == true
error_message = "enable_state_persistence should default to true"
}
}
run "test_disable_state_persistence" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_state_persistence = false
}
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should be false when explicitly disabled"
}
}
run "test_no_api_key_no_env" {
command = plan
+8 -8
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.3"
version = "1.4.2"
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.3"
version = "1.4.2"
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.3"
version = "1.4.2"
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.3"
version = "1.4.2"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -78,7 +78,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.3"
version = "1.4.2"
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.3"
version = "1.4.2"
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.3"
version = "1.4.2"
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.3"
version = "1.4.2"
agent_id = coder_agent.example.id
offline = true
}
+2 -2
View File
@@ -44,7 +44,7 @@ variable "settings" {
default = {}
}
variable "machine_settings" {
variable "machine-settings" {
type = any
description = "A map of template level machine settings to apply to code-server. This will be overwritten at each container start."
default = {}
@@ -167,7 +167,7 @@ resource "coder_script" "code-server" {
INSTALL_PREFIX : var.install_prefix,
// This is necessary otherwise the quotes are stripped!
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
MACHINE_SETTINGS : replace(jsonencode(var.machine_settings), "\"", "\\\""),
MACHINE_SETTINGS : replace(jsonencode(var.machine-settings), "\"", "\\\""),
OFFLINE : var.offline,
USE_CACHED : var.use_cached,
USE_CACHED_EXTENSIONS : var.use_cached_extensions,
@@ -14,9 +14,8 @@ The devcontainers-cli module provides an easy way to install [`@devcontainers/cl
```tf
module "devcontainers-cli" {
source = "registry.coder.com/coder/devcontainers-cli/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
start_blocks_login = false
source = "registry.coder.com/coder/devcontainers-cli/coder"
version = "1.0.34"
agent_id = coder_agent.example.id
}
```
@@ -14,17 +14,10 @@ variable "agent_id" {
description = "The ID of a Coder agent."
}
variable "start_blocks_login" {
type = bool
default = false
description = "Boolean, This option determines whether users can log in immediately or must wait for the workspace to finish running this script upon startup."
}
resource "coder_script" "devcontainers-cli" {
agent_id = var.agent_id
display_name = "devcontainers-cli"
icon = "/icon/devcontainers.svg"
script = templatefile("${path.module}/run.sh", {})
run_on_start = true
start_blocks_login = var.start_blocks_login
agent_id = var.agent_id
display_name = "devcontainers-cli"
icon = "/icon/devcontainers.svg"
script = templatefile("${path.module}/run.sh", {})
run_on_start = true
}
+6 -20
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.3.2"
version = "1.3.0"
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.3.2"
version = "1.3.0"
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.3.2"
version = "1.3.0"
agent_id = coder_agent.example.id
user = "root"
}
@@ -54,34 +54,20 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.2"
version = "1.3.0"
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.3.2"
version = "1.3.0"
agent_id = coder_agent.example.id
user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri
}
```
## SSH vs HTTPS URLs
If your Git provider (e.g. GitLab, GitHub Enterprise) restricts HTTPS cloning, use an SSH URL instead:
```text
# HTTPS (may fail if HTTP cloning is disabled)
https://gitlab.example.com/user/dotfiles.git
# SSH (uses the workspace's SSH key)
git@gitlab.example.com:user/dotfiles.git
```
When a Git provider has HTTPS cloning disabled server-side, the clone will silently fail (the `.git` folder may exist but the working tree will be empty). SSH URLs avoid this because they authenticate with the workspace's SSH key instead of a token-based HTTPS flow.
## Setting a default dotfiles repository
You can set a default dotfiles repository for all users by setting the `default_dotfiles_uri` variable:
@@ -90,7 +76,7 @@ You can set a default dotfiles repository for all users by setting the `default_
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.2"
version = "1.3.0"
agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles"
}
@@ -26,7 +26,6 @@ describe("dotfiles", async () => {
"git@github.com:coder/dotfiles.git",
"git://github.com/coder/dotfiles.git",
"ssh://git@github.com/coder/dotfiles.git",
"ssh://git@bitbucket.example.org:7999/~myusername/dotfiles.git",
];
for (const url of validUrls) {
const state = await runTerraformApply(import.meta.dir, {
+4 -4
View File
@@ -29,7 +29,7 @@ variable "agent_id" {
variable "description" {
type = string
description = "A custom description for the dotfiles parameter. This is shown in the UI - and allows you to customize the instructions you give to your users."
default = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace. Use an SSH URL (e.g. `git@host:user/repo`) if your Git provider restricts HTTPS cloning."
default = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace"
}
variable "default_dotfiles_uri" {
@@ -40,7 +40,7 @@ variable "default_dotfiles_uri" {
validation {
condition = (
var.default_dotfiles_uri == "" ||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$", var.default_dotfiles_uri))
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$", var.default_dotfiles_uri))
)
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
@@ -55,7 +55,7 @@ variable "dotfiles_uri" {
condition = (
var.dotfiles_uri == null ||
var.dotfiles_uri == "" ||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$", var.dotfiles_uri))
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$", var.dotfiles_uri))
)
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
@@ -102,7 +102,7 @@ data "coder_parameter" "dotfiles_uri" {
icon = "/icon/dotfiles.svg"
validation {
regex = "^$|^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$"
regex = "^$|^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$"
error = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
}
@@ -1,75 +0,0 @@
---
display_name: JFrog Xray
description: Fetch container image vulnerability scan results from JFrog Xray
icon: ../../../../.icons/jfrog-xray.svg
verified: true
tags: [jfrog, xray]
---
# JFrog Xray
This module fetches vulnerability scan results from JFrog Xray for container images stored in Artifactory. Use the outputs to display security information as workspace metadata.
```tf
module "jfrog_xray" {
source = "registry.coder.com/coder/jfrog-xray/coder"
version = "1.0.0"
xray_url = "https://example.jfrog.io/xray"
xray_token = var.artifactory_access_token
image = "docker-local/myapp/backend:v1.0.0"
}
resource "coder_metadata" "xray_scan" {
count = data.coder_workspace.me.start_count
resource_id = docker_container.workspace[0].id
icon = "/icon/shield.svg"
item {
key = "Image"
value = "docker-local/myapp/backend:v1.0.0"
}
item {
key = "Total Vulnerabilities"
value = module.jfrog_xray.total
}
item {
key = "Critical"
value = module.jfrog_xray.critical
}
item {
key = "High"
value = module.jfrog_xray.high
}
item {
key = "Medium"
value = module.jfrog_xray.medium
}
item {
key = "Low"
value = module.jfrog_xray.low
}
}
```
## Prerequisites
1. Container images must be stored in JFrog Artifactory
2. JFrog Xray must be configured to scan your repositories
3. A valid JFrog access token with Xray read permissions
## Remote Repositories
When scanning images from remote (proxy) repositories, set `use_cache_repo = true`. This is because Artifactory stores cached images in a companion `-cache` repository where Xray indexes the scan results.
```tf
module "jfrog_xray" {
source = "registry.coder.com/coder/jfrog-xray/coder"
version = "1.0.0"
xray_url = "https://example.jfrog.io/xray"
xray_token = var.artifactory_access_token
image = "docker-remote/library/nginx:latest"
use_cache_repo = true
}
```
@@ -1,244 +0,0 @@
import { serve } from "bun";
import { describe, expect, it } from "bun:test";
import { createJSONResponse, runTerraformInit, runTerraformApply } from "~test";
describe("jfrog-xray", async () => {
await runTerraformInit(import.meta.dir);
// Mock server simulating a local repo with direct scan results
const mockLocalRepo = serve({
fetch: (req) => {
const url = new URL(req.url);
if (url.pathname === "/xray/api/v1/system/version")
return createJSONResponse({
xray_version: "3.80.0",
xray_revision: "abc123",
});
if (url.pathname === "/xray/api/v1/artifacts")
return createJSONResponse({
data: [
{
name: "myapp/backend/v1.0.0",
repo_path: "/myapp/backend/v1.0.0/manifest.json",
size: "50.00 MB",
sec_issues: {
critical: 1,
high: 3,
medium: 5,
low: 10,
total: 19,
},
scans_status: {
overall: {
status: "DONE",
time: "2026-03-04T22:00:02Z",
},
},
violations: 0,
},
],
offset: 0,
});
return createJSONResponse({});
},
port: 0,
});
// Mock server simulating a remote repo with cache behavior
// Returns both tag manifest (0 vulns, 0 size) and SHA manifest (real vulns, real size)
const mockRemoteRepo = serve({
fetch: (req) => {
const url = new URL(req.url);
if (url.pathname === "/xray/api/v1/system/version")
return createJSONResponse({
xray_version: "3.80.0",
xray_revision: "abc123",
});
if (url.pathname === "/xray/api/v1/artifacts")
return createJSONResponse({
data: [
{
name: "codercom/enterprise-base/ubuntu",
repo_path: "/codercom/enterprise-base/ubuntu/list.manifest.json",
size: "0.00 B",
sec_issues: { total: 0 },
scans_status: {
overall: { status: "DONE" },
},
violations: 0,
},
{
name: "codercom/enterprise-base/sha256__abc123def456",
repo_path:
"/codercom/enterprise-base/sha256__abc123def456/manifest.json",
size: "359.33 MB",
sec_issues: {
critical: 2,
high: 6,
medium: 20,
low: 23,
total: 51,
},
scans_status: {
overall: { status: "DONE" },
},
violations: 2,
},
],
offset: 0,
});
return createJSONResponse({});
},
port: 0,
});
// Mock server returning empty results (image not scanned)
const mockEmptyResults = serve({
fetch: (req) => {
const url = new URL(req.url);
if (url.pathname === "/xray/api/v1/system/version")
return createJSONResponse({
xray_version: "3.80.0",
xray_revision: "abc123",
});
if (url.pathname === "/xray/api/v1/artifacts")
return createJSONResponse({ data: [], offset: -1 });
return createJSONResponse({});
},
port: 0,
});
const localRepoUrl = `http://${mockLocalRepo.hostname}:${mockLocalRepo.port}`;
const remoteRepoUrl = `http://${mockRemoteRepo.hostname}:${mockRemoteRepo.port}`;
const emptyResultsUrl = `http://${mockEmptyResults.hostname}:${mockEmptyResults.port}`;
const getProviderEnv = (url: string) => ({
XRAY_URL: url,
XRAY_ACCESS_TOKEN: "test-token",
});
it("validates required variable: xray_url", async () => {
try {
await runTerraformApply(
import.meta.dir,
{
xray_token: "test-token",
image: "docker-local/test/image:latest",
},
getProviderEnv(localRepoUrl),
);
throw new Error("Expected apply to fail without xray_url");
} catch (ex) {
if (!(ex instanceof Error)) throw new Error("Unknown error");
expect(ex.message).toContain('input variable "xray_url" is not set');
}
});
it("validates required variable: xray_token", async () => {
try {
await runTerraformApply(
import.meta.dir,
{
xray_url: localRepoUrl,
image: "docker-local/test/image:latest",
},
getProviderEnv(localRepoUrl),
);
throw new Error("Expected apply to fail without xray_token");
} catch (ex) {
if (!(ex instanceof Error)) throw new Error("Unknown error");
expect(ex.message).toContain('input variable "xray_token" is not set');
}
});
it("validates required variable: image", async () => {
try {
await runTerraformApply(
import.meta.dir,
{
xray_url: localRepoUrl,
xray_token: "test-token",
},
getProviderEnv(localRepoUrl),
);
throw new Error("Expected apply to fail without image");
} catch (ex) {
if (!(ex instanceof Error)) throw new Error("Unknown error");
expect(ex.message).toContain('input variable "image" is not set');
}
});
it("returns vulnerability counts for local repository", async () => {
const state = await runTerraformApply(
import.meta.dir,
{
xray_url: localRepoUrl,
xray_token: "test-token",
image: "docker-local/myapp/backend:v1.0.0",
},
getProviderEnv(localRepoUrl),
);
expect(state.outputs.critical.value).toBe(1);
expect(state.outputs.high.value).toBe(3);
expect(state.outputs.medium.value).toBe(5);
expect(state.outputs.low.value).toBe(10);
expect(state.outputs.total.value).toBe(19);
});
it("returns zero counts when image has no scan results", async () => {
const state = await runTerraformApply(
import.meta.dir,
{
xray_url: emptyResultsUrl,
xray_token: "test-token",
image: "docker-local/unscanned/image:latest",
},
getProviderEnv(emptyResultsUrl),
);
expect(state.outputs.critical.value).toBe(0);
expect(state.outputs.high.value).toBe(0);
expect(state.outputs.medium.value).toBe(0);
expect(state.outputs.low.value).toBe(0);
expect(state.outputs.total.value).toBe(0);
});
it("uses cache repo when use_cache_repo is enabled", async () => {
const state = await runTerraformApply(
import.meta.dir,
{
xray_url: remoteRepoUrl,
xray_token: "test-token",
image: "docker-remote/codercom/enterprise-base:ubuntu",
use_cache_repo: true,
},
getProviderEnv(remoteRepoUrl),
);
// Should find the SHA artifact with actual vulnerabilities
expect(state.outputs.critical.value).toBe(2);
expect(state.outputs.high.value).toBe(6);
expect(state.outputs.medium.value).toBe(20);
expect(state.outputs.low.value).toBe(23);
expect(state.outputs.total.value).toBe(51);
expect(state.outputs.violations.value).toBe(2);
expect(state.outputs.artifact_name.value).toContain("sha256__");
});
it("allows custom repo and repo_path override", async () => {
const state = await runTerraformApply(
import.meta.dir,
{
xray_url: localRepoUrl,
xray_token: "test-token",
image: "ignored/path:tag",
repo: "docker-local",
repo_path: "/myapp/backend/v1.0.0",
},
getProviderEnv(localRepoUrl),
);
expect(state.outputs.total.value).toBe(19);
});
});
-135
View File
@@ -1,135 +0,0 @@
terraform {
required_version = ">= 1.0"
required_providers {
xray = {
source = "jfrog/xray"
version = ">= 2.0"
}
}
}
provider "xray" {
url = var.xray_url
access_token = var.xray_token
}
variable "xray_url" {
description = "The URL of your JFrog Xray instance (e.g., https://mycompany.jfrog.io/xray). This should point to the Xray API endpoint, not Artifactory."
type = string
validation {
condition = can(regex("^https?://", var.xray_url))
error_message = "The xray_url must be a valid URL starting with http:// or https://."
}
}
variable "xray_token" {
description = "The access token for authenticating with JFrog Xray. This token needs read permissions on Xray scan results. You can generate one in JFrog Platform under User Management > Access Tokens."
type = string
sensitive = true
}
variable "image" {
description = "The Docker image to check for vulnerabilities, in the format 'repo/path/image:tag'. For example: 'docker-local/myapp/backend:v1.0.0' or 'docker-remote/library/nginx:latest'. The repository name is extracted from the first path segment."
type = string
validation {
condition = length(split("/", var.image)) >= 2
error_message = "The image must include at least a repository and image name (e.g., 'docker-local/myimage:tag')."
}
}
variable "repo" {
description = "Override the repository name extracted from the image path. Use this when your Artifactory repository name differs from the first segment of your image path."
type = string
default = ""
}
variable "repo_path" {
description = "Override the full Xray repository path. Use this for custom path structures that don't follow the standard 'repo/image:tag' format. When set, this takes precedence over automatic path construction."
type = string
default = ""
}
variable "use_cache_repo" {
description = "Set to true when scanning images from remote (proxy) repositories. Remote repositories in Artifactory store cached artifacts in a companion '-cache' repository (e.g., 'docker-remote-cache'), which is where Xray indexes the scan results."
type = bool
default = false
}
locals {
# Parse the image string into components
# Example: "docker-local/myapp/backend:v1.0.0"
# -> repo: "docker-local", image_name: "myapp/backend", tag: "v1.0.0"
image_parts = split("/", var.image)
base_repo = var.repo != "" ? var.repo : local.image_parts[0]
parsed_repo = var.use_cache_repo ? "${local.base_repo}-cache" : local.base_repo
image_path = join("/", slice(local.image_parts, 1, length(local.image_parts)))
image_name = split(":", local.image_path)[0]
image_tag = length(split(":", local.image_path)) > 1 ? split(":", local.image_path)[1] : "latest"
# Construct the Xray query path based on repository type:
# - Local repositories: Query the exact tag path (e.g., /myapp/backend/v1.0.0)
# - Remote repositories: Query by image name only (e.g., /myapp/backend) because
# the Terraform provider only returns the SHA manifest (with actual scan data)
# when querying the broader path
parsed_path = var.repo_path != "" ? var.repo_path : (
var.use_cache_repo ? "/${local.image_name}" : "/${local.image_name}/${local.image_tag}"
)
results = coalesce(try(data.xray_artifacts_scan.image_scan.results, []), [])
# For remote repositories, filter to find the actual scanned image (not tag pointers):
# - Tag manifests have size "0.00 B" (they're just pointers to SHA manifests)
# - SHA manifests have actual size (e.g., "359.33 MB") and contain the real scan data
# For local repositories, there's typically only one result which is the actual image
scanned_images = var.use_cache_repo ? [
for r in local.results : r if r.size != "0.00 B"
] : local.results
# The artifact we'll report scan results for
scan_result = (
length(local.scanned_images) > 0 ? local.scanned_images[0] :
length(local.results) > 0 ? local.results[0] :
null
)
}
data "xray_artifacts_scan" "image_scan" {
repo = local.parsed_repo
repo_path = local.parsed_path
}
output "critical" {
description = "The number of critical severity vulnerabilities found in the image. Critical vulnerabilities typically require immediate attention."
value = try(local.scan_result.sec_issues.critical, 0)
}
output "high" {
description = "The number of high severity vulnerabilities found in the image."
value = try(local.scan_result.sec_issues.high, 0)
}
output "medium" {
description = "The number of medium severity vulnerabilities found in the image."
value = try(local.scan_result.sec_issues.medium, 0)
}
output "low" {
description = "The number of low severity vulnerabilities found in the image."
value = try(local.scan_result.sec_issues.low, 0)
}
output "total" {
description = "The total number of vulnerabilities found across all severity levels."
value = try(local.scan_result.sec_issues.total, 0)
}
output "artifact_name" {
description = "The name of the artifact that was scanned, as reported by Xray. For remote repositories, this will be the SHA-based manifest name (e.g., 'myimage/sha256__abc123...')."
value = try(local.scan_result.name, "")
}
output "violations" {
description = "The number of Xray policy violations detected. Violations are triggered when vulnerabilities match rules defined in your Xray security policies."
value = try(local.scan_result.violations, 0)
}
+10 -56
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 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.
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module installs `mux@next` from npm (with a fallback to downloading the npm tarball if npm is unavailable). Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.0"
version = "1.1.0"
agent_id = coder_agent.main.id
}
```
@@ -37,7 +37,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.0"
version = "1.1.0"
agent_id = coder_agent.main.id
}
```
@@ -48,7 +48,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.0"
version = "1.1.0"
agent_id = coder_agent.main.id
# Default is "latest"; set to a specific version to pin
install_version = "0.4.0"
@@ -63,24 +63,9 @@ 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.0"
version = "1.1.0"
agent_id = coder_agent.main.id
add_project = "/path/to/project"
}
```
### Pass Arbitrary `mux server` Arguments
Use `additional_arguments` to append additional arguments to `mux server`.
The module parses quoted values, so grouped arguments remain intact.
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.0"
agent_id = coder_agent.main.id
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
add-project = "/path/to/project"
}
```
@@ -90,40 +75,12 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.0"
version = "1.1.0"
agent_id = coder_agent.main.id
port = 8080
}
```
### Custom Package Manager
Force a specific package manager instead of auto-detection:
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.0"
agent_id = coder_agent.main.id
package_manager = "pnpm" # or "npm", "bun"
}
```
### Custom Registry
Use a private or mirrored npm registry:
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.0"
agent_id = coder_agent.main.id
registry_url = "https://npm.pkg.github.com"
}
```
### Use Cached Installation
Run an existing copy of Mux if found, otherwise install from npm:
@@ -132,7 +89,7 @@ Run an existing copy of Mux if found, otherwise install from npm:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.0"
version = "1.1.0"
agent_id = coder_agent.main.id
use_cached = true
}
@@ -146,7 +103,7 @@ Run without installing from the network (requires Mux to be pre-installed):
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.0"
version = "1.1.0"
agent_id = coder_agent.main.id
install = false
}
@@ -160,7 +117,4 @@ module "mux" {
- Mux is currently in preview and you may encounter bugs
- Requires internet connectivity for agent operations (unless `install` is set to false)
- Auto-detects `npm`, `pnpm`, or `bun` by default; set `package_manager` to force a specific one
- 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
- Installs `mux@next` from npm by default (falls back to the npm tarball if npm is unavailable)
+2 -107
View File
@@ -1,11 +1,6 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
execContainer,
findResourceInstance,
readFileContainer,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
@@ -35,7 +30,7 @@ describe("mux", async () => {
}
expect(output.exitCode).toBe(0);
const expectedLines = [
"📥 No package manager found; downloading tarball from registry...",
"📥 npm not found; downloading tarball from npm registry...",
"🥳 mux has been installed in /tmp/mux",
"🚀 Starting mux server on port 4000...",
"Check logs at /tmp/mux.log!",
@@ -45,106 +40,6 @@ describe("mux", async () => {
}
}, 60000);
it("parses custom additional_arguments", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
additional_arguments:
"--open-mode pinned --add-project '/workspaces/my repo'",
});
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
i=1
for arg in "$@"; do
echo "arg$i=$arg"
i=$((i + 1))
done
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 1"]);
const log = await readFileContainer(id, "/tmp/mux.log");
expect(log).toContain("arg1=server");
expect(log).toContain("arg2=--port");
expect(log).toContain("arg3=4000");
expect(log).toContain("arg4=--open-mode");
expect(log).toContain("arg5=pinned");
expect(log).toContain("arg6=--add-project");
expect(log).toContain("arg7=/workspaces/my repo");
} finally {
await removeContainer(id);
}
}, 60000);
it("logs signal-based exits after startup", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
});
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
target_pid="$$"
(
sleep 1
kill -9 "$target_pid"
) &
while true; do
sleep 1
done
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 2"]);
const log = await readFileContainer(id, "/tmp/mux.log");
expect(log).toContain("shell exit code 137");
expect(log).toContain(
"SIGKILL usually means the process was killed externally or by the OOM killer.",
);
} finally {
await removeContainer(id);
}
}, 60000);
it("runs with npm present", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
@@ -160,7 +55,7 @@ chmod +x /tmp/mux/mux`,
expect(output.exitCode).toBe(0);
const expectedLines = [
"📦 Installing mux via npm into /tmp/mux...",
"⏭️ Skipping lifecycle scripts with --ignore-scripts",
"⏭️ Skipping npm lifecycle scripts with --ignore-scripts",
"🥳 mux has been installed in /tmp/mux",
"🚀 Starting mux server on port 4000...",
"Check logs at /tmp/mux.log!",
+2 -29
View File
@@ -49,41 +49,18 @@ variable "log_path" {
default = "/tmp/mux.log"
}
variable "add_project" {
variable "add-project" {
type = string
description = "Optional path to add/open as a project in Mux on startup."
default = null
}
variable "additional_arguments" {
type = string
description = "Additional command-line arguments to pass to `mux server` (for example: `--add-project /path --open-mode pinned`)."
default = ""
}
variable "install_version" {
type = string
description = "The version or dist-tag of Mux to install."
default = "next"
}
variable "package_manager" {
type = string
description = "Package manager to install Mux. 'auto' detects npm, pnpm, or bun (falling back to tarball download). Set to 'npm', 'pnpm', or 'bun' to force a specific one."
default = "auto"
validation {
condition = contains(["auto", "npm", "pnpm", "bun"], var.package_manager)
error_message = "The 'package_manager' variable must be one of: 'auto', 'npm', 'pnpm', 'bun'."
}
}
variable "registry_url" {
type = string
description = "The npm-compatible registry URL to install Mux from. Override this for private registries or mirrors."
default = "https://registry.npmjs.org"
}
variable "share" {
type = string
default = "owner"
@@ -154,7 +131,6 @@ resource "random_password" "mux_auth_token" {
locals {
mux_auth_token = random_password.mux_auth_token.result
registry_url = trimsuffix(var.registry_url, "/")
}
resource "coder_script" "mux" {
@@ -165,14 +141,11 @@ resource "coder_script" "mux" {
VERSION : var.install_version,
PORT : var.port,
LOG_PATH : var.log_path,
ADD_PROJECT : var.add_project == null ? "" : var.add_project,
ADDITIONAL_ARGUMENTS : var.additional_arguments,
ADD_PROJECT : var.add-project == null ? "" : var.add-project,
INSTALL_PREFIX : var.install_prefix,
OFFLINE : !var.install,
USE_CACHED : var.use_cached,
AUTH_TOKEN : local.mux_auth_token,
PACKAGE_MANAGER : var.package_manager,
REGISTRY_URL : local.registry_url,
})
run_on_start = true
-125
View File
@@ -79,38 +79,6 @@ run "auth_token_in_url" {
}
}
run "custom_additional_arguments" {
command = plan
variables {
agent_id = "foo"
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "--open-mode pinned --add-project '/workspaces/my repo'")
error_message = "mux launch script must include the configured additional arguments"
}
}
run "launcher_logs_external_kills" {
command = plan
variables {
agent_id = "foo"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "shell exit code $exit_code")
error_message = "mux launcher must log the shell exit code when the server dies unexpectedly"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "SIGKILL usually means the process was killed externally or by the OOM killer.")
error_message = "mux launcher must explain SIGKILL exits in the log"
}
}
run "custom_version" {
command = plan
@@ -139,96 +107,3 @@ run "use_cached_only_success" {
use_cached = true
}
}
# Custom package_manager should appear in generated script
run "custom_package_manager_npm" {
command = plan
variables {
agent_id = "foo"
package_manager = "npm"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"npm\"")
error_message = "mux script must set PM_CMD to the configured package manager"
}
}
run "custom_package_manager_pnpm" {
command = plan
variables {
agent_id = "foo"
package_manager = "pnpm"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"pnpm\"")
error_message = "mux script must set PM_CMD to the configured package manager"
}
}
run "custom_package_manager_bun" {
command = plan
variables {
agent_id = "foo"
package_manager = "bun"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"bun\"")
error_message = "mux script must set PM_CMD to the configured package manager"
}
}
# Invalid package_manager should fail validation
run "invalid_package_manager" {
command = plan
variables {
agent_id = "foo"
package_manager = "yarn"
}
expect_failures = [
var.package_manager
]
}
# Custom registry_url should appear in generated script
run "custom_registry_url" {
command = plan
variables {
agent_id = "foo"
registry_url = "https://npm.example.com"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "https://npm.example.com")
error_message = "mux script must use the configured registry URL"
}
assert {
condition = !strcontains(resource.coder_script.mux.script, "registry.npmjs.org")
error_message = "mux script must not contain hardcoded registry.npmjs.org when custom registry is set"
}
}
# registry_url trailing slash should be stripped
run "registry_url_trailing_slash" {
command = plan
variables {
agent_id = "foo"
registry_url = "https://npm.example.com/"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "https://npm.example.com/mux/")
error_message = "registry URL trailing slash must be stripped to avoid double slashes"
}
}
+11 -142
View File
@@ -15,112 +15,16 @@ function run_mux() {
if [ -z "$port_value" ]; then
port_value="4000"
fi
mkdir -p "$(dirname "${LOG_PATH}")"
# Build args for mux (POSIX-compatible, avoid bash arrays)
set -- server --port "$port_value"
if [ -n "${ADD_PROJECT}" ]; then
set -- "$@" --add-project "${ADD_PROJECT}"
fi
# Parse additional user-supplied server arguments while preserving quoted groups.
if [ -n "${ADDITIONAL_ARGUMENTS}" ]; then
local parsed_additional_arguments
if ! parsed_additional_arguments="$(printf "%s\n" "${ADDITIONAL_ARGUMENTS}" | xargs -n1 printf "%s\n" 2> /dev/null)"; then
echo "❌ Failed to parse additional_arguments. Ensure quotes are balanced."
exit 1
fi
while IFS= read -r parsed_arg; do
[ -n "$parsed_arg" ] || continue
set -- "$@" "$parsed_arg"
done << EOF_ARGS
$${parsed_additional_arguments}
EOF_ARGS
fi
echo "🚀 Starting mux server on port $port_value..."
echo "Check logs at ${LOG_PATH}!"
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" \
bash -s -- "$@" > /dev/null 2>&1 << 'EOF_LAUNCHER' &
signal_name() {
local signal_number="$1"
local resolved_signal
resolved_signal="$(kill -l "$signal_number" 2> /dev/null || true)"
if [ -n "$resolved_signal" ]; then
printf '%s' "$resolved_signal"
return 0
fi
printf 'SIG%s' "$signal_number"
MUX_SERVER_AUTH_TOKEN="$auth_token_value" PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
}
append_kernel_kill_context() {
local mux_pid="$1"
local kernel_context=""
if command -v dmesg > /dev/null 2>&1; then
kernel_context="$(dmesg -T 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)"
fi
if [ -z "$kernel_context" ] && command -v journalctl > /dev/null 2>&1; then
kernel_context="$(journalctl -k -n 200 --no-pager 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)"
fi
if [ -n "$kernel_context" ]; then
echo "Recent kernel kill context:"
echo "$kernel_context"
else
echo "No kernel OOM/kill context was available (dmesg/journalctl unavailable or permission denied)."
fi
}
log_mux_exit() {
local mux_pid="$1"
local exit_code="$2"
local timestamp
timestamp="$(date -Iseconds 2> /dev/null || date)"
if [ "$exit_code" -eq 0 ]; then
echo "[$timestamp] mux server exited cleanly."
return 0
fi
if [ "$exit_code" -gt 128 ]; then
local signal_number=$((exit_code - 128))
local signal_label
signal_label="$(signal_name "$signal_number")"
echo "[$timestamp] mux server exited due to signal $signal_label ($signal_number); shell exit code $exit_code."
if [ "$signal_number" -eq 9 ]; then
echo "[$timestamp] SIGKILL usually means the process was killed externally or by the OOM killer."
append_kernel_kill_context "$mux_pid"
fi
echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself."
return 0
fi
echo "[$timestamp] mux server exited with code $exit_code."
echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself."
}
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
if [ "${OFFLINE}" = true ]; then
if [ -f "$MUX_BINARY" ]; then
@@ -134,7 +38,7 @@ fi
# If there is no cached install OR we don't want to use a cached install
if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
printf "$${BOLD}Installing mux...\n"
printf "$${BOLD}Installing mux from npm...\n"
# Clean up from other install (in case install prefix changed).
if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/mux" ]; then
@@ -143,76 +47,41 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
mkdir -p "$(dirname "$MUX_BINARY")"
# Determine which package manager to use
PM_CMD=""
if [ "${PACKAGE_MANAGER}" = "auto" ]; then
for pm in npm pnpm bun; do
if command -v "$pm" > /dev/null 2>&1; then
PM_CMD="$pm"
break
fi
done
else
PM_CMD="${PACKAGE_MANAGER}"
if ! command -v "$PM_CMD" > /dev/null 2>&1; then
echo "❌ Configured package manager '${PACKAGE_MANAGER}' not found on PATH"
exit 1
fi
fi
if [ -n "$PM_CMD" ]; then
echo "📦 Installing mux via $PM_CMD into ${INSTALL_PREFIX}..."
if command -v npm > /dev/null 2>&1; then
echo "📦 Installing mux via npm into ${INSTALL_PREFIX}..."
NPM_WORKDIR="${INSTALL_PREFIX}/npm"
mkdir -p "$NPM_WORKDIR"
cd "$NPM_WORKDIR" || exit 1
if [ ! -f package.json ]; then
echo '{}' > package.json
fi
echo "⏭️ Skipping lifecycle scripts with --ignore-scripts"
echo "⏭️ Skipping npm lifecycle scripts with --ignore-scripts"
PKG="mux"
if [ -z "${VERSION}" ] || [ "${VERSION}" = "latest" ]; then
PKG_SPEC="$PKG@latest"
else
PKG_SPEC="$PKG@${VERSION}"
fi
INSTALL_OK=true
case "$PM_CMD" in
npm)
if ! npm install --no-audit --no-fund --omit=dev --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
INSTALL_OK=false
fi
;;
pnpm)
if ! pnpm add --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
INSTALL_OK=false
fi
;;
bun)
if ! bun add --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
INSTALL_OK=false
fi
;;
esac
if [ "$INSTALL_OK" != true ]; then
echo "❌ Failed to install mux via $PM_CMD"
if ! npm install --no-audit --no-fund --omit=dev --ignore-scripts "$PKG_SPEC"; then
echo "❌ Failed to install mux via npm"
exit 1
fi
# Determine the installed binary path
BIN_DIR="$NPM_WORKDIR/node_modules/.bin"
CANDIDATE="$BIN_DIR/mux"
if [ ! -f "$CANDIDATE" ]; then
echo "❌ Could not locate mux binary after $PM_CMD install"
echo "❌ Could not locate mux binary after npm install"
exit 1
fi
chmod +x "$CANDIDATE" || true
ln -sf "$CANDIDATE" "$MUX_BINARY"
else
echo "📥 No package manager found; downloading tarball from registry..."
echo "📥 npm not found; downloading tarball from npm registry..."
VERSION_TO_USE="${VERSION}"
if [ -z "$VERSION_TO_USE" ]; then
VERSION_TO_USE="next"
fi
META_URL="${REGISTRY_URL}/mux/$VERSION_TO_USE"
META_URL="https://registry.npmjs.org/mux/$VERSION_TO_USE"
META_JSON="$(curl -fsSL "$META_URL" || true)"
if [ -z "$META_JSON" ]; then
echo "❌ Failed to fetch npm metadata: $META_URL"
@@ -251,7 +120,7 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
echo "❌ Could not determine version for mux"
exit 1
fi
TARBALL_URL="${REGISTRY_URL}/mux/-/mux-$VERSION_TO_USE.tgz"
TARBALL_URL="https://registry.npmjs.org/mux/-/mux-$VERSION_TO_USE.tgz"
fi
TMP_DIR="$(mktemp -d)"
TAR_PATH="$TMP_DIR/mux.tgz"
+44 -27
View File
@@ -8,13 +8,13 @@ tags: [ide, vscode, web]
# VS Code Web
Automatically install [Visual Studio Code Server](https://code.visualstudio.com/docs/remote/vscode-server) in a workspace and create an app to access it via the dashboard.
Automatically install the [VS Code CLI](https://code.visualstudio.com/docs/editor/command-line) and run `code serve-web` in a workspace to access VS Code via the browser.
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.example.id
accept_license = true
}
@@ -30,7 +30,7 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.example.id
install_prefix = "/home/coder/.vscode-web"
folder = "/home/coder"
@@ -44,7 +44,7 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.example.id
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
accept_license = true
@@ -59,7 +59,7 @@ Configure VS Code's [Machine settings.json](https://code.visualstudio.com/docs/g
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -69,23 +69,7 @@ module "vscode-web" {
}
```
> [!WARNING]
> Merging settings requires `jq` or `python3`. If neither is available, existing machine settings will be preserved. User settings configured through the VS Code UI are stored in browser local storage and will not persist across different browsers or devices.
### Pin a specific VS Code Web version
By default, this module installs the latest. To pin a specific version, retrieve the commit ID from the [VS Code Update API](https://update.code.visualstudio.com/api/commits/stable/server-linux-x64-web) and verify its corresponding release on the [VS Code GitHub Releases](https://github.com/microsoft/vscode/releases).
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.5.0"
agent_id = coder_agent.example.id
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
accept_license = true
}
```
> **Note:** Merging settings requires `jq` or `python3`. If neither is available, existing machine settings will be preserved. User settings configured through the VS Code UI are stored in browser local storage and will not persist across different browsers or devices.
### Open an existing workspace on startup
@@ -94,10 +78,43 @@ Note: Either `workspace` or `folder` can be used, but not both simultaneously. T
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.5.0"
agent_id = coder_agent.example.id
workspace = "/home/coder/coder.code-workspace"
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "2.0.0"
agent_id = coder_agent.example.id
workspace = "/home/coder/coder.code-workspace"
accept_license = true
}
```
### Use VS Code Insiders
Use the VS Code Insiders release channel to get the latest features and bug fixes:
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "2.0.0"
agent_id = coder_agent.example.id
release_channel = "insiders"
accept_license = true
}
```
### Pin a specific VS Code version
Use the `commit_id` variable to pin a specific VS Code Server version by its commit SHA:
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "2.0.0"
agent_id = coder_agent.example.id
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
accept_license = true
}
```
You can find the commit SHA for a specific VS Code version on the [VS Code releases page](https://code.visualstudio.com/updates) or by checking the "About" dialog in VS Code.
+702 -207
View File
@@ -15,8 +15,8 @@ import {
findResourceInstance,
} from "~test";
// Set timeout to 2 minutes for tests that install packages
setDefaultTimeout(2 * 60 * 1000);
// Set timeout to 5 minutes for tests that download VS Code CLI
setDefaultTimeout(5 * 60 * 1000);
let cleanupContainers: string[] = [];
@@ -36,263 +36,758 @@ describe("vscode-web", async () => {
await runTerraformInit(import.meta.dir);
});
it("accept_license should be set to true", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: false,
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain("Invalid value for variable");
}
describe("terraform validation", () => {
it("accept_license should be set to true", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: false,
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain("Invalid value for variable");
}
});
it("use_cached and offline can not be used together", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
use_cached: true,
offline: true,
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain(
"Offline and Use Cached can not be used together",
);
}
});
it("offline and extensions can not be used together", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
offline: true,
extensions: '["ms-python.python"]',
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain(
"Offline mode does not allow extensions to be installed",
);
}
});
it("workspace and folder can not be used together", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
folder: "/home/coder",
workspace: "/home/coder/test.code-workspace",
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain(
"Set only one of `workspace` or `folder`",
);
}
});
});
it("use_cached and offline can not be used together", async () => {
try {
await runTerraformApply(import.meta.dir, {
describe("script generation", () => {
it("generates script with correct port", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
port: 8080,
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("--port 8080");
});
it("generates script with extensions directory", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
extensions_dir: "/custom/extensions",
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("--extensions-dir=/custom/extensions");
});
it("generates script with telemetry level", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
telemetry_level: "off",
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("--telemetry-level off");
});
it("generates script with disable trust", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
disable_trust: true,
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("--disable-workspace-trust");
});
it("generates script with serve-web command", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("serve-web");
expect(script.script).toContain("--accept-server-license-terms");
expect(script.script).toContain("--without-connection-token");
});
it("generates script with stable release channel by default", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("build=stable");
});
it("generates script with insiders release channel", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
release_channel: "insiders",
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("build=insiders");
});
it("generates script without commit-id value when not specified", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
});
const script = findResourceInstance(state, "coder_script");
// The if condition should have an empty string, so no commit-id value is passed
expect(script.script).toContain('if [ -n "" ]; then');
// Should not contain any actual commit hash
expect(script.script).not.toMatch(/--commit-id [a-f0-9]{40}/);
});
it("generates script with commit-id when specified", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
commit_id: "e54c774e0add60467559eb0d1e229c6452cf8447",
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain(
"--commit-id e54c774e0add60467559eb0d1e229c6452cf8447",
);
});
});
describe("container integration tests", () => {
it("uses existing code CLI in PATH", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI that logs when serve-web is called
await execContainer(containerId, [
"bash",
"-c",
`cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
if [ "\$1" = "serve-web" ]; then
echo "MOCK_SERVER_STARTED with args: \$@"
exit 0
fi
echo "code mock called: \$@"
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
// Run the script - the mock will capture the serve-web call
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Found VS Code CLI");
});
it("offline mode fails when CLI not present", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
offline: true,
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toContain(
"Offline mode enabled but no VS Code CLI, code-server, or cached VS Code Server found",
);
});
it("offline mode uses code-server as fallback", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
offline: true,
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Install mock code-server in PATH
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code-server << 'MOCKEOF'
#!/bin/bash
echo "MOCK_CODE_SERVER_STARTED with args: $@"
exit 0
MOCKEOF
chmod +x /usr/local/bin/code-server`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("offline fallback");
expect(result.stdout).toContain("Starting code-server");
});
it("offline mode works with pre-installed CLI", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
offline: true,
install_prefix: "/tmp/vscode-web",
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Pre-install mock code CLI at expected location
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code << 'MOCKEOF'
#!/bin/bash
if [ "\$1" = "serve-web" ]; then
echo "MOCK_OFFLINE_SERVER_STARTED"
exit 0
fi
exit 0
MOCKEOF
chmod +x /tmp/vscode-web/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Using cached VS Code CLI");
expect(result.stdout).toContain("Starting VS Code Web");
});
it("use_cached mode works with pre-installed CLI", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
use_cached: true,
offline: true,
install_prefix: "/tmp/vscode-web",
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain(
"Offline and Use Cached can not be used together",
);
}
});
it("offline and extensions can not be used together", async () => {
try {
await runTerraformApply(import.meta.dir, {
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Pre-install mock code CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code << 'MOCKEOF'
#!/bin/bash
if [ "\$1" = "serve-web" ]; then
echo "MOCK_CACHED_SERVER_STARTED"
exit 0
fi
exit 0
MOCKEOF
chmod +x /tmp/vscode-web/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Using cached VS Code CLI");
});
it("creates settings file with correct content", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
offline: true,
extensions: '["ms-python.python"]',
settings: '{"editor.fontSize": 14}',
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain(
"Offline mode does not allow extensions to be installed",
);
}
});
it("creates settings file with correct content", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
use_cached: true,
settings: '{"editor.fontSize": 14}',
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code-server CLI that the script expects
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF'
// Create a mock code CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
echo "Mock code-server running"
exit 0
MOCKEOF
chmod +x /tmp/vscode-web/bin/code-server`,
]);
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
const script = findResourceInstance(state, "coder_script");
const scriptResult = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(scriptResult.exitCode).toBe(0);
await execContainer(containerId, ["bash", "-c", script.script]);
// Check that settings file was created
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
// Check that settings file was created
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(settingsResult.exitCode).toBe(0);
expect(settingsResult.stdout).toContain("editor.fontSize");
expect(settingsResult.stdout).toContain("14");
});
it("merges settings with existing settings file", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
use_cached: true,
settings: '{"new.setting": "new_value"}',
expect(settingsResult.exitCode).toBe(0);
expect(settingsResult.stdout).toContain("editor.fontSize");
expect(settingsResult.stdout).toContain("14");
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
it("creates settings file with multiple settings", async () => {
const settings = {
"editor.fontSize": 16,
"editor.tabSize": 2,
"workbench.colorTheme": "Dracula",
"editor.formatOnSave": true,
};
// Install jq and create mock code-server CLI
await execContainer(containerId, ["apt-get", "update", "-qq"]);
await execContainer(containerId, ["apt-get", "install", "-y", "-qq", "jq"]);
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF'
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
settings: JSON.stringify(settings),
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
echo "Mock code-server running"
exit 0
MOCKEOF
chmod +x /tmp/vscode-web/bin/code-server`,
]);
chmod +x /usr/local/bin/code`,
]);
// Pre-create an existing settings file
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`,
]);
const script = findResourceInstance(state, "coder_script");
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
const scriptResult = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(scriptResult.exitCode).toBe(0);
// Check that settings file was created with all settings
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
// Check that settings were merged (both existing and new should be present)
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(settingsResult.exitCode).toBe(0);
// Should contain both existing and new settings
expect(settingsResult.stdout).toContain("existing.setting");
expect(settingsResult.stdout).toContain("existing_value");
expect(settingsResult.stdout).toContain("new.setting");
expect(settingsResult.stdout).toContain("new_value");
});
it("merges settings using python3 fallback when jq unavailable", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
use_cached: true,
settings: '{"new.setting": "new_value"}',
expect(settingsResult.exitCode).toBe(0);
expect(settingsResult.stdout).toContain("editor.fontSize");
expect(settingsResult.stdout).toContain("16");
expect(settingsResult.stdout).toContain("editor.tabSize");
expect(settingsResult.stdout).toContain("2");
expect(settingsResult.stdout).toContain("workbench.colorTheme");
expect(settingsResult.stdout).toContain("Dracula");
expect(settingsResult.stdout).toContain("editor.formatOnSave");
expect(settingsResult.stdout).toContain("true");
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
it("creates settings file in correct directory structure", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
settings: '{"test.setting": "value"}',
});
// Install python3 (ubuntu:22.04 doesn't have it by default)
await execContainer(containerId, ["apt-get", "update", "-qq"]);
await execContainer(containerId, [
"apt-get",
"install",
"-y",
"-qq",
"python3",
]);
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create mock code-server CLI (no jq installed)
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF'
// Create a mock code CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
echo "Mock code-server running"
exit 0
MOCKEOF
chmod +x /tmp/vscode-web/bin/code-server`,
]);
chmod +x /usr/local/bin/code`,
]);
// Pre-create an existing settings file
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`,
]);
const script = findResourceInstance(state, "coder_script");
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
const scriptResult = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(scriptResult.exitCode).toBe(0);
// Verify directory structure was created
const dirResult = await execContainer(containerId, [
"ls",
"-la",
"/root/.vscode-server/data/Machine/",
]);
// Check that settings were merged using python3 fallback
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(dirResult.exitCode).toBe(0);
expect(dirResult.stdout).toContain("settings.json");
expect(settingsResult.exitCode).toBe(0);
// Should contain both existing and new settings
expect(settingsResult.stdout).toContain("existing.setting");
expect(settingsResult.stdout).toContain("existing_value");
expect(settingsResult.stdout).toContain("new.setting");
expect(settingsResult.stdout).toContain("new_value");
});
// Verify parent directories exist
const parentDirResult = await execContainer(containerId, [
"ls",
"-la",
"/root/.vscode-server/data/",
]);
it("preserves existing settings when neither jq nor python3 available", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
use_cached: true,
settings: '{"new.setting": "new_value"}',
expect(parentDirResult.exitCode).toBe(0);
expect(parentDirResult.stdout).toContain("Machine");
});
// Use ubuntu without installing jq or python3 (neither available by default)
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
it("merges settings with existing settings file", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
settings: '{"new.setting": "new_value"}',
});
// Create mock code-server CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF'
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Install jq and create mock code CLI
await execContainer(containerId, ["apt-get", "update", "-qq"]);
await execContainer(containerId, [
"apt-get",
"install",
"-y",
"-qq",
"jq",
]);
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
echo "Mock code-server running"
exit 0
MOCKEOF
chmod +x /tmp/vscode-web/bin/code-server`,
]);
chmod +x /usr/local/bin/code`,
]);
// Pre-create an existing settings file
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`,
]);
// Pre-create an existing settings file
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`,
]);
const script = findResourceInstance(state, "coder_script");
const script = findResourceInstance(state, "coder_script");
// Run script - should warn but not fail
const scriptResult = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(scriptResult.exitCode).toBe(0);
expect(scriptResult.stdout).toContain("Could not merge settings");
await execContainer(containerId, ["bash", "-c", script.script]);
// Existing settings should be preserved (not overwritten)
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
// Check that settings were merged (both existing and new should be present)
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(settingsResult.exitCode).toBe(0);
expect(settingsResult.stdout).toContain("existing.setting");
expect(settingsResult.stdout).toContain("existing_value");
expect(settingsResult.stdout).not.toContain("new.setting");
expect(settingsResult.stdout).not.toContain("new_value");
expect(settingsResult.exitCode).toBe(0);
// Should contain both existing and new settings
expect(settingsResult.stdout).toContain("existing.setting");
expect(settingsResult.stdout).toContain("existing_value");
expect(settingsResult.stdout).toContain("new.setting");
expect(settingsResult.stdout).toContain("new_value");
});
it("creates valid JSON settings file", async () => {
const settings = {
"editor.fontSize": 14,
"editor.wordWrap": "on",
"files.autoSave": "afterDelay",
"files.autoSaveDelay": 1000,
};
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
settings: JSON.stringify(settings),
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Install jq and create mock code CLI
await execContainer(containerId, ["apt-get", "update", "-qq"]);
await execContainer(containerId, [
"apt-get",
"install",
"-y",
"-qq",
"jq",
]);
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
// Validate JSON using jq
const jsonValidResult = await execContainer(containerId, [
"bash",
"-c",
"jq '.' /root/.vscode-server/data/Machine/settings.json",
]);
expect(jsonValidResult.exitCode).toBe(0);
// Extract specific values using jq
const fontSizeResult = await execContainer(containerId, [
"bash",
"-c",
"jq '.\"editor.fontSize\"' /root/.vscode-server/data/Machine/settings.json",
]);
expect(fontSizeResult.stdout.trim()).toBe("14");
const wordWrapResult = await execContainer(containerId, [
"bash",
"-c",
"jq '.\"editor.wordWrap\"' /root/.vscode-server/data/Machine/settings.json",
]);
expect(wordWrapResult.stdout.trim()).toBe('"on"');
const autoSaveDelayResult = await execContainer(containerId, [
"bash",
"-c",
"jq '.\"files.autoSaveDelay\"' /root/.vscode-server/data/Machine/settings.json",
]);
expect(autoSaveDelayResult.stdout.trim()).toBe("1000");
});
it("installs extensions", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
extensions: '["ms-python.python", "golang.go"]',
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI that logs extension installs
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
if [ "\$1" = "--install-extension" ]; then
echo "MOCK_EXTENSION_INSTALL: \$2"
fi
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Installing extension");
expect(result.stdout).toContain("ms-python.python");
expect(result.stdout).toContain("golang.go");
});
it("runs with correct server arguments", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
port: 9999,
telemetry_level: "off",
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI that captures all arguments
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
echo "MOCK_CODE_ARGS: \$@"
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
// Check the output contains expected port message
expect(result.stdout).toContain("Starting VS Code Web on port 9999");
});
it("passes commit-id to code CLI when specified", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
commit_id: "abc123def456",
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI that logs arguments to the log file (where output is redirected)
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
echo "MOCK_CODE_ARGS: $@"
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
// Wait briefly for background process to write to log
await new Promise((resolve) => setTimeout(resolve, 500));
// Check the log file for the arguments (code CLI output goes there)
const logResult = await execContainer(containerId, [
"cat",
"/tmp/vscode-web.log",
]);
expect(logResult.exitCode).toBe(0);
expect(logResult.stdout).toContain("--commit-id abc123def456");
});
// This test downloads and starts the real VS Code server
it("starts real VS Code CLI and responds to healthcheck (requires network)", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
port: 13338,
install_prefix: "/tmp/vscode-web",
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Install curl for downloading CLI and healthcheck
await execContainer(containerId, ["apt-get", "update", "-qq"]);
await execContainer(containerId, [
"apt-get",
"install",
"-y",
"-qq",
"curl",
]);
const script = findResourceInstance(state, "coder_script");
// Run the script - it will start the server in background
const startResult = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(startResult.exitCode).toBe(0);
expect(startResult.stdout).toContain("Starting VS Code Web");
// Wait for server to start and check healthcheck
await new Promise((resolve) => setTimeout(resolve, 10000));
const healthResult = await execContainer(containerId, [
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"http://127.0.0.1:13338/healthz",
]);
// Server should respond (200, 202, or 404 is acceptable - means server is running)
expect(["200", "202", "404"]).toContain(healthResult.stdout.trim());
});
});
});
+17 -17
View File
@@ -59,12 +59,6 @@ variable "install_prefix" {
default = "/tmp/vscode-web"
}
variable "commit_id" {
type = string
description = "Specify the commit ID of the VS Code Web binary to pin to a specific version. If left empty, the latest stable version is used."
default = ""
}
variable "extensions" {
type = list(string)
description = "A list of extensions to install."
@@ -148,22 +142,28 @@ variable "subdomain" {
default = true
}
variable "platform" {
type = string
description = "The platform to use for the VS Code Web."
default = ""
validation {
condition = var.platform == "" || var.platform == "linux" || var.platform == "darwin" || var.platform == "alpine" || var.platform == "win32"
error_message = "Incorrect value. Please set either 'linux', 'darwin', or 'alpine' or 'win32'."
}
}
variable "workspace" {
type = string
description = "Path to a .code-workspace file to open in vscode-web."
default = ""
}
variable "release_channel" {
type = string
description = "The release channel for VS Code CLI (stable or insiders)."
default = "stable"
validation {
condition = var.release_channel == "stable" || var.release_channel == "insiders"
error_message = "Incorrect value. Please set either 'stable' or 'insiders'."
}
}
variable "commit_id" {
type = string
description = "The commit SHA to use for the VS Code Server. Leave empty to use the latest version."
default = ""
}
data "coder_workspace_owner" "me" {}
data "coder_workspace" "me" {}
@@ -190,8 +190,8 @@ resource "coder_script" "vscode-web" {
WORKSPACE : var.workspace,
AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
SERVER_BASE_PATH : local.server_base_path,
RELEASE_CHANNEL : var.release_channel,
COMMIT_ID : var.commit_id,
PLATFORM : var.platform,
})
run_on_start = true
+344 -109
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
BOLD='\033[0;1m'
RESET='\033[0m'
CODE='\033[36;40;1m'
EXTENSIONS=("${EXTENSIONS}")
VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server"
# Merge settings from module with existing settings file
# Uses jq if available, falls back to Python3 for deep merge
@@ -17,7 +18,7 @@ merge_settings() {
if [ ! -f "$settings_file" ]; then
mkdir -p "$(dirname "$settings_file")"
printf '%s\n' "$new_settings" > "$settings_file"
printf "⚙️ Creating settings file...\n"
printf "Creating settings file...\n"
return 0
fi
@@ -27,7 +28,7 @@ merge_settings() {
if command -v jq > /dev/null 2>&1; then
if jq -s '.[0] * .[1]' "$settings_file" <(printf '%s\n' "$new_settings") > "$tmpfile" 2> /dev/null; then
mv "$tmpfile" "$settings_file"
printf "⚙️ Merging settings...\n"
printf "Merging settings...\n"
return 0
fi
fi
@@ -35,148 +36,382 @@ merge_settings() {
if command -v python3 > /dev/null 2>&1; then
if python3 -c "import json,sys;m=lambda a,b:{**a,**{k:m(a[k],v)if k in a and type(a[k])==type(v)==dict else v for k,v in b.items()}};print(json.dumps(m(json.load(open(sys.argv[1])),json.loads(sys.argv[2])),indent=2))" "$settings_file" "$new_settings" > "$tmpfile" 2> /dev/null; then
mv "$tmpfile" "$settings_file"
printf "⚙️ Merging settings...\n"
printf "Merging settings...\n"
return 0
fi
fi
rm -f "$tmpfile"
printf "Warning: Could not merge settings (jq or python3 required). Keeping existing settings.\n"
printf "Warning: Could not merge settings. Keeping existing settings.\n"
return 0
}
# Set extension directory
# Set extension directory argument
EXTENSION_ARG=""
if [ -n "${EXTENSIONS_DIR}" ]; then
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
fi
# Set server base path
# Set server base path argument
SERVER_BASE_PATH_ARG=""
if [ -n "${SERVER_BASE_PATH}" ]; then
SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}"
fi
# Set disable workspace trust
# Set disable workspace trust argument
DISABLE_TRUST_ARG=""
if [ "${DISABLE_TRUST}" = true ]; then
DISABLE_TRUST_ARG="--disable-workspace-trust"
fi
run_vscode_web() {
echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_ARG $DISABLE_TRUST_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..."
echo "Check logs at ${LOG_PATH}!"
"$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" "$DISABLE_TRUST_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
# Check if code CLI is installed
check_code_cli() {
if command -v code > /dev/null 2>&1; then
echo "code"
return 0
fi
if [ -f "${INSTALL_PREFIX}/bin/code" ]; then
echo "${INSTALL_PREFIX}/bin/code"
return 0
fi
return 1
}
# Check if code-server is installed (fallback option)
check_code_server() {
if command -v code-server > /dev/null 2>&1; then
echo "code-server"
return 0
fi
if [ -f "${INSTALL_PREFIX}/bin/code-server" ]; then
echo "${INSTALL_PREFIX}/bin/code-server"
return 0
fi
return 1
}
# Find existing vscode-server binary (used by code serve-web internally)
find_vscode_server() {
# Check common locations for pre-downloaded vscode-server
local server_dirs=(
"$HOME/.vscode-server/bin"
"$HOME/.vscode/cli/serve-web"
)
for dir in "$${server_dirs[@]}"; do
if [ -d "$dir" ]; then
# Find the most recent server version
local latest
latest=$(ls -t "$dir" 2> /dev/null | head -1)
if [ -n "$latest" ] && [ -f "$dir/$latest/bin/code-server" ]; then
echo "$dir/$latest/bin/code-server"
return 0
fi
if [ -n "$latest" ] && [ -f "$dir/$latest/code-server" ]; then
echo "$dir/$latest/code-server"
return 0
fi
fi
done
return 1
}
# Install VS Code CLI if not present
install_code_cli() {
printf "$${BOLD}Installing VS Code CLI...$${RESET}\n"
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="x64" ;;
aarch64 | arm64) ARCH="arm64" ;;
armv7l) ARCH="armhf" ;;
*)
echo "Unsupported architecture: $ARCH"
exit 1
;;
esac
# Detect platform
# Note: VS Code CLI uses 'alpine' for all Linux distributions
PLATFORM=$(uname -s)
case "$PLATFORM" in
Linux)
PLATFORM="alpine"
;;
Darwin)
PLATFORM="darwin"
;;
*)
echo "Unsupported platform: $PLATFORM"
exit 1
;;
esac
# Create install directory
mkdir -p "${INSTALL_PREFIX}/bin"
# Download VS Code CLI
CLI_URL="https://code.visualstudio.com/sha/download?build=${RELEASE_CHANNEL}&os=cli-$PLATFORM-$ARCH"
printf "Downloading VS Code CLI from %s\n" "$CLI_URL"
if command -v curl > /dev/null 2>&1; then
curl -fsSL "$CLI_URL" -o "/tmp/vscode-cli.tar.gz"
elif command -v wget > /dev/null 2>&1; then
wget -q "$CLI_URL" -O "/tmp/vscode-cli.tar.gz"
else
echo "Neither curl nor wget is available. Please install one of them."
exit 1
fi
# Extract CLI
tar -xzf /tmp/vscode-cli.tar.gz -C "${INSTALL_PREFIX}/bin"
rm -f /tmp/vscode-cli.tar.gz
# The CLI binary is named 'code'
if [ -f "${INSTALL_PREFIX}/bin/code" ]; then
chmod +x "${INSTALL_PREFIX}/bin/code"
export PATH="${INSTALL_PREFIX}/bin:$PATH"
printf "$${BOLD}VS Code CLI installed successfully.$${RESET}\n"
else
echo "Failed to install VS Code CLI"
exit 1
fi
}
# Run VS Code Web using the code CLI (serve-web command)
run_vscode_web_cli() {
local CODE_CMD="$1"
# Build the command arguments
ARGS="serve-web --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL}"
if [ -n "$EXTENSION_ARG" ]; then
ARGS="$ARGS $EXTENSION_ARG"
fi
if [ -n "$SERVER_BASE_PATH_ARG" ]; then
ARGS="$ARGS $SERVER_BASE_PATH_ARG"
fi
if [ -n "$DISABLE_TRUST_ARG" ]; then
ARGS="$ARGS $DISABLE_TRUST_ARG"
fi
if [ -n "${COMMIT_ID}" ]; then
ARGS="$ARGS --commit-id ${COMMIT_ID}"
fi
printf "Starting VS Code Web on port ${PORT}...\n"
printf "Check logs at ${LOG_PATH}\n"
# shellcheck disable=SC2086
"$CODE_CMD" $ARGS > "${LOG_PATH}" 2>&1 &
}
# Run VS Code Web using code-server (fallback for offline mode)
run_code_server() {
local SERVER_CMD="$1"
printf "Starting code-server on port ${PORT}...\n"
printf "Check logs at ${LOG_PATH}\n"
# Build arguments for code-server
ARGS="--port ${PORT} --host 127.0.0.1 --auth none"
if [ -n "$EXTENSION_ARG" ]; then
ARGS="$ARGS $EXTENSION_ARG"
fi
# shellcheck disable=SC2086
"$SERVER_CMD" $ARGS > "${LOG_PATH}" 2>&1 &
}
# Run VS Code Web using vscode-server binary directly
run_vscode_server() {
local SERVER_CMD="$1"
printf "Starting VS Code Server on port ${PORT}...\n"
printf "Check logs at ${LOG_PATH}\n"
# Build arguments for vscode-server
ARGS="--port ${PORT} --host 127.0.0.1 --without-connection-token --accept-server-license-terms --telemetry-level ${TELEMETRY_LEVEL}"
if [ -n "$EXTENSION_ARG" ]; then
ARGS="$ARGS $EXTENSION_ARG"
fi
if [ -n "$SERVER_BASE_PATH_ARG" ]; then
ARGS="$ARGS $SERVER_BASE_PATH_ARG"
fi
# shellcheck disable=SC2086
"$SERVER_CMD" serve-local $ARGS > "${LOG_PATH}" 2>&1 &
}
# Install a single extension by downloading VSIX from marketplace
install_extension_vsix() {
local ext_id="$1"
local publisher
local ext_name
publisher="$${ext_id%%.*}"
ext_name="$${ext_id#*.}"
# Download VSIX from marketplace
local vsix_url="https://$publisher.gallery.vsassets.io/_apis/public/gallery/publisher/$publisher/extension/$ext_name/latest/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage"
local tmp_vsix="/tmp/ext-$ext_id.vsix"
local tmp_dir="/tmp/ext-$ext_id"
if command -v curl > /dev/null 2>&1; then
curl -fsSL "$vsix_url" -o "$tmp_vsix" 2> /dev/null
elif command -v wget > /dev/null 2>&1; then
wget -q "$vsix_url" -O "$tmp_vsix" 2> /dev/null
else
echo "Failed to install extension $ext_id: neither curl nor wget available"
return 1
fi
if [ ! -f "$tmp_vsix" ]; then
echo "Failed to download extension: $ext_id"
return 1
fi
# Extract VSIX (it's a ZIP file)
rm -rf "$tmp_dir"
mkdir -p "$tmp_dir"
if ! unzip -q "$tmp_vsix" -d "$tmp_dir" 2> /dev/null; then
echo "Failed to extract extension: $ext_id"
rm -f "$tmp_vsix"
return 1
fi
# Get version from package.json
local version=""
if [ -f "$tmp_dir/extension/package.json" ]; then
version=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$tmp_dir/extension/package.json" | head -1 | cut -d'"' -f4)
fi
if [ -z "$version" ]; then
version="0.0.0"
fi
# Install to extensions directory
local ext_dir="$HOME/.vscode-server/extensions/$ext_id-$version"
mkdir -p "$HOME/.vscode-server/extensions"
rm -rf "$ext_dir"
mv "$tmp_dir/extension" "$ext_dir"
# Cleanup
rm -rf "$tmp_vsix" "$tmp_dir"
printf "Extension $ext_id v$version installed successfully.\n"
return 0
}
install_extensions() {
# Install specified extensions
IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}"
for extension in "$${EXTENSIONLIST[@]}"; do
if [ -z "$extension" ]; then
continue
fi
printf "Installing extension $extension...\n"
install_extension_vsix "$extension"
done
# Auto-install extensions from workspace or folder
if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
if ! command -v jq > /dev/null; then
echo "jq is required to install extensions from a workspace file."
else
if [ -n "${WORKSPACE}" ] && [ -f "${WORKSPACE}" ]; then
printf "Installing extensions from %s...\n" "${WORKSPACE}"
extensions=$(sed 's|//.*||g' "${WORKSPACE}" | jq -r '(.extensions.recommendations // [])[]')
for extension in $extensions; do
install_extension_vsix "$extension"
done
else
WORKSPACE_DIR="$HOME"
if [ -n "${FOLDER}" ]; then
WORKSPACE_DIR="${FOLDER}"
fi
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
printf "Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR/.vscode/extensions.json" | jq -r '.recommendations[]')
for extension in $extensions; do
install_extension_vsix "$extension"
done
fi
fi
fi
fi
}
# Apply machine settings (merge with existing if present)
SETTINGS_B64='${SETTINGS_B64}'
if [ -n "$SETTINGS_B64" ]; then
if SETTINGS_JSON="$(echo -n "$SETTINGS_B64" | base64 -d 2> /dev/null)" && [ -n "$SETTINGS_JSON" ]; then
merge_settings "$SETTINGS_JSON" ~/.vscode-server/data/Machine/settings.json
else
printf "Warning: Failed to decode settings. Skipping settings configuration.\n"
fi
SETTINGS_JSON="$(echo -n "$SETTINGS_B64" | base64 -d)"
merge_settings "$SETTINGS_JSON" ~/.vscode-server/data/Machine/settings.json
fi
# Check if vscode-server is already installed for offline or cached mode
if [ -f "$VSCODE_WEB" ]; then
if [ "${OFFLINE}" = true ] || [ "${USE_CACHED}" = true ]; then
echo "🥳 Found a copy of VS Code Web"
run_vscode_web
# Determine which command to use
CODE_CMD=""
RUN_MODE=""
# Check for code CLI first (preferred)
if CODE_CMD=$(check_code_cli); then
printf "$${BOLD}Found VS Code CLI at $CODE_CMD$${RESET}\n"
RUN_MODE="cli"
fi
# Handle offline mode
if [ "${OFFLINE}" = true ]; then
if [ -n "$CODE_CMD" ]; then
# Check if vscode-server is already downloaded (code serve-web won't need to download)
if VSCODE_SERVER=$(find_vscode_server); then
printf "Found cached VS Code Server at $VSCODE_SERVER\n"
printf "Using cached VS Code CLI.\n"
run_vscode_web_cli "$CODE_CMD"
exit 0
fi
# Code CLI exists but vscode-server not cached - try using it anyway
# (it might work if server was pre-downloaded, or fail gracefully)
printf "Warning: VS Code Server may not be cached. Attempting to start...\n"
printf "Using cached VS Code CLI.\n"
run_vscode_web_cli "$CODE_CMD"
exit 0
fi
fi
# Offline mode always expects a copy of vscode-server to be present
if [ "${OFFLINE}" = true ]; then
echo "Failed to find a copy of VS Code Web"
# Try code-server as fallback for offline mode
if SERVER_CMD=$(check_code_server); then
printf "$${BOLD}Found code-server at $SERVER_CMD (offline fallback)$${RESET}\n"
run_code_server "$SERVER_CMD"
exit 0
fi
# Try vscode-server binary directly
if VSCODE_SERVER=$(find_vscode_server); then
printf "$${BOLD}Found VS Code Server at $VSCODE_SERVER (offline fallback)$${RESET}\n"
run_vscode_server "$VSCODE_SERVER"
exit 0
fi
echo "Offline mode enabled but no VS Code CLI, code-server, or cached VS Code Server found."
exit 1
fi
# Create install prefix
mkdir -p ${INSTALL_PREFIX}
printf "$${BOLD}Installing Microsoft Visual Studio Code Server!\n"
# Download and extract vscode-server
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="x64" ;;
aarch64) ARCH="arm64" ;;
*)
echo "Unsupported architecture"
exit 1
;;
esac
# Detect the platform
if [ -n "${PLATFORM}" ]; then
DETECTED_PLATFORM="${PLATFORM}"
elif [ -f /etc/alpine-release ] || grep -qi 'ID=alpine' /etc/os-release 2> /dev/null || command -v apk > /dev/null 2>&1; then
DETECTED_PLATFORM="alpine"
elif [ "$(uname -s)" = "Darwin" ]; then
DETECTED_PLATFORM="darwin"
else
DETECTED_PLATFORM="linux"
# Handle use_cached mode
if [ "${USE_CACHED}" = true ] && [ -n "$CODE_CMD" ]; then
printf "Using cached VS Code CLI.\n"
install_extensions
run_vscode_web_cli "$CODE_CMD"
exit 0
fi
# Check if a specific VS Code Web commit ID was provided
if [ -n "${COMMIT_ID}" ]; then
HASH="${COMMIT_ID}"
else
HASH=$(curl -fsSL https://update.code.visualstudio.com/api/commits/stable/server-$DETECTED_PLATFORM-$ARCH-web | cut -d '"' -f 2)
fi
printf "$${BOLD}VS Code Web commit id version $HASH.\n"
output=$(curl -fsSL "https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-$DETECTED_PLATFORM-$ARCH-web.tar.gz" | tar -xz -C "${INSTALL_PREFIX}" --strip-components 1)
if [ $? -ne 0 ]; then
echo "Failed to install Microsoft Visual Studio Code Server: $output"
exit 1
fi
printf "$${BOLD}VS Code Web has been installed.\n"
# Install each extension...
IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}"
# shellcheck disable=SC2066
for extension in "$${EXTENSIONLIST[@]}"; do
if [ -z "$extension" ]; then
continue
fi
printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n"
output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force)
if [ $? -ne 0 ]; then
echo "Failed to install extension: $extension: $output"
fi
done
if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
if ! command -v jq > /dev/null; then
echo "jq is required to install extensions from a workspace file."
else
# Prefer WORKSPACE if set and points to a file
if [ -n "${WORKSPACE}" ] && [ -f "${WORKSPACE}" ]; then
printf "🧩 Installing extensions from %s...\n" "${WORKSPACE}"
# Strip single-line comments then parse .extensions.recommendations[]
extensions=$(sed 's|//.*||g' "${WORKSPACE}" | jq -r '(.extensions.recommendations // [])[]')
for extension in $extensions; do
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
done
else
# Fallback to folder-based .vscode/extensions.json (existing behavior)
WORKSPACE_DIR="$HOME"
if [ -n "${FOLDER}" ]; then
WORKSPACE_DIR="${FOLDER}"
fi
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR/.vscode/extensions.json" | jq -r '.recommendations[]')
for extension in $extensions; do
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
done
fi
fi
fi
# Install VS Code CLI if not present
if [ -z "$CODE_CMD" ]; then
install_code_cli
CODE_CMD="${INSTALL_PREFIX}/bin/code"
RUN_MODE="cli"
fi
run_vscode_web
# Install extensions and run VS Code Web
install_extensions
run_vscode_web_cli "$CODE_CMD"
@@ -0,0 +1,151 @@
run "required_vars" {
command = plan
variables {
agent_id = "foo"
accept_license = true
}
}
run "accept_license_required" {
command = plan
variables {
agent_id = "foo"
accept_license = false
}
expect_failures = [
var.accept_license
]
}
run "offline_and_use_cached_conflict" {
command = plan
variables {
agent_id = "foo"
accept_license = true
use_cached = true
offline = true
}
expect_failures = [
resource.coder_script.vscode-web
]
}
run "offline_disallows_extensions" {
command = plan
variables {
agent_id = "foo"
accept_license = true
offline = true
extensions = ["ms-python.python", "golang.go"]
}
expect_failures = [
resource.coder_script.vscode-web
]
}
run "workspace_and_folder_conflict" {
command = plan
variables {
agent_id = "foo"
accept_license = true
folder = "/home/coder/project"
workspace = "/home/coder/project.code-workspace"
}
expect_failures = [
resource.coder_script.vscode-web
]
}
run "url_with_folder_query" {
command = plan
variables {
agent_id = "foo"
accept_license = true
folder = "/home/coder/project"
port = 13338
}
assert {
condition = resource.coder_app.vscode-web.url == "http://localhost:13338?folder=%2Fhome%2Fcoder%2Fproject"
error_message = "coder_app URL must include encoded folder query param"
}
}
run "url_with_workspace_query" {
command = plan
variables {
agent_id = "foo"
accept_license = true
workspace = "/home/coder/project.code-workspace"
port = 13338
}
assert {
condition = resource.coder_app.vscode-web.url == "http://localhost:13338?workspace=%2Fhome%2Fcoder%2Fproject.code-workspace"
error_message = "coder_app URL must include encoded workspace query param"
}
}
run "release_channel_stable" {
command = plan
variables {
agent_id = "foo"
accept_license = true
release_channel = "stable"
}
}
run "release_channel_insiders" {
command = plan
variables {
agent_id = "foo"
accept_license = true
release_channel = "insiders"
}
}
run "release_channel_invalid" {
command = plan
variables {
agent_id = "foo"
accept_license = true
release_channel = "invalid"
}
expect_failures = [
var.release_channel
]
}
run "commit_id_empty_by_default" {
command = plan
variables {
agent_id = "foo"
accept_license = true
}
}
run "commit_id_with_value" {
command = plan
variables {
agent_id = "foo"
accept_license = true
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
}
}
-38
View File
@@ -11,34 +11,6 @@ set -euo pipefail
#
# This script only validates changed modules. Documentation and template changes are ignored.
# Validates that Terraform variable names use underscores (snake_case) instead
# of hyphens. Hyphens are technically valid but deprecated and non-idiomatic.
# See: https://developer.hashicorp.com/terraform/language/values/variables
validate_variable_names() {
local dir="$1"
local found_issues=0
while IFS= read -r tf_file; do
while IFS= read -r match; do
local line_num
line_num=$(echo "$match" | cut -d: -f1)
local line_content
line_content=$(echo "$match" | cut -d: -f2-)
local var_name
var_name=$(echo "$line_content" | sed -n 's/.*variable "\([^"]*\)".*/\1/p')
if [[ -n "$var_name" ]]; then
echo " ERROR: $tf_file:$line_num"
echo " Variable \"$var_name\" contains a hyphen."
echo " Rename to \"${var_name//-/_}\" (use underscores instead of hyphens)."
found_issues=$((found_issues + 1))
fi
done < <(grep -n 'variable "[^"]*-[^"]*"' "$tf_file" 2> /dev/null || true)
done < <(find "$dir" -name '*.tf' -type f | sort)
return "$found_issues"
}
validate_terraform_directory() {
local dir="$1"
echo "Running \`terraform validate\` in $dir"
@@ -119,16 +91,6 @@ main() {
fi
done
echo ""
echo "==> Validating Terraform variable names use snake_case..."
for dir in $subdirs; do
if test -f "$dir/main.tf"; then
if ! validate_variable_names "$dir"; then
status=1
fi
fi
done
exit $status
}