mirror of
https://github.com/coder/registry.git
synced 2026-06-03 04:58:15 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a241ebce2 | |||
| 4b3045e637 | |||
| d7566cc618 | |||
| 40c2916fa9 | |||
| f1748c80f7 | |||
| f6a09d4c34 | |||
| 7e75d5d762 | |||
| b6c2998eb3 | |||
| ac49e6eef5 | |||
| 63e28c0e95 | |||
| eed8e6c29a | |||
| 7b245549ec |
@@ -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@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
|
||||
uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
|
||||
uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2
|
||||
with:
|
||||
# We're using the latest version of Bun for now, but it might be worth
|
||||
# reconsidering. They've pushed breaking changes in patch releases
|
||||
@@ -82,18 +82,18 @@ jobs:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
|
||||
uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2
|
||||
with:
|
||||
bun-version: latest
|
||||
# Need Terraform for its formatter
|
||||
- name: Install Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
|
||||
uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- name: Validate formatting
|
||||
run: bun fmt:ci
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@65120634e79d8374d1aa2f27e54baa0c364fff5a # v1.42.1
|
||||
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0
|
||||
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@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: "1.24.0"
|
||||
- name: Validate contributors
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: golangci-lint
|
||||
|
||||
@@ -26,12 +26,12 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
|
||||
uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Set up Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
|
||||
uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor (blocking, HIGH only)
|
||||
uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
|
||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
with:
|
||||
advanced-security: false
|
||||
annotations: true
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor (SARIF)
|
||||
uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
|
||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
with:
|
||||
inputs: |
|
||||
.github/workflows
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 542 B |
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 745 B |
@@ -28,6 +28,8 @@ 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
|
||||
|
||||
|
||||
@@ -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.1.2"
|
||||
version = "4.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = var.openai_api_key
|
||||
workdir = "/home/coder/project"
|
||||
@@ -32,7 +32,7 @@ module "codex" {
|
||||
module "codex" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.1.2"
|
||||
version = "4.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
workdir = "/home/coder/project"
|
||||
@@ -51,7 +51,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.1.2"
|
||||
version = "4.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_aibridge = true
|
||||
@@ -63,6 +63,8 @@ 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"
|
||||
@@ -75,8 +77,6 @@ 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.1.2"
|
||||
version = "4.2.0"
|
||||
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.1.2"
|
||||
version = "4.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
workdir = "/home/coder/project"
|
||||
@@ -148,6 +148,19 @@ 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
|
||||
|
||||
@@ -131,7 +131,7 @@ variable "install_agentapi" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.11.8"
|
||||
default = "v0.12.1"
|
||||
}
|
||||
|
||||
variable "codex_model" {
|
||||
@@ -164,6 +164,12 @@ 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"
|
||||
@@ -206,25 +212,26 @@ locals {
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.2.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
folder = local.workdir
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = var.web_app_display_name
|
||||
cli_app = var.cli_app
|
||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_subdomain = var.subdomain
|
||||
agentapi_version = var.agentapi_version
|
||||
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
|
||||
enable_state_persistence = var.enable_state_persistence
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
tags: [agent, copilot, ai, github, tasks, aibridge]
|
||||
---
|
||||
|
||||
# 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.3.0"
|
||||
version = "0.4.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.3.0"
|
||||
version = "0.4.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.3.0"
|
||||
version = "0.4.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.3.0"
|
||||
version = "0.4.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.3.0"
|
||||
version = "0.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder"
|
||||
report_tasks = false
|
||||
@@ -164,6 +164,39 @@ 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,3 +234,116 @@ 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,5 +1,5 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
required_version = ">= 1.9"
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
@@ -173,6 +173,35 @@ 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" {}
|
||||
|
||||
@@ -279,6 +308,9 @@ 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,6 +22,9 @@ 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
|
||||
@@ -118,6 +121,48 @@ 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"
|
||||
@@ -157,5 +202,6 @@ start_agentapi() {
|
||||
}
|
||||
|
||||
setup_github_authentication
|
||||
setup_aibridge_proxy
|
||||
validate_copilot_installation
|
||||
start_agentapi
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,112 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/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}"
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
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"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,254 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/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."
|
||||
@@ -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.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_api_key = "xxxx-xxxxx-xxxx"
|
||||
@@ -36,6 +36,19 @@ 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
|
||||
@@ -47,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_boundary = true
|
||||
@@ -68,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_aibridge = true
|
||||
@@ -97,7 +110,7 @@ data "coder_task" "me" {}
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
ai_prompt = data.coder_task.me.prompt
|
||||
@@ -120,7 +133,7 @@ This example shows additional configuration options for version pinning, custom
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -176,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
install_claude_code = true
|
||||
@@ -198,7 +211,7 @@ variable "claude_code_oauth_token" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_code_oauth_token = var.claude_code_oauth_token
|
||||
@@ -271,7 +284,7 @@ resource "coder_env" "bedrock_api_key" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
@@ -328,7 +341,7 @@ resource "coder_env" "google_application_credentials" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "claude-sonnet-4@20250514"
|
||||
|
||||
@@ -67,7 +67,7 @@ variable "cli_app_display_name" {
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing Claude Code."
|
||||
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)."
|
||||
default = null
|
||||
}
|
||||
|
||||
@@ -261,6 +261,12 @@ 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
|
||||
@@ -356,25 +362,26 @@ locals {
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.2.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = var.web_app_display_name
|
||||
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
|
||||
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
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
@@ -387,6 +387,36 @@ 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
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ 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.0.34"
|
||||
agent_id = coder_agent.example.id
|
||||
source = "registry.coder.com/coder/devcontainers-cli/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
start_blocks_login = false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -14,10 +14,17 @@ variable "agent_id" {
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
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
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,244 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.5.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.4.3"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
install_prefix = "/home/coder/.vscode-web"
|
||||
folder = "/home/coder"
|
||||
@@ -44,22 +44,22 @@ module "vscode-web" {
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
|
||||
accept_license = true
|
||||
}
|
||||
```
|
||||
|
||||
### Pre-configure Settings
|
||||
### Pre-configure Machine Settings
|
||||
|
||||
Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file:
|
||||
Configure VS Code's [Machine settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file). These settings are merged with any existing machine settings on startup:
|
||||
|
||||
```tf
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["dracula-theme.theme-dracula"]
|
||||
settings = {
|
||||
@@ -69,6 +69,9 @@ 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).
|
||||
@@ -77,7 +80,7 @@ By default, this module installs the latest. To pin a specific version, retrieve
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
|
||||
accept_license = true
|
||||
@@ -93,7 +96,7 @@ Note: Either `workspace` or `folder` can be used, but not both simultaneously. T
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
workspace = "/home/coder/coder.code-workspace"
|
||||
}
|
||||
|
||||
@@ -1,42 +1,298 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { runTerraformApply, runTerraformInit } from "~test";
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
beforeAll,
|
||||
afterEach,
|
||||
setDefaultTimeout,
|
||||
} from "bun:test";
|
||||
import {
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
runContainer,
|
||||
execContainer,
|
||||
removeContainer,
|
||||
findResourceInstance,
|
||||
} from "~test";
|
||||
|
||||
// Set timeout to 2 minutes for tests that install packages
|
||||
setDefaultTimeout(2 * 60 * 1000);
|
||||
|
||||
let cleanupContainers: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of cleanupContainers) {
|
||||
try {
|
||||
await removeContainer(id);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
cleanupContainers = [];
|
||||
});
|
||||
|
||||
describe("vscode-web", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
it("accept_license should be set to true", () => {
|
||||
const t = async () => {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: "false",
|
||||
});
|
||||
};
|
||||
expect(t).toThrow("Invalid value for variable");
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
it("use_cached and offline can not be used together", () => {
|
||||
const t = async () => {
|
||||
it("accept_license should be set to true", async () => {
|
||||
try {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: "true",
|
||||
use_cached: "true",
|
||||
offline: "true",
|
||||
accept_license: false,
|
||||
});
|
||||
};
|
||||
expect(t).toThrow("Offline and Use Cached can not be used together");
|
||||
throw new Error("Expected terraform apply to fail");
|
||||
} catch (ex) {
|
||||
expect((ex as Error).message).toContain("Invalid value for variable");
|
||||
}
|
||||
});
|
||||
|
||||
it("offline and extensions can not be used together", () => {
|
||||
const t = async () => {
|
||||
it("use_cached and offline can not be used together", async () => {
|
||||
try {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: "true",
|
||||
offline: "true",
|
||||
extensions: '["1", "2"]',
|
||||
accept_license: true,
|
||||
use_cached: true,
|
||||
offline: true,
|
||||
});
|
||||
};
|
||||
expect(t).toThrow("Offline mode does not allow extensions to be installed");
|
||||
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",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// More tests depend on shebang refactors
|
||||
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("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);
|
||||
|
||||
// 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'
|
||||
#!/bin/bash
|
||||
echo "Mock code-server running"
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /tmp/vscode-web/bin/code-server`,
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
const scriptResult = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
expect(scriptResult.exitCode).toBe(0);
|
||||
|
||||
// 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"}',
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// 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'
|
||||
#!/bin/bash
|
||||
echo "Mock code-server running"
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /tmp/vscode-web/bin/code-server`,
|
||||
]);
|
||||
|
||||
// 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 scriptResult = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
expect(scriptResult.exitCode).toBe(0);
|
||||
|
||||
// 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"}',
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// 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",
|
||||
]);
|
||||
|
||||
// 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'
|
||||
#!/bin/bash
|
||||
echo "Mock code-server running"
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /tmp/vscode-web/bin/code-server`,
|
||||
]);
|
||||
|
||||
// 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 scriptResult = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
expect(scriptResult.exitCode).toBe(0);
|
||||
|
||||
// Check that settings were merged using python3 fallback
|
||||
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("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"}',
|
||||
});
|
||||
|
||||
// Use ubuntu without installing jq or python3 (neither available by default)
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Create mock code-server CLI
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
echo "Mock code-server running"
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /tmp/vscode-web/bin/code-server`,
|
||||
]);
|
||||
|
||||
// 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");
|
||||
|
||||
// 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");
|
||||
|
||||
// Existing settings should be preserved (not overwritten)
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ variable "group" {
|
||||
|
||||
variable "settings" {
|
||||
type = any
|
||||
description = "A map of settings to apply to VS Code web."
|
||||
description = "A map of settings to apply to VS Code Web's Machine settings. These settings are merged with any existing machine settings on startup."
|
||||
default = {}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,10 @@ variable "workspace" {
|
||||
data "coder_workspace_owner" "me" {}
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
locals {
|
||||
settings_b64 = var.settings != {} ? base64encode(jsonencode(var.settings)) : ""
|
||||
}
|
||||
|
||||
resource "coder_script" "vscode-web" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "VS Code Web"
|
||||
@@ -177,8 +181,7 @@ resource "coder_script" "vscode-web" {
|
||||
INSTALL_PREFIX : var.install_prefix,
|
||||
EXTENSIONS : join(",", var.extensions),
|
||||
TELEMETRY_LEVEL : var.telemetry_level,
|
||||
// This is necessary otherwise the quotes are stripped!
|
||||
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
|
||||
SETTINGS_B64 : local.settings_b64,
|
||||
OFFLINE : var.offline,
|
||||
USE_CACHED : var.use_cached,
|
||||
DISABLE_TRUST : var.disable_trust,
|
||||
|
||||
@@ -4,13 +4,54 @@ BOLD='\033[0;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
|
||||
merge_settings() {
|
||||
local new_settings="$1"
|
||||
local settings_file="$2"
|
||||
|
||||
if [ -z "$new_settings" ] || [ "$new_settings" = "{}" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$settings_file" ]; then
|
||||
mkdir -p "$(dirname "$settings_file")"
|
||||
printf '%s\n' "$new_settings" > "$settings_file"
|
||||
printf "⚙️ Creating settings file...\n"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local tmpfile
|
||||
tmpfile="$(mktemp)"
|
||||
|
||||
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"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
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"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f "$tmpfile"
|
||||
printf "Warning: Could not merge settings (jq or python3 required). Keeping existing settings.\n"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Set extension directory
|
||||
EXTENSION_ARG=""
|
||||
if [ -n "${EXTENSIONS_DIR}" ]; then
|
||||
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
|
||||
fi
|
||||
|
||||
# Set extension directory
|
||||
# Set server base path
|
||||
SERVER_BASE_PATH_ARG=""
|
||||
if [ -n "${SERVER_BASE_PATH}" ]; then
|
||||
SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}"
|
||||
@@ -28,11 +69,14 @@ run_vscode_web() {
|
||||
"$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 the settings file exists...
|
||||
if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then
|
||||
echo "⚙️ Creating settings file..."
|
||||
mkdir -p ~/.vscode-server/data/Machine
|
||||
echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json
|
||||
# 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
|
||||
fi
|
||||
|
||||
# Check if vscode-server is already installed for offline or cached mode
|
||||
|
||||
Reference in New Issue
Block a user