Compare commits

..

1 Commits

42 changed files with 2783 additions and 3940 deletions

View File

@ -37,7 +37,7 @@ jobs:
all: all:
- '**' - '**'
- name: Set up Terraform - name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@2b778f292c2ddf8ac261683d0d5d8a18da1512f6 # v2.33.3 uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
- name: Set up Bun - name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with: with:
@ -87,13 +87,13 @@ jobs:
bun-version: latest bun-version: latest
# Need Terraform for its formatter # Need Terraform for its formatter
- name: Install Terraform - name: Install Terraform
uses: coder/coder/.github/actions/setup-tf@2b778f292c2ddf8ac261683d0d5d8a18da1512f6 # v2.33.3 uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install
- name: Validate formatting - name: Validate formatting
run: bun fmt:ci run: bun fmt:ci
- name: Check for typos - name: Check for typos
uses: crate-ci/typos@aca895bf05aec0cb7dffa6f94495e923224d9f17 # v1.46.2 uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
with: with:
config: .github/typos.toml config: .github/typos.toml
validate-readme-files: validate-readme-files:

View File

@ -31,7 +31,7 @@ jobs:
bun-version: latest bun-version: latest
- name: Set up Terraform - name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@2b778f292c2ddf8ac261683d0d5d8a18da1512f6 # v2.33.3 uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install

View File

@ -27,7 +27,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Run zizmor (blocking, HIGH only) - name: Run zizmor (blocking, HIGH only)
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
with: with:
advanced-security: false advanced-security: false
annotations: true annotations: true
@ -49,7 +49,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Run zizmor (SARIF) - name: Run zizmor (SARIF)
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
with: with:
inputs: | inputs: |
.github/workflows .github/workflows

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="7" height="7" x="14" y="3" rx="1"/>
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
</svg>

Before

Width:  |  Height:  |  Size: 339 B

View File

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="7" x="3" y="3" rx="1"/>
<rect width="9" height="7" x="3" y="14" rx="1"/>
<rect width="5" height="7" x="16" y="14" rx="1"/>
</svg>

Before

Width:  |  Height:  |  Size: 336 B

100
AGENTS.md
View File

@ -21,84 +21,6 @@ bun test main.test.ts # Run single TS test (from
- **Templates**: `registry/[ns]/templates/[name]/` with `main.tf`, `README.md` - **Templates**: `registry/[ns]/templates/[name]/` with `main.tf`, `README.md`
- **Validation**: `cmd/readmevalidation/` (Go) validates structure/frontmatter; URLs must be relative, not absolute - **Validation**: `cmd/readmevalidation/` (Go) validates structure/frontmatter; URLs must be relative, not absolute
## Module Data Layout
All runtime data a module writes on the workspace MUST live under a single per-module root:
```
$HOME/.coder-modules/<namespace>/<module-name>/
```
For a Coder-owned module named `claude-code`, the root is `$HOME/.coder-modules/coder/claude-code/`.
Within that root, use these standard subdirectories:
| Subdirectory | Purpose | Example |
| ------------ | ----------------------------------------- | ----------------------------------------------------------- |
| `logs/` | Output from install, start, or any script | `$HOME/.coder-modules/coder/claude-code/logs/install.log` |
| `scripts/` | Scripts materialized at runtime (if any) | `$HOME/.coder-modules/coder/claude-code/scripts/install.sh` |
- Name log files after the script that produced them (`install.sh` writes to `logs/install.log`, `start.sh` writes to `logs/start.log`).
- Always `mkdir -p` the target directory before writing; do not assume it exists.
- Do not write module runtime data to `$HOME` directly, to ad-hoc paths like `~/.<module>-module/`, or to `/tmp/` for anything that must survive the session.
- Tool-specific data (config files, caches, state, etc.) lives wherever the tool expects; only standardize paths the module itself controls.
- READMEs and tests should reference paths under this root so troubleshooting has one place to look.
- New modules MUST follow this layout. Existing modules should migrate to it when they are next touched.
## Use `coder-utils` for Script Orchestration
For any new module that runs scripts (or when reworking an existing one), use the [`coder-utils`](registry/coder/modules/coder-utils) module to orchestrate `pre_install`, `install`, `post_install`, and `start` scripts instead of hand-rolling `coder_script` resources.
- `coder-utils` handles script ordering via `coder exp sync`, materializes scripts under `module_directory/scripts/` (e.g., `install.sh`, `start.sh`), and writes logs to `module_directory/logs/` automatically, which aligns with the Module Data Layout above.
- Set `module_directory = "$HOME/.coder-modules/<namespace>/<module-name>"` so the standard root, `scripts/`, and `logs/` subdirectories fall out for free.
### Passing scripts to `coder-utils`
Store each script as a `.tftpl` file under `scripts/`. Render it at **plan time** in a `locals` block using `templatefile()`, then pass the rendered string directly to the `coder-utils` module.
**Encoding rules for template variables:**
| Value type | Terraform side | Template (`.tftpl`) side |
| ------------------------------------- | ----------------------------------- | ---------------------------------------------- |
| String / path | pass as-is | `ARG_FOO='${ARG_FOO}'` |
| Boolean | `tostring(var.foo)` | `ARG_FOO='${ARG_FOO}'` |
| Free-form string (may contain quotes) | `base64encode(var.foo)` | `ARG_FOO=$(echo -n '${ARG_FOO}' \| base64 -d)` |
| Object / list (JSON) | `base64encode(jsonencode(var.foo))` | `ARG_FOO=$(echo -n '${ARG_FOO}' \| base64 -d)` |
In `.tftpl` files, write literal bash `$` as `$$` (e.g., `$${HOME}`) so Terraform does not treat them as template interpolations.
```tf
locals {
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
ARG_FOO = var.foo
ARG_BAR = var.bar
})
}
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
agent_id = var.agent_id
module_directory = "$HOME/.coder-modules/<namespace>/<module-name>"
display_name_prefix = "My Module"
icon = var.icon
pre_install_script = var.pre_install_script
install_script = local.install_script
post_install_script = var.post_install_script
start_script = var.start_script # optional; omit if the module does not start a process
}
```
Always expose the `scripts` output as a pass-through so upstream modules can serialize their own `coder_script` resources behind this module's install pipeline:
```tf
output "scripts" {
description = "Ordered list of coder exp sync names produced by this module, in run order."
value = module.coder_utils.scripts
}
```
## Code Style ## Code Style
- Every module MUST have `.tftest.hcl` tests; optional `main.test.ts` for container/script tests - Every module MUST have `.tftest.hcl` tests; optional `main.test.ts` for container/script tests
@ -109,28 +31,6 @@ output "scripts" {
- **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. - **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. - Usage examples (e.g., a `module "..." { }` block) are encouraged, but not tables enumerating inputs/outputs.
### Variable and output conventions
Order variable blocks: `description``type``default``validation``sensitive`.
```tf
variable "api_key" {
description = "API key for the service."
type = string
default = ""
sensitive = true
}
```
- Mark variables and outputs that hold secrets or tokens `sensitive = true`.
- Every `output` block must have a `description`.
- Use `count = condition ? 1 : 0` for optional singleton resources. Reserve `for_each` for maps/sets where resource identity matters.
### `.tftest.hcl` test commands
- Use `command = plan` only for assertions on **input-derived values** (variables, locals computed from inputs).
- Use `command = apply` for **computed attributes** (resource IDs, anything the provider generates), and for nested blocks of set type (they cannot be indexed with `[0]` under `plan`).
## PR Review Checklist ## PR Review Checklist
- Version bumped via `.github/scripts/version-bump.sh` if module changed (patch=bugfix, minor=feature, major=breaking) - Version bumped via `.github/scripts/version-bump.sh` if module changed (patch=bugfix, minor=feature, major=breaking)

View File

@ -1,107 +1,149 @@
--- ---
display_name: Codex CLI display_name: Codex CLI
icon: ../../../../.icons/openai.svg icon: ../../../../.icons/openai.svg
description: Install and configure the Codex CLI in your workspace. description: Run Codex CLI in your workspace with AgentAPI integration
verified: true verified: true
tags: [agent, codex, ai, openai, ai-gateway] tags: [agent, codex, ai, openai, tasks, aibridge]
--- ---
# Codex CLI # Codex CLI
Install and configure the [Codex CLI](https://github.com/openai/codex) in your workspace. Run Codex CLI in your workspace to access OpenAI's models through the Codex interface, with custom pre/post install scripts. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for Coder Tasks compatibility.
```tf ```tf
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "5.0.0" version = "4.3.1"
agent_id = coder_agent.main.id agent_id = coder_agent.example.id
openai_api_key = var.openai_api_key openai_api_key = var.openai_api_key
workdir = "/home/coder/project"
} }
``` ```
> [!WARNING] ## Prerequisites
> If upgrading from v4.x.x of this module: v5 is a major refactor that drops support for [Coder Tasks](https://coder.com/docs/ai-coder/tasks) and [Boundary](https://coder.com/docs/ai-coder/agent-firewall). v5 also assumes npm is pre-installed; it no longer bootstraps Node.js. Keep using v4.x.x if you depend on them. See the [PR description](https://github.com/coder/registry/pull/879) for a full migration guide.
- OpenAI API key for Codex access
## Examples ## Examples
### Standalone mode with a launcher app ### Run standalone
```tf ```tf
locals { module "codex" {
codex_workdir = "/home/coder/project" count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
report_tasks = false
} }
```
### Usage with AI Bridge
[AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) is a Premium Coder feature that provides centralized LLM proxy management. To use AI Bridge, set `enable_aibridge = true`. Requires Coder version 2.30+
For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage with Tasks](#usage-with-tasks) example below.
#### Standalone usage with AI Bridge
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
enable_aibridge = true
}
```
When `enable_aibridge = true`, the module:
- Configures Codex to use the aibridge model_provider with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token
```toml
model_provider = "aibridge"
[model_providers.aibridge]
name = "AI Bridge"
base_url = "https://example.coder.com/api/v2/aibridge/openai/v1"
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses"
```
This allows Codex to route API requests through Coder's AI Bridge instead of directly to OpenAI's API.
Template build will fail if `openai_api_key` is provided alongside `enable_aibridge = true`.
### Usage with Tasks
This example shows how to configure Codex with Coder tasks.
```tf
resource "coder_ai_task" "task" {
count = data.coder_workspace.me.start_count
app_id = module.codex.task_app_id
}
data "coder_task" "me" {}
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "5.0.0" version = "4.3.1"
agent_id = coder_agent.main.id agent_id = coder_agent.example.id
workdir = local.codex_workdir openai_api_key = "..."
openai_api_key = var.openai_api_key ai_prompt = data.coder_task.me.prompt
} workdir = "/home/coder/project"
resource "coder_app" "codex" { # Optional: route through AI Bridge (Premium feature)
agent_id = coder_agent.main.id # enable_aibridge = true
slug = "codex"
display_name = "Codex"
icon = "/icon/openai.svg"
open_in = "slim-window"
command = <<-EOT
#!/bin/bash
set -e
cd "${local.codex_workdir}"
codex
EOT
} }
``` ```
> [!NOTE] ### Usage with Agent Boundaries
> The `coder_app` command re-executes on every pane reconnect. This works for interactive `codex` (which stays alive), but one-shot commands like `codex exec` will re-run each time. For one-shot prompts, use a `coder_script` (runs once at startup) and a `coder_app` that attaches to the existing session (e.g. via tmux/screen).
### Usage with AI Gateway This example shows how to configure the Codex module to run the agent behind a process-level boundary that restricts its network access.
[AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) is a Premium Coder feature that provides centralized LLM proxy management. Requires Coder >= 2.30.0. By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation.
```tf ```tf
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "5.0.0" version = "4.3.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" openai_api_key = var.openai_api_key
enable_ai_gateway = true workdir = "/home/coder/project"
enable_boundary = true
} }
``` ```
When `enable_ai_gateway = true`, the module configures Codex to use the `aigateway` model provider in `config.toml` with the workspace owner's session token for authentication.
> [!CAUTION]
> `enable_ai_gateway = true` is mutually exclusive with `openai_api_key`. Setting both fails at plan time.
> [!NOTE] > [!NOTE]
> If you provide a custom `base_config_toml`, the module writes it verbatim and does not inject `model_provider = "aigateway"` automatically. Add it to your config yourself: > 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.
>
> ```toml
> model_provider = "aigateway"
> ```
### Advanced Configuration ### Advanced Configuration
This example shows additional configuration options for custom models, MCP servers, and base configuration.
```tf ```tf
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "5.0.0" version = "4.3.1"
agent_id = coder_agent.main.id agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project" workdir = "/home/coder/project"
openai_api_key = var.openai_api_key
codex_version = "0.128.0" codex_version = "0.1.0" # Pin to a specific version
codex_model = "gpt-4o" # Custom model
# Override default configuration
base_config_toml = <<-EOT base_config_toml = <<-EOT
sandbox_mode = "danger-full-access" sandbox_mode = "danger-full-access"
approval_policy = "never" approval_policy = "never"
preferred_auth_method = "apikey" preferred_auth_method = "apikey"
EOT EOT
mcp = <<-EOT # Add extra MCP servers
additional_mcp_servers = <<-EOT
[mcp_servers.GitHub] [mcp_servers.GitHub]
command = "npx" command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"] args = ["-y", "@modelcontextprotocol/server-github"]
@ -110,49 +152,61 @@ module "codex" {
} }
``` ```
### Serialize a downstream `coder_script` after the install pipeline > [!WARNING]
> This module configures Codex with a `workspace-write` sandbox that allows AI tasks to read/write files in the specified workdir. While the sandbox provides security boundaries, Codex can still modify files within the workspace. Use this module _only_ in trusted environments and be aware of the security implications.
The module exposes the `scripts` output: an ordered list of `coder exp sync` names for the scripts this module creates (pre_install, install, post_install). Scripts that were not configured are absent. ## How it Works
- **Install**: The module installs Codex CLI and sets up the environment
- **System Prompt**: If `codex_system_prompt` is set, writes the prompt to `AGENTS.md` in the `~/.codex/` directory
- **Start**: Launches Codex CLI in the specified directory, wrapped by AgentAPI
- **Configuration**: Sets `OPENAI_API_KEY` environment variable and passes `--model` flag to Codex CLI (if variables provided)
- **Session Continuity**: When `continue = true` (default), the module automatically tracks task sessions in `~/.codex-module/.codex-task-session`. On workspace restart, it resumes the existing session with full conversation history. Set `continue = false` to always start fresh sessions.
## State Persistence
AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Codex CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning).
To disable:
```tf ```tf
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" # ... other config
version = "5.0.0" enable_state_persistence = false
agent_id = coder_agent.main.id
openai_api_key = var.openai_api_key
}
resource "coder_script" "post_codex" {
agent_id = coder_agent.main.id
display_name = "Run after Codex install"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -euo pipefail
trap 'coder exp sync complete post-codex' EXIT
coder exp sync want post-codex ${join(" ", module.codex.scripts)}
coder exp sync start post-codex
codex --version
EOT
} }
``` ```
## Configuration ## Configuration
When no custom `base_config_toml` is provided, the module uses a minimal default with `preferred_auth_method = "apikey"`. For advanced options, see [Codex config docs](https://developers.openai.com/codex/config-advanced). ### Default Configuration
When no custom `base_config_toml` is provided, the module uses these secure defaults:
```toml
sandbox_mode = "workspace-write"
approval_policy = "never"
preferred_auth_method = "apikey"
[sandbox_workspace_write]
network_access = true
```
> [!NOTE]
> If no custom configuration is provided, the module uses secure defaults. The Coder MCP server is always included automatically. For containerized workspaces (Docker/Kubernetes), you may need `sandbox_mode = "danger-full-access"` to avoid permission issues. For advanced options, see [Codex config docs](https://github.com/openai/codex/blob/main/codex-rs/config.md).
## Troubleshooting ## Troubleshooting
Check the log files in `~/.coder-modules/coder-labs/codex/logs/` for detailed information. - Check installation and startup logs in `~/.codex-module/`
- Ensure your OpenAI API key has access to the specified model
```bash > [!IMPORTANT]
cat ~/.coder-modules/coder-labs/codex/logs/install.log > To use tasks with Codex CLI, ensure you have the `openai_api_key` variable set. [Tasks Template Example](https://registry.coder.com/templates/coder-labs/tasks-docker).
cat ~/.coder-modules/coder-labs/codex/logs/pre_install.log > The module automatically configures Codex with your API key and model preferences.
cat ~/.coder-modules/coder-labs/codex/logs/post_install.log > workdir is a required variable for the module to function correctly.
```
## References ## References
- [Codex CLI Documentation](https://github.com/openai/codex) - [Codex CLI Documentation](https://github.com/openai/codex)
- [AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) - [AgentAPI Documentation](https://github.com/coder/agentapi)
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
- [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge)

View File

@ -6,67 +6,15 @@ import {
beforeAll, beforeAll,
expect, expect,
} from "bun:test"; } from "bun:test";
import { execContainer, readFileContainer, runTerraformInit } from "~test";
import { import {
execContainer, loadTestFile,
readFileContainer,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
TerraformState,
} from "~test";
import {
extractCoderEnvVars,
writeExecutable, writeExecutable,
setup as setupUtil,
execModuleScript,
expectAgentAPIStarted,
} from "../../../coder/modules/agentapi/test-util"; } from "../../../coder/modules/agentapi/test-util";
import path from "path"; import dedent from "dedent";
interface ModuleScripts {
pre_install?: string;
install: string;
post_install?: string;
}
const SCRIPT_SUFFIXES = [
"Pre-Install Script",
"Install Script",
"Post-Install Script",
] as const;
const collectScripts = (state: TerraformState): ModuleScripts => {
const byDisplayName: Record<string, string> = {};
for (const resource of state.resources) {
if (resource.type !== "coder_script") continue;
for (const instance of resource.instances) {
const attrs = instance.attributes as Record<string, unknown>;
const displayName = attrs.display_name as string | undefined;
const script = attrs.script as string | undefined;
if (displayName && script) {
byDisplayName[displayName] = script;
}
}
}
const scripts: Partial<ModuleScripts> = {};
for (const suffix of SCRIPT_SUFFIXES) {
const key = `Codex: ${suffix}`;
if (!(key in byDisplayName)) continue;
switch (suffix) {
case "Pre-Install Script":
scripts.pre_install = byDisplayName[key];
break;
case "Install Script":
scripts.install = byDisplayName[key];
break;
case "Post-Install Script":
scripts.post_install = byDisplayName[key];
break;
}
}
if (!scripts.install) {
throw new Error("install script not found in terraform state");
}
return scripts as ModuleScripts;
};
let cleanupFunctions: (() => Promise<void>)[] = []; let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => { const registerCleanup = (cleanup: () => Promise<void>) => {
@ -85,90 +33,36 @@ afterEach(async () => {
}); });
interface SetupProps { interface SetupProps {
skipAgentAPIMock?: boolean;
skipCodexMock?: boolean; skipCodexMock?: boolean;
moduleVariables?: Record<string, string>; moduleVariables?: Record<string, string>;
agentapiMockScript?: string;
} }
const setup = async ( const setup = async (props?: SetupProps): Promise<{ id: string }> => {
props?: SetupProps,
): Promise<{
id: string;
coderEnvVars: Record<string, string>;
scripts: ModuleScripts;
}> => {
const projectDir = "/home/coder/project"; const projectDir = "/home/coder/project";
const moduleDir = path.resolve(import.meta.dir); const { id } = await setupUtil({
const state = await runTerraformApply(moduleDir, { moduleDir: import.meta.dir,
agent_id: "foo", moduleVariables: {
workdir: projectDir, install_codex: props?.skipCodexMock ? "true" : "false",
install_codex: "false", install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
...props?.moduleVariables, codex_model: "gpt-4-turbo",
}); workdir: "/home/coder",
const scripts = collectScripts(state); ...props?.moduleVariables,
const coderEnvVars = extractCoderEnvVars(state); },
registerCleanup,
const id = await runContainer("codercom/enterprise-node:latest"); projectDir,
registerCleanup(async () => { skipAgentAPIMock: props?.skipAgentAPIMock,
if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") { agentapiMockScript: props?.agentapiMockScript,
console.log(`Not removing container ${id} in debug mode`);
return;
}
await removeContainer(id);
});
await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]);
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: "#!/bin/bash\nexit 0\n",
}); });
if (!props?.skipCodexMock) { if (!props?.skipCodexMock) {
await writeExecutable({ await writeExecutable({
containerId: id, containerId: id,
filePath: "/usr/bin/codex", filePath: "/usr/bin/codex",
content: await Bun.file( content: await loadTestFile(import.meta.dir, "codex-mock.sh"),
path.join(moduleDir, "testdata", "codex-mock.sh"),
).text(),
}); });
} }
return { id, coderEnvVars, scripts }; return { id };
};
const runScripts = async (
id: string,
scripts: ModuleScripts,
env?: Record<string, string>,
) => {
const entries = env ? Object.entries(env) : [];
const envArgs =
entries.length > 0
? entries
.map(
([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`,
)
.join(" && ") + " && "
: "";
const ordered: [string, string | undefined][] = [
["pre_install", scripts.pre_install],
["install", scripts.install],
["post_install", scripts.post_install],
];
for (const [name, script] of ordered) {
if (!script) continue;
const target = `/tmp/coder-utils-${name}.sh`;
await writeExecutable({
containerId: id,
filePath: target,
content: script,
});
const resp = await execContainer(id, ["bash", "-c", `${envArgs}${target}`]);
if (resp.exitCode !== 0) {
console.log(`script ${name} failed:`);
console.log(resp.stdout);
console.log(resp.stderr);
throw new Error(`coder-utils ${name} script exited ${resp.exitCode}`);
}
}
}; };
setDefaultTimeout(60 * 1000); setDefaultTimeout(60 * 1000);
@ -179,269 +73,444 @@ describe("codex", async () => {
}); });
test("happy-path", async () => { test("happy-path", async () => {
const { id, scripts } = await setup(); const { id } = await setup();
await runScripts(id, scripts); await execModuleScript(id);
const installLog = await readFileContainer( await expectAgentAPIStarted(id);
id,
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
);
expect(installLog).toContain("Skipping Codex installation");
}); });
test("install-codex-version", async () => { test("install-codex-version", async () => {
const version = "0.10.0"; const version_to_install = "0.10.0";
const { id, coderEnvVars, scripts } = await setup({ const { id } = await setup({
skipCodexMock: true, skipCodexMock: true,
moduleVariables: { moduleVariables: {
install_codex: "true", install_codex: "true",
codex_version: version, codex_version: version_to_install,
}, },
}); });
await runScripts(id, scripts, coderEnvVars); await execModuleScript(id);
const installLog = await readFileContainer( const resp = await execContainer(id, [
id, "bash",
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log", "-c",
); `cat /home/coder/.codex-module/install.log`,
expect(installLog).toContain(version); ]);
expect(resp.stdout).toContain(version_to_install);
}); });
test("openai-api-key", async () => { test("check-latest-codex-version-works", async () => {
const apiKey = "test-api-key-123"; const { id } = await setup({
const { coderEnvVars } = await setup({ skipCodexMock: true,
skipAgentAPIMock: true,
moduleVariables: { moduleVariables: {
openai_api_key: apiKey, install_codex: "true",
}, },
}); });
expect(coderEnvVars["OPENAI_API_KEY"]).toBe(apiKey); await execModuleScript(id);
await expectAgentAPIStarted(id);
}); });
test("base-config-toml", async () => { test("base-config-toml", async () => {
const baseConfig = [ const baseConfig = dedent`
'sandbox_mode = "danger-full-access"', sandbox_mode = "danger-full-access"
'approval_policy = "never"', approval_policy = "never"
'preferred_auth_method = "apikey"', preferred_auth_method = "apikey"
"",
"[custom_section]", [custom_section]
"new_feature = true", new_feature = true
].join("\n"); `.trim();
const { id, scripts } = await setup({ const { id } = await setup({
moduleVariables: { moduleVariables: {
base_config_toml: baseConfig, base_config_toml: baseConfig,
}, },
}); });
await runScripts(id, scripts); await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
expect(resp).toContain('sandbox_mode = "danger-full-access"'); expect(resp).toContain('sandbox_mode = "danger-full-access"');
expect(resp).toContain('preferred_auth_method = "apikey"'); expect(resp).toContain('preferred_auth_method = "apikey"');
expect(resp).toContain("[custom_section]"); expect(resp).toContain("[custom_section]");
expect(resp).toContain("[mcp_servers.Coder]");
}); });
test("additional-mcp-servers", async () => { test("codex-api-key", async () => {
const additional = [ const apiKey = "test-api-key-123";
"[mcp_servers.GitHub]", const { id } = await setup({
'command = "npx"',
'args = ["-y", "@modelcontextprotocol/server-github"]',
'type = "stdio"',
'description = "GitHub integration"',
].join("\n");
const { id, scripts } = await setup({
moduleVariables: { moduleVariables: {
mcp: additional, openai_api_key: apiKey,
}, },
}); });
await runScripts(id, scripts); await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
expect(resp).toContain("[mcp_servers.GitHub]");
expect(resp).toContain("GitHub integration");
});
test("minimal-default-config", async () => { const resp = await readFileContainer(
const { id, scripts } = await setup(); id,
await runScripts(id, scripts); "/home/coder/.codex-module/agentapi-start.log",
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); );
expect(resp).toContain('preferred_auth_method = "apikey"'); expect(resp).toContain("OpenAI API Key: Provided");
expect(resp).not.toContain("model_provider");
expect(resp).not.toContain("[model_providers.");
expect(resp).not.toContain("model_reasoning_effort");
}); });
test("pre-post-install-scripts", async () => { test("pre-post-install-scripts", async () => {
const { id, scripts } = await setup({ const { id } = await setup({
moduleVariables: { moduleVariables: {
pre_install_script: "#!/bin/bash\necho 'codex-pre-install-script'", pre_install_script: "#!/bin/bash\necho 'pre-install-script'",
post_install_script: "#!/bin/bash\necho 'codex-post-install-script'", post_install_script: "#!/bin/bash\necho 'post-install-script'",
}, },
}); });
await runScripts(id, scripts); await execModuleScript(id);
const preInstallLog = await readFileContainer( const preInstallLog = await readFileContainer(
id, id,
"/home/coder/.coder-modules/coder-labs/codex/logs/pre_install.log", "/home/coder/.codex-module/pre_install.log",
); );
expect(preInstallLog).toContain("codex-pre-install-script"); expect(preInstallLog).toContain("pre-install-script");
const postInstallLog = await readFileContainer( const postInstallLog = await readFileContainer(
id, id,
"/home/coder/.coder-modules/coder-labs/codex/logs/post_install.log", "/home/coder/.codex-module/post_install.log",
); );
expect(postInstallLog).toContain("codex-post-install-script"); expect(postInstallLog).toContain("post-install-script");
}); });
test("workdir-variable", async () => { test("workdir-variable", async () => {
const workdir = "/home/coder/codex-test-folder"; const workdir = "/tmp/codex-test-workdir";
const { id, scripts } = await setup({ const { id } = await setup({
skipCodexMock: false,
moduleVariables: { moduleVariables: {
workdir, workdir,
}, },
}); });
await runScripts(id, scripts); await execModuleScript(id);
const installLog = await readFileContainer( const resp = await readFileContainer(
id, id,
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log", "/home/coder/.codex-module/install.log",
); );
expect(installLog).toContain(workdir); expect(resp).toContain(workdir);
}); });
test("codex-with-ai-gateway", async () => { test("additional-mcp-servers", async () => {
const { id, coderEnvVars, scripts } = await setup({ const additional = dedent`
[mcp_servers.GitHub]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
type = "stdio"
description = "GitHub integration"
[mcp_servers.FileSystem]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
type = "stdio"
description = "File system access"
`.trim();
const { id } = await setup({
moduleVariables: { moduleVariables: {
enable_ai_gateway: "true", additional_mcp_servers: additional,
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
expect(resp).toContain("[mcp_servers.GitHub]");
expect(resp).toContain("[mcp_servers.FileSystem]");
expect(resp).toContain("[mcp_servers.Coder]");
expect(resp).toContain("GitHub integration");
});
test("full-custom-config", async () => {
const baseConfig = dedent`
sandbox_mode = "read-only"
approval_policy = "untrusted"
preferred_auth_method = "chatgpt"
custom_setting = "test-value"
[advanced_settings]
timeout = 30000
debug = true
logging_level = "verbose"
`.trim();
const additionalMCP = dedent`
[mcp_servers.CustomTool]
command = "/usr/local/bin/custom-tool"
args = ["--serve", "--port", "8080"]
type = "stdio"
description = "Custom development tool"
[mcp_servers.DatabaseMCP]
command = "python"
args = ["-m", "database_mcp_server"]
type = "stdio"
description = "Database query interface"
`.trim();
const { id } = await setup({
moduleVariables: {
base_config_toml: baseConfig,
additional_mcp_servers: additionalMCP,
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
// Check base config
expect(resp).toContain('sandbox_mode = "read-only"');
expect(resp).toContain('preferred_auth_method = "chatgpt"');
expect(resp).toContain('custom_setting = "test-value"');
expect(resp).toContain("[advanced_settings]");
expect(resp).toContain('logging_level = "verbose"');
// Check MCP servers
expect(resp).toContain("[mcp_servers.Coder]");
expect(resp).toContain("[mcp_servers.CustomTool]");
expect(resp).toContain("[mcp_servers.DatabaseMCP]");
expect(resp).toContain("Custom development tool");
expect(resp).toContain("Database query interface");
});
test("minimal-default-config", async () => {
const { id } = await setup({
moduleVariables: {
// No base_config_toml or additional_mcp_servers - should use defaults
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
// Check default base config
expect(resp).toContain('sandbox_mode = "workspace-write"');
expect(resp).toContain('approval_policy = "never"');
expect(resp).toContain("[sandbox_workspace_write]");
expect(resp).toContain("network_access = true");
// Check only Coder MCP server is present
expect(resp).toContain("[mcp_servers.Coder]");
expect(resp).toContain("Report ALL tasks and statuses");
// Ensure no additional MCP servers
const mcpServerCount = (resp.match(/\[mcp_servers\./g) || []).length;
expect(mcpServerCount).toBe(1);
});
test("codex-system-prompt", async () => {
const prompt = "This is a system prompt for Codex.";
const { id } = await setup({
moduleVariables: {
codex_system_prompt: prompt,
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md");
expect(resp).toContain(prompt);
});
test("codex-system-prompt-skip-append-if-exists", async () => {
const prompt_1 = "This is a system prompt for Codex.";
const prompt_2 = "This is a system prompt for Goose.";
const prompt_3 = dedent`
This is a system prompt for Codex.
This is a system prompt for Gemini.
`.trim();
const pre_install_script = dedent`
#!/bin/bash
mkdir -p /home/coder/.codex
echo -e "${prompt_3}" >> /home/coder/.codex/AGENTS.md
`.trim();
const { id } = await setup({
moduleVariables: {
pre_install_script,
codex_system_prompt: prompt_2,
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md");
expect(resp).toContain(prompt_1);
expect(resp).toContain(prompt_2);
// Re-run with a prompt that already exists, it should not append again
const { id: id_2 } = await setup({
moduleVariables: {
pre_install_script,
codex_system_prompt: prompt_1,
},
});
await execModuleScript(id_2);
const resp_2 = await readFileContainer(
id_2,
"/home/coder/.codex/AGENTS.md",
);
expect(resp_2).toContain(prompt_1);
const count = (resp_2.match(new RegExp(prompt_1, "g")) || []).length;
expect(count).toBe(1);
});
test("codex-ai-task-prompt", async () => {
const prompt = "This is a system prompt for Codex.";
const { id } = await setup({
moduleVariables: {
ai_prompt: prompt,
},
});
await execModuleScript(id);
const resp = await execContainer(id, [
"bash",
"-c",
`cat /home/coder/.codex-module/agentapi-start.log`,
]);
expect(resp.stdout).toContain(prompt);
});
test("start-without-prompt", async () => {
const { id } = await setup({
moduleVariables: {
codex_system_prompt: "", // Explicitly disable system prompt
},
});
await execModuleScript(id);
const prompt = await execContainer(id, [
"ls",
"-l",
"/home/coder/.codex/AGENTS.md",
]);
expect(prompt.exitCode).not.toBe(0);
expect(prompt.stderr).toContain("No such file or directory");
});
test("codex-continue-capture-new-session", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
ai_prompt: "test task",
},
});
const workdir = "/home/coder";
const expectedSessionId = "019a1234-5678-9abc-def0-123456789012";
const sessionsDir = "/home/coder/.codex/sessions";
const sessionFile = `${sessionsDir}/${expectedSessionId}.jsonl`;
await execContainer(id, ["mkdir", "-p", sessionsDir]);
await execContainer(id, [
"bash",
"-c",
`echo '{"id":"${expectedSessionId}","cwd":"${workdir}","created":"2024-10-24T20:00:00Z","model":"gpt-4-turbo"}' > ${sessionFile}`,
]);
await execModuleScript(id);
await expectAgentAPIStarted(id);
const trackingFile = "/home/coder/.codex-module/.codex-task-session";
const maxAttempts = 30;
let trackingFileContents = "";
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const result = await execContainer(id, [
"bash",
"-c",
`cat ${trackingFile} 2>/dev/null || echo ""`,
]);
if (result.stdout.trim().length > 0) {
trackingFileContents = result.stdout;
break;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
expect(trackingFileContents).toContain(`${workdir}|${expectedSessionId}`);
const startLog = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
);
expect(startLog).toContain("Capturing new session ID");
expect(startLog).toContain("Session tracked");
expect(startLog).toContain(expectedSessionId);
});
test("codex-continue-resume-existing-session", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
ai_prompt: "test prompt",
},
});
const workdir = "/home/coder";
const mockSessionId = "019a1234-5678-9abc-def0-123456789012";
const trackingFile = "/home/coder/.codex-module/.codex-task-session";
await execContainer(id, ["mkdir", "-p", "/home/coder/.codex-module"]);
await execContainer(id, [
"bash",
"-c",
`echo "${workdir}|${mockSessionId}" > ${trackingFile}`,
]);
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.codex-module/agentapi-start.log",
]);
expect(startLog.stdout).toContain("Found existing task session");
expect(startLog.stdout).toContain(mockSessionId);
expect(startLog.stdout).toContain("Resuming existing session");
expect(startLog.stdout).toContain(
`Starting Codex with arguments: --model gpt-4-turbo resume ${mockSessionId}`,
);
expect(startLog.stdout).not.toContain("test prompt");
});
test("codex-with-aibridge", async () => {
const { id } = await setup({
moduleVariables: {
enable_aibridge: "true",
model_reasoning_effort: "none", model_reasoning_effort: "none",
}, },
}); });
await runScripts(id, scripts, coderEnvVars);
await execModuleScript(id);
const configToml = await readFileContainer( const configToml = await readFileContainer(
id, id,
"/home/coder/.codex/config.toml", "/home/coder/.codex/config.toml",
); );
expect(configToml).toContain('model_provider = "aigateway"'); expect(configToml).toContain('model_provider = "aibridge"');
expect(configToml).toContain('model_reasoning_effort = "none"');
expect(configToml).toContain("[model_providers.aigateway]");
}); });
test("model-reasoning-effort-standalone", async () => { test("boundary-enabled", async () => {
const { id, scripts } = await setup({ const { id } = await setup({
moduleVariables: { moduleVariables: {
model_reasoning_effort: "high", enable_boundary: "true",
boundary_config_path: "/tmp/test-boundary.yaml",
}, },
}); });
await runScripts(id, scripts); // Write boundary config
const configToml = await readFileContainer( await execContainer(id, [
id, "bash",
"/home/coder/.codex/config.toml", "-c",
); `cat > /tmp/test-boundary.yaml <<'EOF'
expect(configToml).toContain('model_reasoning_effort = "high"'); jail_type: landjail
expect(configToml).not.toContain("model_provider"); proxy_port: 8087
}); log_level: warn
allowlist:
test("workdir-trusted-project", async () => { - "domain=api.openai.com"
const workdir = "/home/coder/trusted-project"; EOF`,
const { id, scripts } = await setup({ ]);
moduleVariables: { // Add mock coder binary for boundary setup
workdir, 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 runScripts(id, scripts); await execModuleScript(id);
const configToml = await readFileContainer( await expectAgentAPIStarted(id);
// Verify boundary wrapper was used in start script
const startLog = await readFileContainer(
id, id,
"/home/coder/.codex/config.toml", "/home/coder/.codex-module/agentapi-start.log",
); );
expect(configToml).toContain(`[projects."${workdir}"]`); expect(startLog).toContain("boundary");
expect(configToml).toContain('trust_level = "trusted"');
});
test("no-workdir-no-project-section", async () => {
const { id, scripts } = await setup({
moduleVariables: {
workdir: "",
},
});
await runScripts(id, scripts);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
expect(configToml).not.toContain("[projects.");
});
test("ai-gateway-with-custom-base-config", async () => {
const baseConfig = [
'sandbox_mode = "danger-full-access"',
'model_provider = "aigateway"',
].join("\n");
const { id, coderEnvVars, scripts } = await setup({
moduleVariables: {
enable_ai_gateway: "true",
base_config_toml: baseConfig,
},
});
await runScripts(id, scripts, coderEnvVars);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
expect(configToml).toContain('model_provider = "aigateway"');
expect(configToml).toContain("[model_providers.aigateway]");
});
test("ai-gateway-custom-config-no-duplicate-provider", async () => {
const baseConfig = [
'model_provider = "aigateway"',
"",
"[model_providers.aigateway]",
'name = "Custom AI Bridge"',
'base_url = "https://custom.example.com"',
'env_key = "CODER_AIBRIDGE_SESSION_TOKEN"',
'wire_api = "responses"',
].join("\n");
const { id, coderEnvVars, scripts } = await setup({
moduleVariables: {
enable_ai_gateway: "true",
base_config_toml: baseConfig,
},
});
await runScripts(id, scripts, coderEnvVars);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
const matches = configToml.match(/\[model_providers\.aigateway\]/g) || [];
expect(matches.length).toBe(1);
expect(configToml).toContain("Custom AI Bridge");
});
test("install-codex-latest", async () => {
const { id, coderEnvVars, scripts } = await setup({
skipCodexMock: true,
moduleVariables: {
install_codex: "true",
},
});
await runScripts(id, scripts, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
);
expect(installLog).toContain("Installed Codex CLI");
});
test("custom-config-drops-reasoning-effort", async () => {
const baseConfig = [
'sandbox_mode = "danger-full-access"',
'preferred_auth_method = "apikey"',
].join("\n");
const { id, scripts } = await setup({
moduleVariables: {
base_config_toml: baseConfig,
model_reasoning_effort: "high",
},
});
await runScripts(id, scripts);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
expect(configToml).toContain('sandbox_mode = "danger-full-access"');
expect(configToml).not.toContain("model_reasoning_effort");
}); });
}); });

View File

@ -18,6 +18,18 @@ data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {} data "coder_workspace_owner" "me" {}
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 "icon" { variable "icon" {
type = string type = string
description = "The icon to use for the app." description = "The icon to use for the app."
@ -26,8 +38,106 @@ variable "icon" {
variable "workdir" { variable "workdir" {
type = string type = string
description = "Optional project directory. When set, the module pre-creates it if missing and adds it as a trusted project in Codex config.toml." description = "The folder to run Codex in."
default = null }
variable "report_tasks" {
type = bool
description = "Whether to enable task reporting to Coder UI via AgentAPI"
default = true
}
variable "subdomain" {
type = bool
description = "Whether to use a subdomain for AgentAPI."
default = false
}
variable "cli_app" {
type = bool
description = "Whether to create a CLI app for Codex"
default = false
}
variable "web_app_display_name" {
type = string
description = "Display name for the web app"
default = "Codex"
}
variable "cli_app_display_name" {
type = string
description = "Display name for the CLI app"
default = "Codex CLI"
}
variable "enable_aibridge" {
type = bool
description = "Use AI Bridge for Codex. https://coder.com/docs/ai-coder/ai-bridge"
default = false
validation {
condition = !(var.enable_aibridge && length(var.openai_api_key) > 0)
error_message = "openai_api_key cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials."
}
}
variable "model_reasoning_effort" {
type = string
description = "The reasoning effort for the model. One of: none, low, medium, high. https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort"
default = ""
validation {
condition = contains(["", "none", "minimal", "low", "medium", "high", "xhigh"], var.model_reasoning_effort)
error_message = "model_reasoning_effort must be one of: none, low, medium, high."
}
}
variable "install_codex" {
type = bool
description = "Whether to install Codex."
default = true
}
variable "codex_version" {
type = string
description = "The version of Codex to install."
default = "" # empty string means the latest available version
}
variable "base_config_toml" {
type = string
description = "Complete base TOML configuration for Codex (without mcp_servers section). If empty, uses minimal default configuration with workspace-write sandbox mode and never approval policy. For advanced options, see https://github.com/openai/codex/blob/main/codex-rs/config.md"
default = ""
}
variable "additional_mcp_servers" {
type = string
description = "Additional MCP servers configuration in TOML format. These will be merged with the required Coder MCP server in the [mcp_servers] section."
default = ""
}
variable "openai_api_key" {
type = string
description = "OpenAI API key for Codex CLI"
default = ""
}
variable "install_agentapi" {
type = bool
description = "Whether to install AgentAPI."
default = true
}
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.12.1"
}
variable "codex_model" {
type = string
description = "The model for Codex to use. Defaults to gpt-5.3-codex."
default = "gpt-5.4"
} }
variable "pre_install_script" { variable "pre_install_script" {
@ -42,127 +152,158 @@ variable "post_install_script" {
default = null default = null
} }
variable "install_codex" { variable "ai_prompt" {
type = string
description = "Initial task prompt for Codex CLI when launched via Tasks"
default = ""
}
variable "continue" {
type = bool type = bool
description = "Whether to install Codex." description = "Automatically continue existing sessions on workspace restart. When true, resumes existing conversation if found, otherwise runs prompt or starts new session. When false, always starts fresh (ignores existing sessions)."
default = true default = true
} }
variable "codex_version" { variable "enable_state_persistence" {
type = bool
description = "Enable AgentAPI conversation state persistence across restarts."
default = true
}
variable "codex_system_prompt" {
type = string type = string
description = "The version of Codex to install." description = "System instructions written to AGENTS.md in the ~/.codex directory"
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" default = "latest"
} }
variable "openai_api_key" { variable "compile_boundary_from_source" {
type = string
description = "OpenAI API key for Codex CLI."
sensitive = true
default = ""
}
variable "base_config_toml" {
type = string
description = <<-EOT
Complete base TOML configuration for Codex (without mcp_servers section).
When empty, the module generates a minimal default:
preferred_auth_method = "apikey"
# model_provider = "aigateway" (sets the default profile, when enable_ai_gateway = true)
# model_reasoning_effort = "<value>" (sets the reasoning effort, when model_reasoning_effort is set)
[projects."<workdir>"] (when workdir is set)
trust_level = "trusted"
When non-empty, the value is written verbatim as the base of config.toml;
mcp and AI Gateway sections are still appended after it.
Note: model_reasoning_effort and workdir trust are only applied in the
default config. Include them in your custom config if needed.
EOT
default = ""
}
variable "mcp" {
type = string
description = "MCP server configurations in TOML format. When set, servers are appended to the Codex config.toml."
default = ""
}
variable "model_reasoning_effort" {
type = string
description = "The reasoning effort for the model. One of: none, minimal, low, medium, high, xhigh. See https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort"
default = ""
validation {
condition = contains(["", "none", "minimal", "low", "medium", "high", "xhigh"], var.model_reasoning_effort)
error_message = "model_reasoning_effort must be one of: none, minimal, low, medium, high, xhigh."
}
}
variable "enable_ai_gateway" {
type = bool type = bool
description = "Use AI Gateway for Codex. https://coder.com/docs/ai-coder/ai-gateway" description = "Whether to compile boundary from source instead of using the official install script."
default = false default = false
}
validation { variable "use_boundary_directly" {
condition = !(var.enable_ai_gateway && length(var.openai_api_key) > 0) type = bool
error_message = "openai_api_key cannot be provided when enable_ai_gateway is true. AI Gateway automatically authenticates the client using Coder credentials." description = "Whether to use boundary binary directly instead of coder boundary subcommand."
} default = false
} }
resource "coder_env" "openai_api_key" { resource "coder_env" "openai_api_key" {
count = var.openai_api_key != "" ? 1 : 0
agent_id = var.agent_id agent_id = var.agent_id
name = "OPENAI_API_KEY" name = "OPENAI_API_KEY"
value = var.openai_api_key value = var.openai_api_key
} }
# Authenticates the client against Coder's AI Gateway using the workspace resource "coder_env" "coder_aibridge_session_token" {
# owner's session token. Referenced by config.toml model_providers.aigateway. count = var.enable_aibridge ? 1 : 0
resource "coder_env" "ai_gateway_session_token" {
count = var.enable_ai_gateway ? 1 : 0
agent_id = var.agent_id agent_id = var.agent_id
name = "CODER_AIBRIDGE_SESSION_TOKEN" name = "CODER_AIBRIDGE_SESSION_TOKEN"
value = data.coder_workspace_owner.me.session_token value = data.coder_workspace_owner.me.session_token
} }
locals { locals {
workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" workdir = trimsuffix(var.workdir, "/")
aibridge_config = <<-EOF app_slug = "codex"
[model_providers.aigateway] install_script = file("${path.module}/scripts/install.sh")
name = "AI Gateway" start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".codex-module"
latest_codex_model = "gpt-5.4"
aibridge_config = <<-EOF
[model_providers.aibridge]
name = "AI Bridge"
base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1" base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
env_key = "CODER_AIBRIDGE_SESSION_TOKEN" env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses" wire_api = "responses"
EOF EOF
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
ARG_INSTALL = tostring(var.install_codex)
ARG_CODEX_VERSION = var.codex_version != "" ? base64encode(var.codex_version) : ""
ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : ""
ARG_BASE_CONFIG_TOML = var.base_config_toml != "" ? base64encode(var.base_config_toml) : ""
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : ""
ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway)
ARG_AIBRIDGE_CONFIG = var.enable_ai_gateway ? base64encode(local.aibridge_config) : ""
ARG_MODEL_REASONING_EFFORT = var.model_reasoning_effort
ARG_OPENAI_API_KEY = var.openai_api_key != "" ? base64encode(var.openai_api_key) : ""
})
module_dir_name = ".coder-modules/coder-labs/codex"
} }
module "coder_utils" { module "agentapi" {
source = "registry.coder.com/coder/coder-utils/coder" source = "registry.coder.com/coder/agentapi/coder"
version = "0.0.1" version = "2.3.0"
agent_id = var.agent_id agent_id = var.agent_id
module_directory = "$HOME/${local.module_dir_name}" folder = local.workdir
display_name_prefix = "Codex" web_app_slug = local.app_slug
icon = var.icon web_app_order = var.order
pre_install_script = var.pre_install_script web_app_group = var.group
post_install_script = var.post_install_script web_app_icon = var.icon
install_script = local.install_script 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
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
chmod +x /tmp/start.sh
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_CODEX_MODEL='${var.codex_model}' \
ARG_CODEX_START_DIRECTORY='${local.workdir}' \
ARG_CODEX_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
ARG_CONTINUE='${var.continue}' \
ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
/tmp/start.sh
EOT
install_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
chmod +x /tmp/install.sh
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_CODEX_MODEL='${var.codex_model}' \
ARG_LATEST_CODEX_MODEL='${local.latest_codex_model}' \
ARG_INSTALL='${var.install_codex}' \
ARG_CODEX_VERSION='${var.codex_version}' \
ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \
ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
ARG_AIBRIDGE_CONFIG='${base64encode(var.enable_aibridge ? local.aibridge_config : "")}' \
ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
ARG_CODEX_START_DIRECTORY='${local.workdir}' \
ARG_MODEL_REASONING_EFFORT='${var.model_reasoning_effort}' \
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
/tmp/install.sh
EOT
} }
output "scripts" { output "task_app_id" {
description = "Ordered list of coder exp sync names for the coder_script resources this module creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list." value = module.agentapi.task_app_id
value = module.coder_utils.scripts
} }

View File

@ -1,20 +1,6 @@
run "test_codex_basic" { run "test_codex_basic" {
command = plan command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.install_codex == true
error_message = "install_codex should default to true"
}
}
run "test_codex_with_api_key" {
command = plan
variables { variables {
agent_id = "test-agent" agent_id = "test-agent"
workdir = "/home/coder" workdir = "/home/coder"
@ -22,96 +8,166 @@ run "test_codex_with_api_key" {
} }
assert { assert {
condition = coder_env.openai_api_key[0].value == "test-key" condition = var.agent_id == "test-agent"
error_message = "Agent ID should be set correctly"
}
assert {
condition = var.workdir == "/home/coder"
error_message = "Workdir should be set correctly"
}
assert {
condition = var.install_codex == true
error_message = "install_codex should default to true"
}
assert {
condition = var.install_agentapi == true
error_message = "install_agentapi should default to true"
}
assert {
condition = var.report_tasks == true
error_message = "report_tasks should default to true"
}
assert {
condition = var.continue == true
error_message = "continue should default to true"
}
}
run "test_enable_state_persistence_default" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
}
assert {
condition = var.enable_state_persistence == true
error_message = "enable_state_persistence should default to true"
}
}
run "test_disable_state_persistence" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
enable_state_persistence = false
}
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should be false when explicitly disabled"
}
}
run "test_codex_with_aibridge" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_aibridge = true
}
assert {
condition = var.enable_aibridge == true
error_message = "enable_aibridge should be set to true"
}
}
run "test_aibridge_disabled_with_api_key" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
enable_aibridge = false
}
assert {
condition = var.enable_aibridge == false
error_message = "enable_aibridge should be false"
}
assert {
condition = coder_env.openai_api_key.value == "test-key"
error_message = "OpenAI API key should be set correctly" error_message = "OpenAI API key should be set correctly"
} }
} }
run "test_codex_custom_options" { run "test_custom_options" {
command = plan command = plan
variables { variables {
agent_id = "test-agent" agent_id = "test-agent"
workdir = "/home/coder/project" workdir = "/home/coder/project"
icon = "/icon/custom.svg" openai_api_key = "test-key"
codex_version = "0.128.0" order = 5
group = "ai-tools"
icon = "/icon/custom.svg"
web_app_display_name = "Custom Codex"
cli_app = true
cli_app_display_name = "Codex Terminal"
subdomain = true
report_tasks = false
continue = false
codex_model = "gpt-4o"
codex_version = "0.1.0"
agentapi_version = "v0.12.0"
} }
assert { assert {
condition = length(output.scripts) > 0 condition = var.order == 5
error_message = "scripts output should be non-empty with custom options" error_message = "Order should be set to 5"
}
assert {
condition = var.group == "ai-tools"
error_message = "Group should be set to 'ai-tools'"
}
assert {
condition = var.icon == "/icon/custom.svg"
error_message = "Icon should be set to custom icon"
}
assert {
condition = var.cli_app == true
error_message = "cli_app should be enabled"
}
assert {
condition = var.subdomain == true
error_message = "subdomain should be enabled"
}
assert {
condition = var.report_tasks == false
error_message = "report_tasks should be disabled"
}
assert {
condition = var.continue == false
error_message = "continue should be disabled"
}
assert {
condition = var.codex_model == "gpt-4o"
error_message = "codex_model should be set to 'gpt-4o'"
} }
} }
run "test_ai_gateway_enabled" { run "test_no_api_key_no_aibridge" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_ai_gateway = true
}
override_data {
target = data.coder_workspace_owner.me
values = {
session_token = "mock-session-token"
}
}
assert {
condition = coder_env.ai_gateway_session_token[0].name == "CODER_AIBRIDGE_SESSION_TOKEN"
error_message = "CODER_AIBRIDGE_SESSION_TOKEN should be set"
}
assert {
condition = coder_env.ai_gateway_session_token[0].value == data.coder_workspace_owner.me.session_token
error_message = "Session token should use workspace owner's token"
}
assert {
condition = length(coder_env.openai_api_key) == 0
error_message = "OPENAI_API_KEY should not be created when ai_gateway is enabled"
}
}
run "test_ai_gateway_validation_with_api_key" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_ai_gateway = true
openai_api_key = "test-key"
}
expect_failures = [
var.enable_ai_gateway,
]
}
run "test_ai_gateway_disabled_with_api_key" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_ai_gateway = false
openai_api_key = "test-key-xyz"
}
assert {
condition = coder_env.openai_api_key[0].value == "test-key-xyz"
error_message = "OPENAI_API_KEY should use the provided API key"
}
assert {
condition = length(coder_env.ai_gateway_session_token) == 0
error_message = "Session token should not be set when ai_gateway is disabled"
}
}
run "test_no_api_key_no_env" {
command = plan command = plan
variables { variables {
@ -120,66 +176,12 @@ run "test_no_api_key_no_env" {
} }
assert { assert {
condition = length(coder_env.openai_api_key) == 0 condition = var.openai_api_key == ""
error_message = "OPENAI_API_KEY should not be created when no API key is provided" error_message = "openai_api_key should be empty when not provided"
}
}
run "test_codex_with_scripts" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
pre_install_script = "echo 'Pre-install script'"
post_install_script = "echo 'Post-install script'"
} }
assert { assert {
condition = length(output.scripts) == 3 condition = var.enable_aibridge == false
error_message = "scripts output should have 3 entries when pre/post are configured" error_message = "enable_aibridge should default to false"
}
}
run "test_script_outputs_install_only" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = length(output.scripts) == 1 && output.scripts[0] == "coder-labs-codex-install_script"
error_message = "scripts output should list only the install script when pre/post are not configured"
}
}
run "test_script_outputs_with_pre_and_post" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
pre_install_script = "echo pre"
post_install_script = "echo post"
}
assert {
condition = output.scripts == ["coder-labs-codex-pre_install_script", "coder-labs-codex-install_script", "coder-labs-codex-post_install_script"]
error_message = "scripts output should list pre_install, install, post_install in run order"
}
}
run "test_workdir_optional" {
command = plan
variables {
agent_id = "test-agent"
}
assert {
condition = length(output.scripts) == 1
error_message = "scripts output should have install script even without workdir"
} }
} }

