mirror of
https://github.com/coder/registry.git
synced 2026-06-03 04:58:15 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fdcf0d712 | |||
| 1460293de4 | |||
| 9606297620 | |||
| a0430e6f83 | |||
| 2ee14fdf6e | |||
| 183bd57061 | |||
| 5a241ebce2 | |||
| 4b3045e637 | |||
| d7566cc618 | |||
| 40c2916fa9 |
@@ -37,9 +37,9 @@ jobs:
|
||||
all:
|
||||
- '**'
|
||||
- name: Set up Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
|
||||
uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
|
||||
uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2
|
||||
with:
|
||||
# We're using the latest version of Bun for now, but it might be worth
|
||||
# reconsidering. They've pushed breaking changes in patch releases
|
||||
@@ -82,18 +82,18 @@ jobs:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
|
||||
uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2
|
||||
with:
|
||||
bun-version: latest
|
||||
# Need Terraform for its formatter
|
||||
- name: Install Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
|
||||
uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- name: Validate formatting
|
||||
run: bun fmt:ci
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@65120634e79d8374d1aa2f27e54baa0c364fff5a # v1.42.1
|
||||
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0
|
||||
with:
|
||||
config: .github/typos.toml
|
||||
validate-readme-files:
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: "1.24.0"
|
||||
- name: Validate contributors
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: golangci-lint
|
||||
|
||||
@@ -26,12 +26,12 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
|
||||
uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Set up Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
|
||||
uses: coder/coder/.github/actions/setup-tf@deaacff8437e3f4ee84bc51c4e5162f6dd7d190e # v2.31.3
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor (blocking, HIGH only)
|
||||
uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
|
||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
with:
|
||||
advanced-security: false
|
||||
annotations: true
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor (SARIF)
|
||||
uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
|
||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
with:
|
||||
inputs: |
|
||||
.github/workflows
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none">
|
||||
<g fill="#40BE46">
|
||||
<!-- Eye shape -->
|
||||
<path d="M100 40C55 40 20 80 10 100c10 20 45 60 90 60s80-40 90-60c-10-20-45-60-90-60zm0 100c-35 0-63-28-75-40 12-12 40-40 75-40s63 28 75 40c-12 12-40 40-75 40z"/>
|
||||
<!-- Inner circle (magnifying glass lens) -->
|
||||
<path d="M100 72a28 28 0 1 0 0 56 28 28 0 0 0 0-56zm0 44a16 16 0 1 1 0-32 16 16 0 0 1 0 32z"/>
|
||||
<!-- Horizontal line below -->
|
||||
<rect x="25" y="170" width="150" height="12" rx="6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 542 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M37.3333 213.333C33.0666 213.333 29.3333 211.733 26.1333 208.533C22.9333 205.333 21.3333 201.6 21.3333 197.333V58.6667C21.3333 54.4001 22.9333 50.6667 26.1333 47.4667C29.3333 44.2667 33.0666 42.6667 37.3333 42.6667H218.667C222.933 42.6667 226.667 44.2667 229.867 47.4667C233.067 50.6667 234.667 54.4001 234.667 58.6667V197.333C234.667 201.6 233.067 205.333 229.867 208.533C226.667 211.733 222.933 213.333 218.667 213.333H37.3333ZM37.3333 197.333H218.667V81.0668H37.3333V197.333ZM80 178.133L68.8 166.933L96.2666 139.2L68.5333 111.467L80 100.267L118.933 139.2L80 178.133ZM130.667 179.2V163.2H189.333V179.2H130.667Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 745 B |
@@ -28,6 +28,8 @@ bun test main.test.ts # Run single TS test (from
|
||||
- Use semantic versioning; bump version via script when modifying modules
|
||||
- Docker tests require Linux or Colima/OrbStack (not Docker Desktop)
|
||||
- Use `tf` (not `hcl`) for code blocks in README; use relative icon paths (e.g., `../../../../.icons/`)
|
||||
- **Do NOT include input/output variable tables in module or template READMEs.** The registry automatically generates these from the Terraform source (e.g., variable and output blocks in `main.tf`). Adding them to the README is redundant and creates maintenance drift.
|
||||
- Usage examples (e.g., a `module "..." { }` block) are encouraged, but not tables enumerating inputs/outputs.
|
||||
|
||||
## PR Review Checklist
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.2.0"
|
||||
version = "4.3.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.2.0"
|
||||
version = "4.3.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.2.0"
|
||||
version = "4.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_aibridge = true
|
||||
@@ -94,7 +94,7 @@ data "coder_task" "me" {}
|
||||
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.2.0"
|
||||
version = "4.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
ai_prompt = data.coder_task.me.prompt
|
||||
@@ -105,6 +105,26 @@ module "codex" {
|
||||
}
|
||||
```
|
||||
|
||||
### Usage with Agent Boundaries
|
||||
|
||||
This example shows how to configure the Codex module to run the agent behind a process-level boundary that restricts its network access.
|
||||
|
||||
By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation.
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.3.0"
|
||||
agent_id = coder_agent.main.id
|
||||
openai_api_key = var.openai_api_key
|
||||
workdir = "/home/coder/project"
|
||||
enable_boundary = true
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> For developers: The module also supports installing boundary from a release version (`use_boundary_directly = true`) or compiling from source (`compile_boundary_from_source = true`). These are escape hatches for development and testing purposes.
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This example shows additional configuration options for custom models, MCP servers, and base configuration.
|
||||
@@ -112,7 +132,7 @@ This example shows additional configuration options for custom models, MCP serve
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.2.0"
|
||||
version = "4.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -473,4 +473,47 @@ describe("codex", async () => {
|
||||
);
|
||||
expect(configToml).toContain('profile = "aibridge"');
|
||||
});
|
||||
|
||||
test("boundary-enabled", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
enable_boundary: "true",
|
||||
boundary_config_path: "/tmp/test-boundary.yaml",
|
||||
},
|
||||
});
|
||||
// Write boundary config
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat > /tmp/test-boundary.yaml <<'EOF'
|
||||
jail_type: landjail
|
||||
proxy_port: 8087
|
||||
log_level: warn
|
||||
allowlist:
|
||||
- "domain=api.openai.com"
|
||||
EOF`,
|
||||
]);
|
||||
// Add mock coder binary for boundary setup
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/coder",
|
||||
content: `#!/bin/bash
|
||||
if [ "$1" = "boundary" ]; then
|
||||
if [ "$2" = "--help" ]; then
|
||||
echo "boundary help"
|
||||
exit 0
|
||||
fi
|
||||
shift; shift; exec "$@"
|
||||
fi
|
||||
echo "mock coder"`,
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
// Verify boundary wrapper was used in start script
|
||||
const startLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/agentapi-start.log",
|
||||
);
|
||||
expect(startLog).toContain("boundary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,6 +176,36 @@ variable "codex_system_prompt" {
|
||||
default = "You are a helpful coding assistant. Start every response with `Codex says:`"
|
||||
}
|
||||
|
||||
variable "enable_boundary" {
|
||||
type = bool
|
||||
description = "Enable coder boundary for network filtering."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "boundary_config_path" {
|
||||
type = string
|
||||
description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "boundary_version" {
|
||||
type = string
|
||||
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release."
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
variable "compile_boundary_from_source" {
|
||||
type = bool
|
||||
description = "Whether to compile boundary from source instead of using the official install script."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "use_boundary_directly" {
|
||||
type = bool
|
||||
description = "Whether to use boundary binary directly instead of coder boundary subcommand."
|
||||
default = false
|
||||
}
|
||||
|
||||
resource "coder_env" "openai_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "OPENAI_API_KEY"
|
||||
@@ -212,26 +242,31 @@ locals {
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
folder = local.workdir
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = var.web_app_display_name
|
||||
cli_app = var.cli_app
|
||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_subdomain = var.subdomain
|
||||
agentapi_version = var.agentapi_version
|
||||
enable_state_persistence = var.enable_state_persistence
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
agent_id = var.agent_id
|
||||
folder = local.workdir
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = var.web_app_display_name
|
||||
cli_app = var.cli_app
|
||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_subdomain = var.subdomain
|
||||
agentapi_version = var.agentapi_version
|
||||
enable_state_persistence = var.enable_state_persistence
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
enable_boundary = var.enable_boundary
|
||||
boundary_config_path = var.boundary_config_path
|
||||
boundary_version = var.boundary_version
|
||||
compile_boundary_from_source = var.compile_boundary_from_source
|
||||
use_boundary_directly = var.use_boundary_directly
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
@@ -210,7 +210,16 @@ capture_session_id() {
|
||||
|
||||
start_codex() {
|
||||
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
|
||||
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
|
||||
# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh when
|
||||
# enable_boundary=true. It points to a wrapper script that runs the command
|
||||
# through coder boundary, sandboxing only the agent process.
|
||||
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
|
||||
printf "Starting with coder boundary enabled\n"
|
||||
agentapi server --type codex --term-width 67 --term-height 1190 -- \
|
||||
"${AGENTAPI_BOUNDARY_PREFIX}" codex "${CODEX_ARGS[@]}" &
|
||||
else
|
||||
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
|
||||
fi
|
||||
capture_session_id
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
@@ -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.2.0"
|
||||
version = "2.3.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
@@ -67,8 +67,7 @@ module "agentapi" {
|
||||
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`).
|
||||
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:
|
||||
|
||||
@@ -89,6 +88,47 @@ module "agentapi" {
|
||||
}
|
||||
```
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -613,4 +613,109 @@ describe("agentapi", async () => {
|
||||
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,36 @@ 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."
|
||||
@@ -182,6 +212,13 @@ variable "pid_file_path" {
|
||||
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
|
||||
workdir = trimsuffix(var.folder, "/")
|
||||
@@ -200,6 +237,7 @@ locals {
|
||||
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" {
|
||||
@@ -214,6 +252,9 @@ 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)" \
|
||||
@@ -228,6 +269,10 @@ 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}' \
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -16,6 +16,10 @@ 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:-}"
|
||||
@@ -109,9 +113,18 @@ 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.
|
||||
|
||||
@@ -31,6 +31,15 @@ for (const v of [
|
||||
);
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
|
||||
+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
|
||||
|
||||
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_api_key = "xxxx-xxxxx-xxxx"
|
||||
@@ -60,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_boundary = true
|
||||
@@ -81,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_aibridge = true
|
||||
@@ -110,7 +110,7 @@ data "coder_task" "me" {}
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
ai_prompt = data.coder_task.me.prompt
|
||||
@@ -133,7 +133,7 @@ This example shows additional configuration options for version pinning, custom
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -189,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
install_claude_code = true
|
||||
@@ -211,7 +211,7 @@ variable "claude_code_oauth_token" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_code_oauth_token = var.claude_code_oauth_token
|
||||
@@ -284,7 +284,7 @@ resource "coder_env" "bedrock_api_key" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
@@ -341,7 +341,7 @@ resource "coder_env" "google_application_credentials" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.0"
|
||||
version = "4.8.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "claude-sonnet-4@20250514"
|
||||
|
||||
@@ -88,7 +88,7 @@ TASK_SESSION_ID="cd32e253-ca16-4fd3-9825-d837e74ae3c2"
|
||||
|
||||
get_project_dir() {
|
||||
local workdir_normalized
|
||||
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-')
|
||||
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/._' '-')
|
||||
echo "$HOME/.claude/projects/${workdir_normalized}"
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -31,7 +31,7 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -42,7 +42,7 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
}
|
||||
@@ -54,14 +54,14 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
module "dotfiles-root" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
dotfiles_uri = module.dotfiles.dotfiles_uri
|
||||
@@ -90,7 +90,7 @@ You can set a default dotfiles repository for all users by setting the `default_
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
default_dotfiles_uri = "https://github.com/coder/dotfiles"
|
||||
}
|
||||
|
||||
@@ -62,7 +62,41 @@ describe("dotfiles", async () => {
|
||||
agent_id: "foo",
|
||||
coder_parameter_order: order.toString(),
|
||||
});
|
||||
expect(state.resources).toHaveLength(3);
|
||||
const parameters = state.resources.filter(
|
||||
(r) => r.type === "coder_parameter",
|
||||
);
|
||||
for (const param of parameters) {
|
||||
expect(param.instances[0].attributes.order).toBe(order);
|
||||
}
|
||||
});
|
||||
|
||||
it("set custom dotfiles_branch", async () => {
|
||||
const branch = "develop";
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
dotfiles_branch: branch,
|
||||
});
|
||||
expect(state.resources).toHaveLength(2);
|
||||
expect(state.resources[0].instances[0].attributes.order).toBe(order);
|
||||
const scriptResource = state.resources.find(
|
||||
(r) => r.type === "coder_script",
|
||||
);
|
||||
expect(scriptResource?.instances[0].attributes.script).toContain(
|
||||
`DOTFILES_BRANCH="${branch}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("default dotfiles_branch creates parameter", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
});
|
||||
expect(state.resources).toHaveLength(3);
|
||||
const branchParameter = state.resources.find(
|
||||
(r) =>
|
||||
r.type === "coder_parameter" &&
|
||||
r.instances[0].attributes.name === "dotfiles_branch",
|
||||
);
|
||||
expect(branchParameter).toBeDefined();
|
||||
expect(branchParameter?.instances[0].attributes.default).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,12 @@ variable "default_dotfiles_uri" {
|
||||
}
|
||||
}
|
||||
|
||||
variable "default_dotfiles_branch" {
|
||||
type = string
|
||||
description = "The default dotfiles branch if the workspace user does not provide one"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "dotfiles_uri" {
|
||||
type = string
|
||||
description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
|
||||
@@ -61,6 +67,17 @@ variable "dotfiles_uri" {
|
||||
}
|
||||
}
|
||||
|
||||
variable "dotfiles_branch" {
|
||||
type = string
|
||||
description = "The branch to use for the dotfiles repository (optional, when set, the user isn't prompted for the branch)"
|
||||
default = null
|
||||
|
||||
validation {
|
||||
condition = var.dotfiles_branch == null || var.dotfiles_branch != ""
|
||||
error_message = "dotfiles_branch cannot be an empty string. Use null to prompt the user or provide a valid branch name."
|
||||
}
|
||||
}
|
||||
|
||||
variable "user" {
|
||||
type = string
|
||||
description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
|
||||
@@ -107,8 +124,21 @@ data "coder_parameter" "dotfiles_uri" {
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "dotfiles_branch" {
|
||||
count = var.dotfiles_branch == null ? 1 : 0
|
||||
type = "string"
|
||||
name = "dotfiles_branch"
|
||||
display_name = "Dotfiles Branch"
|
||||
order = var.coder_parameter_order
|
||||
default = var.default_dotfiles_branch
|
||||
description = "The branch to use for the dotfiles repository"
|
||||
mutable = true
|
||||
icon = "/icon/dotfiles.svg"
|
||||
}
|
||||
|
||||
locals {
|
||||
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
|
||||
dotfiles_branch = var.dotfiles_branch != null ? var.dotfiles_branch : data.coder_parameter.dotfiles_branch[0].value
|
||||
user = var.user != null ? var.user : ""
|
||||
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
|
||||
}
|
||||
@@ -118,6 +148,7 @@ resource "coder_script" "dotfiles" {
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
DOTFILES_URI : local.dotfiles_uri,
|
||||
DOTFILES_USER : local.user,
|
||||
DOTFILES_BRANCH : local.dotfiles_branch,
|
||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
||||
})
|
||||
display_name = "Dotfiles"
|
||||
@@ -136,6 +167,7 @@ resource "coder_app" "dotfiles" {
|
||||
command = templatefile("${path.module}/run.sh", {
|
||||
DOTFILES_URI : local.dotfiles_uri,
|
||||
DOTFILES_USER : local.user,
|
||||
DOTFILES_BRANCH : local.dotfiles_branch,
|
||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
|
||||
DOTFILES_URI="${DOTFILES_URI}"
|
||||
DOTFILES_USER="${DOTFILES_USER}"
|
||||
DOTFILES_BRANCH="${DOTFILES_BRANCH}"
|
||||
|
||||
# Validate DOTFILES_URI to prevent command injection (defense in depth)
|
||||
if [ -n "$DOTFILES_URI" ]; then
|
||||
@@ -24,10 +25,18 @@ if [ -n "$${DOTFILES_URI// }" ]; then
|
||||
DOTFILES_USER="$USER"
|
||||
fi
|
||||
|
||||
echo "✨ Applying dotfiles for user $DOTFILES_USER"
|
||||
if [ -n "$DOTFILES_BRANCH" ]; then
|
||||
echo "✨ Applying dotfiles for user $DOTFILES_USER from branch $DOTFILES_BRANCH"
|
||||
else
|
||||
echo "✨ Applying dotfiles for user $DOTFILES_USER"
|
||||
fi
|
||||
|
||||
if [ "$DOTFILES_USER" = "$USER" ]; then
|
||||
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
|
||||
if [ -n "$DOTFILES_BRANCH" ]; then
|
||||
coder dotfiles "$DOTFILES_URI" --branch "$DOTFILES_BRANCH" -y 2>&1 | tee ~/.dotfiles.log
|
||||
else
|
||||
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
|
||||
fi
|
||||
else
|
||||
if command -v getent > /dev/null 2>&1; then
|
||||
DOTFILES_USER_HOME=$(getent passwd "$DOTFILES_USER" | cut -d: -f6)
|
||||
@@ -40,7 +49,11 @@ if [ -n "$${DOTFILES_URI// }" ]; then
|
||||
fi
|
||||
|
||||
CODER_BIN=$(command -v coder)
|
||||
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
|
||||
if [ -n "$DOTFILES_BRANCH" ]; then
|
||||
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" --branch "$DOTFILES_BRANCH" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
|
||||
else
|
||||
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -8,13 +8,13 @@ tags: [ai, agents, development, multiplexer]
|
||||
|
||||
# Mux
|
||||
|
||||
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. 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 keeps watching the mux process after startup, appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime, and can optionally wait a few seconds, remove the stale server lock, and restart Mux after any exit until an optional restart-attempt cap is reached. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
|
||||
|
||||
```tf
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.3.1"
|
||||
version = "1.4.3"
|
||||
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.3.1"
|
||||
version = "1.4.3"
|
||||
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.3.1"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.main.id
|
||||
# Default is "latest"; set to a specific version to pin
|
||||
install_version = "0.4.0"
|
||||
@@ -63,7 +63,7 @@ Start Mux with `mux server --add-project /path/to/project`:
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.3.1"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.main.id
|
||||
add_project = "/path/to/project"
|
||||
}
|
||||
@@ -78,19 +78,35 @@ The module parses quoted values, so grouped arguments remain intact.
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.3.1"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.main.id
|
||||
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
|
||||
}
|
||||
```
|
||||
|
||||
### Restart After Mux Exits
|
||||
|
||||
Enable automatic restarts after Mux exits, including clean exits and intentional shutdown signals such as `SIGTERM`. The launcher waits for `restart_delay_seconds`, removes `~/.mux/server.lock`, and starts Mux again. Set `max_restart_attempts` to a whole number to stop retrying after a fixed number of restarts, or leave it at `0` for unlimited retries.
|
||||
|
||||
```tf
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.main.id
|
||||
restart_on_kill = true
|
||||
restart_delay_seconds = 3
|
||||
max_restart_attempts = 5
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Port
|
||||
|
||||
```tf
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.3.1"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.main.id
|
||||
port = 8080
|
||||
}
|
||||
@@ -104,7 +120,7 @@ Force a specific package manager instead of auto-detection:
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.3.1"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.main.id
|
||||
package_manager = "pnpm" # or "npm", "bun"
|
||||
}
|
||||
@@ -118,7 +134,7 @@ Use a private or mirrored npm registry:
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.3.1"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.main.id
|
||||
registry_url = "https://npm.pkg.github.com"
|
||||
}
|
||||
@@ -132,7 +148,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.3.1"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.main.id
|
||||
use_cached = true
|
||||
}
|
||||
@@ -146,7 +162,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.3.1"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.main.id
|
||||
install = false
|
||||
}
|
||||
@@ -163,3 +179,6 @@ module "mux" {
|
||||
- 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
|
||||
- Set `restart_on_kill = true` to wait `restart_delay_seconds`, remove `~/.mux/server.lock`, and restart Mux after it exits
|
||||
- Set `max_restart_attempts` to a whole-number cap on restart attempts, or leave it at `0` for unlimited retries
|
||||
|
||||
@@ -96,6 +96,192 @@ chmod +x /tmp/mux/mux`,
|
||||
}
|
||||
}, 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("restarts after a clean exit when enabled", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
install: false,
|
||||
log_path: "/tmp/mux.log",
|
||||
restart_on_kill: true,
|
||||
restart_delay_seconds: 1,
|
||||
max_restart_attempts: 1,
|
||||
});
|
||||
|
||||
const instance = findResourceInstance(state, "coder_script");
|
||||
const id = await runContainer("alpine/curl");
|
||||
|
||||
try {
|
||||
const setup = await execContainer(id, [
|
||||
"sh",
|
||||
"-c",
|
||||
`apk add --no-cache bash >/dev/null
|
||||
mkdir -p /tmp/mux
|
||||
cat <<'EOF' > /tmp/mux/mux
|
||||
#!/usr/bin/env sh
|
||||
run_count_file="/tmp/mux-run-count"
|
||||
run_count=0
|
||||
if [ -f "$run_count_file" ]; then
|
||||
run_count=$(cat "$run_count_file")
|
||||
fi
|
||||
run_count=$((run_count + 1))
|
||||
printf '%s' "$run_count" > "$run_count_file"
|
||||
echo "run=$run_count"
|
||||
if [ "$run_count" -eq 1 ]; then
|
||||
mkdir -p "$HOME/.mux"
|
||||
touch "$HOME/.mux/server.lock"
|
||||
exit 0
|
||||
fi
|
||||
if [ -f "$HOME/.mux/server.lock" ]; then
|
||||
echo "lock=present"
|
||||
else
|
||||
echo "lock=cleaned"
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x /tmp/mux/mux`,
|
||||
]);
|
||||
expect(setup.exitCode).toBe(0);
|
||||
|
||||
const output = await execContainer(id, ["sh", "-c", instance.script]);
|
||||
if (output.exitCode !== 0) {
|
||||
console.log("STDOUT:\n" + output.stdout);
|
||||
console.log("STDERR:\n" + output.stderr);
|
||||
}
|
||||
expect(output.exitCode).toBe(0);
|
||||
|
||||
await execContainer(id, ["sh", "-c", "sleep 4"]);
|
||||
const log = await readFileContainer(id, "/tmp/mux.log");
|
||||
const runCount = await readFileContainer(id, "/tmp/mux-run-count");
|
||||
expect(log).toContain("run=1");
|
||||
expect(log).toContain("mux server exited cleanly.");
|
||||
expect(log).toContain(
|
||||
"Waiting 1 seconds before restarting mux after it exited.",
|
||||
);
|
||||
expect(log).toContain(
|
||||
"Removing /root/.mux/server.lock before restarting mux.",
|
||||
);
|
||||
expect(log).toContain("run=2");
|
||||
expect(log).toContain("lock=cleaned");
|
||||
expect(log).toContain(
|
||||
"Reached the max restart attempts limit (1); not restarting mux again.",
|
||||
);
|
||||
expect(runCount.trim()).toBe("2");
|
||||
} finally {
|
||||
await removeContainer(id);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it("restarts after SIGTERM when enabled", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
install: false,
|
||||
log_path: "/tmp/mux.log",
|
||||
restart_on_kill: true,
|
||||
restart_delay_seconds: 1,
|
||||
max_restart_attempts: 1,
|
||||
});
|
||||
|
||||
const instance = findResourceInstance(state, "coder_script");
|
||||
const id = await runContainer("alpine/curl");
|
||||
|
||||
try {
|
||||
const setup = await execContainer(id, [
|
||||
"sh",
|
||||
"-c",
|
||||
`apk add --no-cache bash >/dev/null
|
||||
mkdir -p /tmp/mux
|
||||
cat <<'EOF' > /tmp/mux/mux
|
||||
#!/usr/bin/env sh
|
||||
run_count_file="/tmp/mux-run-count"
|
||||
run_count=0
|
||||
if [ -f "$run_count_file" ]; then
|
||||
run_count=$(cat "$run_count_file")
|
||||
fi
|
||||
run_count=$((run_count + 1))
|
||||
printf '%s' "$run_count" > "$run_count_file"
|
||||
echo "run=$run_count"
|
||||
if [ "$run_count" -eq 1 ]; then
|
||||
kill -TERM $$
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x /tmp/mux/mux`,
|
||||
]);
|
||||
expect(setup.exitCode).toBe(0);
|
||||
|
||||
const output = await execContainer(id, ["sh", "-c", instance.script]);
|
||||
if (output.exitCode !== 0) {
|
||||
console.log("STDOUT:\n" + output.stdout);
|
||||
console.log("STDERR:\n" + output.stderr);
|
||||
}
|
||||
expect(output.exitCode).toBe(0);
|
||||
|
||||
await execContainer(id, ["sh", "-c", "sleep 4"]);
|
||||
const log = await readFileContainer(id, "/tmp/mux.log");
|
||||
const runCount = await readFileContainer(id, "/tmp/mux-run-count");
|
||||
expect(log).toContain("run=1");
|
||||
expect(log).toContain("signal TERM (15); shell exit code 143.");
|
||||
expect(log).toContain(
|
||||
"Waiting 1 seconds before restarting mux after it exited.",
|
||||
);
|
||||
expect(log).toContain("run=2");
|
||||
expect(log).toContain(
|
||||
"Reached the max restart attempts limit (1); not restarting mux again.",
|
||||
);
|
||||
expect(runCount.trim()).toBe("2");
|
||||
} finally {
|
||||
await removeContainer(id);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it("runs with npm present", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
|
||||
@@ -49,6 +49,34 @@ variable "log_path" {
|
||||
default = "/tmp/mux.log"
|
||||
}
|
||||
|
||||
variable "restart_on_kill" {
|
||||
type = bool
|
||||
description = "Restart Mux after it exits by waiting briefly, removing the server lock, and launching it again."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "restart_delay_seconds" {
|
||||
type = number
|
||||
description = "How long to wait before restarting Mux after it exits when restart_on_kill is enabled."
|
||||
default = 5
|
||||
|
||||
validation {
|
||||
condition = var.restart_delay_seconds >= 0
|
||||
error_message = "The 'restart_delay_seconds' variable must be greater than or equal to 0."
|
||||
}
|
||||
}
|
||||
|
||||
variable "max_restart_attempts" {
|
||||
type = number
|
||||
description = "Maximum whole-number restart attempts before giving up. Set to 0 for unlimited restarts when restart_on_kill is enabled."
|
||||
default = 0
|
||||
|
||||
validation {
|
||||
condition = var.max_restart_attempts >= 0 && floor(var.max_restart_attempts) == var.max_restart_attempts
|
||||
error_message = "The 'max_restart_attempts' variable must be a whole number greater than or equal to 0."
|
||||
}
|
||||
}
|
||||
|
||||
variable "add_project" {
|
||||
type = string
|
||||
description = "Optional path to add/open as a project in Mux on startup."
|
||||
@@ -171,6 +199,9 @@ resource "coder_script" "mux" {
|
||||
OFFLINE : !var.install,
|
||||
USE_CACHED : var.use_cached,
|
||||
AUTH_TOKEN : local.mux_auth_token,
|
||||
RESTART_ON_KILL : var.restart_on_kill,
|
||||
RESTART_DELAY_SECONDS : var.restart_delay_seconds,
|
||||
MAX_RESTART_ATTEMPTS : var.max_restart_attempts,
|
||||
PACKAGE_MANAGER : var.package_manager,
|
||||
REGISTRY_URL : local.registry_url,
|
||||
})
|
||||
|
||||
@@ -93,6 +93,129 @@ run "custom_additional_arguments" {
|
||||
}
|
||||
}
|
||||
|
||||
run "launcher_logs_external_kills" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "shell exit code $exit_code")
|
||||
error_message = "mux launcher must log the shell exit code when the server dies unexpectedly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "SIGKILL usually means the process was killed externally or by the OOM killer.")
|
||||
error_message = "mux launcher must explain SIGKILL exits in the log"
|
||||
}
|
||||
}
|
||||
|
||||
run "restart_on_kill_enabled" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
restart_on_kill = true
|
||||
restart_delay_seconds = 7
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "restart_on_kill_value=\"true\"")
|
||||
error_message = "mux launcher must receive the restart_on_kill setting"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "restart_delay_seconds_value=\"7\"")
|
||||
error_message = "mux launcher must receive the configured restart delay"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "Waiting $${RESTART_DELAY_SECONDS_VALUE} seconds before restarting mux after it exited.")
|
||||
error_message = "mux launcher must log the restart delay before relaunching"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "Removing $HOME/.mux/server.lock before restarting mux.")
|
||||
error_message = "mux launcher must clean up the server lock before relaunching"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = !strcontains(resource.coder_script.mux.script, "\"$exit_code\" -le 128")
|
||||
error_message = "mux launcher must no longer exclude non-signal exits from restart handling"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = !strcontains(resource.coder_script.mux.script, "1|2|15)")
|
||||
error_message = "mux launcher must no longer exclude intentional signals from restart handling"
|
||||
}
|
||||
}
|
||||
|
||||
run "restart_on_kill_with_restart_cap" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
restart_on_kill = true
|
||||
restart_delay_seconds = 7
|
||||
max_restart_attempts = 2
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "max_restart_attempts_value=\"2\"")
|
||||
error_message = "mux launcher must receive the configured restart cap"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "Mux will stop restarting after $${max_restart_attempts_value} restart attempts.")
|
||||
error_message = "mux launcher must describe the configured restart cap"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "Reached the max restart attempts limit ($MAX_RESTART_ATTEMPTS_VALUE); not restarting mux again.")
|
||||
error_message = "mux launcher must log when it hits the restart cap"
|
||||
}
|
||||
}
|
||||
|
||||
run "invalid_max_restart_attempts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
max_restart_attempts = -1
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.max_restart_attempts
|
||||
]
|
||||
}
|
||||
|
||||
run "fractional_max_restart_attempts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
max_restart_attempts = 0.5
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.max_restart_attempts
|
||||
]
|
||||
}
|
||||
|
||||
run "invalid_restart_delay_seconds" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
restart_delay_seconds = -1
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.restart_delay_seconds
|
||||
]
|
||||
}
|
||||
|
||||
run "custom_version" {
|
||||
command = plan
|
||||
|
||||
|
||||
@@ -5,16 +5,32 @@ RESET='\033[0m'
|
||||
MUX_BINARY="${INSTALL_PREFIX}/mux"
|
||||
|
||||
function run_mux() {
|
||||
# Remove stale server lock if present
|
||||
rm -f "$HOME/.mux/server.lock"
|
||||
|
||||
local port_value
|
||||
local auth_token_value
|
||||
local restart_on_kill_value
|
||||
local restart_delay_seconds_value
|
||||
local max_restart_attempts_value
|
||||
|
||||
port_value="${PORT}"
|
||||
auth_token_value="${AUTH_TOKEN}"
|
||||
restart_on_kill_value="${RESTART_ON_KILL}"
|
||||
restart_delay_seconds_value="${RESTART_DELAY_SECONDS}"
|
||||
max_restart_attempts_value="${MAX_RESTART_ATTEMPTS}"
|
||||
|
||||
if [ -z "$port_value" ]; then
|
||||
port_value="4000"
|
||||
fi
|
||||
|
||||
if [ -z "$restart_delay_seconds_value" ]; then
|
||||
restart_delay_seconds_value="5"
|
||||
fi
|
||||
|
||||
if [ -z "$max_restart_attempts_value" ]; then
|
||||
max_restart_attempts_value="0"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "${LOG_PATH}")"
|
||||
|
||||
# Build args for mux (POSIX-compatible, avoid bash arrays)
|
||||
set -- server --port "$port_value"
|
||||
if [ -n "${ADD_PROJECT}" ]; then
|
||||
@@ -31,16 +47,153 @@ function run_mux() {
|
||||
while IFS= read -r parsed_arg; do
|
||||
[ -n "$parsed_arg" ] || continue
|
||||
set -- "$@" "$parsed_arg"
|
||||
done << EOF
|
||||
done << EOF_ARGS
|
||||
$${parsed_additional_arguments}
|
||||
EOF
|
||||
EOF_ARGS
|
||||
fi
|
||||
|
||||
echo "🚀 Starting mux server on port $port_value..."
|
||||
echo "Check logs at ${LOG_PATH}!"
|
||||
MUX_SERVER_AUTH_TOKEN="$auth_token_value" PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
|
||||
echo "ℹ️ Mux exit details will be appended to ${LOG_PATH} by the launcher."
|
||||
if [ "$restart_on_kill_value" = true ]; then
|
||||
echo "ℹ️ Auto-restart after mux exits is enabled with a $${restart_delay_seconds_value}-second delay."
|
||||
if [ "$max_restart_attempts_value" = "0" ]; then
|
||||
echo "ℹ️ Automatic restarts are unlimited for every mux exit."
|
||||
else
|
||||
echo "ℹ️ Mux will stop restarting after $${max_restart_attempts_value} restart attempts."
|
||||
fi
|
||||
fi
|
||||
|
||||
nohup env \
|
||||
LOG_PATH="${LOG_PATH}" \
|
||||
MUX_BINARY="$MUX_BINARY" \
|
||||
AUTH_TOKEN="$auth_token_value" \
|
||||
PORT_VALUE="$port_value" \
|
||||
RESTART_ON_KILL_VALUE="$restart_on_kill_value" \
|
||||
RESTART_DELAY_SECONDS_VALUE="$restart_delay_seconds_value" \
|
||||
MAX_RESTART_ATTEMPTS_VALUE="$max_restart_attempts_value" \
|
||||
bash -s -- "$@" > /dev/null 2>&1 << 'EOF_LAUNCHER' &
|
||||
signal_name() {
|
||||
local signal_number="$1"
|
||||
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
|
||||
}
|
||||
|
||||
cleanup_mux_lock() {
|
||||
rm -f "$HOME/.mux/server.lock"
|
||||
}
|
||||
|
||||
should_restart_mux() {
|
||||
[ "$RESTART_ON_KILL_VALUE" = "true" ]
|
||||
}
|
||||
|
||||
log_mux_exit() {
|
||||
local mux_pid="$1"
|
||||
local exit_code="$2"
|
||||
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."
|
||||
}
|
||||
|
||||
log_mux_restart_wait() {
|
||||
local timestamp
|
||||
|
||||
timestamp="$(date -Iseconds 2> /dev/null || date)"
|
||||
echo "[$timestamp] Waiting $${RESTART_DELAY_SECONDS_VALUE} seconds before restarting mux after it exited."
|
||||
}
|
||||
|
||||
log_mux_restart_cleanup() {
|
||||
local timestamp
|
||||
|
||||
timestamp="$(date -Iseconds 2> /dev/null || date)"
|
||||
echo "[$timestamp] Removing $HOME/.mux/server.lock before restarting mux."
|
||||
}
|
||||
|
||||
log_mux_restart_cap_reached() {
|
||||
local timestamp
|
||||
|
||||
timestamp="$(date -Iseconds 2> /dev/null || date)"
|
||||
echo "[$timestamp] Reached the max restart attempts limit ($MAX_RESTART_ATTEMPTS_VALUE); not restarting mux again."
|
||||
}
|
||||
|
||||
restart_attempt_count=0
|
||||
while true; do
|
||||
cleanup_mux_lock
|
||||
MUX_SERVER_AUTH_TOKEN="$AUTH_TOKEN" PORT="$PORT_VALUE" "$MUX_BINARY" "$@" >> "$LOG_PATH" 2>&1 &
|
||||
mux_pid=$!
|
||||
wait "$mux_pid"
|
||||
exit_code=$?
|
||||
log_mux_exit "$mux_pid" "$exit_code" >> "$LOG_PATH" 2>&1
|
||||
|
||||
if should_restart_mux; then
|
||||
if [ "$MAX_RESTART_ATTEMPTS_VALUE" -gt 0 ] && [ "$restart_attempt_count" -ge "$MAX_RESTART_ATTEMPTS_VALUE" ]; then
|
||||
log_mux_restart_cap_reached >> "$LOG_PATH" 2>&1
|
||||
break
|
||||
fi
|
||||
|
||||
restart_attempt_count=$((restart_attempt_count + 1))
|
||||
log_mux_restart_wait >> "$LOG_PATH" 2>&1
|
||||
sleep "$RESTART_DELAY_SECONDS_VALUE"
|
||||
cleanup_mux_lock
|
||||
log_mux_restart_cleanup >> "$LOG_PATH" 2>&1
|
||||
continue
|
||||
fi
|
||||
|
||||
break
|
||||
done
|
||||
EOF_LAUNCHER
|
||||
}
|
||||
# Check if mux is already installed for offline mode
|
||||
if [ "${OFFLINE}" = true ]; then
|
||||
if [ -f "$MUX_BINARY" ]; then
|
||||
|
||||
Reference in New Issue
Block a user