Compare commits

...

8 Commits

Author SHA1 Message Date
35C4n0r f1748c80f7 feat(coder-labs/modules/codex): add support for agentapi state_persistence (#785)
## Description

- add support for agentapi state_persistence

## Type of Change

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

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/coder-labs/modules/codex`  
**New version:** `v4.2.0`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

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

## Related Issues

Closes: #783
2026-03-05 19:20:21 +05:30
Susana Ferreira f6a09d4c34 ci: remove branch filter to support stacked PRs (#786) 2026-03-05 15:39:14 +05:00
Susana Ferreira 7e75d5d762 feat: add AI Bridge Proxy support to copilot module (#725)
## Description

Add AI Bridge Proxy support to the copilot module. When enabled, the module configures proxy environment variables (`HTTPS_PROXY`, `NODE_EXTRA_CA_CERTS`) scoped to the copilot process tree (agentapi and copilot), routing Copilot traffic through AI Bridge Proxy without affecting other workspace traffic.

GitHub authentication is still required, the proxy authenticates with AI Bridge using the Coder session token but does not replace GitHub authentication.

Note: Uses [coder exp sync](https://coder.com/docs/admin/templates/startup-coordination) for startup coordination, ensuring the copilot module waits for the `aibridge-proxy` setup to complete before starting.

## Type of Change

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

## Module Information

**Path:** `registry/coder-labs/modules/copilot`  
**New version:** `v0.4.0`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

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

## Related Issues

Depends on: #721
Related to: https://github.com/coder/internal/issues/1187
2026-03-05 09:34:41 +00:00
Susana Ferreira b6c2998eb3 feat: add aibridge-proxy module for AI Bridge Proxy workspace setup (#721)
## Description

Add `aibridge-proxy` module that configures workspaces to use AI Bridge Proxy. Downloads the proxy's CA certificate and exposes `proxy_auth_url` and `cert_path` outputs for tool-specific modules to configure the proxy scoped to their process. The module does not set proxy environment variables globally in the workspace.

## Type of Change

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

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/coder/modules/aibridge-proxy`  
**New version:** `v1.0.0`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

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

## Related Issues

Closes: https://github.com/coder/internal/issues/1187
2026-03-05 09:27:01 +00:00
Jason Barnett ac49e6eef5 docs(claude-code): document pre_install_script for module dependency ordering (#613)
## Summary

Clarifies that the existing `pre_install_script` variable can be used to
handle dependencies between modules during workspace startup.

## Problem

When using multiple startup modules (e.g., git-clone and claude-code),
there's a race condition where scripts execute in parallel. Module
dependencies need to be managed, such as ensuring git-clone completes
before Claude Code tries to access a workdir.

## Solution

The existing `pre_install_script` variable already provides this
capability. Updated documentation to clarify this use case.

## Example

```hcl
module "claude-code" {
  source = "registry.coder.com/coder/claude-code/coder"
  
  workdir = "/path/to/repo"
  
  # Wait for git-clone to complete before starting
  pre_install_script = <<-EOT
    #!/bin/bash
    set -e
    while [ ! -f /tmp/.git-clone-complete ]; do
      sleep 1
    done
  EOT
}
```

Resolves issue #609.

Co-authored-by: Jason Barnett <Jason.Barnett@altana.ai>
Co-authored-by: DevCats <christofer@coder.com>
2026-03-03 15:28:48 -06:00
justmanuel 63e28c0e95 Enable Devcontainer-cli module to block user login until script finishes running (#759)
## Description
Allow for devcontainer-cli module to prevent users from logging in until
its finished running.

## Type of Change

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

## Module Information

**Path:** `registry/coder/modules/devcontainers-cli`  
**New version:** `1.1.0`  
**Breaking change:** [ ] Yes [x ] No

## Testing & Validation

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

## Related Issues
None

---------

Co-authored-by: DevCats <chris@dualriver.com>
Co-authored-by: DevCats <christofer@coder.com>
2026-03-03 15:01:05 -06:00
DevCats eed8e6c29a feat(vscode-web): enhance settings management and testing for VS Code Web (#758)
This pull request enhances the VS Code Web module by improving how
machine settings are handled and merged, updating documentation to
clarify the settings behavior, and adding robust automated tests for the
new functionality. The most significant changes are grouped below.

**Machine Settings Handling and Merging:**

* Introduced a new `merge_settings` function in `run.sh` that merges
provided settings with any existing machine settings using `jq` or
`python3` if available, falling back gracefully if neither is present.
Settings are now passed as base64-encoded JSON to avoid quoting issues.
[[1]](diffhunk://#diff-c6d09ac3d801a2417c0e3cf8c2cd0f093ba2cf245bad8c213f70115c75276323R7-R54)
[[2]](diffhunk://#diff-c6d09ac3d801a2417c0e3cf8c2cd0f093ba2cf245bad8c213f70115c75276323L31-R76)
[[3]](diffhunk://#diff-0c7f0791e2c2556eb4ed7666ac44534ea3ff5c7f652e01716e5d7b5c31180d92L180-R184)
[[4]](diffhunk://#diff-0c7f0791e2c2556eb4ed7666ac44534ea3ff5c7f652e01716e5d7b5c31180d92R170-R173)
* Updated the `settings` variable in `main.tf` to clarify that it
applies to VS Code Web's Machine settings and will be merged with any
existing settings on startup.

**Documentation Improvements:**

* Updated the README to clarify that settings are merged with existing
machine settings, not simply overwritten, and added a note about the
requirements (`jq` or `python3`) and limitations regarding persistence
of user settings.
[[1]](diffhunk://#diff-24e2e305e46a08f8a30243bdc916241586e4561d97861b4397b14e871f9f085dL54-R56)
[[2]](diffhunk://#diff-24e2e305e46a08f8a30243bdc916241586e4561d97861b4397b14e871f9f085dR72-R73)

**Automated Testing:**

* Expanded `main.test.ts` to include integration tests that verify
settings file creation and merging behavior inside a container, as well
as improved error handling for invalid configuration combinations.

These changes collectively make machine settings management more robust,
user-friendly, and well-documented.
2026-03-03 11:30:32 -06:00
Mathias Fredriksson 7b245549ec feat(coder/modules/claude-code): add enable_state_persistence variable (#749)
feat(coder/modules/claude-code): add enable_state_persistence variable

Expose the agentapi module's state persistence toggle so users can
control conversation state persistence across workspace restarts.
Enabled by default, set `enable_state_persistence = false` to disable.

Also bumps agentapi dependency from 2.0.0 to 2.2.0 and claude-code
to 4.8.0.

Refs coder/internal#1258
2026-03-03 18:03:57 +02:00
22 changed files with 1623 additions and 115 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
name: CI
on:
pull_request:
branches: [main]
# Cancel in-progress runs for pull requests when developers push new changes
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
+20 -7
View File
@@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.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
+26 -19
View File
@@ -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"
}
}
+39 -6
View File
@@ -3,7 +3,7 @@ display_name: Copilot CLI
description: GitHub Copilot CLI agent for AI-powered terminal assistance
icon: ../../../../.icons/github.svg
verified: false
tags: [agent, copilot, ai, github, tasks]
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"
}
}
+33 -1
View File
@@ -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,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."
+22 -9
View File
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.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"
+26 -19
View File
@@ -67,7 +67,7 @@ variable "cli_app_display_name" {
variable "pre_install_script" {
type = string
description = "Custom script to run before installing Claude Code."
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
}
+11 -8
View File
@@ -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"
}
+283 -27
View File
@@ -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");
});
});
+6 -3
View File
@@ -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,
+50 -6
View File
@@ -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