View File

@ -0,0 +1,228 @@
#!/bin/bash
source "$HOME"/.bashrc
BOLD='\033[0;1m'
command_exists() {
command -v "$1" > /dev/null 2>&1
}
set -o errexit
set -o pipefail
set -o nounset
ARG_BASE_CONFIG_TOML=$(echo -n "$ARG_BASE_CONFIG_TOML" | base64 -d)
ARG_ADDITIONAL_MCP_SERVERS=$(echo -n "$ARG_ADDITIONAL_MCP_SERVERS" | base64 -d)
ARG_CODEX_INSTRUCTION_PROMPT=$(echo -n "$ARG_CODEX_INSTRUCTION_PROMPT" | base64 -d)
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
ARG_AIBRIDGE_CONFIG=$(echo -n "$ARG_AIBRIDGE_CONFIG" | base64 -d)
echo "=== Codex Module Configuration ==="
printf "Install Codex: %s\n" "$ARG_INSTALL"
printf "Codex Version: %s\n" "$ARG_CODEX_VERSION"
printf "App Slug: %s\n" "$ARG_CODER_MCP_APP_STATUS_SLUG"
printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}"
printf "Latest Codex Model: %s\n" "${ARG_LATEST_CODEX_MODEL}"
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
printf "Has Base Config: %s\n" "$([ -n "$ARG_BASE_CONFIG_TOML" ] && echo "Yes" || echo "No")"
printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && echo "Yes" || echo "No")"
printf "Has System Prompt: %s\n" "$([ -n "$ARG_CODEX_INSTRUCTION_PROMPT" ] && echo "Yes" || echo "No")"
printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")"
printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS"
printf "Enable Coder AI Bridge: %s\n" "$ARG_ENABLE_AIBRIDGE"
echo "======================================"
set +o nounset
function install_node() {
if ! command_exists npm; then
printf "npm not found, checking for Node.js installation...\n"
if ! command_exists node; then
printf "Node.js not found, installing Node.js via NVM...\n"
export NVM_DIR="$HOME/.nvm"
if [ ! -d "$NVM_DIR" ]; then
mkdir -p "$NVM_DIR"
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
else
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
fi
nvm install --lts
nvm use --lts
nvm alias default node
printf "Node.js installed: %s\n" "$(node --version)"
printf "npm installed: %s\n" "$(npm --version)"
else
printf "Node.js is installed but npm is not available. Please install npm manually.\n"
exit 1
fi
fi
}
function install_codex() {
if [ "${ARG_INSTALL}" = "true" ]; then
install_node
if ! command_exists nvm; then
printf "which node: %s\n" "$(which node)"
printf "which npm: %s\n" "$(which npm)"
mkdir -p "$HOME"/.npm-global
npm config set prefix "$HOME/.npm-global"
export PATH="$HOME/.npm-global/bin:$PATH"
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
fi
fi
printf "%s Installing Codex CLI\n" "${BOLD}"
if [ -n "$ARG_CODEX_VERSION" ]; then
npm install -g "@openai/codex@$ARG_CODEX_VERSION"
else
npm install -g "@openai/codex"
fi
printf "%s Successfully installed Codex CLI. Version: %s\n" "${BOLD}" "$(codex --version)"
fi
}
write_minimal_default_config() {
local config_path="$1"
ARG_OPTIONAL_TOP_LEVEL_CONFIG=""
if [[ "${ARG_ENABLE_AIBRIDGE}" = "true" ]]; then
ARG_OPTIONAL_TOP_LEVEL_CONFIG='model_provider = "aibridge"'
fi
if [[ "${ARG_MODEL_REASONING_EFFORT}" != "" ]]; then
ARG_OPTIONAL_TOP_LEVEL_CONFIG+=$'\n'"model_reasoning_effort = \"${ARG_MODEL_REASONING_EFFORT}\""
fi
cat << EOF > "$config_path"
# Minimal Default Codex Configuration
sandbox_mode = "workspace-write"
approval_policy = "never"
preferred_auth_method = "apikey"
${ARG_OPTIONAL_TOP_LEVEL_CONFIG}
[sandbox_workspace_write]
network_access = true
[notice.model_migrations]
"${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}"
[projects."${ARG_CODEX_START_DIRECTORY}"]
trust_level = "trusted"
EOF
}
append_mcp_servers_section() {
local config_path="$1"
if [ "${ARG_REPORT_TASKS}" == "false" ]; then
ARG_CODER_MCP_APP_STATUS_SLUG=""
CODER_MCP_AI_AGENTAPI_URL=""
else
CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
fi
cat << EOF >> "$config_path"
# MCP Servers Configuration
[mcp_servers.Coder]
command = "coder"
args = ["exp", "mcp", "server"]
env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL" = "${CODER_MCP_AI_AGENTAPI_URL}" , "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}", "CODER_MCP_ALLOWED_TOOLS" = "coder_report_task" }
description = "Report ALL tasks and statuses (in progress, done, failed) you are working on."
type = "stdio"
EOF
if [ -n "$ARG_ADDITIONAL_MCP_SERVERS" ]; then
printf "Adding additional MCP servers\n"
echo "$ARG_ADDITIONAL_MCP_SERVERS" >> "$config_path"
fi
}
append_aibridge_config_section() {
local config_path="$1"
if [ -n "$ARG_AIBRIDGE_CONFIG" ]; then
printf "Adding AI Bridge configuration\n"
echo -e "\n# AI Bridge Configuration\n$ARG_AIBRIDGE_CONFIG" >> "$config_path"
fi
}
function populate_config_toml() {
CONFIG_PATH="$HOME/.codex/config.toml"
mkdir -p "$(dirname "$CONFIG_PATH")"
if [ -n "$ARG_BASE_CONFIG_TOML" ]; then
printf "Using provided base configuration\n"
echo "$ARG_BASE_CONFIG_TOML" > "$CONFIG_PATH"
else
printf "Using minimal default configuration\n"
write_minimal_default_config "$CONFIG_PATH"
fi
append_mcp_servers_section "$CONFIG_PATH"
if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then
printf "AI Bridge is enabled\n"
append_aibridge_config_section "$CONFIG_PATH"
fi
}
function add_instruction_prompt_if_exists() {
if [ -n "${ARG_CODEX_INSTRUCTION_PROMPT:-}" ]; then
AGENTS_PATH="$HOME/.codex/AGENTS.md"
printf "Creating AGENTS.md in .codex directory: %s\\n" "${AGENTS_PATH}"
mkdir -p "$HOME/.codex"
if [ -f "${AGENTS_PATH}" ] && grep -Fq "${ARG_CODEX_INSTRUCTION_PROMPT}" "${AGENTS_PATH}"; then
printf "AGENTS.md already contains the instruction prompt. Skipping append.\n"
else
printf "Appending instruction prompt to AGENTS.md in .codex directory\n"
echo -e "\n${ARG_CODEX_INSTRUCTION_PROMPT}" >> "${AGENTS_PATH}"
fi
if [ ! -d "${ARG_CODEX_START_DIRECTORY}" ]; then
printf "Creating start directory '%s'\\n" "${ARG_CODEX_START_DIRECTORY}"
mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
exit 1
}
fi
else
printf "AGENTS.md instruction prompt is not set.\n"
fi
}
function add_auth_json() {
AUTH_JSON_PATH="$HOME/.codex/auth.json"
mkdir -p "$(dirname "$AUTH_JSON_PATH")"
AUTH_JSON=$(
cat << EOF
{
"OPENAI_API_KEY": "${ARG_OPENAI_API_KEY}"
}
EOF
)
echo "$AUTH_JSON" > "$AUTH_JSON_PATH"
}
install_codex
codex --version
populate_config_toml
add_instruction_prompt_if_exists
if [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then
add_auth_json
fi

View File

@ -1,195 +0,0 @@
#!/bin/bash
set -euo pipefail
BOLD='\033[0;1m'
command_exists() {
command -v "$1" > /dev/null 2>&1
}
ARG_INSTALL='${ARG_INSTALL}'
ARG_CODEX_VERSION=$(echo -n '${ARG_CODEX_VERSION}' | base64 -d)
ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d)
ARG_BASE_CONFIG_TOML=$(echo -n '${ARG_BASE_CONFIG_TOML}' | base64 -d)
ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d)
ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}'
ARG_AIBRIDGE_CONFIG=$(echo -n '${ARG_AIBRIDGE_CONFIG}' | base64 -d)
ARG_MODEL_REASONING_EFFORT='${ARG_MODEL_REASONING_EFFORT}'
ARG_OPENAI_API_KEY=$(echo -n '${ARG_OPENAI_API_KEY}' | base64 -d)
echo "--------------------------------"
printf "codex_version: %s\n" "$${ARG_CODEX_VERSION}"
printf "workdir: %s\n" "$${ARG_WORKDIR}"
printf "enable_ai_gateway: %s\n" "$${ARG_ENABLE_AI_GATEWAY}"
printf "install_codex: %s\n" "$${ARG_INSTALL}"
printf "model_reasoning_effort: %s\n" "$${ARG_MODEL_REASONING_EFFORT}"
echo "--------------------------------"
function add_path_to_shell_profiles() {
local path_dir="$1"
for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do
if [ -f "$${profile}" ]; then
if ! grep -q "$${path_dir}" "$${profile}" 2> /dev/null; then
echo "export PATH=\"\$PATH:$${path_dir}\"" >> "$${profile}"
echo "Added $${path_dir} to $${profile}"
fi
fi
done
local fish_config="$HOME/.config/fish/config.fish"
if [ -f "$${fish_config}" ]; then
if ! grep -q "$${path_dir}" "$${fish_config}" 2> /dev/null; then
echo "fish_add_path $${path_dir}" >> "$${fish_config}"
echo "Added $${path_dir} to $${fish_config}"
fi
fi
}
function ensure_codex_in_path() {
local CODEX_BIN=""
if command -v codex > /dev/null 2>&1; then
CODEX_BIN=$(command -v codex)
elif [ -x "$HOME/.npm-global/bin/codex" ]; then
CODEX_BIN="$HOME/.npm-global/bin/codex"
fi
if [ -z "$${CODEX_BIN}" ] || [ ! -x "$${CODEX_BIN}" ]; then
echo "Warning: Could not find codex binary after install"
return
fi
local CODEX_DIR
CODEX_DIR=$(dirname "$${CODEX_BIN}")
if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/codex" ]; then
ln -s "$${CODEX_BIN}" "$${CODER_SCRIPT_BIN_DIR}/codex"
echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/codex -> $${CODEX_BIN}"
fi
add_path_to_shell_profiles "$${CODEX_DIR}"
}
function install_codex() {
if [ "$${ARG_INSTALL}" != "true" ]; then
echo "Skipping Codex installation as per configuration."
ensure_codex_in_path
return
fi
if [ -s "$HOME/.nvm/nvm.sh" ]; then
export NVM_DIR="$HOME/.nvm"
. "$NVM_DIR/nvm.sh"
fi
# Detect a package manager for global installs.
if command_exists npm; then
PKG_INSTALL="npm install -g"
if ! command_exists nvm; then
mkdir -p "$HOME/.npm-global"
npm config set prefix "$HOME/.npm-global"
export PATH="$HOME/.npm-global/bin:$PATH"
fi
elif command_exists pnpm; then
PKG_INSTALL="pnpm add -g"
elif command_exists bun; then
PKG_INSTALL="bun add -g"
else
echo "Error: npm, pnpm, or bun is required to install Codex. Install one of them first or set install_codex = false."
exit 1
fi
printf "%s Installing Codex CLI\n" "$${BOLD}"
if [ -n "$${ARG_CODEX_VERSION}" ]; then
$PKG_INSTALL "@openai/codex@$${ARG_CODEX_VERSION}"
else
$PKG_INSTALL "@openai/codex"
fi
printf "%s Installed Codex CLI: %s\n" "$${BOLD}" "$(codex --version)"
ensure_codex_in_path
}
function write_minimal_default_config() {
local config_path="$1"
local optional_config=""
if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ]; then
optional_config='model_provider = "aigateway"'
fi
if [ -n "$${ARG_MODEL_REASONING_EFFORT}" ]; then
optional_config+=$'\n'"model_reasoning_effort = \"$${ARG_MODEL_REASONING_EFFORT}\""
fi
cat << EOF > "$${config_path}"
preferred_auth_method = "apikey"
$${optional_config}
EOF
if [ -n "$${ARG_WORKDIR}" ]; then
cat << EOF >> "$${config_path}"
[projects."$${ARG_WORKDIR}"]
trust_level = "trusted"
EOF
fi
}
function populate_config_toml() {
local config_path="$HOME/.codex/config.toml"
mkdir -p "$(dirname "$${config_path}")"
if [ -n "$${ARG_BASE_CONFIG_TOML}" ]; then
printf "Using provided base configuration\n"
echo "$${ARG_BASE_CONFIG_TOML}" > "$${config_path}"
else
printf "Using minimal default configuration\n"
write_minimal_default_config "$${config_path}"
fi
if [ -n "$${ARG_MCP}" ]; then
printf "Adding MCP servers\n"
echo "$${ARG_MCP}" >> "$${config_path}"
fi
if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] && [ -n "$${ARG_AIBRIDGE_CONFIG}" ]; then
if ! grep -q '\[model_providers\.aigateway\]' "$${config_path}" 2>/dev/null; then
printf "Adding AI Gateway configuration\n"
echo -e "\n$${ARG_AIBRIDGE_CONFIG}" >> "$${config_path}"
else
printf "AI Gateway provider already defined in config, skipping append\n"
fi
fi
}
function setup_workdir() {
if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then
echo "Creating workdir: $${ARG_WORKDIR}"
mkdir -p "$${ARG_WORKDIR}"
fi
}
function add_auth_json() {
if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] || [ -z "$${ARG_OPENAI_API_KEY}" ]; then
return
fi
local auth_path="$HOME/.codex/auth.json"
mkdir -p "$(dirname "$${auth_path}")"
cat << EOF > "$${auth_path}"
{
"auth_mode": "apikey",
"OPENAI_API_KEY": "$${ARG_OPENAI_API_KEY}"
}
EOF
echo "Seeded auth.json with API key"
}
install_codex
populate_config_toml
setup_workdir
add_auth_json

View File

@ -0,0 +1,229 @@
#!/bin/bash
source "$HOME"/.bashrc
set -o errexit
set -o pipefail
command_exists() {
command -v "$1" > /dev/null 2>&1
}
if [ -f "$HOME/.nvm/nvm.sh" ]; then
source "$HOME"/.nvm/nvm.sh
else
export PATH="$HOME/.npm-global/bin:$PATH"
fi
printf "Version: %s\n" "$(codex --version)"
set -o nounset
ARG_CODEX_TASK_PROMPT=$(echo -n "$ARG_CODEX_TASK_PROMPT" | base64 -d)
ARG_CONTINUE=${ARG_CONTINUE:-true}
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
echo "=== Codex Launch Configuration ==="
printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")"
printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}"
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
printf "Has Task Prompt: %s\n" "$([ -n "$ARG_CODEX_TASK_PROMPT" ] && echo "Yes" || echo "No")"
printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS"
printf "Continue Sessions: %s\n" "$ARG_CONTINUE"
printf "Enable Coder AI Bridge: %s\n" "$ARG_ENABLE_AIBRIDGE"
echo "======================================"
set +o nounset
SESSION_TRACKING_FILE="$HOME/.codex-module/.codex-task-session"
find_session_for_directory() {
local target_dir="$1"
if [ ! -f "$SESSION_TRACKING_FILE" ]; then
return 1
fi
local session_id
session_id=$(grep "^$target_dir|" "$SESSION_TRACKING_FILE" | cut -d'|' -f2 | head -1)
if [ -n "$session_id" ]; then
echo "$session_id"
return 0
fi
return 1
}
store_session_mapping() {
local dir="$1"
local session_id="$2"
mkdir -p "$(dirname "$SESSION_TRACKING_FILE")"
if [ -f "$SESSION_TRACKING_FILE" ]; then
grep -v "^$dir|" "$SESSION_TRACKING_FILE" > "$SESSION_TRACKING_FILE.tmp" 2> /dev/null || true
mv "$SESSION_TRACKING_FILE.tmp" "$SESSION_TRACKING_FILE"
fi
echo "$dir|$session_id" >> "$SESSION_TRACKING_FILE"
}
find_recent_session_file() {
local target_dir="$1"
local sessions_dir="$HOME/.codex/sessions"
if [ ! -d "$sessions_dir" ]; then
return 1
fi
local latest_file=""
local latest_time=0
while IFS= read -r session_file; do
local file_time
file_time=$(stat -c %Y "$session_file" 2> /dev/null || stat -f %m "$session_file" 2> /dev/null || echo "0")
local first_line
first_line=$(head -n 1 "$session_file" 2> /dev/null)
local session_cwd
session_cwd=$(echo "$first_line" | grep -o '"cwd":"[^"]*"' | cut -d'"' -f4)
if [ "$session_cwd" = "$target_dir" ] && [ "$file_time" -gt "$latest_time" ]; then
latest_file="$session_file"
latest_time="$file_time"
fi
done < <(find "$sessions_dir" -type f -name "*.jsonl" 2> /dev/null)
if [ -n "$latest_file" ]; then
local first_line
first_line=$(head -n 1 "$latest_file")
local session_id
session_id=$(echo "$first_line" | grep -o '"id":"[^"]*"' | cut -d'"' -f4)
if [ -n "$session_id" ]; then
echo "$session_id"
return 0
fi
fi
return 1
}
wait_for_session_file() {
local target_dir="$1"
local max_attempts=20
local attempt=0
while [ $attempt -lt $max_attempts ]; do
local session_id
session_id=$(find_recent_session_file "$target_dir" 2> /dev/null || echo "")
if [ -n "$session_id" ]; then
echo "$session_id"
return 0
fi
sleep 0.5
attempt=$((attempt + 1))
done
return 1
}
validate_codex_installation() {
if command_exists codex; then
printf "Codex is installed\n"
else
printf "Error: Codex is not installed. Please enable install_codex or install it manually\n"
exit 1
fi
}
setup_workdir() {
if [ -d "${ARG_CODEX_START_DIRECTORY}" ]; then
printf "Directory '%s' exists. Changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
cd "${ARG_CODEX_START_DIRECTORY}" || {
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
exit 1
}
else
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
exit 1
}
cd "${ARG_CODEX_START_DIRECTORY}" || {
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
exit 1
}
fi
}
build_codex_args() {
CODEX_ARGS=()
if [[ -n "${ARG_CODEX_MODEL}" ]]; then
CODEX_ARGS+=("--model" "${ARG_CODEX_MODEL}")
fi
if [ "$ARG_CONTINUE" = "true" ]; then
existing_session=$(find_session_for_directory "$ARG_CODEX_START_DIRECTORY" 2> /dev/null || echo "")
if [ -n "$existing_session" ]; then
printf "Found existing task session for this directory: %s\n" "$existing_session"
printf "Resuming existing session...\n"
CODEX_ARGS+=("resume" "$existing_session")
else
printf "No existing task session found for this directory\n"
printf "Starting new task session...\n"
if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then
if [ "${ARG_REPORT_TASKS}" == "true" ]; then
PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
else
PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT"
fi
CODEX_ARGS+=("$PROMPT")
fi
fi
else
printf "Continue disabled, starting fresh session\n"
if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then
if [ "${ARG_REPORT_TASKS}" == "true" ]; then
PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using Coder.coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
else
PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT"
fi
CODEX_ARGS+=("$PROMPT")
fi
fi
}
capture_session_id() {
if [ "$ARG_CONTINUE" = "true" ] && [ -z "$existing_session" ]; then
printf "Capturing new session ID...\n"
new_session=$(wait_for_session_file "$ARG_CODEX_START_DIRECTORY" || echo "")
if [ -n "$new_session" ]; then
store_session_mapping "$ARG_CODEX_START_DIRECTORY" "$new_session"
printf "✓ Session tracked: %s\n" "$new_session"
printf "This session will be automatically resumed on next restart\n"
else
printf "⚠ Could not capture session ID after 10s timeout\n"
fi
fi
}
start_codex() {
printf "Starting Codex with arguments: %s\n" "${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
}
validate_codex_installation
setup_workdir
build_codex_args
start_codex

View File

@ -1,9 +1,38 @@
#!/bin/bash #!/bin/bash
# Handle --version flag
if [[ "$1" == "--version" ]]; then if [[ "$1" == "--version" ]]; then
echo "HELLO: $(bash -c env)"
echo "codex version v1.0.0" echo "codex version v1.0.0"
exit 0 exit 0
fi fi
echo "codex invoked with: $*" set -e
exit 0
SESSION_ID=""
IS_RESUME=false
while [[ $# -gt 0 ]]; do
case $1 in
resume)
IS_RESUME=true
SESSION_ID="$2"
shift 2
;;
*)
shift
;;
esac
done
if [ "$IS_RESUME" = false ]; then
SESSION_ID="019a1234-5678-9abc-def0-123456789012"
echo "Created new session: $SESSION_ID"
else
echo "Resuming session: $SESSION_ID"
fi
while true; do
echo "$(date) - codex-mock (session: $SESSION_ID)"
sleep 15
done

View File

@ -13,7 +13,7 @@ Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace
```tf ```tf
module "gemini" { module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder" source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1" version = "3.0.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
folder = "/home/coder/project" folder = "/home/coder/project"
} }
@ -46,7 +46,7 @@ variable "gemini_api_key" {
module "gemini" { module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder" source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1" version = "3.0.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key gemini_api_key = var.gemini_api_key
folder = "/home/coder/project" folder = "/home/coder/project"
@ -94,7 +94,7 @@ data "coder_parameter" "ai_prompt" {
module "gemini" { module "gemini" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/gemini/coder" source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1" version = "3.0.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key gemini_api_key = var.gemini_api_key
gemini_model = "gemini-2.5-flash" gemini_model = "gemini-2.5-flash"
@ -105,22 +105,6 @@ module "gemini" {
You are a helpful coding assistant. Always explain your code changes clearly. You are a helpful coding assistant. Always explain your code changes clearly.
YOU MUST REPORT ALL TASKS TO CODER. YOU MUST REPORT ALL TASKS TO CODER.
EOT EOT
pre_install_script = <<-EOT
#!/bin/bash
set -e
echo "Installing Node.js via NodeSource..."
sudo apt-get update -qq && sudo apt-get install -y curl ca-certificates
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo bash -
sudo apt-get install -y nodejs
echo "Node version: $(node -v)"
echo "npm version: $(npm -v)"
echo "Node install complete."
EOT
} }
``` ```
@ -134,7 +118,7 @@ For enterprise users who prefer Google's Vertex AI platform:
```tf ```tf
module "gemini" { module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder" source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1" version = "3.0.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key gemini_api_key = var.gemini_api_key
folder = "/home/coder/project" folder = "/home/coder/project"

View File

@ -148,16 +148,22 @@ locals {
base_extensions = <<-EOT base_extensions = <<-EOT
{ {
"coder": { "coder": {
"command": "coder",
"args": [ "args": [
"exp", "exp",
"mcp", "mcp",
"server" "server"
], ],
"command": "coder",
"description": "Report ALL tasks and statuses (in progress, done, failed) you are working on.",
"enabled": true,
"env": { "env": {
"CODER_MCP_APP_STATUS_SLUG": "${local.app_slug}", "CODER_MCP_APP_STATUS_SLUG": "${local.app_slug}",
"CODER_MCP_AI_AGENTAPI_URL": "http://localhost:3284" "CODER_MCP_AI_AGENTAPI_URL": "http://localhost:3284"
} },
"name": "Coder",
"timeout": 3000,
"type": "stdio",
"trust": true
} }
} }
EOT EOT

View File

@ -17,7 +17,6 @@ echo "--------------------------------"
printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG" printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG"
printf "install: %s\n" "$ARG_INSTALL" printf "install: %s\n" "$ARG_INSTALL"
printf "gemini_version: %s\n" "$ARG_GEMINI_VERSION" printf "gemini_version: %s\n" "$ARG_GEMINI_VERSION"
printf "BASE_EXTENSIONS: %s\n" "$BASE_EXTENSIONS"
echo "--------------------------------" echo "--------------------------------"
set +o nounset set +o nounset
@ -141,25 +140,6 @@ function add_system_prompt_if_exists() {
fi fi
} }
function patch_coder_mcp_command() {
CODER_BIN=$(which coder)
SETTINGS_PATH="$HOME/.gemini/settings.json"
if [ -z "$CODER_BIN" ]; then
printf "Warning: could not find coder binary, MCP command path not patched.\n"
return
fi
printf "Patching coder MCP command path to: %s\n" "$CODER_BIN"
TMP_SETTINGS=$(mktemp)
jq --arg bin "$CODER_BIN" \
'.mcpServers.coder.command = $bin' \
"$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
printf "Patch complete.\n"
}
function configure_mcp() { function configure_mcp() {
export CODER_MCP_APP_STATUS_SLUG="gemini" export CODER_MCP_APP_STATUS_SLUG="gemini"
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
@ -169,5 +149,4 @@ function configure_mcp() {
install_gemini install_gemini
populate_settings_json populate_settings_json
add_system_prompt_if_exists add_system_prompt_if_exists
patch_coder_mcp_command
configure_mcp configure_mcp

View File

@ -1,146 +0,0 @@
---
display_name: Agent Firewall
description: Configures agent-firewall for network isolation in Coder workspaces
icon: ../../../../.icons/coder.svg
verified: true
tags: [agent-firewall, ai, agents, firewall, boundary]
---
# Agent Firewall
Installs [agent-firewall](https://coder.com/docs/ai-coder/agent-firewall) for network isolation in Coder workspaces.
This module:
- Installs agent-firewall (via coder subcommand, direct installation, or compilation from source)
- Creates a wrapper script at `$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh`
- Writes a [default agent-firewall config](https://github.com/coder/registry/blob/main/registry/coder/modules/agent-firewall/config.yaml.tftpl) to `$HOME/.coder-modules/coder/agent-firewall/config/config.yaml` (customizable)
- Provides the wrapper path, config path, and script names via outputs
- Uses coder-utils and output `scripts` for synchronization. https://registry.coder.com/modules/coder/coder-utils?tab=outputs
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
}
```
## Examples
Use the `agent_firewall_wrapper_path` output to access the wrapper path and `agent_firewall_config_path` to access config path in Terraform and pass it to scripts that should run commands in network isolation.
### With Claude Code
Use agent-firewall alongside the `claude-code` module to run Claude in a
network-isolated environment.
#### As an automated task
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
}
resource "coder_script" "claude_with_agent_firewall" {
agent_id = coder_agent.main.id
display_name = "Claude (Agent Firewall)"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
coder exp sync want claude-agent-firewall \
${join(" ", module.agent-firewall.scripts)} \
${join(" ", module.claude-code.scripts)}
coder exp sync start claude-agent-firewall
"${module.agent-firewall.agent_firewall_wrapper_path}" --config="${module.agent-firewall.agent_firewall_config_path}" -- claude -p "Fix issue #840 from coder/coder"
EOT
}
```
#### As a Coder app
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
}
resource "coder_app" "claude_with_agent_firewall" {
agent_id = coder_agent.main.id
display_name = "Claude Code"
slug = "claude-code"
command = <<-EOT
#!/bin/bash
set -e
exec tmux new-session -A -s claude-code \
'"${module.agent-firewall.agent_firewall_wrapper_path}" --config="${module.agent-firewall.agent_firewall_config_path}" -- claude'
EOT
}
```
## Configuration
The module ships with a comprehensive default config based on the
[Coder dogfood allowlist](https://github.com/coder/coder/blob/main/dogfood/coder/boundary-config.yaml). It covers Anthropic services,
OpenAI services, version control, package managers, container registries,
cloud platforms, and common development tools.
The Coder deployment domain is automatically added to the allowlist using
`data.coder_workspace.me.access_url`.
By default the config is written to
`$HOME/.coder-modules/coder/agent-firewall/config/config.yaml`. You can
access the resolved path via the `agent_firewall_config_path` output. Override
it in two ways:
### Inline config
Pass the full YAML content directly:
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
agent_firewall_config = <<-YAML
allowlist:
- domain=your-deployment.coder.com
- domain=api.anthropic.com
- domain=api.openai.com
log_dir: /tmp/agent_firewall_logs
proxy_port: 8087
log_level: warn
YAML
}
```
### External config file
Point to an existing config file in the workspace. The module will not
write any config and the `agent_firewall_config_path` output will point to
your path. The file must exist on disk before agent-firewall starts.
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
agent_firewall_config_path = "/workspace/my-agent-firewall-config.yaml"
}
```
> **Note:** `agent_firewall_config` and `agent_firewall_config_path` are mutually
> exclusive, setting both produces a validation error.
See the [Agent Firewall docs](https://coder.com/docs/ai-coder/agent-firewall)
for the full config reference.
## References
- [Agent Firewall Documentation](https://coder.com/docs/ai-coder/agent-firewall)

View File

@ -1,157 +0,0 @@
# Test for agent-firewall module
run "plan_with_required_vars" {
command = plan
variables {
agent_id = "test-agent-id"
}
# Verify the agent_firewall_wrapper_path output
assert {
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
error_message = "agent_firewall_wrapper_path output should be correct"
}
# Verify agent_firewall_config_path output defaults to the managed path
assert {
condition = output.agent_firewall_config_path == "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml"
error_message = "agent_firewall_config_path output should default to managed config path"
}
# Verify the scripts output contains the install script name
assert {
condition = contains(output.scripts, "coder-agent-firewall-install_script")
error_message = "scripts should contain the install script name"
}
}
run "plan_with_compile_from_source" {
command = plan
variables {
agent_id = "test-agent-id"
compile_agent_firewall_from_source = true
agent_firewall_version = "main"
}
assert {
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
error_message = "agent_firewall_wrapper_path output should be correct"
}
assert {
condition = contains(output.scripts, "coder-agent-firewall-install_script")
error_message = "scripts should contain the install script name"
}
}
run "plan_with_use_directly" {
command = plan
variables {
agent_id = "test-agent-id"
use_agent_firewall_directly = true
agent_firewall_version = "latest"
}
assert {
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
error_message = "agent_firewall_wrapper_path output should be correct"
}
assert {
condition = contains(output.scripts, "coder-agent-firewall-install_script")
error_message = "scripts should contain the install script name"
}
}
run "plan_with_custom_hooks" {
command = plan
variables {
agent_id = "test-agent-id"
pre_install_script = "echo 'Before install'"
post_install_script = "echo 'After install'"
}
assert {
condition = contains(output.scripts, "coder-agent-firewall-install_script")
error_message = "scripts should contain the install script name"
}
# Verify pre and post install script names are set
assert {
condition = contains(output.scripts, "coder-agent-firewall-pre_install_script")
error_message = "scripts should contain the pre_install script name"
}
assert {
condition = contains(output.scripts, "coder-agent-firewall-post_install_script")
error_message = "scripts should contain the post_install script name"
}
}
run "plan_with_custom_module_directory" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/custom/agent-firewall"
}
assert {
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/custom/agent-firewall/scripts/agent-firewall-wrapper.sh"
error_message = "agent_firewall_wrapper_path output should use custom module directory"
}
# Config path should also follow the module directory
assert {
condition = output.agent_firewall_config_path == "$HOME/.coder-modules/custom/agent-firewall/config/config.yaml"
error_message = "agent_firewall_config_path output should use custom module directory"
}
}
run "plan_with_inline_config" {
command = plan
variables {
agent_id = "test-agent-id"
agent_firewall_config = "allowlist:\n - domain=example.com\nlog_level: debug\n"
}
# Inline config should still point to the managed path.
assert {
condition = output.agent_firewall_config_path == "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml"
error_message = "agent_firewall_config_path output should point to managed config path"
}
}
run "plan_with_config_path" {
command = plan
variables {
agent_id = "test-agent-id"
agent_firewall_config_path = "/workspace/my-boundary-config.yaml"
}
# agent_firewall_config_path output should point to the user-provided path.
assert {
condition = output.agent_firewall_config_path == "/workspace/my-boundary-config.yaml"
error_message = "agent_firewall_config_path output should point to user-provided path"
}
}
run "plan_with_both_configs_should_fail" {
command = plan
variables {
agent_id = "test-agent-id"
agent_firewall_config = "allowlist: []"
agent_firewall_config_path = "/workspace/config.yaml"
}
expect_failures = [
var.agent_firewall_config,
]
}

View File

@ -1,218 +0,0 @@
allowlist:
- domain=${CODER_DOMAIN}
# Anthropic Services
- domain=api.anthropic.com
- domain=statsig.anthropic.com
- domain=claude.ai
# OpenAI Services
- domain=api.openai.com
- domain=platform.openai.com
- domain=openai.com
- domain=chatgpt.com
- domain=*.oaiusercontent.com
- domain=*.oaistatic.com
# Version Control
- domain=github.com
- domain=www.github.com
- domain=api.github.com
- domain=raw.githubusercontent.com
- domain=objects.githubusercontent.com
- domain=codeload.github.com
- domain=avatars.githubusercontent.com
- domain=camo.githubusercontent.com
- domain=gist.github.com
- domain=gitlab.com
- domain=www.gitlab.com
- domain=registry.gitlab.com
- domain=bitbucket.org
- domain=www.bitbucket.org
- domain=api.bitbucket.org
# Container Registries
- domain=registry-1.docker.io
- domain=auth.docker.io
- domain=index.docker.io
- domain=hub.docker.com
- domain=www.docker.com
- domain=production.cloudflare.docker.com
- domain=download.docker.com
- domain=*.gcr.io
- domain=ghcr.io
- domain=mcr.microsoft.com
- domain=*.data.mcr.microsoft.com
# Cloud Platforms
- domain=cloud.google.com
- domain=accounts.google.com
- domain=gcloud.google.com
- domain=*.googleapis.com
- domain=storage.googleapis.com
- domain=compute.googleapis.com
- domain=container.googleapis.com
- domain=azure.com
- domain=portal.azure.com
- domain=microsoft.com
- domain=www.microsoft.com
- domain=*.microsoftonline.com
- domain=packages.microsoft.com
- domain=dotnet.microsoft.com
- domain=dot.net
- domain=visualstudio.com
- domain=dev.azure.com
- domain=oracle.com
- domain=www.oracle.com
- domain=java.com
- domain=www.java.com
- domain=java.net
- domain=www.java.net
- domain=download.oracle.com
- domain=yum.oracle.com
# Package Managers - JavaScript/Node
- domain=registry.npmjs.org
- domain=www.npmjs.com
- domain=www.npmjs.org
- domain=npmjs.com
- domain=npmjs.org
- domain=yarnpkg.com
- domain=registry.yarnpkg.com
# Package Managers - Python
- domain=pypi.org
- domain=www.pypi.org
- domain=files.pythonhosted.org
- domain=pythonhosted.org
- domain=test.pypi.org
- domain=pypi.python.org
- domain=pypa.io
- domain=www.pypa.io
# Package Managers - Ruby
- domain=rubygems.org
- domain=www.rubygems.org
- domain=api.rubygems.org
- domain=index.rubygems.org
- domain=ruby-lang.org
- domain=www.ruby-lang.org
- domain=rubyforge.org
- domain=www.rubyforge.org
- domain=rubyonrails.org
- domain=www.rubyonrails.org
- domain=rvm.io
- domain=get.rvm.io
# Package Managers - Rust
- domain=crates.io
- domain=www.crates.io
- domain=static.crates.io
- domain=rustup.rs
- domain=static.rust-lang.org
- domain=www.rust-lang.org
# Package Managers - Go
- domain=proxy.golang.org
- domain=sum.golang.org
- domain=index.golang.org
- domain=golang.org
- domain=www.golang.org
- domain=go.dev
- domain=dl.google.com
- domain=goproxy.io
- domain=pkg.go.dev
# Package Managers - JVM
- domain=maven.org
- domain=repo.maven.org
- domain=central.maven.org
- domain=repo1.maven.org
- domain=jcenter.bintray.com
- domain=gradle.org
- domain=www.gradle.org
- domain=services.gradle.org
- domain=spring.io
- domain=repo.spring.io
# Package Managers - Other Languages
- domain=packagist.org
- domain=www.packagist.org
- domain=repo.packagist.org
- domain=nuget.org
- domain=www.nuget.org
- domain=api.nuget.org
- domain=pub.dev
- domain=api.pub.dev
- domain=hex.pm
- domain=www.hex.pm
- domain=cpan.org
- domain=www.cpan.org
- domain=metacpan.org
- domain=www.metacpan.org
- domain=api.metacpan.org
- domain=cocoapods.org
- domain=www.cocoapods.org
- domain=cdn.cocoapods.org
- domain=haskell.org
- domain=www.haskell.org
- domain=hackage.haskell.org
- domain=swift.org
- domain=www.swift.org
# Linux Distributions
- domain=archive.ubuntu.com
- domain=security.ubuntu.com
- domain=ubuntu.com
- domain=www.ubuntu.com
- domain=*.ubuntu.com
- domain=ppa.launchpad.net
- domain=launchpad.net
- domain=www.launchpad.net
# Development Tools & Platforms
- domain=dl.k8s.io
- domain=pkgs.k8s.io
- domain=k8s.io
- domain=www.k8s.io
- domain=releases.hashicorp.com
- domain=apt.releases.hashicorp.com
- domain=rpm.releases.hashicorp.com
- domain=archive.releases.hashicorp.com
- domain=hashicorp.com
- domain=www.hashicorp.com
- domain=repo.anaconda.com
- domain=conda.anaconda.org
- domain=anaconda.org
- domain=www.anaconda.com
- domain=anaconda.com
- domain=continuum.io
- domain=apache.org
- domain=www.apache.org
- domain=archive.apache.org
- domain=downloads.apache.org
- domain=eclipse.org
- domain=www.eclipse.org
- domain=download.eclipse.org
- domain=nodejs.org
- domain=www.nodejs.org
# Cloud Services & Monitoring
- domain=statsig.com
- domain=www.statsig.com
- domain=api.statsig.com
- domain=*.sentry.io
# Content Delivery & Mirrors
- domain=*.sourceforge.net
- domain=packagecloud.io
- domain=*.packagecloud.io
# Schema & Configuration
- domain=json-schema.org
- domain=www.json-schema.org
- domain=json.schemastore.org
- domain=www.schemastore.org
log_dir: ${BOUNDARY_LOG_DIR}
log_level: warn
proxy_port: 8087

View File

@ -1,376 +0,0 @@
import {
test,
afterEach,
describe,
setDefaultTimeout,
beforeAll,
expect,
} from "bun:test";
import {
execContainer,
readFileContainer,
runTerraformInit,
runTerraformApply,
testRequiredVariables,
runContainer,
removeContainer,
} from "~test";
import {
loadTestFile,
writeExecutable,
execModuleScript,
extractCoderEnvVars,
} from "../agentapi/test-util";
let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => {
cleanupFunctions.push(cleanup);
};
afterEach(async () => {
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
cleanupFunctions = [];
for (const cleanup of cleanupFnsCopy) {
try {
await cleanup();
} catch (error) {
console.error("Error during cleanup:", error);
}
}
});
interface SetupProps {
moduleVariables?: Record<string, string>;
skipCoderMock?: boolean;
}
const MODULE_DIR = "/home/coder/.coder-modules/coder/agent-firewall";
const CONFIG_PATH = `${MODULE_DIR}/config/config.yaml`;
const WRAPPER_PATH = `${MODULE_DIR}/scripts/agent-firewall-wrapper.sh`;
const setup = async (
props?: SetupProps,
): Promise<{ id: string; coderEnvVars: Record<string, string> }> => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
...props?.moduleVariables,
});
const coderEnvVars = extractCoderEnvVars(state);
const id = await runContainer("codercom/enterprise-node:latest");
registerCleanup(async () => {
await removeContainer(id);
});
await execContainer(id, ["bash", "-c", "mkdir -p /home/coder/project"]);
// Create a mock coder binary with boundary subcommand and exp sync support
if (!props?.skipCoderMock) {
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: await loadTestFile(import.meta.dir, "coder-mock.sh"),
});
}
// Extract ALL coder_scripts from the state (coder-utils creates multiple)
const allScripts = state.resources
.filter((r) => r.type === "coder_script")
.map((r) => ({
name: r.name,
script: r.instances[0].attributes.script as string,
}));
// Run scripts in lifecycle order
const executionOrder = [
"pre_install_script",
"install_script",
"post_install_script",
];
const orderedScripts = executionOrder
.map((name) => allScripts.find((s) => s.name === name))
.filter((s): s is NonNullable<typeof s> => s != null);
// Write each script individually and create a combined runner
const scriptPaths: string[] = [];
for (const s of orderedScripts) {
const scriptPath = `/home/coder/${s.name}.sh`;
await writeExecutable({
containerId: id,
filePath: scriptPath,
content: s.script,
});
scriptPaths.push(scriptPath);
}
const combinedScript = [
"#!/bin/bash",
"set -o errexit",
"set -o pipefail",
...scriptPaths.map((p) => `bash "${p}"`),
].join("\n");
await writeExecutable({
containerId: id,
filePath: "/home/coder/script.sh",
content: combinedScript,
});
return { id, coderEnvVars };
};
setDefaultTimeout(60 * 1000);
describe("agent-firewall", async () => {
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
testRequiredVariables(import.meta.dir, {
agent_id: "test-agent-id",
});
test("terraform-state-basic", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent-id",
});
const resources = state.resources;
// No coder_env resources should exist
const envResources = resources.filter((r) => r.type === "coder_env");
expect(envResources).toHaveLength(0);
// Verify no env vars are exported
const coderEnvVars = extractCoderEnvVars(state);
expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBeUndefined();
expect(coderEnvVars["BOUNDARY_CONFIG"]).toBeUndefined();
// Verify agent_firewall_config_path output
expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
"$HOME/.coder-modules/coder/agent-firewall/config/config.yaml",
);
// Verify agent_firewall_wrapper_path output
expect(state.outputs["agent_firewall_wrapper_path"]?.value).toBe(
"$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh",
);
// Verify scripts output contains install script
const scripts = state.outputs["scripts"]?.value as string[];
expect(scripts).toContain("coder-agent-firewall-install_script");
});
test("terraform-state-custom-module-directory", async () => {
const customDir = "$HOME/.coder-modules/custom/agent-firewall";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent-id",
module_directory: customDir,
});
// Verify output uses custom dir
const outputs = state.outputs;
expect(outputs["agent_firewall_wrapper_path"]?.value).toBe(
`${customDir}/scripts/agent-firewall-wrapper.sh`,
);
// Config path follows module directory
expect(outputs["agent_firewall_config_path"]?.value).toBe(
`${customDir}/config/config.yaml`,
);
});
test("terraform-state-inline-config", async () => {
const inlineConfig =
"allowlist:\n - domain=example.com\nlog_level: debug\n";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent-id",
agent_firewall_config: inlineConfig,
});
// Inline config still writes to the managed path.
expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
"$HOME/.coder-modules/coder/agent-firewall/config/config.yaml",
);
});
test("terraform-state-config-path", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent-id",
agent_firewall_config_path: "/workspace/my-config.yaml",
});
// agent_firewall_config_path output should point to the user-provided path.
expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
"/workspace/my-config.yaml",
);
});
test("happy-path-coder-subcommand", async () => {
const { id } = await setup();
await execModuleScript(id);
// Verify the wrapper script was created
const wrapperContent = await readFileContainer(id, WRAPPER_PATH);
expect(wrapperContent).toContain("#!/usr/bin/env bash");
expect(wrapperContent).toContain("coder-no-caps");
expect(wrapperContent).toContain("boundary");
// Verify the wrapper script is executable
const statResult = await execContainer(id, [
"stat",
"-c",
"%a",
WRAPPER_PATH,
]);
expect(statResult.stdout.trim()).toMatch(/7[0-9][0-9]/);
// Verify coder-no-caps binary was created
const coderNoCapsResult = await execContainer(id, [
"test",
"-f",
`${MODULE_DIR}/scripts/coder-no-caps`,
]);
expect(coderNoCapsResult.exitCode).toBe(0);
// Verify default boundary config was written inside module directory
const configContent = await readFileContainer(id, CONFIG_PATH);
expect(configContent).toContain("allowlist:");
expect(configContent).toContain("domain=api.anthropic.com");
expect(configContent).toContain("domain=api.openai.com");
expect(configContent).toContain("proxy_port: 8087");
// Verify Coder domain was auto-filled from data.coder_workspace.me
// (the placeholder should be replaced with the actual deployment domain).
expect(configContent).not.toContain("domain=your-deployment.coder.com");
// Verify $HOME was expanded in log_dir (should be absolute, not literal $HOME).
expect(configContent).toContain("log_dir: /home/coder/");
expect(configContent).not.toContain("$HOME");
// Check install log
const installLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/install.log`,
);
expect(installLog).toContain("Using coder boundary subcommand");
expect(installLog).toContain("Boundary config written to");
expect(installLog).toContain("boundary wrapper configured");
});
test("inline-config-written", async () => {
const customConfig =
"allowlist:\n - domain=custom.example.com\nlog_level: info\n";
const { id } = await setup({
moduleVariables: {
agent_firewall_config: customConfig,
},
});
await execModuleScript(id);
// Verify the inline config was written
const configContent = await readFileContainer(id, CONFIG_PATH);
expect(configContent).toContain("domain=custom.example.com");
expect(configContent).toContain("log_level: info");
});
test("config-path-skips-write", async () => {
const { id } = await setup({
moduleVariables: {
agent_firewall_config_path: "/workspace/external-config.yaml",
},
});
await execModuleScript(id);
// Verify NO config was written to the default path
const checkResult = await execContainer(id, ["test", "-f", CONFIG_PATH]);
expect(checkResult.exitCode).not.toBe(0);
// Check install log confirms skip
const installLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/install.log`,
);
expect(installLog).toContain(
"Using external boundary config, skipping config write",
);
});
// Note: Tests for use_agent_firewall_directly and
// compile_agent_firewall_from_source are skipped because they require
// network access (downloading boundary) or compilation which are too
// slow for unit tests. These modes are tested manually.
test("custom-hooks", async () => {
const preInstallMarker = "pre-install-executed";
const postInstallMarker = "post-install-executed";
const { id } = await setup({
moduleVariables: {
pre_install_script: `#!/bin/bash\necho '${preInstallMarker}'`,
post_install_script: `#!/bin/bash\necho '${postInstallMarker}'`,
},
});
await execModuleScript(id);
// Verify pre-install script ran
const preInstallLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/pre_install.log`,
);
expect(preInstallLog).toContain(preInstallMarker);
// Verify post-install script ran
const postInstallLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/post_install.log`,
);
expect(postInstallLog).toContain(postInstallMarker);
// Verify main install still ran
const installLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/install.log`,
);
expect(installLog).toContain("boundary wrapper configured");
});
test("no-env-vars", async () => {
const { coderEnvVars } = await setup();
// No env vars should be exported by this module.
expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBeUndefined();
expect(coderEnvVars["BOUNDARY_CONFIG"]).toBeUndefined();
});
test("wrapper-script-execution", async () => {
const { id } = await setup();
await execModuleScript(id);
// Try executing the wrapper script with a command
const wrapperResult = await execContainer(id, [
"bash",
"-c",
`${WRAPPER_PATH} echo boundary-test`,
]);
// The wrapper passes the command directly to the boundary command
expect(wrapperResult.stdout).toContain("boundary-test");
});
test("installation-idempotency", async () => {
const { id } = await setup();
// Run the installation twice
await execModuleScript(id);
const firstInstallLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/install.log`,
);
// Run again
const secondRun = await execModuleScript(id);
expect(secondRun.exitCode).toBe(0);
// Both runs should succeed
expect(firstInstallLog).toContain("boundary wrapper configured");
});
});

View File

@ -1,128 +0,0 @@
terraform {
required_version = ">= 1.9"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
data "coder_workspace" "me" {}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "agent_firewall_version" {
type = string
description = "Agent firewall version. When use_agent_firewall_directly is true, a release version should be provided or 'latest' for the latest release. When compile_agent_firewall_from_source is true, a valid git reference should be provided (tag, commit, branch)."
default = "latest"
}
variable "compile_agent_firewall_from_source" {
type = bool
description = "Whether to compile agent-firewall from source instead of using the official install script."
default = false
}
variable "use_agent_firewall_directly" {
type = bool
description = "Whether to use agent-firewall binary directly instead of `coder boundary` subcommand. When false (default), uses `coder boundary` subcommand. When true, installs and uses agent-firewall binary from release."
default = false
}
variable "agent_firewall_config" {
type = string
description = "Inline agent-firewall configuration content (YAML). Overrides the module's default config. Mutually exclusive with agent_firewall_config_path."
default = null
validation {
condition = !(var.agent_firewall_config != null && var.agent_firewall_config_path != null)
error_message = "Only one of agent_firewall_config or agent_firewall_config_path may be set."
}
}
variable "agent_firewall_config_path" {
type = string
description = "Path to an existing agent-firewall config file in the workspace. When set, no config is written and the agent_firewall_config_path output points to this path. Mutually exclusive with agent_firewall_config."
default = null
}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing agent-firewall."
default = null
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing agent-firewall."
default = null
}
variable "module_directory" {
type = string
description = "Directory where the agent-firewall module scripts will be located. Default is $HOME/.coder-modules/coder/agent-firewall."
default = "$HOME/.coder-modules/coder/agent-firewall"
}
locals {
boundary_wrapper_path = "${var.module_directory}/scripts/agent-firewall-wrapper.sh"
# Extract domain from the Coder access URL for the default config
# allowlist (e.g., "https://dev.coder.com/" -> "dev.coder.com").
coder_domain = try(regex("^https?://([^/:]+)", data.coder_workspace.me.access_url)[0], "")
# Config handling: resolve which config content to write and where
# agent_firewall_config_path output points to.
default_boundary_config = templatefile("${path.module}/config.yaml.tftpl", {
CODER_DOMAIN = local.coder_domain
BOUNDARY_LOG_DIR = "${var.module_directory}/logs/agent_firewall_logs"
})
boundary_config_content = var.agent_firewall_config != null ? var.agent_firewall_config : local.default_boundary_config
boundary_config_dir = "${var.module_directory}/config"
boundary_config_file_path = "${local.boundary_config_dir}/config.yaml"
effective_boundary_config_path = var.agent_firewall_config_path != null ? var.agent_firewall_config_path : local.boundary_config_file_path
write_boundary_config = var.agent_firewall_config_path == null
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
BOUNDARY_VERSION = var.agent_firewall_version
COMPILE_BOUNDARY_FROM_SOURCE = tostring(var.compile_agent_firewall_from_source)
USE_BOUNDARY_DIRECTLY = tostring(var.use_agent_firewall_directly)
MODULE_DIR = var.module_directory
BOUNDARY_WRAPPER_PATH = local.boundary_wrapper_path
WRITE_BOUNDARY_CONFIG = tostring(local.write_boundary_config)
BOUNDARY_CONFIG_CONTENT_B64 = local.write_boundary_config ? base64encode(local.boundary_config_content) : ""
BOUNDARY_CONFIG_DIR = local.boundary_config_dir
BOUNDARY_CONFIG_FILE = local.boundary_config_file_path
})
}
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
agent_id = var.agent_id
display_name_prefix = "Agent Firewall"
module_directory = var.module_directory
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
install_script = local.install_script
}
output "agent_firewall_wrapper_path" {
description = "Path to the agent-firewall wrapper script."
value = local.boundary_wrapper_path
}
output "agent_firewall_config_path" {
description = "Effective path to the agent-firewall config file."
value = local.effective_boundary_config_path
}
output "scripts" {
description = "List of script names for coder exp sync coordination."
value = module.coder_utils.scripts
}

View File

@ -1,131 +0,0 @@
#!/bin/bash
# Sets up boundary for network isolation in Coder workspaces.
set -euo pipefail
BOUNDARY_VERSION='${BOUNDARY_VERSION}'
COMPILE_BOUNDARY_FROM_SOURCE='${COMPILE_BOUNDARY_FROM_SOURCE}'
USE_BOUNDARY_DIRECTLY='${USE_BOUNDARY_DIRECTLY}'
MODULE_DIR="${MODULE_DIR}"
BOUNDARY_WRAPPER_PATH="${BOUNDARY_WRAPPER_PATH}"
WRITE_BOUNDARY_CONFIG='${WRITE_BOUNDARY_CONFIG}'
BOUNDARY_CONFIG_CONTENT=$(echo -n '${BOUNDARY_CONFIG_CONTENT_B64}' | base64 -d | sed "s|\$HOME|$HOME|g")
BOUNDARY_CONFIG_DIR="${BOUNDARY_CONFIG_DIR}"
BOUNDARY_CONFIG_FILE="${BOUNDARY_CONFIG_FILE}"
printf "BOUNDARY_VERSION: %s\n" "$${BOUNDARY_VERSION}"
printf "COMPILE_BOUNDARY_FROM_SOURCE: %s\n" "$${COMPILE_BOUNDARY_FROM_SOURCE}"
printf "USE_BOUNDARY_DIRECTLY: %s\n" "$${USE_BOUNDARY_DIRECTLY}"
printf "MODULE_DIR: %s\n" "$${MODULE_DIR}"
printf "BOUNDARY_WRAPPER_PATH: %s\n" "$${BOUNDARY_WRAPPER_PATH}"
printf "WRITE_BOUNDARY_CONFIG: %s\n" "$${WRITE_BOUNDARY_CONFIG}"
printf "BOUNDARY_CONFIG_DIR: %s\n" "$${BOUNDARY_CONFIG_DIR}"
printf "BOUNDARY_CONFIG_FILE: %s\n" "$${BOUNDARY_CONFIG_FILE}"
validate_boundary_subcommand() {
if ! command -v coder > /dev/null 2>&1; then
echo "Error: 'coder' command not found. boundary cannot be enabled." >&2
exit 1
fi
local output
echo "Checking for license"
if ! output=$(coder boundary 2>&1); then
if echo "$${output}" | grep -qi "license is not entitled"; then
echo "Error: your Coder deployment is not licensed for the boundary feature." >&2
echo "$${output}" >&2
echo "" >&2
exit 1
fi
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
}
# Write boundary config file if the module is responsible for it.
write_boundary_config() {
if [[ "$${WRITE_BOUNDARY_CONFIG}" != "true" ]]; then
echo "Using external boundary config, skipping config write."
return 0
fi
mkdir -p "$${BOUNDARY_CONFIG_DIR}"
echo "$${BOUNDARY_CONFIG_CONTENT}" > "$${BOUNDARY_CONFIG_FILE}"
echo "Boundary config written to $${BOUNDARY_CONFIG_FILE}"
}
# Set up boundary: install, write config, create wrapper script.
setup_boundary() {
echo "Setting up coder boundary..."
# Install boundary binary if needed
install_boundary
# Write boundary config
write_boundary_config
# Ensure the wrapper script directory exists.
mkdir -p "$(dirname "$${BOUNDARY_WRAPPER_PATH}")"
if [[ "$${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]] || [[ "$${USE_BOUNDARY_DIRECTLY}" = "true" ]]; then
# Use boundary binary directly (from compilation or release installation)
cat > "$${BOUNDARY_WRAPPER_PATH}" << '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_DIR}/scripts/coder-no-caps"
if ! cp "$(command -v 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_PATH}" << '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_PATH}"
echo "boundary wrapper configured: $${BOUNDARY_WRAPPER_PATH}"
}
setup_boundary

View File

@ -1,38 +0,0 @@
#!/bin/bash
# Mock coder command for testing boundary module
# Handles: coder boundary [--help | <command>]
# Handles: coder exp sync [want|start|complete] (no-op for testing)
# Handle exp sync commands (no-op for testing)
if [[ "$1" == "exp" ]] && [[ "$2" == "sync" ]]; then
exit 0
fi
if [[ "$1" == "boundary" ]]; then
shift
# Handle --help flag
if [[ "$1" == "--help" ]]; then
cat << 'EOF'
boundary - Run commands in network isolation
Usage:
coder boundary [flags] -- <command> [args...]
Examples:
coder boundary -- curl https://example.com
coder boundary -- npm install
Flags:
-h, --help help for boundary
EOF
exit 0
fi
# Execute the remaining arguments as a command
exec "$@"
fi
echo "Mock coder: Unknown command: $*"
exit 1

View File

@ -1,148 +1,152 @@
--- ---
display_name: Claude Code display_name: Claude Code
description: Install and configure the Claude Code CLI in your workspace. description: Run the Claude Code agent in your workspace.
icon: ../../../../.icons/claude.svg icon: ../../../../.icons/claude.svg
verified: true verified: true
tags: [agent, claude-code, ai, anthropic, ai-gateway] tags: [agent, claude-code, ai, tasks, anthropic, aibridge]
--- ---
# Claude Code # Claude Code
Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) CLI in your workspace. Starting Claude is left to the caller (template command, IDE launcher, or a custom `coder_script`). Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) agent in your workspace to generate code and perform tasks. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for task reporting in the Coder UI.
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "4.9.2"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
anthropic_api_key = "xxxx-xxxxx-xxxx" workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
} }
``` ```
> [!WARNING] > [!WARNING]
> If upgrading from v4.x.x of this module: v5 is a major refactor that drops support for [Coder Tasks](https://coder.com/docs/ai-coder/tasks) and [Boundary](https://coder.com/docs/ai-coder/agent-firewall). We plan to add those back in a follow-up. Keep using v4.x.x if you depend on them. See [#861](https://github.com/coder/registry/pull/861) for the full migration guide. > **Security Notice**: This module uses the `--dangerously-skip-permissions` flag when running Claude Code tasks. This flag bypasses standard permission checks and allows Claude Code broader access to your system than normally permitted. While this enables more functionality, it also means Claude Code can potentially execute commands with the same privileges as the user running it. Use this module _only_ in trusted environments and be aware of the security implications.
> [!NOTE]
> By default, this module is configured to run the embedded chat interface as a path-based application. In production, we recommend that you configure a [wildcard access URL](https://coder.com/docs/admin/setup#wildcard-access-url) and set `subdomain = true`. See [here](https://coder.com/docs/tutorials/best-practices/security-best-practices#disable-path-based-apps) for more details.
## Prerequisites ## Prerequisites
Provide exactly one authentication method: - An **Anthropic API key** or a _Claude Session Token_ is required for tasks.
- You can get the API key from the [Anthropic Console](https://console.anthropic.com/dashboard).
- You can get the Session Token using the `claude setup-token` command. This is a long-lived authentication token (requires Claude subscription)
- **Anthropic API key**: get one from the [Anthropic Console](https://console.anthropic.com/dashboard) and pass it as `anthropic_api_key`. ### Session Resumption Behavior
- **Claude.ai OAuth token** (Pro, Max, or Enterprise accounts): generate one by running `claude setup-token` locally and pass it as `claude_code_oauth_token`.
- **Coder AI Gateway** (Coder Premium, Coder >= 2.30.0): set `enable_ai_gateway = true`. The module authenticates against the gateway using the workspace owner's session token. Do not combine with `anthropic_api_key` or `claude_code_oauth_token`.
## workdir By default, Claude Code automatically resumes existing conversations when your workspace restarts. Sessions are tracked per workspace directory, so conversations continue where you left off. If no session exists (first start), your `ai_prompt` will run normally. To disable this behavior and always start fresh, set `continue = false`
`workdir` is optional. When set, the module pre-creates the directory if it is missing and pre-accepts the Claude Code trust/onboarding prompt for it in `~/.claude.json`. Leave `workdir` unset if you only want the module to install the CLI and configure authentication; users can still open any project interactively and accept the trust dialog per project. ## State Persistence
AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Claude CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning).
To disable:
```tf
module "claude-code" {
# ... other config
enable_state_persistence = false
}
```
## Examples ## Examples
### Standalone mode with a launcher app ### Usage with Agent Boundaries
Authenticate Claude directly against Anthropic's API and add a `coder_app` that users can click from the workspace dashboard to open an interactive Claude session. This example shows how to configure the Claude Code 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 ```tf
locals {
claude_workdir = "/home/coder/project"
}
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "4.9.2"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = local.claude_workdir workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx" enable_boundary = true
}
resource "coder_app" "claude" {
agent_id = coder_agent.main.id
slug = "claude"
display_name = "Claude Code"
icon = "/icon/claude.svg"
open_in = "slim-window"
command = <<-EOT
#!/bin/bash
set -e
cd ${local.claude_workdir}
claude
EOT
} }
``` ```
> [!NOTE] > [!NOTE]
> `coder_app.command` runs when the user clicks the app tile. Combine with `anthropic_api_key`, `claude_code_oauth_token`, or `enable_ai_gateway = true` on the module to pre-authenticate the CLI. > 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.
### Usage with AI Gateway ### Usage with AI Bridge
[AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) is a Premium Coder feature that provides centralized LLM proxy management. Requires Coder >= 2.30.0. [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) is a Premium Coder feature that provides centralized LLM proxy management. To use AI Bridge, set `enable_aibridge = true`. Requires Coder version >= 2.29.0.
For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage with Tasks](#usage-with-tasks) example below.
#### Standalone usage with AI Bridge
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "4.9.2"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
enable_ai_gateway = true enable_aibridge = true
} }
``` ```
When `enable_ai_gateway = true`, the module sets: When `enable_aibridge = true`, the module automatically sets:
- `ANTHROPIC_BASE_URL` to `${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic` - `ANTHROPIC_BASE_URL` to `${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic`
- `ANTHROPIC_AUTH_TOKEN` to the workspace owner's Coder session token - `CLAUDE_API_KEY` to the workspace owner's session token
Claude Code then routes API requests through Coder's AI Gateway instead of directly to Anthropic. This allows Claude Code to route API requests through Coder's AI Bridge instead of directly to Anthropic's API.
Template build will fail if either `claude_api_key` or `claude_code_oauth_token` is provided alongside `enable_aibridge = true`.
> [!CAUTION] ### Usage with Tasks
> `enable_ai_gateway = true` is mutually exclusive with `anthropic_api_key` and `claude_code_oauth_token`. Setting any of them together fails at plan time.
### Enterprise policy via managed settings This example shows how to configure Claude Code with Coder tasks.
The `managed_settings` input writes a policy file to `/etc/claude-code/managed-settings.d/10-coder.json` inside the workspace. Claude Code reads this directory at startup with the highest configuration precedence, so users cannot override these values in their own `~/.claude/settings.json`. This is a local file mechanism and works with any inference backend (Anthropic API, AWS Bedrock, Google Vertex AI, or AI Gateway).
```tf ```tf
module "claude-code" { resource "coder_ai_task" "task" {
source = "registry.coder.com/coder/claude-code/coder" count = data.coder_workspace.me.start_count
version = "5.2.0" app_id = module.claude-code.task_app_id
agent_id = coder_agent.main.id }
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
managed_settings = { data "coder_task" "me" {}
permissions = {
defaultMode = "acceptEdits" module "claude-code" {
disableBypassPermissionsMode = "disable" source = "registry.coder.com/coder/claude-code/coder"
deny = ["Bash(curl:*)", "Bash(wget:*)", "WebFetch"] version = "4.9.2"
} agent_id = coder_agent.main.id
env = { workdir = "/home/coder/project"
DISABLE_TELEMETRY = "0" ai_prompt = data.coder_task.me.prompt
}
} # Optional: route through AI Bridge (Premium feature)
# enable_aibridge = true
} }
``` ```
See the [Claude Code settings reference](https://docs.anthropic.com/en/docs/claude-code/settings) for the full schema. Common keys: `permissions` (`defaultMode`, `allow`, `deny`, `disableBypassPermissionsMode`, `additionalDirectories`), `env`, `model`, `apiKeyHelper`, `hooks`, `cleanupPeriodDays`.
### Advanced Configuration ### Advanced Configuration
This example shows version pinning, a pre-installed binary path, a custom model, and MCP servers. This example shows additional configuration options for version pinning, custom models, and MCP servers.
> [!NOTE]
> The `claude_binary_path` variable can be used to specify where a pre-installed Claude binary is located.
> [!WARNING]
> **Deprecation Notice**: The npm installation method (`install_via_npm = true`) will be deprecated and removed in the next major release. Please use the default binary installation method instead.
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "4.9.2"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx" claude_api_key = "xxxx-xxxxx-xxxx"
# OR
claude_code_oauth_token = "xxxxx-xxxx-xxxx"
claude_code_version = "2.0.62" # Pin to a specific Claude CLI version. claude_code_version = "2.0.62" # Pin to a specific version
claude_binary_path = "/opt/claude/bin" # Path to pre-installed Claude binary
agentapi_version = "0.11.4"
# Skip the module's installer and point at a pre-installed Claude binary. model = "sonnet"
# claude_binary_path can only be customized when install_claude_code is false. permission_mode = "plan"
install_claude_code = false
claude_binary_path = "/opt/claude/bin"
model = "sonnet"
mcp = <<-EOF mcp = <<-EOF
{ {
@ -162,12 +166,6 @@ module "claude-code" {
} }
``` ```
> [!NOTE]
> Swap `anthropic_api_key` for `claude_code_oauth_token = "xxxxx-xxxx-xxxx"` to authenticate via a Claude.ai OAuth token instead. Pass exactly one.
> [!NOTE]
> Servers configured through `mcp` or `mcp_config_remote_path` are added at Claude Code's [user scope](https://docs.claude.com/en/docs/claude-code/mcp#scope), making them available across every project the workspace owner opens. For project-local MCP servers, commit a `.mcp.json` to the project repository instead.
> [!NOTE] > [!NOTE]
> Remote URLs should return a JSON body in the following format: > Remote URLs should return a JSON body in the following format:
> >
@ -182,37 +180,41 @@ module "claude-code" {
> } > }
> ``` > ```
> >
> The `Content-Type` header doesn't matter, both `text/plain` and `application/json` work fine. > The `Content-Type` header doesn't matterboth `text/plain` and `application/json` work fine.
### Serialize a downstream `coder_script` after the install pipeline ### Standalone Mode
The module exposes the `coder exp sync` name of each script it creates via the `scripts` output: an ordered list (`pre_install`, `install`, `post_install`) of names for scripts this module actually creates. Scripts that were not configured are absent from the list. Run and configure Claude Code as a standalone CLI in your workspace.
Downstream `coder_script` resources can wait for this module's install pipeline to finish using `coder exp sync want <self> <each name>`:
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "4.9.2"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx" install_claude_code = true
claude_code_version = "2.0.62"
report_tasks = false
}
```
### Usage with Claude Code Subscription
```tf
variable "claude_code_oauth_token" {
type = string
description = "Generate one using `claude setup-token` command"
sensitive = true
value = "xxxx-xxx-xxxx"
} }
resource "coder_script" "post_claude" { module "claude-code" {
agent_id = coder_agent.main.id source = "registry.coder.com/coder/claude-code/coder"
display_name = "Run after Claude Code install" version = "4.9.2"
run_on_start = true agent_id = coder_agent.main.id
script = <<-EOT workdir = "/home/coder/project"
#!/bin/bash claude_code_oauth_token = var.claude_code_oauth_token
set -euo pipefail
trap 'coder exp sync complete post-claude' EXIT
coder exp sync want post-claude ${join(" ", module.claude-code.scripts)}
coder exp sync start post-claude
# Your work here runs after claude-code finishes installing.
claude --version
EOT
} }
``` ```
@ -243,12 +245,14 @@ variable "aws_access_key_id" {
type = string type = string
description = "Your AWS access key ID. Create this in the AWS IAM console under 'Security credentials'." description = "Your AWS access key ID. Create this in the AWS IAM console under 'Security credentials'."
sensitive = true sensitive = true
value = "xxxx-xxx-xxxx"
} }
variable "aws_secret_access_key" { variable "aws_secret_access_key" {
type = string type = string
description = "Your AWS secret access key. This is shown once when you create an access key in the AWS IAM console." description = "Your AWS secret access key. This is shown once when you create an access key in the AWS IAM console."
sensitive = true sensitive = true
value = "xxxx-xxx-xxxx"
} }
resource "coder_env" "aws_access_key_id" { resource "coder_env" "aws_access_key_id" {
@ -269,6 +273,7 @@ variable "aws_bearer_token_bedrock" {
type = string type = string
description = "Your AWS Bedrock bearer token. This provides access to Bedrock without needing separate access key and secret key." description = "Your AWS Bedrock bearer token. This provides access to Bedrock without needing separate access key and secret key."
sensitive = true sensitive = true
value = "xxxx-xxx-xxxx"
} }
resource "coder_env" "bedrock_api_key" { resource "coder_env" "bedrock_api_key" {
@ -279,7 +284,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "4.9.2"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@ -336,7 +341,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "4.9.2"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514" model = "claude-sonnet-4@20250514"
@ -368,47 +373,28 @@ module "claude-code" {
> [!NOTE] > [!NOTE]
> For additional Vertex AI configuration options (model selection, token limits, region overrides, etc.), see the [Claude Code Vertex AI documentation](https://docs.claude.com/en/docs/claude-code/google-vertex-ai). > For additional Vertex AI configuration options (model selection, token limits, region overrides, etc.), see the [Claude Code Vertex AI documentation](https://docs.claude.com/en/docs/claude-code/google-vertex-ai).
### Telemetry export (OpenTelemetry)
Claude Code can emit OpenTelemetry metrics and events covering token usage, tool calls, session lifecycle, and errors (see the [monitoring docs](https://docs.anthropic.com/en/docs/claude-code/monitoring-usage)). Set `telemetry.enabled = true` and point `otlp_endpoint` at your OTLP collector.
The module automatically tags every span and metric with `coder.workspace_id`, `coder.workspace_name`, `coder.workspace_owner`, and `coder.template_name` via `OTEL_RESOURCE_ATTRIBUTES`, so Claude Code telemetry can be joined directly against Coder's [audit logs](https://coder.com/docs/admin/security/audit-logs) and `exectrace` records on `workspace_id`.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
telemetry = {
enabled = true
otlp_endpoint = "http://otel-collector.observability:4317"
otlp_protocol = "grpc"
otlp_headers = {
authorization = "Bearer ${var.otel_token}"
}
resource_attributes = {
"service.name" = "claude-code"
}
}
}
```
## Troubleshooting ## Troubleshooting
If you encounter any issues, check the log files in the `~/.coder-modules/coder/claude-code/logs` directory within your workspace for detailed information. If you encounter any issues, check the log files in the `~/.claude-module` directory within your workspace for detailed information.
```bash ```bash
# Installation logs # Installation logs
cat ~/.coder-modules/coder/claude-code/logs/install.log cat ~/.claude-module/install.log
# Startup logs
cat ~/.claude-module/agentapi-start.log
# Pre/post install script logs # Pre/post install script logs
cat ~/.coder-modules/coder/claude-code/logs/pre_install.log cat ~/.claude-module/pre_install.log
cat ~/.coder-modules/coder/claude-code/logs/post_install.log cat ~/.claude-module/post_install.log
``` ```
> [!NOTE]
> To use tasks with Claude Code, you must provide an `anthropic_api_key` or `claude_code_oauth_token`.
> The `workdir` variable is required and specifies the directory where Claude Code will run.
## References ## References
- [Claude Code Documentation](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) - [Claude Code Documentation](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview)
- [AgentAPI Documentation](https://github.com/coder/agentapi)
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)

View File

@ -6,72 +6,15 @@ import {
beforeAll, beforeAll,
expect, expect,
} from "bun:test"; } from "bun:test";
import { execContainer, readFileContainer, runTerraformInit } from "~test";
import { import {
execContainer, loadTestFile,
readFileContainer, writeExecutable,
removeContainer, setup as setupUtil,
runContainer, execModuleScript,
runTerraformApply, expectAgentAPIStarted,
runTerraformInit, } from "../agentapi/test-util";
TerraformState, import dedent from "dedent";
} from "~test";
import { extractCoderEnvVars, writeExecutable } from "../agentapi/test-util";
import path from "path";
// coder-utils orchestrates this module's scripts and can produce multiple
// coder_script resources (pre_install, install, post_install). The shared
// `setup` helper in ../agentapi/test-util.ts assumes a single coder_script
// via findResourceInstance, so we define a local setup helper that collects
// every coder_script in run order.
interface ModuleScripts {
pre_install?: string;
install: string;
post_install?: string;
}
// Script display_names produced by coder-utils (Claude Code prefix + suffix).
// Order matters: scripts run sequentially in this order at agent startup.
const SCRIPT_SUFFIXES = [
"Pre-Install Script",
"Install Script",
"Post-Install Script",
] as const;
const collectScripts = (state: TerraformState): ModuleScripts => {
const byDisplayName: Record<string, string> = {};
for (const resource of state.resources) {
if (resource.type !== "coder_script") continue;
for (const instance of resource.instances) {
const attrs = instance.attributes as Record<string, unknown>;
const displayName = attrs.display_name as string | undefined;
const script = attrs.script as string | undefined;
if (displayName && script) {
byDisplayName[displayName] = script;
}
}
}
const scripts: Partial<ModuleScripts> = {};
for (const suffix of SCRIPT_SUFFIXES) {
const key = `Claude Code: ${suffix}`;
if (!(key in byDisplayName)) continue;
switch (suffix) {
case "Pre-Install Script":
scripts.pre_install = byDisplayName[key];
break;
case "Install Script":
scripts.install = byDisplayName[key];
break;
case "Post-Install Script":
scripts.post_install = byDisplayName[key];
break;
}
}
if (!scripts.install) {
throw new Error("install script not found in terraform state");
}
return scripts as ModuleScripts;
};
let cleanupFunctions: (() => Promise<void>)[] = []; let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => { const registerCleanup = (cleanup: () => Promise<void>) => {
@ -90,96 +33,37 @@ afterEach(async () => {
}); });
interface SetupProps { interface SetupProps {
skipAgentAPIMock?: boolean;
skipClaudeMock?: boolean; skipClaudeMock?: boolean;
moduleVariables?: Record<string, string>; moduleVariables?: Record<string, string>;
agentapiMockScript?: string;
} }
const setup = async ( const setup = async (
props?: SetupProps, props?: SetupProps,
): Promise<{ ): Promise<{ id: string; coderEnvVars: Record<string, string> }> => {
id: string;
coderEnvVars: Record<string, string>;
scripts: ModuleScripts;
}> => {
const projectDir = "/home/coder/project"; const projectDir = "/home/coder/project";
const moduleDir = path.resolve(import.meta.dir); const { id, coderEnvVars } = await setupUtil({
const state = await runTerraformApply(moduleDir, { moduleDir: import.meta.dir,
agent_id: "foo", moduleVariables: {
workdir: projectDir, install_claude_code: props?.skipClaudeMock ? "true" : "false",
// Default to skipping the real installer; individual tests opt in. install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
install_claude_code: "false", workdir: projectDir,
...props?.moduleVariables, ...props?.moduleVariables,
}); },
const scripts = collectScripts(state); registerCleanup,
const coderEnvVars = extractCoderEnvVars(state); projectDir,
skipAgentAPIMock: props?.skipAgentAPIMock,
const id = await runContainer("codercom/enterprise-node:latest"); agentapiMockScript: props?.agentapiMockScript,
registerCleanup(async () => {
if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") {
console.log(`Not removing container ${id} in debug mode`);
return;
}
await removeContainer(id);
});
await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]);
// Mock `coder` CLI so `coder exp sync` calls from coder-utils wrappers
// succeed without a real control plane.
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: "#!/bin/bash\nexit 0\n",
}); });
if (!props?.skipClaudeMock) { if (!props?.skipClaudeMock) {
await writeExecutable({ await writeExecutable({
containerId: id, containerId: id,
filePath: "/usr/bin/claude", filePath: "/usr/bin/claude",
content: await Bun.file( content: await loadTestFile(import.meta.dir, "claude-mock.sh"),
path.join(moduleDir, "testdata", "claude-mock.sh"),
).text(),
}); });
} }
return { id, coderEnvVars, scripts }; return { id, coderEnvVars };
};
// Runs the coder-utils script pipeline (pre_install, install, post_install) in
// order inside the container. Each script is written to /tmp and executed
// under bash with the test's env vars exported first.
const runScripts = async (
id: string,
scripts: ModuleScripts,
env?: Record<string, string>,
) => {
const entries = env ? Object.entries(env) : [];
const envArgs =
entries.length > 0
? entries
.map(
([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`,
)
.join(" && ") + " && "
: "";
const ordered: [string, string | undefined][] = [
["pre_install", scripts.pre_install],
["install", scripts.install],
["post_install", scripts.post_install],
];
for (const [name, script] of ordered) {
if (!script) continue;
const target = `/tmp/coder-utils-${name}.sh`;
await writeExecutable({
containerId: id,
filePath: target,
content: script,
});
const resp = await execContainer(id, ["bash", "-c", `${envArgs}${target}`]);
if (resp.exitCode !== 0) {
console.log(`script ${name} failed:`);
console.log(resp.stdout);
console.log(resp.stderr);
throw new Error(`coder-utils ${name} script exited ${resp.exitCode}`);
}
}
}; };
setDefaultTimeout(60 * 1000); setDefaultTimeout(60 * 1000);
@ -190,50 +74,56 @@ describe("claude-code", async () => {
}); });
test("happy-path", async () => { test("happy-path", async () => {
const { id, scripts } = await setup(); const { id } = await setup();
await runScripts(id, scripts); await execModuleScript(id);
const installLog = await readFileContainer( await expectAgentAPIStarted(id);
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(installLog).toContain("Skipping Claude Code installation");
}); });
test("install-claude-code-version", async () => { test("install-claude-code-version", async () => {
const version = "1.0.40"; const version_to_install = "1.0.40";
const { id, coderEnvVars, scripts } = await setup({ const { id, coderEnvVars } = await setup({
skipClaudeMock: true, skipClaudeMock: true,
moduleVariables: { moduleVariables: {
install_claude_code: "true", install_claude_code: "true",
claude_code_version: version, claude_code_version: version_to_install,
}, },
}); });
await runScripts(id, scripts, coderEnvVars); await execModuleScript(id, coderEnvVars);
const installLog = await readFileContainer( const resp = await execContainer(id, [
id, "bash",
"/home/coder/.coder-modules/coder/claude-code/logs/install.log", "-c",
); "cat /home/coder/.claude-module/install.log",
expect(installLog).toContain(version); ]);
expect(resp.stdout).toContain(version_to_install);
}); });
test("anthropic-api-key", async () => { test("check-latest-claude-code-version-works", async () => {
const { id, coderEnvVars } = await setup({
skipClaudeMock: true,
skipAgentAPIMock: true,
moduleVariables: {
install_claude_code: "true",
},
});
await execModuleScript(id, coderEnvVars);
await expectAgentAPIStarted(id);
});
test("claude-api-key", async () => {
const apiKey = "test-api-key-123"; const apiKey = "test-api-key-123";
const { coderEnvVars } = await setup({ const { id } = await setup({
moduleVariables: { moduleVariables: {
anthropic_api_key: apiKey, claude_api_key: apiKey,
}, },
}); });
expect(coderEnvVars["ANTHROPIC_API_KEY"]).toBe(apiKey); await execModuleScript(id);
});
test("claude-code-oauth-token", async () => { const envCheck = await execContainer(id, [
const token = "test-oauth-token-456"; "bash",
const { coderEnvVars } = await setup({ "-c",
moduleVariables: { 'env | grep CLAUDE_API_KEY || echo "CLAUDE_API_KEY not found"',
claude_code_oauth_token: token, ]);
}, expect(envCheck.stdout).toContain("CLAUDE_API_KEY");
});
expect(coderEnvVars["CLAUDE_CODE_OAUTH_TOKEN"]).toBe(token);
}); });
test("claude-mcp-config", async () => { test("claude-mcp-config", async () => {
@ -245,67 +135,349 @@ describe("claude-code", async () => {
}, },
}, },
}); });
const { id, coderEnvVars, scripts } = await setup({ const { id, coderEnvVars } = await setup({
skipClaudeMock: true, skipClaudeMock: true,
moduleVariables: { moduleVariables: {
install_claude_code: "true",
mcp: mcpConfig, mcp: mcpConfig,
}, },
}); });
await runScripts(id, scripts, coderEnvVars); await execModuleScript(id, coderEnvVars);
const claudeConfig = await readFileContainer(
id, const resp = await readFileContainer(id, "/home/coder/.claude.json");
"/home/coder/.claude.json", expect(resp).toContain("test-cmd");
); });
expect(claudeConfig).toContain("test-cmd");
test("claude-task-prompt", async () => {
const prompt = "This is a task prompt for Claude.";
const { id } = await setup({
moduleVariables: {
ai_prompt: prompt,
},
});
await execModuleScript(id);
const resp = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(resp.stdout).toContain(prompt);
});
test("claude-permission-mode", async () => {
const mode = "plan";
const { id } = await setup({
moduleVariables: {
permission_mode: mode,
ai_prompt: "test prompt",
},
});
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(startLog.stdout).toContain(`--permission-mode ${mode}`);
});
test("claude-auto-permission-mode", async () => {
const mode = "auto";
const { id } = await setup({
moduleVariables: {
permission_mode: mode,
ai_prompt: "test prompt",
},
});
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(startLog.stdout).toContain(`--permission-mode ${mode}`);
}); });
test("claude-model", async () => { test("claude-model", async () => {
const model = "opus"; const model = "opus";
const { coderEnvVars } = await setup({ const { coderEnvVars } = await setup({
moduleVariables: { moduleVariables: {
model, model: model,
ai_prompt: "test prompt",
}, },
}); });
// Verify ANTHROPIC_MODEL env var is set via coder_env
expect(coderEnvVars["ANTHROPIC_MODEL"]).toBe(model); expect(coderEnvVars["ANTHROPIC_MODEL"]).toBe(model);
}); });
test("claude-continue-resume-task-session", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
report_tasks: "true",
ai_prompt: "test prompt",
},
});
// Create a mock task session file with the hardcoded task session ID
// Note: Claude CLI creates files without "session-" prefix when using --session-id
const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2";
const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
await execContainer(id, ["mkdir", "-p", sessionDir]);
await execContainer(id, [
"bash",
"-c",
`cat > ${sessionDir}/${taskSessionId}.jsonl << 'SESSIONEOF'
{"sessionId":"${taskSessionId}","message":{"content":"Task"},"timestamp":"2020-01-01T10:00:00.000Z"}
{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-01T10:00:05.000Z"}
SESSIONEOF`,
]);
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(startLog.stdout).toContain("--resume");
expect(startLog.stdout).toContain(taskSessionId);
expect(startLog.stdout).toContain("Resuming task session");
expect(startLog.stdout).toContain("--dangerously-skip-permissions");
});
test("pre-post-install-scripts", async () => { test("pre-post-install-scripts", async () => {
const { id, scripts } = await setup({ const { id } = await setup({
moduleVariables: { moduleVariables: {
pre_install_script: "#!/bin/bash\necho 'claude-pre-install-script'", pre_install_script: "#!/bin/bash\necho 'claude-pre-install-script'",
post_install_script: "#!/bin/bash\necho 'claude-post-install-script'", post_install_script: "#!/bin/bash\necho 'claude-post-install-script'",
}, },
}); });
await runScripts(id, scripts); await execModuleScript(id);
const preInstallLog = await readFileContainer( const preInstallLog = await readFileContainer(
id, id,
"/home/coder/.coder-modules/coder/claude-code/logs/pre_install.log", "/home/coder/.claude-module/pre_install.log",
); );
expect(preInstallLog).toContain("claude-pre-install-script"); expect(preInstallLog).toContain("claude-pre-install-script");
const postInstallLog = await readFileContainer( const postInstallLog = await readFileContainer(
id, id,
"/home/coder/.coder-modules/coder/claude-code/logs/post_install.log", "/home/coder/.claude-module/post_install.log",
); );
expect(postInstallLog).toContain("claude-post-install-script"); expect(postInstallLog).toContain("claude-post-install-script");
}); });
test("workdir-variable", async () => { test("workdir-variable", async () => {
const workdir = "/home/coder/claude-test-folder"; const workdir = "/home/coder/claude-test-folder";
const { id, scripts } = await setup({ const { id } = await setup({
skipClaudeMock: false,
moduleVariables: { moduleVariables: {
workdir, workdir,
}, },
}); });
await runScripts(id, scripts); await execModuleScript(id);
// install.sh.tftpl echoes ARG_WORKDIR and creates the directory if missing.
const resp = await readFileContainer(
id,
"/home/coder/.claude-module/agentapi-start.log",
);
expect(resp).toContain(workdir);
});
test("coder-mcp-config-created", async () => {
const { id } = await setup({
moduleVariables: {
install_claude_code: "false",
},
});
await execModuleScript(id);
const installLog = await readFileContainer( const installLog = await readFileContainer(
id, id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log", "/home/coder/.claude-module/install.log",
); );
expect(installLog).toContain(workdir); expect(installLog).toContain(
"Configuring Claude Code to report tasks via Coder MCP",
);
});
test("dangerously-skip-permissions", async () => {
const { id } = await setup({
moduleVariables: {
dangerously_skip_permissions: "true",
},
});
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(startLog.stdout).toContain(`--dangerously-skip-permissions`);
});
test("subdomain-false", async () => {
const { id } = await setup({
skipAgentAPIMock: true,
moduleVariables: {
subdomain: "false",
post_install_script: dedent`
#!/bin/bash
env | grep AGENTAPI_CHAT_BASE_PATH || echo "AGENTAPI_CHAT_BASE_PATH not found"
`,
},
});
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/post_install.log",
]);
expect(startLog.stdout).toContain(
"ARG_AGENTAPI_CHAT_BASE_PATH=/@default/default.foo/apps/ccw/chat",
);
});
test("partial-initialization-detection", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
report_tasks: "true",
ai_prompt: "test prompt",
},
});
const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2";
const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
await execContainer(id, ["mkdir", "-p", sessionDir]);
await execContainer(id, [
"bash",
"-c",
`echo '{"sessionId":"${taskSessionId}"}' > ${sessionDir}/${taskSessionId}.jsonl`,
]);
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
// Should start new session, not try to resume invalid one
expect(startLog.stdout).toContain("Starting new task session");
expect(startLog.stdout).toContain("--session-id");
});
test("standalone-first-build-no-sessions", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
report_tasks: "false",
},
});
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
// Should start fresh, not try to continue
expect(startLog.stdout).toContain("No sessions found");
expect(startLog.stdout).toContain("starting fresh standalone session");
expect(startLog.stdout).not.toContain("--continue");
});
test("standalone-with-sessions-continues", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
report_tasks: "false",
},
});
const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
await execContainer(id, ["mkdir", "-p", sessionDir]);
await execContainer(id, [
"bash",
"-c",
`cat > ${sessionDir}/generic-123.jsonl << 'EOF'
{"sessionId":"generic-123","message":{"content":"User session"},"timestamp":"2020-01-01T10:00:00.000Z"}
{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-01T10:00:05.000Z"}
EOF`,
]);
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
// Should continue existing session
expect(startLog.stdout).toContain("Sessions found");
expect(startLog.stdout).toContain(
"Continuing most recent standalone session",
);
expect(startLog.stdout).toContain("--continue");
});
test("task-mode-ignores-manual-sessions", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
report_tasks: "true",
ai_prompt: "test prompt",
},
});
const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2";
const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
await execContainer(id, ["mkdir", "-p", sessionDir]);
// Create task session (without "session-" prefix, as CLI does)
await execContainer(id, [
"bash",
"-c",
`cat > ${sessionDir}/${taskSessionId}.jsonl << 'EOF'
{"sessionId":"${taskSessionId}","message":{"content":"Task"},"timestamp":"2020-01-01T10:00:00.000Z"}
{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-01T10:00:05.000Z"}
EOF`,
]);
// Create manual session (newer)
await execContainer(id, [
"bash",
"-c",
`cat > ${sessionDir}/manual-456.jsonl << 'EOF'
{"sessionId":"manual-456","message":{"content":"Manual"},"timestamp":"2020-01-02T10:00:00.000Z"}
{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-02T10:00:05.000Z"}
EOF`,
]);
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
// Should resume task session, not manual session
expect(startLog.stdout).toContain("Resuming task session");
expect(startLog.stdout).toContain(taskSessionId);
expect(startLog.stdout).not.toContain("manual-456");
}); });
test("mcp-config-remote-path", async () => { test("mcp-config-remote-path", async () => {
@ -313,43 +485,43 @@ describe("claude-code", async () => {
const successUrl = const successUrl =
"https://raw.githubusercontent.com/coder/coder/main/.mcp.json"; "https://raw.githubusercontent.com/coder/coder/main/.mcp.json";
const { id, coderEnvVars, scripts } = await setup({ const { id, coderEnvVars } = await setup({
skipClaudeMock: true, skipClaudeMock: true,
moduleVariables: { moduleVariables: {
install_claude_code: "true",
mcp_config_remote_path: JSON.stringify([failingUrl, successUrl]), mcp_config_remote_path: JSON.stringify([failingUrl, successUrl]),
}, },
}); });
await runScripts(id, scripts, coderEnvVars); await execModuleScript(id, coderEnvVars);
const installLog = await readFileContainer( const installLog = await readFileContainer(
id, id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log", "/home/coder/.claude-module/install.log",
); );
// Verify both URLs are attempted. // Verify both URLs are attempted
expect(installLog).toContain(failingUrl); expect(installLog).toContain(failingUrl);
expect(installLog).toContain(successUrl); expect(installLog).toContain(successUrl);
// First URL should fail gracefully. // First URL should fail gracefully
expect(installLog).toContain( expect(installLog).toContain(
`Warning: Failed to fetch MCP configuration from '${failingUrl}'`, `Warning: Failed to fetch MCP configuration from '${failingUrl}'`,
); );
// Second URL should succeed. // Second URL should succeed - no failure warning for it
expect(installLog).not.toContain( expect(installLog).not.toContain(
`Warning: Failed to fetch MCP configuration from '${successUrl}'`, `Warning: Failed to fetch MCP configuration from '${successUrl}'`,
); );
// Should contain the MCP server add command from the successful fetch. // Should contain the MCP server add command from successful fetch
expect(installLog).toContain( expect(installLog).toContain(
"Added stdio MCP server go-language-server to user config", "Added stdio MCP server go-language-server to local config",
);
expect(installLog).toContain(
"Added stdio MCP server typescript-language-server to user config",
); );
// Verify the MCP config was added to .claude.json. expect(installLog).toContain(
"Added stdio MCP server typescript-language-server to local config",
);
// Verify the MCP config was added to claude.json
const claudeConfig = await readFileContainer( const claudeConfig = await readFileContainer(
id, id,
"/home/coder/.claude.json", "/home/coder/.claude.json",
@ -357,165 +529,4 @@ describe("claude-code", async () => {
expect(claudeConfig).toContain("typescript-language-server"); expect(claudeConfig).toContain("typescript-language-server");
expect(claudeConfig).toContain("go-language-server"); expect(claudeConfig).toContain("go-language-server");
}); });
test("standalone-mode-with-api-key", async () => {
const apiKey = "test-api-key-standalone";
const workdir = "/home/coder/project";
const { id, coderEnvVars, scripts } = await setup({
moduleVariables: {
anthropic_api_key: apiKey,
},
});
await runScripts(id, scripts, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(installLog).toContain("Configuring Claude Code for standalone mode");
expect(installLog).toContain("Standalone mode configured successfully");
const claudeConfig = await readFileContainer(
id,
"/home/coder/.claude.json",
);
const parsed = JSON.parse(claudeConfig);
expect(parsed.autoUpdaterStatus).toBe("disabled");
expect(parsed.hasCompletedOnboarding).toBe(true);
expect(parsed.hasAcknowledgedCostThreshold).toBe(true);
expect(parsed.projects[workdir].hasCompletedProjectOnboarding).toBe(true);
expect(parsed.projects[workdir].hasTrustDialogAccepted).toBe(true);
// Permission posture is delivered via /etc/claude-code/managed-settings.d/,
// not user-writable ~/.claude.json acceptance flags.
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined();
expect(parsed.autoModeAccepted).toBeUndefined();
});
test("standalone-mode-with-oauth-token", async () => {
const token = "test-oauth-token-standalone";
const { id, coderEnvVars, scripts } = await setup({
moduleVariables: {
claude_code_oauth_token: token,
},
});
await runScripts(id, scripts, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(installLog).toContain("Standalone mode configured successfully");
expect(installLog).not.toContain("skipping onboarding bypass");
// Onboarding bypass flags must be present. Authentication happens via
// the ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN env vars, not via
// .claude.json.
const claudeConfig = await readFileContainer(
id,
"/home/coder/.claude.json",
);
const parsed = JSON.parse(claudeConfig);
expect(parsed.hasCompletedOnboarding).toBe(true);
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined();
});
test("standalone-mode-no-auth", async () => {
const { id, coderEnvVars, scripts } = await setup();
await runScripts(id, scripts, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(installLog).toContain("No authentication configured");
expect(installLog).toContain("skipping onboarding bypass");
// .claude.json should not exist when no auth is configured.
const resp = await execContainer(id, [
"bash",
"-c",
"test -e /home/coder/.claude.json && echo EXISTS || echo ABSENT",
]);
expect(resp.stdout.trim()).toBe("ABSENT");
});
test("claude-managed-settings-written", async () => {
const { id, scripts } = await setup({
moduleVariables: {
managed_settings: JSON.stringify({
permissions: {
defaultMode: "acceptEdits",
disableBypassPermissionsMode: "disable",
deny: ["Bash(rm -rf*)"],
},
}),
},
});
await runScripts(id, scripts);
const policy = await execContainer(id, [
"bash",
"-c",
"cat /etc/claude-code/managed-settings.d/10-coder.json",
]);
expect(policy.exitCode).toBe(0);
expect(policy.stdout).toContain('"defaultMode":"acceptEdits"');
expect(policy.stdout).toContain('"disableBypassPermissionsMode":"disable"');
expect(policy.stdout).toContain('"deny":["Bash(rm -rf*)"]');
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(installLog).toContain("Wrote Claude Code managed settings");
});
test("claude-managed-settings-not-set", async () => {
const { id, scripts } = await setup();
await runScripts(id, scripts);
const resp = await execContainer(id, [
"bash",
"-c",
"test -e /etc/claude-code/managed-settings.d/10-coder.json && echo EXISTS || echo ABSENT",
]);
expect(resp.stdout.trim()).toBe("ABSENT");
});
test("telemetry-otel", async () => {
const { coderEnvVars } = await setup({
moduleVariables: {
telemetry: JSON.stringify({
enabled: true,
otlp_endpoint: "http://otel-collector:4317",
otlp_protocol: "grpc",
otlp_headers: { authorization: "Bearer test-token" },
resource_attributes: { "service.name": "claude-code" },
}),
},
});
expect(coderEnvVars["CLAUDE_CODE_ENABLE_TELEMETRY"]).toBe("1");
expect(coderEnvVars["OTEL_EXPORTER_OTLP_ENDPOINT"]).toBe(
"http://otel-collector:4317",
);
expect(coderEnvVars["OTEL_EXPORTER_OTLP_PROTOCOL"]).toBe("grpc");
expect(coderEnvVars["OTEL_EXPORTER_OTLP_HEADERS"]).toBe(
"authorization=Bearer test-token",
);
const attrs = coderEnvVars["OTEL_RESOURCE_ATTRIBUTES"];
expect(attrs).toContain("coder.workspace_id=");
expect(attrs).toContain("coder.workspace_name=");
expect(attrs).toContain("coder.workspace_owner=");
expect(attrs).toContain("coder.template_name=");
expect(attrs).toContain("service.name=claude-code");
});
test("telemetry-disabled-by-default", async () => {
const { coderEnvVars } = await setup();
expect(coderEnvVars["CLAUDE_CODE_ENABLE_TELEMETRY"]).toBeUndefined();
expect(coderEnvVars["OTEL_EXPORTER_OTLP_ENDPOINT"]).toBeUndefined();
expect(coderEnvVars["OTEL_EXPORTER_OTLP_PROTOCOL"]).toBeUndefined();
expect(coderEnvVars["OTEL_EXPORTER_OTLP_HEADERS"]).toBeUndefined();
expect(coderEnvVars["OTEL_RESOURCE_ATTRIBUTES"]).toBeUndefined();
});
}); });

View File

@ -18,18 +18,24 @@ data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {} data "coder_workspace_owner" "me" {}
variable "icon" { 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 type = string
description = "The icon to use for the app." description = "The name of a group that this app belongs to."
default = "/icon/claude.svg" default = null
} }
variable "workdir" { variable "workdir" {
type = string type = string
description = "Optional project directory. When set, the module pre-creates it if missing and pre-accepts the Claude Code trust/onboarding prompt for it in ~/.claude.json." description = "The folder to run Claude Code in."
default = null
} }
variable "pre_install_script" { variable "pre_install_script" {
type = string type = string
description = "Custom script to run before installing Claude Code. Can be used for dependency ordering between modules (e.g., waiting for git-clone to complete before Claude Code initialization)." description = "Custom script to run before installing Claude Code. Can be used for dependency ordering between modules (e.g., waiting for git-clone to complete before Claude Code initialization)."
@ -42,6 +48,24 @@ variable "post_install_script" {
default = null default = null
} }
variable "install_agentapi" {
type = bool
description = "Whether to install AgentAPI."
default = true
}
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.11.8"
}
variable "subdomain" {
type = bool
description = "Whether to use a subdomain for AgentAPI."
default = false
}
variable "install_claude_code" { variable "install_claude_code" {
type = bool type = bool
description = "Whether to install Claude Code." description = "Whether to install Claude Code."
@ -60,9 +84,9 @@ variable "disable_autoupdater" {
default = false default = false
} }
variable "anthropic_api_key" { variable "claude_api_key" {
type = string type = string
description = "API key passed to Claude Code via the ANTHROPIC_API_KEY env var." description = "The API key to use for the Claude Code server."
default = "" default = ""
} }
@ -74,23 +98,42 @@ variable "model" {
variable "mcp" { variable "mcp" {
type = string type = string
description = "JSON-encoded string of MCP server configurations. When set, servers are added at Claude Code's user scope so they are available across every project the workspace owner opens." description = "MCP JSON to be added to the claude code local scope"
default = "" default = ""
} }
variable "mcp_config_remote_path" { variable "mcp_config_remote_path" {
type = list(string) type = list(string)
description = "List of URLs that return JSON MCP server configurations (text/plain with valid JSON). Servers are added at Claude Code's user scope." description = "List of URLs that return JSON MCP server configurations (text/plain with valid JSON)"
default = [] default = []
} }
variable "allowed_tools" {
type = string
description = "A list of tools that should be allowed without prompting the user for permission, in addition to settings.json files."
default = ""
}
variable "disallowed_tools" {
type = string
description = "A list of tools that should be disallowed without prompting the user for permission, in addition to settings.json files."
default = ""
}
variable "claude_code_oauth_token" { variable "claude_code_oauth_token" {
type = string type = string
description = "OAuth token passed to Claude Code via the CLAUDE_CODE_OAUTH_TOKEN env var. Generate one with `claude setup-token`." description = "Set up a long-lived authentication token (requires Claude subscription). Generated using `claude setup-token` command"
sensitive = true sensitive = true
default = "" default = ""
} }
variable "claude_md_path" {
type = string
description = "The path to CLAUDE.md."
default = "$HOME/.claude/CLAUDE.md"
}
variable "claude_binary_path" { variable "claude_binary_path" {
type = string type = string
description = "Directory where the Claude Code binary is located. Use this if Claude is pre-installed or installed outside the module to a non-default location." description = "Directory where the Claude Code binary is located. Use this if Claude is pre-installed or installed outside the module to a non-default location."
@ -102,61 +145,53 @@ variable "claude_binary_path" {
} }
} }
variable "managed_settings" { variable "install_via_npm" {
type = any type = bool
description = "Policy settings written to /etc/claude-code/managed-settings.d/10-coder.json. Highest-precedence client config; works with any inference backend (Anthropic API, Bedrock, Vertex, AI Gateway). See https://docs.anthropic.com/en/docs/claude-code/settings for the schema." description = "Install Claude Code via npm instead of the official installer. Useful if npm is preferred or the official installer fails."
default = null default = false
} }
variable "enable_ai_gateway" { variable "enable_aibridge" {
type = bool type = bool
description = "Use AI Gateway for Claude Code. https://coder.com/docs/ai-coder/ai-gateway" description = "Use AI Bridge for Claude Code. https://coder.com/docs/ai-coder/ai-bridge"
default = false default = false
validation { validation {
condition = !(var.enable_ai_gateway && length(var.anthropic_api_key) > 0) condition = !(var.enable_aibridge && length(var.claude_api_key) > 0)
error_message = "anthropic_api_key cannot be provided when enable_ai_gateway is true. AI Gateway automatically authenticates the client using Coder credentials." error_message = "claude_api_key cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials."
} }
validation { validation {
condition = !(var.enable_ai_gateway && length(var.claude_code_oauth_token) > 0) condition = !(var.enable_aibridge && length(var.claude_code_oauth_token) > 0)
error_message = "claude_code_oauth_token cannot be provided when enable_ai_gateway is true. AI Gateway automatically authenticates the client using Coder credentials." error_message = "claude_code_oauth_token cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials."
} }
} }
variable "telemetry" { variable "enable_state_persistence" {
type = object({ type = bool
enabled = optional(bool, false) description = "Enable AgentAPI conversation state persistence across restarts."
otlp_endpoint = optional(string, "") default = true
otlp_protocol = optional(string, "http/protobuf") }
otlp_headers = optional(map(string), {})
resource_attributes = optional(map(string), {}) resource "coder_env" "claude_code_md_path" {
}) count = var.claude_md_path == "" ? 0 : 1
default = {} agent_id = var.agent_id
description = "Configure Claude Code OpenTelemetry export. When enabled, sets CLAUDE_CODE_ENABLE_TELEMETRY and the standard OTEL_EXPORTER_OTLP_* environment variables. Coder workspace identifiers (coder.workspace_id, coder.workspace_name, coder.workspace_owner, coder.template_name) are automatically appended to OTEL_RESOURCE_ATTRIBUTES so Claude Code telemetry can be joined with Coder audit and exectrace logs." name = "CODER_MCP_CLAUDE_MD_PATH"
value = var.claude_md_path
} }
resource "coder_env" "claude_code_oauth_token" { resource "coder_env" "claude_code_oauth_token" {
count = var.claude_code_oauth_token != "" ? 1 : 0
agent_id = var.agent_id agent_id = var.agent_id
name = "CLAUDE_CODE_OAUTH_TOKEN" name = "CLAUDE_CODE_OAUTH_TOKEN"
value = var.claude_code_oauth_token value = var.claude_code_oauth_token
} }
resource "coder_env" "anthropic_api_key" { resource "coder_env" "claude_api_key" {
count = var.anthropic_api_key != "" ? 1 : 0 count = (var.enable_aibridge || (var.claude_api_key != "")) ? 1 : 0
agent_id = var.agent_id
name = "ANTHROPIC_API_KEY"
value = var.anthropic_api_key
}
# ANTHROPIC_AUTH_TOKEN authenticates the client against Coder's AI Gateway
# using the workspace owner's session token, per the AI Gateway docs.
resource "coder_env" "anthropic_auth_token" {
count = var.enable_ai_gateway ? 1 : 0
agent_id = var.agent_id agent_id = var.agent_id
name = "ANTHROPIC_AUTH_TOKEN" name = "CLAUDE_API_KEY"
value = data.coder_workspace_owner.me.session_token value = local.claude_api_key
} }
resource "coder_env" "disable_autoupdater" { resource "coder_env" "disable_autoupdater" {
@ -166,7 +201,6 @@ resource "coder_env" "disable_autoupdater" {
value = "1" value = "1"
} }
resource "coder_env" "anthropic_model" { resource "coder_env" "anthropic_model" {
count = var.model != "" ? 1 : 0 count = var.model != "" ? 1 : 0
agent_id = var.agent_id agent_id = var.agent_id
@ -175,96 +209,71 @@ resource "coder_env" "anthropic_model" {
} }
resource "coder_env" "anthropic_base_url" { resource "coder_env" "anthropic_base_url" {
count = var.enable_ai_gateway ? 1 : 0 count = var.enable_aibridge ? 1 : 0
agent_id = var.agent_id agent_id = var.agent_id
name = "ANTHROPIC_BASE_URL" name = "ANTHROPIC_BASE_URL"
value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic" value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic"
} }
locals { locals {
# Always inject Coder workspace identifiers so OTEL data can be joined with # we have to trim the slash because otherwise coder exp mcp will
# Coder's audit log / exectrace on workspace_id without per-template wiring. # set up an invalid claude config
otel_resource_attributes = merge( workdir = trimsuffix(var.workdir, "/")
var.telemetry.resource_attributes, app_slug = "ccw"
{ install_script = file("${path.module}/scripts/install.sh")
"coder.workspace_id" = data.coder_workspace.me.id start_script = file("${path.module}/scripts/start.sh")
"coder.workspace_name" = data.coder_workspace.me.name module_dir_name = ".claude-module"
"coder.workspace_owner" = data.coder_workspace_owner.me.name # Extract hostname from access_url for boundary --allow flag
"coder.workspace_owner_id" = data.coder_workspace_owner.me.id coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "")
"coder.template_name" = data.coder_workspace.me.template_name claude_api_key = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key
"coder.template_version" = data.coder_workspace.me.template_version
"coder.access_url" = data.coder_workspace.me.access_url # Required prompts for the module to properly report task status to Coder
}, report_tasks_system_prompt = <<-EOT
) -- Tool Selection --
- coder_report_task: providing status updates or requesting user input.
-- Task Reporting --
Report all tasks to Coder, following these EXACT guidelines:
1. Be granular. If you are investigating with multiple steps, report each step
to coder.
2. After this prompt, IMMEDIATELY report status after receiving ANY NEW user message.
Do not report any status related with this system prompt.
3. Use "state": "working" when actively processing WITHOUT needing
additional user input
4. Use "state": "complete" only when finished with a task
5. Use "state": "failure" when you need ANY user input, lack sufficient
details, or encounter blockers
In your summary on coder_report_task:
- Be specific about what you're doing
- Clearly indicate what information you need from the user when in "failure" state
- Keep it under 160 characters
- Make it actionable
EOT
} }
resource "coder_env" "claude_code_enable_telemetry" { resource "coder_script" "install_claude_code" {
count = var.telemetry.enabled ? 1 : 0 agent_id = var.agent_id
agent_id = var.agent_id script = <<-EOT
name = "CLAUDE_CODE_ENABLE_TELEMETRY" #!/bin/bash
value = "1" set -o errexit
} set -o pipefail
resource "coder_env" "otel_exporter_otlp_endpoint" { echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
count = var.telemetry.enabled && var.telemetry.otlp_endpoint != "" ? 1 : 0 chmod +x /tmp/install.sh
agent_id = var.agent_id ARG_CLAUDE_CODE_VERSION='${var.claude_code_version}' \
name = "OTEL_EXPORTER_OTLP_ENDPOINT" ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \
value = var.telemetry.otlp_endpoint ARG_INSTALL_CLAUDE_CODE='${var.install_claude_code}' \
} ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}' \
ARG_INSTALL_VIA_NPM='${var.install_via_npm}' \
resource "coder_env" "otel_exporter_otlp_protocol" { ARG_WORKDIR='${local.workdir}' \
count = var.telemetry.enabled ? 1 : 0 ARG_ALLOWED_TOOLS='${var.allowed_tools}' \
agent_id = var.agent_id ARG_DISALLOWED_TOOLS='${var.disallowed_tools}' \
name = "OTEL_EXPORTER_OTLP_PROTOCOL" ARG_MCP='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \
value = var.telemetry.otlp_protocol ARG_MCP_CONFIG_REMOTE_PATH='${base64encode(jsonencode(var.mcp_config_remote_path))}' \
} ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
/tmp/install.sh
resource "coder_env" "otel_exporter_otlp_headers" { EOT
count = var.telemetry.enabled && length(var.telemetry.otlp_headers) > 0 ? 1 : 0 display_name = "ClaudeCode Install Script"
agent_id = var.agent_id
name = "OTEL_EXPORTER_OTLP_HEADERS"
value = join(",", [for k, v in var.telemetry.otlp_headers : "${k}=${v}"])
}
resource "coder_env" "otel_resource_attributes" {
count = var.telemetry.enabled ? 1 : 0
agent_id = var.agent_id
name = "OTEL_RESOURCE_ATTRIBUTES"
value = join(",", [for k, v in local.otel_resource_attributes : "${k}=${v}"])
}
locals {
workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : ""
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
ARG_CLAUDE_CODE_VERSION = var.claude_code_version
ARG_INSTALL_CLAUDE_CODE = tostring(var.install_claude_code)
ARG_CLAUDE_BINARY_PATH = var.claude_binary_path
ARG_WORKDIR = local.workdir
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : ""
ARG_MCP_CONFIG_REMOTE_PATH = base64encode(jsonencode(var.mcp_config_remote_path))
ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway)
ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : ""
})
module_dir_name = ".coder-modules/coder/claude-code"
}
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
agent_id = var.agent_id
module_directory = "$HOME/${local.module_dir_name}"
display_name_prefix = "Claude Code"
icon = var.icon
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
install_script = local.install_script
}
# Pass-through of coder-utils script outputs so upstream modules can serialize
# their coder_script resources behind this module's install pipeline using
# `coder exp sync want <self> <each name>`.
output "scripts" {
description = "Ordered list of coder exp sync names for the coder_script resources this module actually creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list."
value = module.coder_utils.scripts
} }

View File

@ -20,20 +20,30 @@ run "test_claude_code_basic" {
condition = var.install_claude_code == true condition = var.install_claude_code == true
error_message = "Install claude_code should default to true" error_message = "Install claude_code should default to true"
} }
assert {
condition = var.install_agentapi == true
error_message = "Install agentapi should default to true"
}
assert {
condition = var.report_tasks == true
error_message = "report_tasks should default to true"
}
} }
run "test_claude_code_with_api_key" { run "test_claude_code_with_api_key" {
command = plan command = plan
variables { variables {
agent_id = "test-agent-456" agent_id = "test-agent-456"
workdir = "/home/coder/workspace" workdir = "/home/coder/workspace"
anthropic_api_key = "test-api-key-123" claude_api_key = "test-api-key-123"
} }
assert { assert {
condition = coder_env.anthropic_api_key[0].value == "test-api-key-123" condition = coder_env.claude_api_key[0].value == "test-api-key-123"
error_message = "Anthropic API key value should match the input" error_message = "Claude API key value should match the input"
} }
} }
@ -41,12 +51,30 @@ run "test_claude_code_with_custom_options" {
command = plan command = plan
variables { variables {
agent_id = "test-agent-789" agent_id = "test-agent-789"
workdir = "/home/coder/custom" workdir = "/home/coder/custom"
icon = "/icon/custom.svg" order = 5
model = "opus" group = "development"
install_claude_code = false icon = "/icon/custom.svg"
claude_code_version = "1.0.0" model = "opus"
ai_prompt = "Help me write better code"
permission_mode = "plan"
continue = true
install_claude_code = false
install_agentapi = false
claude_code_version = "1.0.0"
agentapi_version = "v0.6.0"
dangerously_skip_permissions = true
}
assert {
condition = var.order == 5
error_message = "Order variable should be set to 5"
}
assert {
condition = var.group == "development"
error_message = "Group variable should be set to 'development'"
} }
assert { assert {
@ -59,13 +87,38 @@ run "test_claude_code_with_custom_options" {
error_message = "Claude model variable should be set to 'opus'" error_message = "Claude model variable should be set to 'opus'"
} }
assert {
condition = var.ai_prompt == "Help me write better code"
error_message = "AI prompt variable should be set correctly"
}
assert {
condition = var.permission_mode == "plan"
error_message = "Permission mode should be set to 'plan'"
}
assert {
condition = var.continue == true
error_message = "Continue should be set to true"
}
assert { assert {
condition = var.claude_code_version == "1.0.0" condition = var.claude_code_version == "1.0.0"
error_message = "Claude Code version should be set to '1.0.0'" error_message = "Claude Code version should be set to '1.0.0'"
} }
assert {
condition = var.agentapi_version == "v0.6.0"
error_message = "AgentAPI version should be set to 'v0.6.0'"
}
assert {
condition = var.dangerously_skip_permissions == true
error_message = "dangerously_skip_permissions should be set to true"
}
} }
run "test_claude_code_with_mcp" { run "test_claude_code_with_mcp_and_tools" {
command = plan command = plan
variables { variables {
@ -79,12 +132,24 @@ run "test_claude_code_with_mcp" {
} }
} }
}) })
allowed_tools = "bash,python"
disallowed_tools = "rm"
} }
assert { assert {
condition = var.mcp != "" condition = var.mcp != ""
error_message = "MCP configuration should be provided" error_message = "MCP configuration should be provided"
} }
assert {
condition = var.allowed_tools == "bash,python"
error_message = "Allowed tools should be set"
}
assert {
condition = var.disallowed_tools == "rm"
error_message = "Disallowed tools should be set"
}
} }
run "test_claude_code_with_scripts" { run "test_claude_code_with_scripts" {
@ -108,13 +173,144 @@ run "test_claude_code_with_scripts" {
} }
} }
run "test_ai_gateway_enabled" { run "test_claude_code_permission_mode_validation" {
command = plan command = plan
variables { variables {
agent_id = "test-agent-ai-gateway" agent_id = "test-agent-validation"
workdir = "/home/coder/ai-gateway" workdir = "/home/coder/test"
enable_ai_gateway = true permission_mode = "acceptEdits"
}
assert {
condition = contains(["", "default", "acceptEdits", "plan", "auto", "bypassPermissions"], var.permission_mode)
error_message = "Permission mode should be one of the valid options"
}
}
run "test_claude_code_auto_permission_mode" {
command = plan
variables {
agent_id = "test-agent-auto"
workdir = "/home/coder/test"
permission_mode = "auto"
}
assert {
condition = var.permission_mode == "auto"
error_message = "Permission mode should be set to auto"
}
}
run "test_claude_code_with_boundary" {
command = plan
variables {
agent_id = "test-agent-boundary"
workdir = "/home/coder/boundary-test"
enable_boundary = true
}
assert {
condition = var.enable_boundary == true
error_message = "Boundary should be enabled"
}
assert {
condition = local.coder_host != ""
error_message = "Coder host should be extracted from access URL"
}
}
run "test_claude_code_system_prompt" {
command = plan
variables {
agent_id = "test-agent-system-prompt"
workdir = "/home/coder/test"
system_prompt = "Custom addition"
}
assert {
condition = trimspace(coder_env.claude_code_system_prompt.value) != ""
error_message = "System prompt should not be empty"
}
assert {
condition = length(regexall("Custom addition", coder_env.claude_code_system_prompt.value)) > 0
error_message = "System prompt should have system_prompt variable value"
}
}
run "test_claude_report_tasks_default" {
command = plan
variables {
agent_id = "test-agent-report-tasks"
workdir = "/home/coder/test"
# report_tasks: default is true
}
assert {
condition = trimspace(coder_env.claude_code_system_prompt.value) != ""
error_message = "System prompt should not be empty"
}
# Ensure system prompt is wrapped by <system>
assert {
condition = startswith(trimspace(coder_env.claude_code_system_prompt.value), "<system>")
error_message = "System prompt should start with <system>"
}
assert {
condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "</system>")
error_message = "System prompt should end with </system>"
}
# Ensure Coder sections are injected when report_tasks=true (default)
assert {
condition = length(regexall("-- Tool Selection --", coder_env.claude_code_system_prompt.value)) > 0
error_message = "System prompt should have Tool Selection section"
}
assert {
condition = length(regexall("-- Task Reporting --", coder_env.claude_code_system_prompt.value)) > 0
error_message = "System prompt should have Task Reporting section"
}
}
run "test_claude_report_tasks_disabled" {
command = plan
variables {
agent_id = "test-agent-report-tasks"
workdir = "/home/coder/test"
report_tasks = false
}
assert {
condition = trimspace(coder_env.claude_code_system_prompt.value) != ""
error_message = "System prompt should not be empty"
}
# Ensure system prompt is wrapped by <system>
assert {
condition = startswith(trimspace(coder_env.claude_code_system_prompt.value), "<system>")
error_message = "System prompt should start with <system>"
}
assert {
condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "</system>")
error_message = "System prompt should end with </system>"
}
}
run "test_aibridge_enabled" {
command = plan
variables {
agent_id = "test-agent-aibridge"
workdir = "/home/coder/aibridge"
enable_aibridge = true
} }
override_data { override_data {
@ -125,8 +321,8 @@ run "test_ai_gateway_enabled" {
} }
assert { assert {
condition = var.enable_ai_gateway == true condition = var.enable_aibridge == true
error_message = "AI Gateway should be enabled" error_message = "AI Bridge should be enabled"
} }
assert { assert {
@ -136,78 +332,102 @@ run "test_ai_gateway_enabled" {
assert { assert {
condition = length(regexall("/api/v2/aibridge/anthropic", coder_env.anthropic_base_url[0].value)) > 0 condition = length(regexall("/api/v2/aibridge/anthropic", coder_env.anthropic_base_url[0].value)) > 0
error_message = "ANTHROPIC_BASE_URL should point to AI Gateway endpoint" error_message = "ANTHROPIC_BASE_URL should point to AI Bridge endpoint"
} }
assert { assert {
condition = coder_env.anthropic_auth_token[0].name == "ANTHROPIC_AUTH_TOKEN" condition = coder_env.claude_api_key[0].name == "CLAUDE_API_KEY"
error_message = "ANTHROPIC_AUTH_TOKEN environment variable should be set" error_message = "CLAUDE_API_KEY environment variable should be set"
} }
assert { assert {
condition = coder_env.anthropic_auth_token[0].value == data.coder_workspace_owner.me.session_token condition = coder_env.claude_api_key[0].value == data.coder_workspace_owner.me.session_token
error_message = "ANTHROPIC_AUTH_TOKEN should use workspace owner's session token when ai_gateway is enabled" error_message = "CLAUDE_API_KEY should use workspace owner's session token when aibridge is enabled"
}
assert {
condition = length(coder_env.anthropic_api_key) == 0
error_message = "ANTHROPIC_API_KEY env should not be created when ai_gateway is enabled and no anthropic_api_key is provided"
} }
} }
run "test_ai_gateway_validation_with_api_key" { run "test_aibridge_validation_with_api_key" {
command = plan command = plan
variables { variables {
agent_id = "test-agent-validation" agent_id = "test-agent-validation"
workdir = "/home/coder/test" workdir = "/home/coder/test"
enable_ai_gateway = true enable_aibridge = true
anthropic_api_key = "test-api-key" claude_api_key = "test-api-key"
} }
expect_failures = [ expect_failures = [
var.enable_ai_gateway, var.enable_aibridge,
] ]
} }
run "test_ai_gateway_validation_with_oauth_token" { run "test_aibridge_validation_with_oauth_token" {
command = plan command = plan
variables { variables {
agent_id = "test-agent-validation" agent_id = "test-agent-validation"
workdir = "/home/coder/test" workdir = "/home/coder/test"
enable_ai_gateway = true enable_aibridge = true
claude_code_oauth_token = "test-auth-token" claude_code_oauth_token = "test-oauth-token"
} }
expect_failures = [ expect_failures = [
var.enable_ai_gateway, var.enable_aibridge,
] ]
} }
run "test_ai_gateway_disabled_with_api_key" { run "test_aibridge_disabled_with_api_key" {
command = plan command = plan
variables { variables {
agent_id = "test-agent-no-ai-gateway" agent_id = "test-agent-no-aibridge"
workdir = "/home/coder/test" workdir = "/home/coder/test"
enable_ai_gateway = false enable_aibridge = false
anthropic_api_key = "test-api-key-xyz" claude_api_key = "test-api-key-xyz"
} }
assert { assert {
condition = var.enable_ai_gateway == false condition = var.enable_aibridge == false
error_message = "AI Gateway should be disabled" error_message = "AI Bridge should be disabled"
} }
assert { assert {
condition = coder_env.anthropic_api_key[0].value == "test-api-key-xyz" condition = coder_env.claude_api_key[0].value == "test-api-key-xyz"
error_message = "ANTHROPIC_API_KEY should use the provided API key when ai_gateway is disabled" error_message = "CLAUDE_API_KEY should use the provided API key when aibridge is disabled"
} }
assert { assert {
condition = length(coder_env.anthropic_base_url) == 0 condition = length(coder_env.anthropic_base_url) == 0
error_message = "ANTHROPIC_BASE_URL should not be set when ai_gateway is disabled" error_message = "ANTHROPIC_BASE_URL should not be set when aibridge is disabled"
}
}
run "test_enable_state_persistence_default" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.enable_state_persistence == true
error_message = "enable_state_persistence should default to true"
}
}
run "test_disable_state_persistence" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_state_persistence = false
}
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should be false when explicitly disabled"
} }
} }
@ -215,115 +435,28 @@ run "test_no_api_key_no_env" {
command = plan command = plan
variables { variables {
agent_id = "test-agent-no-key" agent_id = "test-agent-no-key"
workdir = "/home/coder/test" workdir = "/home/coder/test"
enable_ai_gateway = false enable_aibridge = false
} }
assert { assert {
condition = length(coder_env.anthropic_api_key) == 0 condition = length(coder_env.claude_api_key) == 0
error_message = "ANTHROPIC_API_KEY should not be created when no API key is provided and ai_gateway is disabled" error_message = "CLAUDE_API_KEY should not be created when no API key is provided and aibridge is disabled"
} }
} }
run "test_api_key_count_with_ai_gateway_no_override" { run "test_api_key_count_with_aibridge_no_override" {
command = plan command = plan
variables { variables {
agent_id = "test-agent-count" agent_id = "test-agent-count"
workdir = "/home/coder/test" workdir = "/home/coder/test"
enable_ai_gateway = true enable_aibridge = true
} }
assert { assert {
condition = length(coder_env.anthropic_auth_token) == 1 condition = length(coder_env.claude_api_key) == 1
error_message = "ANTHROPIC_AUTH_TOKEN env should be created when ai_gateway is enabled" error_message = "CLAUDE_API_KEY env should be created when aibridge is enabled, regardless of session_token value"
}
}
run "test_script_outputs_install_only" {
command = plan
variables {
agent_id = "test-agent-outputs"
workdir = "/home/coder/test"
}
assert {
condition = length(output.scripts) == 1 && output.scripts[0] == "coder-claude-code-install_script"
error_message = "scripts output should list only the install script when pre/post are not configured"
}
}
run "test_script_outputs_with_pre_and_post" {
command = plan
variables {
agent_id = "test-agent-outputs-all"
workdir = "/home/coder/test"
pre_install_script = "echo pre"
post_install_script = "echo post"
}
assert {
condition = output.scripts == ["coder-claude-code-pre_install_script", "coder-claude-code-install_script", "coder-claude-code-post_install_script"]
error_message = "scripts output should list pre_install, install, post_install in run order"
}
}
run "test_workdir_optional" {
command = plan
variables {
agent_id = "test-agent-no-workdir"
}
assert {
condition = var.workdir == null
error_message = "workdir should default to null when omitted"
}
}
run "test_managed_settings" {
command = plan
variables {
agent_id = "test-agent-managed-settings"
workdir = "/home/coder/project"
managed_settings = {
permissions = {
defaultMode = "acceptEdits"
disableBypassPermissionsMode = "disable"
deny = ["Bash(rm -rf*)"]
}
}
}
assert {
condition = var.managed_settings.permissions.defaultMode == "acceptEdits"
error_message = "managed_settings should accept the permissions object"
}
assert {
condition = strcontains(local.install_script, "/etc/claude-code/managed-settings.d")
error_message = "install script should reference the managed-settings.d drop-in directory"
}
assert {
condition = strcontains(local.install_script, base64encode(jsonencode(var.managed_settings)))
error_message = "install script should embed the base64-encoded managed_settings JSON"
}
}
run "test_managed_settings_default_null" {
command = plan
variables {
agent_id = "test-agent-managed-settings-default"
}
assert {
condition = var.managed_settings == null
error_message = "managed_settings should default to null when omitted"
} }
} }

View File

@ -0,0 +1,252 @@
#!/bin/bash
set -euo pipefail
BOLD='\033[0;1m'
command_exists() {
command -v "$1" > /dev/null 2>&1
}
ARG_CLAUDE_CODE_VERSION=${ARG_CLAUDE_CODE_VERSION:-}
ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
ARG_INSTALL_CLAUDE_CODE=${ARG_INSTALL_CLAUDE_CODE:-}
ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"}
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
ARG_INSTALL_VIA_NPM=${ARG_INSTALL_VIA_NPM:-false}
ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-}
ARG_MCP=$(echo -n "${ARG_MCP:-}" | base64 -d)
ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n "${ARG_MCP_CONFIG_REMOTE_PATH:-}" | base64 -d)
ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-}
ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-}
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-}
export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"
echo "--------------------------------"
printf "ARG_CLAUDE_CODE_VERSION: %s\n" "$ARG_CLAUDE_CODE_VERSION"
printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR"
printf "ARG_INSTALL_CLAUDE_CODE: %s\n" "$ARG_INSTALL_CLAUDE_CODE"
printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$ARG_CLAUDE_BINARY_PATH"
printf "ARG_INSTALL_VIA_NPM: %s\n" "$ARG_INSTALL_VIA_NPM"
printf "ARG_MCP_APP_STATUS_SLUG: %s\n" "$ARG_MCP_APP_STATUS_SLUG"
printf "ARG_MCP: %s\n" "$ARG_MCP"
printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$ARG_MCP_CONFIG_REMOTE_PATH"
printf "ARG_ALLOWED_TOOLS: %s\n" "$ARG_ALLOWED_TOOLS"
printf "ARG_DISALLOWED_TOOLS: %s\n" "$ARG_DISALLOWED_TOOLS"
printf "ARG_ENABLE_AIBRIDGE: %s\n" "$ARG_ENABLE_AIBRIDGE"
echo "--------------------------------"
function add_mcp_servers() {
local mcp_json="$1"
local source_desc="$2"
while IFS= read -r server_name && IFS= read -r server_json; do
echo "------------------------"
echo "Executing: claude mcp add-json \"$server_name\" '$server_json' ($source_desc)"
claude mcp add-json "$server_name" "$server_json" || echo "Warning: Failed to add MCP server '$server_name', continuing..."
echo "------------------------"
echo ""
done < <(echo "$mcp_json" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)')
}
function add_path_to_shell_profiles() {
local path_dir="$1"
for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do
if [ -f "$profile" ]; then
if ! grep -q "$path_dir" "$profile" 2> /dev/null; then
echo "export PATH=\"\$PATH:$path_dir\"" >> "$profile"
echo "Added $path_dir to $profile"
fi
fi
done
local fish_config="$HOME/.config/fish/config.fish"
if [ -f "$fish_config" ]; then
if ! grep -q "$path_dir" "$fish_config" 2> /dev/null; then
echo "fish_add_path $path_dir" >> "$fish_config"
echo "Added $path_dir to $fish_config"
fi
fi
}
function ensure_claude_in_path() {
local CLAUDE_BIN=""
if command -v claude > /dev/null 2>&1; then
CLAUDE_BIN=$(command -v claude)
elif [ -x "$ARG_CLAUDE_BINARY_PATH/claude" ]; then
CLAUDE_BIN="$ARG_CLAUDE_BINARY_PATH/claude"
elif [ -x "$HOME/.local/bin/claude" ]; then
CLAUDE_BIN="$HOME/.local/bin/claude"
fi
if [ -z "$CLAUDE_BIN" ] || [ ! -x "$CLAUDE_BIN" ]; then
echo "Warning: Could not find claude binary"
return
fi
local CLAUDE_DIR
CLAUDE_DIR=$(dirname "$CLAUDE_BIN")
if [ -n "${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$CODER_SCRIPT_BIN_DIR/claude" ]; then
ln -s "$CLAUDE_BIN" "$CODER_SCRIPT_BIN_DIR/claude"
echo "Created symlink: $CODER_SCRIPT_BIN_DIR/claude -> $CLAUDE_BIN"
fi
add_path_to_shell_profiles "$CLAUDE_DIR"
}
function install_claude_code_cli() {
if [ "$ARG_INSTALL_CLAUDE_CODE" != "true" ]; then
echo "Skipping Claude Code installation as per configuration."
ensure_claude_in_path
return
fi
# Use npm when install_via_npm is true
if [ "$ARG_INSTALL_VIA_NPM" = "true" ]; then
echo "WARNING: npm installation method will be deprecated and removed in the next major release."
echo "Installing Claude Code via npm (version: $ARG_CLAUDE_CODE_VERSION)"
npm install -g "@anthropic-ai/claude-code@$ARG_CLAUDE_CODE_VERSION"
echo "Installed Claude Code via npm. Version: $(claude --version || echo 'unknown')"
else
echo "Installing Claude Code via official installer"
set +e
curl -fsSL claude.ai/install.sh | bash -s -- "$ARG_CLAUDE_CODE_VERSION" 2>&1
CURL_EXIT=${PIPESTATUS[0]}
set -e
if [ $CURL_EXIT -ne 0 ]; then
echo "Claude Code installer failed with exit code $CURL_EXIT"
fi
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
fi
ensure_claude_in_path
}
function setup_claude_configurations() {
if [ ! -d "$ARG_WORKDIR" ]; then
echo "Warning: The specified folder '$ARG_WORKDIR' does not exist."
echo "Creating the folder..."
mkdir -p "$ARG_WORKDIR"
echo "Folder created successfully."
fi
module_path="$HOME/.claude-module"
mkdir -p "$module_path"
if [ "$ARG_MCP" != "" ]; then
(
cd "$ARG_WORKDIR"
add_mcp_servers "$ARG_MCP" "in $ARG_WORKDIR"
)
fi
if [ -n "$ARG_MCP_CONFIG_REMOTE_PATH" ] && [ "$ARG_MCP_CONFIG_REMOTE_PATH" != "[]" ]; then
(
cd "$ARG_WORKDIR"
for url in $(echo "$ARG_MCP_CONFIG_REMOTE_PATH" | jq -r '.[]'); do
echo "Fetching MCP configuration from: $url"
mcp_json=$(curl -fsSL "$url") || {
echo "Warning: Failed to fetch MCP configuration from '$url', continuing..."
continue
}
if ! echo "$mcp_json" | jq -e '.mcpServers' > /dev/null 2>&1; then
echo "Warning: Invalid MCP configuration from '$url' (missing mcpServers), continuing..."
continue
fi
add_mcp_servers "$mcp_json" "from $url"
done
)
fi
if [ -n "$ARG_ALLOWED_TOOLS" ]; then
coder --allowedTools "$ARG_ALLOWED_TOOLS"
fi
if [ -n "$ARG_DISALLOWED_TOOLS" ]; then
coder --disallowedTools "$ARG_DISALLOWED_TOOLS"
fi
}
function configure_standalone_mode() {
echo "Configuring Claude Code for standalone mode..."
if [ -z "${CLAUDE_API_KEY:-}" ] && [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then
echo "Note: Neither claude_api_key nor enable_aibridge is set, skipping authentication setup"
return
fi
local claude_config="$HOME/.claude.json"
local workdir_normalized
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-')
# Create or update .claude.json with minimal configuration for API key auth
# This skips the interactive login prompt and onboarding screens
if [ -f "$claude_config" ]; then
echo "Updating existing Claude configuration at $claude_config"
jq --arg workdir "$ARG_WORKDIR" --arg apikey "${CLAUDE_API_KEY:-}" \
'.autoUpdaterStatus = "disabled" |
.autoModeAccepted = true |
.bypassPermissionsModeAccepted = true |
.hasAcknowledgedCostThreshold = true |
.hasCompletedOnboarding = true |
.primaryApiKey = $apikey |
.projects[$workdir].hasCompletedProjectOnboarding = true |
.projects[$workdir].hasTrustDialogAccepted = true' \
"$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config"
else
echo "Creating new Claude configuration at $claude_config"
cat > "$claude_config" << EOF
{
"autoUpdaterStatus": "disabled",
"autoModeAccepted": true,
"bypassPermissionsModeAccepted": true,
"hasAcknowledgedCostThreshold": true,
"hasCompletedOnboarding": true,
"primaryApiKey": "${CLAUDE_API_KEY:-}",
"projects": {
"$ARG_WORKDIR": {
"hasCompletedProjectOnboarding": true,
"hasTrustDialogAccepted": true
}
}
}
EOF
fi
echo "Standalone mode configured successfully"
}
function accept_auto_mode() {
# Pre-accept the auto mode TOS prompt so it doesn't appear interactively.
# Claude Code shows a confirmation dialog for auto mode that blocks
# non-interactive/headless usage.
# Note: bypassPermissions acceptance is already handled by
# coder exp mcp configure (task mode) and configure_standalone_mode.
local claude_config="$HOME/.claude.json"
if [ -f "$claude_config" ]; then
jq '.autoModeAccepted = true' \
"$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config"
else
echo '{"autoModeAccepted": true}' > "$claude_config"
fi
echo "Pre-accepted auto mode prompt"
}
install_claude_code_cli
setup_claude_configurations
cofigure_standalone_mode
if [ "$ARG_PERMISSION_MODE" = "auto" ]; then
accept_auto_mode
fi

View File

@ -1,217 +0,0 @@
#!/bin/bash
set -euo pipefail
BOLD='\033[0;1m'
command_exists() {
command -v "$1" > /dev/null 2>&1
}
ARG_CLAUDE_CODE_VERSION='${ARG_CLAUDE_CODE_VERSION}'
ARG_WORKDIR='${ARG_WORKDIR}'
ARG_INSTALL_CLAUDE_CODE='${ARG_INSTALL_CLAUDE_CODE}'
ARG_CLAUDE_BINARY_PATH='${ARG_CLAUDE_BINARY_PATH}'
ARG_CLAUDE_BINARY_PATH="$${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
ARG_CLAUDE_BINARY_PATH="$${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d)
ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n '${ARG_MCP_CONFIG_REMOTE_PATH}' | base64 -d)
ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}'
ARG_MANAGED_SETTINGS_JSON=$(echo -n '${ARG_MANAGED_SETTINGS_JSON}' | base64 -d)
export PATH="$${ARG_CLAUDE_BINARY_PATH}:$PATH"
echo "--------------------------------"
printf "ARG_CLAUDE_CODE_VERSION: %s\n" "$${ARG_CLAUDE_CODE_VERSION}"
printf "ARG_WORKDIR: %s\n" "$${ARG_WORKDIR}"
printf "ARG_INSTALL_CLAUDE_CODE: %s\n" "$${ARG_INSTALL_CLAUDE_CODE}"
printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$${ARG_CLAUDE_BINARY_PATH}"
printf "ARG_MCP: %s\n" "$${ARG_MCP}"
printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$${ARG_MCP_CONFIG_REMOTE_PATH}"
printf "ARG_ENABLE_AI_GATEWAY: %s\n" "$${ARG_ENABLE_AI_GATEWAY}"
printf "ARG_MANAGED_SETTINGS_JSON: %s\n" "$${ARG_MANAGED_SETTINGS_JSON}"
echo "--------------------------------"
function add_mcp_servers() {
local mcp_json="$1"
local source_desc="$2"
while IFS= read -r server_name && IFS= read -r server_json; do
echo "------------------------"
echo "Executing: claude mcp add-json --scope user \"$${server_name}\" '$${server_json}' ($${source_desc})"
claude mcp add-json --scope user "$${server_name}" "$${server_json}" || echo "Warning: Failed to add MCP server '$${server_name}', continuing..."
echo "------------------------"
echo ""
done < <(echo "$${mcp_json}" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)')
}
function add_path_to_shell_profiles() {
local path_dir="$1"
for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do
if [ -f "$${profile}" ]; then
if ! grep -q "$${path_dir}" "$${profile}" 2> /dev/null; then
echo "export PATH=\"\$PATH:$${path_dir}\"" >> "$${profile}"
echo "Added $${path_dir} to $${profile}"
fi
fi
done
local fish_config="$HOME/.config/fish/config.fish"
if [ -f "$${fish_config}" ]; then
if ! grep -q "$${path_dir}" "$${fish_config}" 2> /dev/null; then
echo "fish_add_path $${path_dir}" >> "$${fish_config}"
echo "Added $${path_dir} to $${fish_config}"
fi
fi
}
function ensure_claude_in_path() {
local CLAUDE_BIN=""
if command -v claude > /dev/null 2>&1; then
CLAUDE_BIN=$(command -v claude)
elif [ -x "$${ARG_CLAUDE_BINARY_PATH}/claude" ]; then
CLAUDE_BIN="$${ARG_CLAUDE_BINARY_PATH}/claude"
elif [ -x "$HOME/.local/bin/claude" ]; then
CLAUDE_BIN="$HOME/.local/bin/claude"
fi
if [ -z "$${CLAUDE_BIN}" ] || [ ! -x "$${CLAUDE_BIN}" ]; then
echo "Warning: Could not find claude binary"
return
fi
local CLAUDE_DIR
CLAUDE_DIR=$(dirname "$${CLAUDE_BIN}")
if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/claude" ]; then
ln -s "$${CLAUDE_BIN}" "$${CODER_SCRIPT_BIN_DIR}/claude"
echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/claude -> $${CLAUDE_BIN}"
fi
add_path_to_shell_profiles "$${CLAUDE_DIR}"
}
function install_claude_code_cli() {
if [ "$${ARG_INSTALL_CLAUDE_CODE}" != "true" ]; then
echo "Skipping Claude Code installation as per configuration."
ensure_claude_in_path
return
fi
echo "Installing Claude Code via official installer"
set +e
curl -fsSL claude.ai/install.sh | bash -s -- "$${ARG_CLAUDE_CODE_VERSION}" 2>&1
CURL_EXIT=$${PIPESTATUS[0]}
set -e
if [ $${CURL_EXIT} -ne 0 ]; then
echo "Claude Code installer failed with exit code $${CURL_EXIT}"
fi
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
ensure_claude_in_path
}
function setup_claude_configurations() {
if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then
echo "Warning: The specified folder '$${ARG_WORKDIR}' does not exist."
echo "Creating the folder..."
mkdir -p "$${ARG_WORKDIR}"
echo "Folder created successfully."
fi
module_path="$HOME/.coder-modules/coder/claude-code"
mkdir -p "$${module_path}"
if [ "$${ARG_MCP}" != "" ]; then
add_mcp_servers "$${ARG_MCP}" "from module input"
fi
if [ -n "$${ARG_MCP_CONFIG_REMOTE_PATH}" ] && [ "$${ARG_MCP_CONFIG_REMOTE_PATH}" != "[]" ]; then
for url in $(echo "$${ARG_MCP_CONFIG_REMOTE_PATH}" | jq -r '.[]'); do
echo "Fetching MCP configuration from: $${url}"
mcp_json=$(curl -fsSL "$${url}") || {
echo "Warning: Failed to fetch MCP configuration from '$${url}', continuing..."
continue
}
if ! echo "$${mcp_json}" | jq -e '.mcpServers' > /dev/null 2>&1; then
echo "Warning: Invalid MCP configuration from '$${url}' (missing mcpServers), continuing..."
continue
fi
add_mcp_servers "$${mcp_json}" "from $${url}"
done
fi
}
function write_managed_settings() {
if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then
return
fi
local dropin_dir="/etc/claude-code/managed-settings.d"
local target="$${dropin_dir}/10-coder.json"
if ! echo "$${ARG_MANAGED_SETTINGS_JSON}" | jq empty 2> /dev/null; then
echo "Warning: managed_settings is not valid JSON, skipping policy write"
return
fi
if command_exists sudo; then
sudo mkdir -p "$${dropin_dir}"
echo "$${ARG_MANAGED_SETTINGS_JSON}" | sudo tee "$${target}" > /dev/null
sudo chmod 0644 "$${target}"
else
mkdir -p "$${dropin_dir}"
echo "$${ARG_MANAGED_SETTINGS_JSON}" > "$${target}"
chmod 0644 "$${target}"
fi
echo "Wrote Claude Code managed settings to $${target}"
}
function configure_standalone_mode() {
echo "Configuring Claude Code for standalone mode..."
if [ -z "$${ANTHROPIC_API_KEY:-}" ] && [ -z "$${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ "$${ARG_ENABLE_AI_GATEWAY}" = "false" ]; then
echo "Note: No authentication configured (anthropic_api_key, claude_code_oauth_token, enable_ai_gateway), skipping onboarding bypass"
return
fi
local claude_config="$HOME/.claude.json"
if [ -f "$${claude_config}" ]; then
echo "Updating existing Claude configuration at $${claude_config}"
jq '.autoUpdaterStatus = "disabled" |
.hasAcknowledgedCostThreshold = true |
.hasCompletedOnboarding = true' \
"$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}"
else
echo "Creating new Claude configuration at $${claude_config}"
cat > "$${claude_config}" << EOF
{
"autoUpdaterStatus": "disabled",
"hasAcknowledgedCostThreshold": true,
"hasCompletedOnboarding": true
}
EOF
fi
if [ -n "$${ARG_WORKDIR}" ]; then
echo "Pre-accepting trust dialog for $${ARG_WORKDIR}"
jq --arg workdir "$${ARG_WORKDIR}" \
'.projects[$workdir].hasCompletedProjectOnboarding = true |
.projects[$workdir].hasTrustDialogAccepted = true' \
"$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}"
fi
echo "Standalone mode configured successfully"
}
install_claude_code_cli
setup_claude_configurations
write_managed_settings
configure_standalone_mode

View File

@ -5,6 +5,9 @@ if [[ "$1" == "--version" ]]; then
exit 0 exit 0
fi fi
# Mirror invocation for test assertions and exit cleanly. set -e
echo "claude invoked with: $*"
exit 0 while true; do
echo "$(date) - claude-mock"
sleep 15
done

View File

@ -13,13 +13,18 @@ tags: [internal, library]
The Coder Utils module is a building block for modules that need to run multiple scripts in a specific order. It uses `coder exp sync` for dependency management and is designed for orchestrating pre-install, install, post-install, and start scripts. The Coder Utils module is a building block for modules that need to run multiple scripts in a specific order. It uses `coder exp sync` for dependency management and is designed for orchestrating pre-install, install, post-install, and start scripts.
> [!NOTE]
>
> - The `agent_name` should be the same as that of the agentapi module's `agent_name` if used together.
```tf ```tf
module "coder_utils" { module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder" source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1" version = "1.0.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
module_directory = "$HOME/.coder-modules/coder/claude-code" agent_name = "myagent"
module_dir_name = ".my-module"
pre_install_script = <<-EOT pre_install_script = <<-EOT
#!/bin/bash #!/bin/bash
@ -51,51 +56,10 @@ module "coder_utils" {
The module orchestrates scripts in the following order: The module orchestrates scripts in the following order:
1. **Pre-Install Script** (optional) - Runs before installation 1. **Log File Creation** - Creates module directory and log files
2. **Install Script** (required) - Main installation 2. **Pre-Install Script** (optional) - Runs before installation
3. **Post-Install Script** (optional) - Runs after installation 3. **Install Script** - Main installation
4. **Start Script** (optional) - Starts the application 4. **Post-Install Script** (optional) - Runs after installation
5. **Start Script** - Starts the application
Each script waits for its prerequisites to complete before running using `coder exp sync` dependency management. Each script waits for its prerequisites to complete before running using `coder exp sync` dependency management.
## Customizing Script Display
By default each `coder_script` renders in the Coder UI as plain "Install Script", "Pre-Install Script", etc. Downstream modules can brand them:
```tf
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
module_directory = "$HOME/.coder-modules/coder/claude-code"
install_script = "echo installing"
display_name_prefix = "Claude Code" # yields "Claude Code: Install Script", etc.
icon = "/icon/claude.svg"
}
```
Both variables are optional. `display_name_prefix` defaults to `""` (no prefix), and `icon` defaults to `null` (use the Coder provider's default).
## Log file locations
The module writes each script's stdout+stderr to `${module_directory}/logs/`:
- `pre_install.log`
- `install.log`
- `post_install.log`
- `start.log`
Each `coder_script` `mkdir -p`s this subdirectory before its `tee` runs, so the first script to execute creates it.
## Script file locations
The module materializes each script to `${module_directory}/scripts/` before running it:
- `pre_install.sh`
- `install.sh`
- `post_install.sh`
- `start.sh`
The pre-install and install `coder_script`s `mkdir -p` this subdirectory; post-install and start sync-depend on install, so the directory already exists by the time they run.

View File

@ -1,38 +1,13 @@
import { describe, expect, it } from "bun:test"; import { describe } from "bun:test";
import { import { runTerraformInit, testRequiredVariables } from "~test";
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
describe("coder-utils", async () => { describe("coder-utils", async () => {
await runTerraformInit(import.meta.dir); await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, { testRequiredVariables(import.meta.dir, {
agent_id: "test-agent-id", agent_id: "test-agent-id",
module_directory: "$HOME/.coder-modules/test/example", agent_name: "test-agent",
install_script: "echo 'install'", module_dir_name: ".test-module",
}); start_script: "echo 'start'",
it("rejects invalid module_directory", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "test-agent-id",
module_directory: "$HOME/.coder-modules/test",
install_script: "echo 'install'",
});
} catch (ex) {
if (!(ex instanceof Error)) {
throw new Error("Unknown error generated");
}
expect(ex.message).toContain("module_directory must match the pattern");
expect(ex.message).toContain(
"'$HOME/.coder-modules/<namespace>/<module-name>'",
);
return;
}
throw new Error("module_directory validation should have failed");
}); });
}); });

View File

@ -29,6 +29,7 @@ variable "pre_install_script" {
variable "install_script" { variable "install_script" {
type = string type = string
description = "Script to install the agent used by AgentAPI." description = "Script to install the agent used by AgentAPI."
default = null
} }
variable "post_install_script" { variable "post_install_script" {
@ -40,83 +41,54 @@ variable "post_install_script" {
variable "start_script" { variable "start_script" {
type = string type = string
description = "Script that starts AgentAPI." description = "Script that starts AgentAPI."
default = null
} }
variable "module_directory" { variable "agent_name" {
type = string type = string
description = "The calling module's working directory. Must follow the pattern '$HOME/.coder-modules/<namespace>/<module-name>'." description = "The name of the agent. This is used to construct unique script names for the experiment sync."
validation {
condition = can(regex("^\\$HOME/\\.coder-modules/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$", var.module_directory))
error_message = "module_directory must match the pattern '$HOME/.coder-modules/<namespace>/<module-name>' (e.g. '$HOME/.coder-modules/coder/claude-code')."
}
} }
variable "display_name_prefix" { variable "module_dir_name" {
type = string type = string
description = "Prefix for each coder_script display_name. Example: setting 'Claude Code' yields 'Claude Code: Install Script', 'Claude Code: Pre-Install Script', etc. When unset, scripts show as plain 'Install Script'." description = "The name of the module directory."
default = ""
}
variable "icon" {
type = string
description = "Icon shown in the Coder UI for every coder_script this module creates. Falls back to the Coder provider's default when unset."
default = null
} }
locals { locals {
path_parts = split("/", var.module_directory)
caller_name = "${local.path_parts[length(local.path_parts) - 2]}-${local.path_parts[length(local.path_parts) - 1]}"
encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : "" encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
encoded_install_script = base64encode(var.install_script) encoded_install_script = var.install_script != null ? base64encode(var.install_script) : ""
encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : "" encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : ""
encoded_start_script = var.start_script != null ? base64encode(var.start_script) : "" encoded_start_script = base64encode(var.start_script)
pre_install_script_name = "${local.caller_name}-pre_install_script" pre_install_script_name = "${var.agent_name}-pre_install_script"
install_script_name = "${local.caller_name}-install_script" install_script_name = "${var.agent_name}-install_script"
post_install_script_name = "${local.caller_name}-post_install_script" post_install_script_name = "${var.agent_name}-post_install_script"
start_script_name = "${local.caller_name}-start_script" start_script_name = "${var.agent_name}-start_script"
pre_install_path = "${local.scripts_directory}/pre_install.sh" module_dir_path = "$HOME/${var.module_dir_name}"
install_path = "${local.scripts_directory}/install.sh"
post_install_path = "${local.scripts_directory}/post_install.sh"
start_path = "${local.scripts_directory}/start.sh"
pre_install_log_path = "${local.log_directory}/pre_install.log" pre_install_path = "${local.module_dir_path}/pre_install.sh"
install_log_path = "${local.log_directory}/install.log" install_path = "${local.module_dir_path}/install.sh"
post_install_log_path = "${local.log_directory}/post_install.log" post_install_path = "${local.module_dir_path}/post_install.sh"
start_log_path = "${local.log_directory}/start.log" start_path = "${local.module_dir_path}/start.sh"
scripts_directory = "${var.module_directory}/scripts" pre_install_log_path = "${local.module_dir_path}/pre_install.log"
log_directory = "${var.module_directory}/logs" install_log_path = "${local.module_dir_path}/install.log"
post_install_log_path = "${local.module_dir_path}/post_install.log"
install_sync_deps = var.pre_install_script != null ? local.pre_install_script_name : null start_log_path = "${local.module_dir_path}/start.log"
start_sync_deps = (
var.post_install_script != null
? "${local.install_script_name} ${local.post_install_script_name}"
: local.install_script_name
)
display_name_prefix = var.display_name_prefix != "" ? "${var.display_name_prefix}: " : ""
} }
resource "coder_script" "pre_install_script" { resource "coder_script" "pre_install_script" {
count = var.pre_install_script == null ? 0 : 1 count = var.pre_install_script == null ? 0 : 1
agent_id = var.agent_id agent_id = var.agent_id
display_name = "${local.display_name_prefix}Pre-Install Script" display_name = "Pre-Install Script"
icon = var.icon
run_on_start = true run_on_start = true
script = <<-EOT script = <<-EOT
#!/bin/bash #!/bin/bash
set -o errexit set -o errexit
set -o pipefail set -o pipefail
mkdir -p ${var.module_directory} mkdir -p ${local.module_dir_path}
mkdir -p ${local.scripts_directory}
mkdir -p ${local.log_directory}
trap 'coder exp sync complete ${local.pre_install_script_name}' EXIT trap 'coder exp sync complete ${local.pre_install_script_name}' EXIT
coder exp sync start ${local.pre_install_script_name} coder exp sync start ${local.pre_install_script_name}
@ -124,41 +96,37 @@ resource "coder_script" "pre_install_script" {
echo -n '${local.encoded_pre_install_script}' | base64 -d > ${local.pre_install_path} echo -n '${local.encoded_pre_install_script}' | base64 -d > ${local.pre_install_path}
chmod +x ${local.pre_install_path} chmod +x ${local.pre_install_path}
${local.pre_install_path} 2>&1 | tee ${local.pre_install_log_path} ${local.pre_install_path} > ${local.pre_install_log_path} 2>&1
EOT EOT
} }
resource "coder_script" "install_script" { resource "coder_script" "install_script" {
agent_id = var.agent_id agent_id = var.agent_id
display_name = "${local.display_name_prefix}Install Script" display_name = "Install Script"
icon = var.icon
run_on_start = true run_on_start = true
script = <<-EOT script = <<-EOT
#!/bin/bash #!/bin/bash
set -o errexit set -o errexit
set -o pipefail set -o pipefail
mkdir -p ${var.module_directory} mkdir -p ${local.module_dir_path}
mkdir -p ${local.scripts_directory}
mkdir -p ${local.log_directory}
trap 'coder exp sync complete ${local.install_script_name}' EXIT trap 'coder exp sync complete ${local.install_script_name}' EXIT
%{if local.install_sync_deps != null~} %{if var.pre_install_script != null~}
coder exp sync want ${local.install_script_name} ${local.install_sync_deps} coder exp sync want ${local.install_script_name} ${local.pre_install_script_name}
%{endif~} %{endif~}
coder exp sync start ${local.install_script_name} coder exp sync start ${local.install_script_name}
echo -n '${local.encoded_install_script}' | base64 -d > ${local.install_path} echo -n '${local.encoded_install_script}' | base64 -d > ${local.install_path}
chmod +x ${local.install_path} chmod +x ${local.install_path}
${local.install_path} 2>&1 | tee ${local.install_log_path} ${local.install_path} > ${local.install_log_path} 2>&1
EOT EOT
} }
resource "coder_script" "post_install_script" { resource "coder_script" "post_install_script" {
count = var.post_install_script != null ? 1 : 0 count = var.post_install_script != null ? 1 : 0
agent_id = var.agent_id agent_id = var.agent_id
display_name = "${local.display_name_prefix}Post-Install Script" display_name = "Post-Install Script"
icon = var.icon
run_on_start = true run_on_start = true
script = <<-EOT script = <<-EOT
#!/bin/bash #!/bin/bash
@ -172,15 +140,13 @@ resource "coder_script" "post_install_script" {
echo -n '${local.encoded_post_install_script}' | base64 -d > ${local.post_install_path} echo -n '${local.encoded_post_install_script}' | base64 -d > ${local.post_install_path}
chmod +x ${local.post_install_path} chmod +x ${local.post_install_path}
${local.post_install_path} 2>&1 | tee ${local.post_install_log_path} ${local.post_install_path} > ${local.post_install_log_path} 2>&1
EOT EOT
} }
resource "coder_script" "start_script" { resource "coder_script" "start_script" {
count = var.start_script != null ? 1 : 0
agent_id = var.agent_id agent_id = var.agent_id
display_name = "${local.display_name_prefix}Start Script" display_name = "Start Script"
icon = var.icon
run_on_start = true run_on_start = true
script = <<-EOT script = <<-EOT
#!/bin/bash #!/bin/bash
@ -189,28 +155,36 @@ resource "coder_script" "start_script" {
trap 'coder exp sync complete ${local.start_script_name}' EXIT trap 'coder exp sync complete ${local.start_script_name}' EXIT
coder exp sync want ${local.start_script_name} ${local.start_sync_deps} %{if var.post_install_script != null~}
coder exp sync want ${local.start_script_name} ${local.install_script_name} ${local.post_install_script_name}
%{else~}
coder exp sync want ${local.start_script_name} ${local.install_script_name}
%{endif~}
coder exp sync start ${local.start_script_name} coder exp sync start ${local.start_script_name}
echo -n '${local.encoded_start_script}' | base64 -d > ${local.start_path} echo -n '${local.encoded_start_script}' | base64 -d > ${local.start_path}
chmod +x ${local.start_path} chmod +x ${local.start_path}
${local.start_path} 2>&1 | tee ${local.start_log_path} ${local.start_path} > ${local.start_log_path} 2>&1
EOT EOT
} }
# Filtered, run-order list of the `coder exp sync` names for every output "pre_install_script_name" {
# coder_script this module actually creates. Absent scripts (pre/post/start description = "The name of the pre-install script for sync."
# when their inputs are null) are omitted entirely, not padded with empty value = local.pre_install_script_name
# strings. Downstream modules can use this with }
# `coder exp sync want <self> <each of these>` to serialize their own
# scripts behind the install pipeline. output "install_script_name" {
output "scripts" { description = "The name of the install script for sync."
description = "Ordered list of `coder exp sync` names for the coder_script resources this module creates, in the run order it enforces (pre_install, install, post_install, start). Scripts that were not configured are absent from the list." value = local.install_script_name
value = concat( }
var.pre_install_script != null ? [local.pre_install_script_name] : [],
[local.install_script_name], output "post_install_script_name" {
var.post_install_script != null ? [local.post_install_script_name] : [], description = "The name of the post-install script for sync."
var.start_script != null ? [local.start_script_name] : [], value = local.post_install_script_name
) }
output "start_script_name" {
description = "The name of the start script for sync."
value = local.start_script_name
} }

View File

@ -6,7 +6,8 @@ run "test_with_all_scripts" {
variables { variables {
agent_id = "test-agent-id" agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example" agent_name = "test-agent"
module_dir_name = ".test-module"
pre_install_script = "echo 'pre-install'" pre_install_script = "echo 'pre-install'"
install_script = "echo 'install'" install_script = "echo 'install'"
post_install_script = "echo 'post-install'" post_install_script = "echo 'post-install'"
@ -34,7 +35,7 @@ run "test_with_all_scripts" {
error_message = "Pre-install script should run on start" error_message = "Pre-install script should run on start"
} }
# Verify install_script is always created # Verify install_script is created
assert { assert {
condition = coder_script.install_script.agent_id == "test-agent-id" condition = coder_script.install_script.agent_id == "test-agent-id"
error_message = "Install script agent ID should match input" error_message = "Install script agent ID should match input"
@ -50,12 +51,6 @@ run "test_with_all_scripts" {
error_message = "Install script should run on start" error_message = "Install script should run on start"
} }
# install should sync-want pre_install
assert {
condition = can(regex("sync want test-example-install_script test-example-pre_install_script", coder_script.install_script.script))
error_message = "Install script should sync-want pre_install_script when pre_install is provided"
}
# Verify post_install_script is created when provided # Verify post_install_script is created when provided
assert { assert {
condition = length(coder_script.post_install_script) == 1 condition = length(coder_script.post_install_script) == 1
@ -77,115 +72,98 @@ run "test_with_all_scripts" {
error_message = "Post-install script should run on start" error_message = "Post-install script should run on start"
} }
# Verify start_script is created when provided # Verify start_script is created
assert { assert {
condition = length(coder_script.start_script) == 1 condition = coder_script.start_script.agent_id == "test-agent-id"
error_message = "Start script should be created when start_script is provided"
}
assert {
condition = coder_script.start_script[0].agent_id == "test-agent-id"
error_message = "Start script agent ID should match input" error_message = "Start script agent ID should match input"
} }
assert { assert {
condition = coder_script.start_script[0].display_name == "Start Script" condition = coder_script.start_script.display_name == "Start Script"
error_message = "Start script should have correct display name" error_message = "Start script should have correct display name"
} }
assert { assert {
condition = coder_script.start_script[0].run_on_start == true condition = coder_script.start_script.run_on_start == true
error_message = "Start script should run on start" error_message = "Start script should run on start"
} }
# Verify outputs for script names
assert {
condition = output.pre_install_script_name == "test-agent-pre_install_script"
error_message = "Pre-install script name output should be correctly formatted"
}
assert {
condition = output.install_script_name == "test-agent-install_script"
error_message = "Install script name output should be correctly formatted"
}
assert {
condition = output.post_install_script_name == "test-agent-post_install_script"
error_message = "Post-install script name output should be correctly formatted"
}
assert {
condition = output.start_script_name == "test-agent-start_script"
error_message = "Start script name output should be correctly formatted"
}
} }
run "test_invalid_module_directory" { # Test with only required scripts (no pre/post install)
run "test_without_optional_scripts" {
command = plan command = plan
variables { variables {
agent_id = "test-agent-id" agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test" agent_name = "test-agent"
install_script = "echo 'install'" module_dir_name = ".test-module"
install_script = "echo 'install'"
start_script = "echo 'start'"
} }
expect_failures = [ # Verify pre_install_script is NOT created when not provided
var.module_directory,
]
}
# Test with only install_script (minimum required input)
run "test_install_only" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
install_script = "echo 'install'"
}
# Verify optional scripts are NOT created
assert { assert {
condition = length(coder_script.pre_install_script) == 0 condition = length(coder_script.pre_install_script) == 0
error_message = "Pre-install script should not be created when not provided" error_message = "Pre-install script should not be created when pre_install_script is null"
} }
# Verify post_install_script is NOT created when not provided
assert { assert {
condition = length(coder_script.post_install_script) == 0 condition = length(coder_script.post_install_script) == 0
error_message = "Post-install script should not be created when not provided" error_message = "Post-install script should not be created when post_install_script is null"
}
assert {
condition = length(coder_script.start_script) == 0
error_message = "Start script should not be created when not provided"
}
# Verify install_script is created
assert {
condition = coder_script.install_script.agent_id == "test-agent-id"
error_message = "Install script should be created"
}
}
# Test with install and start scripts (no pre/post install)
run "test_install_and_start" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
install_script = "echo 'install'"
start_script = "echo 'start'"
}
assert {
condition = length(coder_script.pre_install_script) == 0
error_message = "Pre-install script should not be created when not provided"
}
assert {
condition = length(coder_script.post_install_script) == 0
error_message = "Post-install script should not be created when not provided"
} }
# Verify required scripts are still created
assert { assert {
condition = coder_script.install_script.agent_id == "test-agent-id" condition = coder_script.install_script.agent_id == "test-agent-id"
error_message = "Install script should be created" error_message = "Install script should be created"
} }
assert { assert {
condition = length(coder_script.start_script) == 1 condition = coder_script.start_script.agent_id == "test-agent-id"
error_message = "Start script should be created" error_message = "Start script should be created"
} }
# Verify outputs
assert { assert {
condition = coder_script.start_script[0].agent_id == "test-agent-id" condition = output.pre_install_script_name == "test-agent-pre_install_script"
error_message = "Start script agent ID should match input" error_message = "Pre-install script name output should be generated even when script is not created"
} }
# start should sync-want install (no post_install)
assert { assert {
condition = can(regex("sync want test-example-start_script test-example-install_script", coder_script.start_script[0].script)) condition = output.install_script_name == "test-agent-install_script"
error_message = "Start script should sync-want install_script" error_message = "Install script name output should be correctly formatted"
}
assert {
condition = output.post_install_script_name == "test-agent-post_install_script"
error_message = "Post-install script name output should be generated even when script is not created"
}
assert {
condition = output.start_script_name == "test-agent-start_script"
error_message = "Start script name output should be correctly formatted"
} }
} }
@ -194,12 +172,14 @@ run "test_with_mock_data" {
command = plan command = plan
variables { variables {
agent_id = "mock-agent" agent_id = "mock-agent"
module_directory = "$HOME/.coder-modules/test/mock" agent_name = "mock-agent"
install_script = "echo 'install'" module_dir_name = ".mock-module"
start_script = "echo 'start'" install_script = "echo 'install'"
start_script = "echo 'start'"
} }
# Mock the data sources for testing
override_data { override_data {
target = data.coder_workspace.me target = data.coder_workspace.me
values = { values = {
@ -232,397 +212,60 @@ run "test_with_mock_data" {
} }
} }
# Verify scripts are created with mocked data
assert { assert {
condition = coder_script.install_script.agent_id == "mock-agent" condition = coder_script.install_script.agent_id == "mock-agent"
error_message = "Install script should use the mocked agent ID" error_message = "Install script should use the mocked agent ID"
} }
assert { assert {
condition = coder_script.start_script[0].agent_id == "mock-agent" condition = coder_script.start_script.agent_id == "mock-agent"
error_message = "Start script should use the mocked agent ID" error_message = "Start script should use the mocked agent ID"
} }
} }
# Test sync naming derived from module_directory # Test script naming with custom agent_name
run "test_script_naming_from_module_directory" { run "test_script_naming" {
command = plan command = plan
variables { variables {
agent_id = "test-agent" agent_id = "test-agent"
module_directory = "$HOME/.coder-modules/custom/name" agent_name = "custom-name"
install_script = "echo 'install'" module_dir_name = ".test-module"
start_script = "echo 'start'" install_script = "echo 'install'"
start_script = "echo 'start'"
} }
# Verify script names are constructed correctly
# The script should contain references to custom-name-* in the sync commands
assert { assert {
condition = can(regex("custom-name-install_script", coder_script.install_script.script)) condition = can(regex("custom-name-install_script", coder_script.install_script.script))
error_message = "Install script should derive sync names from module_directory" error_message = "Install script should use custom agent_name in sync commands"
} }
assert { assert {
condition = can(regex("custom-name-start_script", coder_script.start_script[0].script)) condition = can(regex("custom-name-start_script", coder_script.start_script.script))
error_message = "Start script should derive sync names from module_directory" error_message = "Start script should use custom agent_name in sync commands"
} }
}
# Verify outputs use custom agent_name
# Test install syncs with pre_install when provided assert {
run "test_install_syncs_with_pre_install" { condition = output.pre_install_script_name == "custom-name-pre_install_script"
command = plan error_message = "Pre-install script name output should use custom agent_name"
}
variables {
agent_id = "test-agent-id" assert {
module_directory = "$HOME/.coder-modules/test/example" condition = output.install_script_name == "custom-name-install_script"
pre_install_script = "echo 'pre-install'" error_message = "Install script name output should use custom agent_name"
install_script = "echo 'install'" }
}
assert {
assert { condition = output.post_install_script_name == "custom-name-post_install_script"
condition = length(coder_script.pre_install_script) == 1 error_message = "Post-install script name output should use custom agent_name"
error_message = "Pre-install script should be created" }
}
assert {
assert { condition = output.start_script_name == "custom-name-start_script"
condition = can(regex("sync want test-example-install_script test-example-pre_install_script", coder_script.install_script.script)) error_message = "Start script name output should use custom agent_name"
error_message = "Install script should sync-want pre_install_script"
}
}
# Test start script sync deps with post_install present
run "test_start_syncs_with_post_install" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
install_script = "echo 'install'"
post_install_script = "echo 'post-install'"
start_script = "echo 'start'"
}
# start should sync-want both install and post_install
assert {
condition = can(regex("sync want test-example-start_script test-example-install_script test-example-post_install_script", coder_script.start_script[0].script))
error_message = "Start script should sync-want both install_script and post_install_script"
}
# post_install should sync-want install
assert {
condition = can(regex("sync want test-example-post_install_script test-example-install_script", coder_script.post_install_script[0].script))
error_message = "Post-install script should sync-want install_script"
}
}
# Verify display_name_prefix is prepended to every script's display_name
run "test_display_name_prefix_applied" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
display_name_prefix = "Claude Code"
pre_install_script = "echo 'pre-install'"
install_script = "echo 'install'"
post_install_script = "echo 'post-install'"
start_script = "echo 'start'"
}
assert {
condition = coder_script.pre_install_script[0].display_name == "Claude Code: Pre-Install Script"
error_message = "Pre-install script display_name should be prefixed"
}
assert {
condition = coder_script.install_script.display_name == "Claude Code: Install Script"
error_message = "Install script display_name should be prefixed"
}
assert {
condition = coder_script.post_install_script[0].display_name == "Claude Code: Post-Install Script"
error_message = "Post-install script display_name should be prefixed"
}
assert {
condition = coder_script.start_script[0].display_name == "Claude Code: Start Script"
error_message = "Start script display_name should be prefixed"
}
}
# Verify icon is propagated to every coder_script
run "test_icon_applied" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
icon = "/icon/claude.svg"
pre_install_script = "echo 'pre-install'"
install_script = "echo 'install'"
post_install_script = "echo 'post-install'"
start_script = "echo 'start'"
}
assert {
condition = coder_script.pre_install_script[0].icon == "/icon/claude.svg"
error_message = "Pre-install script icon should match input"
}
assert {
condition = coder_script.install_script.icon == "/icon/claude.svg"
error_message = "Install script icon should match input"
}
assert {
condition = coder_script.post_install_script[0].icon == "/icon/claude.svg"
error_message = "Post-install script icon should match input"
}
assert {
condition = coder_script.start_script[0].icon == "/icon/claude.svg"
error_message = "Start script icon should match input"
}
}
# Verify optional scripts are not created when their variables are unset
run "test_optional_scripts_absent_by_default" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
install_script = "echo install"
}
assert {
condition = length(coder_script.pre_install_script) == 0
error_message = "Pre-install coder_script should not be created when pre_install_script is unset"
}
assert {
condition = length(coder_script.post_install_script) == 0
error_message = "Post-install coder_script should not be created when post_install_script is unset"
}
assert {
condition = length(coder_script.start_script) == 0
error_message = "Start coder_script should not be created when start_script is unset"
}
}
# Verify `scripts` output is a filtered, run-order list
run "test_scripts_output_with_all" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
pre_install_script = "echo pre"
install_script = "echo install"
post_install_script = "echo post"
start_script = "echo start"
}
assert {
condition = length(output.scripts) == 4
error_message = "scripts should have 4 entries when every script is set"
}
assert {
condition = output.scripts[0] == "test-example-pre_install_script"
error_message = "scripts[0] must be the pre-install name"
}
assert {
condition = output.scripts[1] == "test-example-install_script"
error_message = "scripts[1] must be the install name"
}
assert {
condition = output.scripts[2] == "test-example-post_install_script"
error_message = "scripts[2] must be the post-install name"
}
assert {
condition = output.scripts[3] == "test-example-start_script"
error_message = "scripts[3] must be the start name"
}
}
run "test_scripts_output_with_install_only" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
install_script = "echo install"
}
assert {
condition = length(output.scripts) == 1
error_message = "scripts should have exactly 1 entry (install) when pre/post/start are unset"
}
assert {
condition = output.scripts[0] == "test-example-install_script"
error_message = "scripts[0] must be the install name"
}
}
run "test_scripts_output_with_install_and_post" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
install_script = "echo install"
post_install_script = "echo post"
}
assert {
condition = length(output.scripts) == 2
error_message = "scripts should have 2 entries (install, post)"
}
assert {
condition = output.scripts[0] == "test-example-install_script"
error_message = "scripts[0] must be the install name"
}
assert {
condition = output.scripts[1] == "test-example-post_install_script"
error_message = "scripts[1] must be the post-install name"
}
}
# Every script must stream combined stdout+stderr to both the agent log
# (via stdout) and the on-disk log file (via tee), so workspace users
# watching `coder_script` output in the UI see progress live and can
# read the same content from the log file after the fact.
run "test_scripts_tee_stdout_and_log_file" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
pre_install_script = "echo pre"
install_script = "echo install"
post_install_script = "echo post"
start_script = "echo start"
}
assert {
condition = can(regex("pre_install\\.sh 2>&1 \\| tee .*logs/pre_install\\.log", coder_script.pre_install_script[0].script))
error_message = "pre_install wrapper must tee combined output to the logs/ subdirectory"
}
assert {
condition = can(regex("install\\.sh 2>&1 \\| tee .*logs/install\\.log", coder_script.install_script.script))
error_message = "install wrapper must tee combined output to the logs/ subdirectory"
}
assert {
condition = can(regex("post_install\\.sh 2>&1 \\| tee .*logs/post_install\\.log", coder_script.post_install_script[0].script))
error_message = "post_install wrapper must tee combined output to the logs/ subdirectory"
}
assert {
condition = can(regex("start\\.sh 2>&1 \\| tee .*logs/start\\.log", coder_script.start_script[0].script))
error_message = "start wrapper must tee combined output to the logs/ subdirectory"
}
}
# Logs unconditionally land under ${module_directory}/logs/. Each script
# mkdirs that path before tee runs so the first script to execute creates it.
run "test_logs_nested_under_module_directory" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
pre_install_script = "echo pre"
install_script = "echo install"
post_install_script = "echo post"
start_script = "echo start"
}
assert {
condition = strcontains(coder_script.pre_install_script[0].script, "tee $HOME/.coder-modules/test/example/logs/pre_install.log")
error_message = "pre_install log must land under module_directory/logs"
}
assert {
condition = strcontains(coder_script.install_script.script, "tee $HOME/.coder-modules/test/example/logs/install.log")
error_message = "install log must land under module_directory/logs"
}
assert {
condition = strcontains(coder_script.post_install_script[0].script, "tee $HOME/.coder-modules/test/example/logs/post_install.log")
error_message = "post_install log must land under module_directory/logs"
}
assert {
condition = strcontains(coder_script.start_script[0].script, "tee $HOME/.coder-modules/test/example/logs/start.log")
error_message = "start log must land under module_directory/logs"
}
# Only pre_install and install mkdir the logs/ sub-path. post_install
# and start sync-depend on install so the directory already exists by
# the time they run.
assert {
condition = strcontains(coder_script.pre_install_script[0].script, "mkdir -p $HOME/.coder-modules/test/example/logs")
error_message = "pre_install script must mkdir -p the logs/ sub-path"
}
assert {
condition = strcontains(coder_script.install_script.script, "mkdir -p $HOME/.coder-modules/test/example/logs")
error_message = "install script must mkdir -p the logs/ sub-path"
}
}
# Scripts unconditionally land under ${module_directory}/scripts/. Each
# script that materializes its own `.sh` file mkdirs that path first; the
# first script to execute creates it for the rest.
run "test_scripts_nested_under_module_directory" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
pre_install_script = "echo pre"
install_script = "echo install"
post_install_script = "echo post"
start_script = "echo start"
}
assert {
condition = strcontains(coder_script.pre_install_script[0].script, "> $HOME/.coder-modules/test/example/scripts/pre_install.sh")
error_message = "pre_install script must be written under module_directory/scripts"
}
assert {
condition = strcontains(coder_script.install_script.script, "> $HOME/.coder-modules/test/example/scripts/install.sh")
error_message = "install script must be written under module_directory/scripts"
}
assert {
condition = strcontains(coder_script.post_install_script[0].script, "> $HOME/.coder-modules/test/example/scripts/post_install.sh")
error_message = "post_install script must be written under module_directory/scripts"
}
assert {
condition = strcontains(coder_script.start_script[0].script, "> $HOME/.coder-modules/test/example/scripts/start.sh")
error_message = "start script must be written under module_directory/scripts"
}
# Only pre_install and install mkdir the scripts/ sub-path. post_install
# and start sync-depend on install so the directory already exists by
# the time they run.
assert {
condition = strcontains(coder_script.pre_install_script[0].script, "mkdir -p $HOME/.coder-modules/test/example/scripts")
error_message = "pre_install script must mkdir -p the scripts/ sub-path"
}
assert {
condition = strcontains(coder_script.install_script.script, "mkdir -p $HOME/.coder-modules/test/example/scripts")
error_message = "install script must mkdir -p the scripts/ sub-path"
} }
} }

View File

@ -14,7 +14,7 @@ A file browser for your workspace.
module "filebrowser" { module "filebrowser" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder" source = "registry.coder.com/coder/filebrowser/coder"
version = "1.1.5" version = "1.1.4"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
} }
``` ```
@ -29,7 +29,7 @@ module "filebrowser" {
module "filebrowser" { module "filebrowser" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder" source = "registry.coder.com/coder/filebrowser/coder"
version = "1.1.5" version = "1.1.4"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
folder = "/home/coder/project" folder = "/home/coder/project"
} }
@ -41,7 +41,7 @@ module "filebrowser" {
module "filebrowser" { module "filebrowser" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder" source = "registry.coder.com/coder/filebrowser/coder"
version = "1.1.5" version = "1.1.4"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
database_path = ".config/filebrowser.db" database_path = ".config/filebrowser.db"
} }
@ -49,13 +49,11 @@ module "filebrowser" {
### Serve from the same domain (no subdomain) ### Serve from the same domain (no subdomain)
When `subdomain = false`, you must also set `agent_name` to the name of your `coder_agent` resource. Coder serves path-based apps at `/@<owner>/<workspace>.<agent>/apps/<slug>/`, so the agent name is required to build a base URL that matches the URL the user is actually browsing. If `agent_name` is omitted in this mode, `terraform apply` will fail with an explanatory error.
```tf ```tf
module "filebrowser" { module "filebrowser" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder" source = "registry.coder.com/coder/filebrowser/coder"
version = "1.1.5" version = "1.1.4"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
agent_name = "main" agent_name = "main"
subdomain = false subdomain = false

View File

@ -102,19 +102,4 @@ describe("filebrowser", async () => {
testBaseLine(output); testBaseLine(output);
}, 15000); }, 15000);
it("fails when subdomain=false and agent_name is not provided", async () => {
let caught: Error | undefined;
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
subdomain: false,
});
} catch (e) {
caught = e as Error;
}
expect(caught).toBeDefined();
expect(caught!.message).toContain("agent_name");
expect(caught!.message).toContain("subdomain");
}, 15000);
}); });

View File

@ -20,7 +20,7 @@ data "coder_workspace_owner" "me" {}
variable "agent_name" { variable "agent_name" {
type = string type = string
description = "The name of the coder_agent resource. Required when `subdomain` is `false` so the path-based base URL matches the URL Coder serves." description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)"
default = null default = null
} }
@ -102,13 +102,6 @@ resource "coder_script" "filebrowser" {
SERVER_BASE_PATH : local.server_base_path SERVER_BASE_PATH : local.server_base_path
}) })
run_on_start = true run_on_start = true
lifecycle {
precondition {
condition = var.subdomain || var.agent_name != null
error_message = "`agent_name` is required when `subdomain` is `false`. Coder always builds path-based app URLs as `/@<owner>/<workspace>.<agent>/apps/<slug>/`, so the filebrowser base URL must include the agent name to match. Set `agent_name = \"<your coder_agent name>\"` (e.g. `\"main\"`)."
}
}
} }
resource "coder_app" "filebrowser" { resource "coder_app" "filebrowser" {

View File

@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
} }
@ -28,7 +28,7 @@ module "git-clone" {
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
base_dir = "~/projects/coder" base_dir = "~/projects/coder"
@ -43,7 +43,7 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
} }
@ -70,7 +70,7 @@ data "coder_parameter" "git_repo" {
module "git_clone" { module "git_clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = data.coder_parameter.git_repo.value url = data.coder_parameter.git_repo.value
} }
@ -105,7 +105,7 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.example.com/coder/coder/tree/feat/example" url = "https://github.example.com/coder/coder/tree/feat/example"
git_providers = { git_providers = {
@ -125,7 +125,7 @@ To GitLab clone with a specific branch like `feat/example`
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://gitlab.com/coder/coder/-/tree/feat/example" url = "https://gitlab.com/coder/coder/-/tree/feat/example"
} }
@ -137,7 +137,7 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com`
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://gitlab.example.com/coder/coder/-/tree/feat/example" url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
git_providers = { git_providers = {
@ -159,7 +159,7 @@ For example, to clone the `feat/example` branch:
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
branch_name = "feat/example" branch_name = "feat/example"
@ -177,7 +177,7 @@ For example, this will clone into the `~/projects/coder/coder-dev` folder:
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
folder_name = "coder-dev" folder_name = "coder-dev"
@ -196,36 +196,13 @@ If not defined, the default, `0`, performs a full clone.
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
depth = 1 depth = 1
} }
``` ```
## Pre-clone script
Run a custom script before cloning the repository by setting the `pre_clone_script` variable.
This is useful for preparing the environment or validating prerequisites before cloning.
```tf
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
pre_clone_script = <<-EOT
#!/bin/bash
echo "Preparing to clone repository..."
# Check prerequisites
command -v npm >/dev/null 2>&1 || { echo "npm is required but not installed."; exit 1; }
# Set up environment
export NODE_ENV=development
EOT
}
```
## Post-clone script ## Post-clone script
Run a custom script after cloning the repository by setting the `post_clone_script` variable. Run a custom script after cloning the repository by setting the `post_clone_script` variable.
@ -235,7 +212,7 @@ This is useful for running initialization tasks like installing dependencies or
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
post_clone_script = <<-EOT post_clone_script = <<-EOT

View File

@ -250,59 +250,15 @@ describe("git-clone", async () => {
const state = await runTerraformApply(import.meta.dir, { const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo", agent_id: "foo",
url: "fake-url", url: "fake-url",
base_dir: "/tmp",
post_clone_script: "echo 'Post-clone script executed'", post_clone_script: "echo 'Post-clone script executed'",
}); });
const output = await executeScriptInContainer( const output = await executeScriptInContainer(
state, state,
"alpine/git", "alpine/git",
"sh", "sh",
"mkdir -p /tmp/fake-url && echo 'existing' > /tmp/fake-url/file.txt", "mkdir -p ~/fake-url && echo 'existing' > ~/fake-url/file.txt",
); );
expect(output.stdout).toContain("Running post-clone script..."); expect(output.stdout).toContain("Running post-clone script...");
expect(output.stdout).toContain("Post-clone script executed"); expect(output.stdout).toContain("Post-clone script executed");
}); });
it("runs pre-clone script", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
pre_clone_script: "echo 'Pre-clone script executed'",
});
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.stdout).toContain("Running pre-clone script...");
expect(output.stdout).toContain("Pre-clone script executed");
expect(output.stdout).toContain("Cloning fake-url to ~/fake-url...");
});
it("fails when pre-clone script fails", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
pre_clone_script: "echo 'Pre-clone script failed'; exit 42",
});
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.exitCode).toBe(42);
expect(output.stdout).toContain("Running pre-clone script...");
expect(output.stdout).toContain("Pre-clone script failed");
expect(output.stdout).not.toContain("Cloning fake-url to ~/fake-url...");
});
it("fails when post-clone script fails", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
base_dir: "/tmp",
post_clone_script: "echo 'Post-clone script failed'; exit 43",
});
const output = await executeScriptInContainer(
state,
"alpine/git",
"sh",
"mkdir -p /tmp/fake-url && echo 'existing' > /tmp/fake-url/file.txt",
);
expect(output.exitCode).toBe(43);
expect(output.stdout).toContain("Running post-clone script...");
expect(output.stdout).toContain("Post-clone script failed");
});
}); });

View File

@ -68,12 +68,6 @@ variable "post_clone_script" {
default = null default = null
} }
variable "pre_clone_script" {
description = "Custom script to run before cloning the repository. Runs before git clone, even if the repository already exists."
type = string
default = null
}
locals { locals {
# Remove query parameters and fragments from the URL # Remove query parameters and fragments from the URL
url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "") url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "")
@ -95,8 +89,6 @@ locals {
web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url
# Encode the post_clone_script for passing to the shell script # Encode the post_clone_script for passing to the shell script
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : "" encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
# Encode the pre_clone_script for passing to the shell script
encoded_pre_clone_script = var.pre_clone_script != null ? base64encode(var.pre_clone_script) : ""
} }
output "repo_dir" { output "repo_dir" {
@ -137,7 +129,6 @@ resource "coder_script" "git_clone" {
BRANCH_NAME : local.branch_name, BRANCH_NAME : local.branch_name,
DEPTH = var.depth, DEPTH = var.depth,
POST_CLONE_SCRIPT : local.encoded_post_clone_script, POST_CLONE_SCRIPT : local.encoded_post_clone_script,
PRE_CLONE_SCRIPT : local.encoded_pre_clone_script,
}) })
display_name = "Git Clone" display_name = "Git Clone"
icon = "/icon/git.svg" icon = "/icon/git.svg"

View File

@ -1,7 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail
REPO_URL="${REPO_URL}" REPO_URL="${REPO_URL}"
CLONE_PATH="${CLONE_PATH}" CLONE_PATH="${CLONE_PATH}"
BRANCH_NAME="${BRANCH_NAME}" BRANCH_NAME="${BRANCH_NAME}"
@ -9,7 +7,6 @@ BRANCH_NAME="${BRANCH_NAME}"
CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}" CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}"
DEPTH="${DEPTH}" DEPTH="${DEPTH}"
POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}" POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}"
PRE_CLONE_SCRIPT="${PRE_CLONE_SCRIPT}"
# Check if the variable is empty... # Check if the variable is empty...
if [ -z "$REPO_URL" ]; then if [ -z "$REPO_URL" ]; then
@ -36,16 +33,6 @@ if [ ! -d "$CLONE_PATH" ]; then
mkdir -p "$CLONE_PATH" mkdir -p "$CLONE_PATH"
fi fi
# Run pre-clone script if provided
if [ -n "$PRE_CLONE_SCRIPT" ]; then
echo "Running pre-clone script..."
PRE_CLONE_TMP=$(mktemp)
echo "$PRE_CLONE_SCRIPT" | base64 -d > "$PRE_CLONE_TMP"
chmod +x "$PRE_CLONE_TMP"
$PRE_CLONE_TMP
rm "$PRE_CLONE_TMP"
fi
# Check if the directory is empty # Check if the directory is empty
# and if it is, clone the repo, otherwise skip cloning # and if it is, clone the repo, otherwise skip cloning
if [ -z "$(ls -A "$CLONE_PATH")" ]; then if [ -z "$(ls -A "$CLONE_PATH")" ]; then