Compare commits

..

18 Commits

Author SHA1 Message Date
Atif Ali bb128fa077 docs(codex): clarify standalone and AI Bridge usage 2026-02-04 12:45:55 +00:00
Atif Ali 7c5f9b2adc style(codex): remove trailing whitespace in test file 2026-02-04 12:39:29 +00:00
Atif Ali 0a92c5c18f fix(codex): update tests to work with conditional agentapi module
- Only set install_agentapi when explicitly installing real AgentAPI
- Let enable_tasks (defaults to true) control agentapi module in tests
- This fixes tests that were inadvertently triggering standalone mode

All 18 tests now pass 
2026-02-04 11:58:07 +00:00
Atif Ali a443767ef3 chore(codex): apply bun fmt to README 2026-02-04 11:43:32 +00:00
Atif Ali 316269e437 fix(codex): only remove top-level profile key, preserve profiles sections
Use awk to track when we enter a TOML section and only remove 'profile ='
lines that appear at the top level (before any [section] headers). This
ensures user-provided [profiles.*] sections are preserved intact.
2026-02-04 10:32:21 +00:00
Atif Ali 9af62366b7 fix(codex): P2 - override profile when enable_aibridge=true
The previous fix would skip setting profile when one already existed,
breaking enable_aibridge=true with custom base configs. Now when
enable_aibridge=true, we explicitly remove any existing profile line
and set profile="aibridge" (since enable_aibridge=true is an explicit
user intention to use AI Bridge).
2026-02-04 10:29:54 +00:00
Atif Ali 46726a903d style(codex): terraform fmt 2026-02-04 07:24:09 +00:00
Atif Ali 8be5d5e01c fix(codex): address review comments
- P1: Make agentapi module conditional when enable_tasks=false
  - Add standalone coder_script for install when tasks disabled
  - Prevents AgentAPI requirement errors in standalone mode
- P2: Guard against duplicate profile key in config.toml
  - Check if profile already exists before prepending
  - Prevents TOML parsing errors with custom base configs
2026-02-04 07:23:56 +00:00
Atif Ali fa5fb31454 chore(codex): add TODO for removing deprecated install_agentapi in 5.0.0 2026-02-04 07:20:22 +00:00
Atif Ali 52603754cd docs(codex): simplify AI Bridge section in README 2026-02-04 07:19:19 +00:00
Atif Ali e18caa5a46 feat(codex): optimize for standalone usage with optional workdir and default AI Bridge profile
- Add 'enable_tasks' variable (defaults to true) for better discoverability
- Make 'workdir' optional when enable_tasks=false for standalone CLI usage
- Keep 'install_agentapi' as deprecated alias for backward compatibility
- Prepend 'profile = "aibridge"' to config.toml when AI Bridge is enabled
  so users can run 'codex' directly without --profile flag
- Update README with standalone usage examples and version 4.2.0
- Add tests for aibridge profile at config top and standalone mode
2026-02-04 07:12:00 +00:00
Harsh Singh Panwar 49a7985bc6 fix(coder/modules/jupyterlab): fix a typo (#689)
Closes https://github.com/coder/registry/issues/685

---------

Co-authored-by: Atif Ali <atif@coder.com>
Co-authored-by: Muhammad Atif Ali <me@matifali.dev>
2026-02-04 09:10:27 +05:00
Andreas Skorczyk 08e68a2da4 Don't create CLAUDE_API_KEY coder_env if not set (#686)
## Description

At the moment, the `CLAUDE_API_KEY` coder_env will always be created,
even if the variable itself is not. This can lead to the environment
variable being unset if it has been set outside of Terraform.

With this PR, we make the `claude_api_key` coder_env conditional, so it
will only be created if an API key has been set.

## Type of Change

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

## Module Information

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

**Path:** `registry/coder/modules/claude-code/main.tf`  
**New version:** `v4.7.4`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

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

## Related Issues

None

---------

Co-authored-by: 35C4n0r <70096901+35C4n0r@users.noreply.github.com>
2026-02-04 08:10:16 +05:30
Atif Ali 66662db5aa fix(claude-code): fix example for using AI Bridge (#691)
Co-authored-by: 35C4n0r <70096901+35C4n0r@users.noreply.github.com>
2026-02-02 16:02:06 +00:00
35C4n0r e25a972d7d fix(workflows/version-bump.yaml): fix typo in case statement (#687)
## Description
- Fix typo in version bump workflow

## Type of Change

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

## Testing & Validation

- [x] Tests pass (`bun test`)
- [x] Code formatted (`bun fmt`)
- [x] Changes tested locally
2026-02-02 15:33:56 +00:00
35C4n0r a10d5fa6a0 fix(coder/modules/claude-code): update terraform required version to >= 1.9 (#688)
## Description

- Update terraform version for claude-code module.
- Update coder version required in readme

## Type of Change

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

## Module Information

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

**Path:** `registry/coder/modules/claude-code`  
**New version:** `v4.7.2`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->
2026-01-31 09:44:41 +05:30
35C4n0r 360b3cd3ce feat(coder-labs/modules/codex): add support for aibridge (#655)
## Description
- Add support for AI Bridge

<!-- Briefly describe what this PR does and why -->

## Type of Change

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

## Module Information

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

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

## Testing & Validation

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

## Related Issues
Closes: #650

---------

Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: Atif Ali <atif@coder.com>
2026-01-31 08:41:19 +05:30
Mathias Fredriksson fa30191394 feat(coder/modules/agentapi): add log snapshot capture on shutdown (#676)
Captures the last 10 messages from AgentAPI when task workspaces stop,
allowing users to view conversation history while the task is paused.

The shutdown script fetches messages, builds a payload with last 10
messages, truncates to 64KB if needed (removes old messages first, then
truncates content of the last message), and posts to the log snapshot
endpoint.

Gracefully handles non-task workspaces (skips), older Coder versions
without the endpoint (logs and continues), and empty message sets.

Enabled by default via task_log_snapshot variable. Task ID is
automatically resolved from data.coder_task when available.

Updates coder/internal#1257
2026-01-30 09:31:04 +02:00
19 changed files with 933 additions and 104 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
LABEL_NAME: ${{ github.event.label.name }}
id: bump-type
run: |
case "$LABEL_NAME" in in
case "$LABEL_NAME" in
"version:patch")
echo "type=patch" >> $GITHUB_OUTPUT
;;
+98 -50
View File
@@ -1,19 +1,19 @@
---
display_name: Codex CLI
icon: ../../../../.icons/openai.svg
description: Run Codex CLI in your workspace with AgentAPI integration
description: Run Codex CLI in your workspace with optional Tasks integration
verified: true
tags: [agent, codex, ai, openai, tasks]
tags: [agent, codex, ai, openai, tasks, aibridge]
---
# Codex CLI
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.
Install Codex CLI in your workspace with optional Coder Tasks integration via [AgentAPI](https://github.com/coder/agentapi). The module supports AI Bridge, custom install scripts, and MCP server configuration.
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.0.0"
version = "4.2.0"
agent_id = coder_agent.example.id
openai_api_key = var.openai_api_key
workdir = "/home/coder/project"
@@ -22,25 +22,69 @@ module "codex" {
## Prerequisites
- OpenAI API key for Codex access
- OpenAI API key for Codex access (not required when `enable_aibridge = true`)
## Examples
### Run standalone
### Standalone (no Tasks UI)
Use `enable_tasks = false` to install Codex without AgentAPI/Tasks. `workdir` is optional in this mode.
```tf
module "codex" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.0.0"
version = "4.2.0"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
report_tasks = false
enable_tasks = false
# workdir not required in standalone mode
}
```
### Tasks integration
### 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. Set `enable_aibridge = true` to use it (requires Coder 2.30+). When AI Bridge is enabled, authentication uses the workspace owner session token, so `openai_api_key` should be omitted.
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.2.0"
agent_id = coder_agent.example.id
enable_aibridge = true
enable_tasks = false # Standalone mode - just CLI, no Tasks UI
# workdir not required in standalone mode
}
```
For Tasks integration, add `enable_aibridge = true` to the [Usage with Tasks](#usage-with-tasks) example below.
When `enable_aibridge = true`, the module:
- Configures Codex to use the AI Bridge profile with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token
- Sets `profile = "aibridge"` at the top of `config.toml` so Codex uses AI Bridge by default
```toml
profile = "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"
[profiles.aibridge]
model_provider = "aibridge"
model = "<model>" # as configured in the module input
model_reasoning_effort = "<model_reasoning_effort>" # as configured in the module input
```
Codex uses the AI Bridge profile by default, so running `codex` manually does not require `--profile aibridge`.
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" {
@@ -52,55 +96,31 @@ data "coder_task" "me" {}
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.0.0"
version = "4.2.0"
agent_id = coder_agent.example.id
openai_api_key = "..."
ai_prompt = data.coder_task.me.prompt
workdir = "/home/coder/project"
# Custom configuration for full auto mode
base_config_toml = <<-EOT
approval_policy = "never"
preferred_auth_method = "apikey"
EOT
# Optional: route through AI Bridge (Premium feature)
# enable_aibridge = true
}
```
> [!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.
### Advanced Configuration
## 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.
## Configuration
### 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
```
### Custom Configuration
For custom Codex configuration, use `base_config_toml` and/or `additional_mcp_servers`:
This example shows additional configuration options for custom models, MCP servers, and base configuration.
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.0.0"
# ... other variables ...
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.2.0"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
codex_version = "0.1.0" # Pin to a specific version
codex_model = "gpt-4o" # Custom model
# Override default configuration
base_config_toml = <<-EOT
@@ -119,21 +139,49 @@ module "codex" {
}
```
> [!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.
## How it Works
- **Install**: Installs Codex CLI and prepares configuration.
- **System Prompt**: If `codex_system_prompt` is set, writes it to `~/.codex/AGENTS.md`.
- **Start**: When `enable_tasks = true`, launches Codex via AgentAPI in the selected `workdir`. When `enable_tasks = false`, only the install script runs.
- **Configuration**: Writes `OPENAI_API_KEY` when provided, and sets the AI Bridge profile when `enable_aibridge = true`.
- **Session Continuity**: When `continue = true` (default), task sessions are tracked in `~/.codex-module/.codex-task-session` for resume on restart. Set `continue = false` to always start fresh sessions.
## Configuration
### 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
- Check installation and startup logs in `~/.codex-module/`
- Ensure your OpenAI API key has access to the specified model
- Tasks mode: check installation/startup logs in `~/.codex-module/`.
- Standalone mode: review the workspace script output for the "Install Codex" script.
- Ensure your OpenAI API key has access to the specified model (unless using AI Bridge).
> [!IMPORTANT]
> 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).
> The module automatically configures Codex with your API key and model preferences.
> workdir is a required variable for the module to function correctly.
> `workdir` is required when `enable_tasks = true` (default). For standalone CLI usage, set `enable_tasks = false` and `workdir` becomes optional.
## References
- [Codex CLI Documentation](https://github.com/openai/codex)
- [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)
+72 -11
View File
@@ -41,15 +41,25 @@ interface SetupProps {
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
const projectDir = "/home/coder/project";
const moduleVars: Record<string, string> = {
install_codex: props?.skipCodexMock ? "true" : "false",
codex_model: "gpt-4-turbo",
workdir: "/home/coder",
...props?.moduleVariables,
};
// For backward compatibility: install_agentapi takes precedence over enable_tasks
// Only set install_agentapi when explicitly installing real AgentAPI
if (props?.skipAgentAPIMock) {
moduleVars.install_agentapi = "true";
}
// Otherwise, let enable_tasks control whether agentapi module runs
// (defaults to true unless explicitly disabled in moduleVariables)
const { id } = await setupUtil({
moduleDir: import.meta.dir,
moduleVariables: {
install_codex: props?.skipCodexMock ? "true" : "false",
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
codex_model: "gpt-4-turbo",
workdir: "/home/coder",
...props?.moduleVariables,
},
moduleVariables: moduleVars,
registerCleanup,
projectDir,
skipAgentAPIMock: props?.skipAgentAPIMock,
@@ -113,7 +123,7 @@ describe("codex", async () => {
sandbox_mode = "danger-full-access"
approval_policy = "never"
preferred_auth_method = "apikey"
[custom_section]
new_feature = true
`.trim();
@@ -189,7 +199,7 @@ describe("codex", async () => {
args = ["-y", "@modelcontextprotocol/server-github"]
type = "stdio"
description = "GitHub integration"
[mcp_servers.FileSystem]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
@@ -215,7 +225,7 @@ describe("codex", async () => {
approval_policy = "untrusted"
preferred_auth_method = "chatgpt"
custom_setting = "test-value"
[advanced_settings]
timeout = 30000
debug = true
@@ -228,7 +238,7 @@ describe("codex", async () => {
args = ["--serve", "--port", "8080"]
type = "stdio"
description = "Custom development tool"
[mcp_servers.DatabaseMCP]
command = "python"
args = ["-m", "database_mcp_server"]
@@ -454,4 +464,55 @@ describe("codex", async () => {
);
expect(startLog.stdout).not.toContain("test prompt");
});
test("codex-with-aibridge", async () => {
const { id } = await setup({
moduleVariables: {
enable_aibridge: "true",
model_reasoning_effort: "none",
},
});
await execModuleScript(id);
const startLog = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
expect(startLog).toContain("AI Bridge is enabled, using profile aibridge");
expect(startLog).toContain(
"Starting Codex with arguments: --profile aibridge",
);
expect(configToml).toContain(
"[profiles.aibridge]\n" + 'model_provider = "aibridge"',
);
// Verify profile = "aibridge" is set at the top of the config
expect(configToml.startsWith('profile = "aibridge"')).toBe(true);
});
test("codex-standalone-mode", async () => {
// Test standalone mode without tasks (enable_tasks = false)
// workdir should default to /home/coder when not explicitly provided
const { id } = await setup({
moduleVariables: {
enable_tasks: "false",
enable_aibridge: "true",
},
});
await execModuleScript(id);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
// In standalone mode, config should still have aibridge profile set as default
expect(configToml.startsWith('profile = "aibridge"')).toBe(true);
expect(configToml).toContain("[profiles.aibridge]");
});
});
+100 -10
View File
@@ -1,5 +1,5 @@
terraform {
required_version = ">= 1.0"
required_version = ">= 1.9"
required_providers {
coder = {
@@ -38,7 +38,19 @@ variable "icon" {
variable "workdir" {
type = string
description = "The folder to run Codex in."
description = "The folder to run Codex in. Required when enable_tasks is true."
default = null
validation {
condition = var.workdir != null || !local.tasks_enabled
error_message = "workdir is required when enable_tasks is true. Set workdir or set enable_tasks = false for standalone CLI usage."
}
}
variable "enable_tasks" {
type = bool
description = "Enable Tasks UI for Codex (requires workdir). When false, only installs Codex CLI with config for standalone usage."
default = true
}
variable "report_tasks" {
@@ -71,6 +83,27 @@ variable "cli_app_display_name" {
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 AI Bridge model. One of: none, low, medium, high. https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort"
default = "medium"
validation {
condition = contains(["none", "low", "medium", "high"], 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."
@@ -101,10 +134,11 @@ variable "openai_api_key" {
default = ""
}
# TODO: Remove install_agentapi in next major version (5.0.0)
variable "install_agentapi" {
type = bool
description = "Whether to install AgentAPI."
default = true
description = "DEPRECATED: Use enable_tasks instead. Whether to install AgentAPI."
default = null
}
variable "agentapi_version" {
@@ -115,8 +149,8 @@ variable "agentapi_version" {
variable "codex_model" {
type = string
description = "The model for Codex to use. Defaults to gpt-5.1-codex-max."
default = ""
description = "The model for Codex to use. Defaults to gpt-5.2-codex."
default = "gpt-5.2-codex"
}
variable "pre_install_script" {
@@ -155,15 +189,37 @@ resource "coder_env" "openai_api_key" {
value = var.openai_api_key
}
resource "coder_env" "coder_aibridge_session_token" {
count = var.enable_aibridge ? 1 : 0
agent_id = var.agent_id
name = "CODER_AIBRIDGE_SESSION_TOKEN"
value = data.coder_workspace_owner.me.session_token
}
locals {
workdir = trimsuffix(var.workdir, "/")
# Use enable_tasks, but fall back to install_agentapi if explicitly set (for backward compat)
tasks_enabled = var.install_agentapi != null ? var.install_agentapi : var.enable_tasks
workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "/home/coder"
app_slug = "codex"
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".codex-module"
aibridge_config = <<-EOF
[model_providers.aibridge]
name = "AI Bridge"
base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses"
[profiles.aibridge]
model_provider = "aibridge"
model = "${var.codex_model}"
model_reasoning_effort = "${var.model_reasoning_effort}"
EOF
}
module "agentapi" {
count = local.tasks_enabled ? 1 : 0
source = "registry.coder.com/coder/agentapi/coder"
version = "2.0.0"
@@ -178,7 +234,7 @@ module "agentapi" {
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
install_agentapi = true
agentapi_subdomain = var.subdomain
agentapi_version = var.agentapi_version
pre_install_script = var.pre_install_script
@@ -196,6 +252,7 @@ module "agentapi" {
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
@@ -211,6 +268,8 @@ module "agentapi" {
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}' \
@@ -219,6 +278,37 @@ module "agentapi" {
EOT
}
output "task_app_id" {
value = module.agentapi.task_app_id
# Standalone installation (when tasks are disabled)
resource "coder_script" "standalone_install" {
count = local.tasks_enabled ? 0 : 1
agent_id = var.agent_id
display_name = "Install Codex"
icon = var.icon
run_on_start = true
start_blocks_login = false
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='false' \
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='' \
ARG_CODEX_START_DIRECTORY='${local.workdir}' \
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
/tmp/install.sh
EOT
}
output "task_app_id" {
value = local.tasks_enabled ? module.agentapi[0].task_app_id : null
}
@@ -13,6 +13,8 @@ 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"
@@ -24,6 +26,7 @@ printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && ech
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
@@ -127,6 +130,15 @@ EOF
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")"
@@ -139,7 +151,31 @@ function populate_config_toml() {
write_minimal_default_config "$CONFIG_PATH"
fi
# Set aibridge as default profile when AI Bridge is enabled
# This allows users to run `codex` without --profile flag
if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then
printf "Setting aibridge as default profile\n"
# Remove any existing top-level profile line (before first section header)
# This only removes profile = ... at top level, not inside [profiles.*] sections
awk '
BEGIN { in_top_level = 1 }
/^\[/ { in_top_level = 0 }
in_top_level && /^profile[ \t]*=/ { next }
{ print }
' "$CONFIG_PATH" > "${CONFIG_PATH}.tmp"
mv "${CONFIG_PATH}.tmp" "$CONFIG_PATH"
# Prepend profile = "aibridge" to the config
local temp_config
temp_config=$(cat "$CONFIG_PATH")
echo -e "profile = \"aibridge\"\n\n$temp_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() {
@@ -185,4 +221,7 @@ install_codex
codex --version
populate_config_toml
add_instruction_prompt_if_exists
add_auth_json
if [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then
add_auth_json
fi
@@ -18,6 +18,7 @@ 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")"
@@ -26,6 +27,7 @@ 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
@@ -153,7 +155,10 @@ setup_workdir() {
build_codex_args() {
CODEX_ARGS=()
if [ -n "$ARG_CODEX_MODEL" ]; then
if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then
printf "AI Bridge is enabled, using profile aibridge\n"
CODEX_ARGS+=("--profile" "aibridge")
elif [ -n "$ARG_CODEX_MODEL" ]; then
CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
fi
+14 -1
View File
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
```tf
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.0.0"
version = "2.1.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
@@ -49,6 +49,19 @@ module "agentapi" {
}
```
## Task log snapshot
Captures the last 10 messages from AgentAPI when a task workspace stops. This allows viewing conversation history while the task is paused.
To enable for task workspaces:
```tf
module "agentapi" {
# ... other config
task_log_snapshot = true # default: true
}
```
## For module developers
For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
@@ -257,4 +257,157 @@ describe("agentapi", async () => {
);
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
});
describe("shutdown script", async () => {
const setupMocks = async (
containerId: string,
agentapiPreset: string,
httpCode: number = 204,
) => {
const agentapiMock = await loadTestFile(
import.meta.dir,
"agentapi-mock-shutdown.js",
);
const coderMock = await loadTestFile(
import.meta.dir,
"coder-instance-mock.js",
);
await writeExecutable({
containerId,
filePath: "/usr/local/bin/mock-agentapi",
content: agentapiMock,
});
await writeExecutable({
containerId,
filePath: "/usr/local/bin/mock-coder",
content: coderMock,
});
await execContainer(containerId, [
"bash",
"-c",
`PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
]);
await execContainer(containerId, [
"bash",
"-c",
`HTTP_CODE=${httpCode} nohup node /usr/local/bin/mock-coder 18080 > /tmp/mock-coder.log 2>&1 &`,
]);
await new Promise((resolve) => setTimeout(resolve, 1000));
};
const runShutdownScript = async (
containerId: string,
taskId: string = "test-task",
) => {
const shutdownScript = await loadTestFile(
import.meta.dir,
"../scripts/agentapi-shutdown.sh",
);
await writeExecutable({
containerId,
filePath: "/tmp/shutdown.sh",
content: shutdownScript,
});
return await execContainer(containerId, [
"bash",
"-c",
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
]);
};
test("posts snapshot with normal messages", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "normal");
const result = await runShutdownScript(id);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Retrieved 5 messages for log snapshot");
expect(result.stdout).toContain("Log snapshot posted successfully");
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
expect(snapshot.task_id).toBe("test-task");
expect(snapshot.payload.messages).toHaveLength(5);
expect(snapshot.payload.messages[0].content).toBe("Hello");
expect(snapshot.payload.messages[4].content).toBe("Great");
});
test("truncates to last 10 messages", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "many");
const result = await runShutdownScript(id);
expect(result.exitCode).toBe(0);
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
expect(snapshot.task_id).toBe("test-task");
expect(snapshot.payload.messages).toHaveLength(10);
expect(snapshot.payload.messages[0].content).toBe("Message 6");
expect(snapshot.payload.messages[9].content).toBe("Message 15");
});
test("truncates huge message content", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "huge");
const result = await runShutdownScript(id);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("truncating final message content");
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
expect(snapshot.task_id).toBe("test-task");
expect(snapshot.payload.messages).toHaveLength(1);
expect(snapshot.payload.messages[0].content).toContain(
"[...content truncated",
);
});
test("skips gracefully when TASK_ID is empty", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const result = await runShutdownScript(id, "");
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("No task ID, skipping log snapshot");
});
test("handles 404 gracefully for older Coder versions", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "normal", 404);
const result = await runShutdownScript(id);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"Log snapshot endpoint not supported by this Coder version",
);
});
});
});
+31 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.12"
version = ">= 2.13"
}
}
}
@@ -18,6 +18,8 @@ data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_task" "me" {}
variable "web_app_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)."
@@ -126,6 +128,12 @@ variable "agentapi_port" {
default = 3284
}
variable "task_log_snapshot" {
type = bool
description = "Capture last 10 messages when workspace stops for offline viewing while task is paused."
default = true
}
locals {
# agentapi_subdomain_false_min_version_expr matches a semantic version >= v0.3.3.
# Initial support was added in v0.3.1 but configuration via environment variable
@@ -173,6 +181,7 @@ locals {
// for backward compatibility.
agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat"
main_script = file("${path.module}/scripts/main.sh")
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
}
resource "coder_script" "agentapi" {
@@ -198,11 +207,32 @@ resource "coder_script" "agentapi" {
ARG_POST_INSTALL_SCRIPT="$(echo -n '${local.encoded_post_install_script}' | base64 -d)" \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
/tmp/main.sh
EOT
run_on_start = true
}
resource "coder_script" "agentapi_shutdown" {
agent_id = var.agent_id
display_name = "AgentAPI Shutdown"
icon = var.web_app_icon
run_on_stop = true
script = <<-EOT
#!/bin/bash
set -o pipefail
echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh
chmod +x /tmp/agentapi-shutdown.sh
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
/tmp/agentapi-shutdown.sh
EOT
}
resource "coder_app" "agentapi_web" {
slug = var.web_app_slug
display_name = var.web_app_display_name
@@ -0,0 +1,212 @@
#!/usr/bin/env bash
# AgentAPI shutdown script.
#
# Captures the last 10 messages from AgentAPI and posts them to Coder instance
# as a snapshot. This script is called during workspace shutdown to access
# conversation history for paused tasks.
set -euo pipefail
# Configuration (set via Terraform interpolation).
readonly TASK_ID="${ARG_TASK_ID:-}"
readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}"
# Runtime environment variables.
readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}"
readonly CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN:-}"
# Constants.
readonly MAX_PAYLOAD_SIZE=65536 # 64KB
readonly MAX_MESSAGE_CONTENT=57344 # 56KB
readonly MAX_MESSAGES=10
readonly FETCH_TIMEOUT=5
readonly POST_TIMEOUT=10
log() {
echo "$*"
}
error() {
echo "Error: $*" >&2
}
fetch_and_build_messages_payload() {
local payload_file="$1"
local messages_url="http://localhost:${AGENTAPI_PORT}/messages"
log "Fetching messages from AgentAPI on port $AGENTAPI_PORT"
if ! curl -fsSL --max-time "$FETCH_TIMEOUT" "$messages_url" > "$payload_file"; then
error "Failed to fetch messages from AgentAPI (may not be running)"
return 1
fi
# Update messages field to keep only last N messages.
if ! jq --argjson n "$MAX_MESSAGES" '.messages |= .[-$n:]' < "$payload_file" > "${payload_file}.tmp"; then
error "Failed to select last $MAX_MESSAGES messages"
return 1
fi
mv "${payload_file}.tmp" "$payload_file"
return 0
}
truncate_messages_payload_to_size() {
local payload_file="$1"
local max_size="$2"
while true; do
local size
size=$(wc -c < "$payload_file")
if ((size <= max_size)); then
break
fi
local count
count=$(jq '.messages | length' < "$payload_file")
if ((count == 1)); then
# Down to last message, truncate its content keeping the tail.
log "Payload size $size bytes exceeds limit, truncating final message content"
# Keep tail of content with truncation indicator, leaving room for JSON
# overhead.
if ! jq --argjson maxlen "$MAX_MESSAGE_CONTENT" '.messages[0].content |= (if length > $maxlen then "[...content truncated, showing last 56KB...]\n\n" + .[-$maxlen:] else . end)' < "$payload_file" > "${payload_file}.tmp"; then
error "Failed to truncate message content"
return 1
fi
mv "${payload_file}.tmp" "$payload_file"
# Verify the truncation was sufficient.
size=$(wc -c < "$payload_file")
if ((size > max_size)); then
error "Payload still too large after content truncation, giving up"
return 1
fi
break
else
# More than one message, remove the oldest.
log "Payload size $size bytes exceeds limit, removing oldest message"
if ! jq '.messages |= .[1:]' < "$payload_file" > "${payload_file}.tmp"; then
error "Failed to remove oldest message"
return 1
fi
mv "${payload_file}.tmp" "$payload_file"
fi
done
return 0
}
post_task_log_snapshot() {
local payload_file="$1"
local tmpdir="$2"
local snapshot_url="${CODER_AGENT_URL}/api/v2/workspaceagents/me/tasks/${TASK_ID}/log-snapshot?format=agentapi"
local response_file="${tmpdir}/response.txt"
log "Posting log snapshot to Coder instance"
local http_code
if ! http_code=$(curl -sS -w "%{http_code}" -o "$response_file" \
--max-time "$POST_TIMEOUT" \
-X POST "$snapshot_url" \
-H "Coder-Session-Token: $CODER_AGENT_TOKEN" \
-H "Content-Type: application/json" \
--data-binary "@$payload_file"); then
error "Failed to connect to Coder instance (curl failed)"
return 1
fi
if [[ $http_code == 204 ]]; then
log "Log snapshot posted successfully"
return 0
elif [[ $http_code == 404 ]]; then
log "Log snapshot endpoint not supported by this Coder version, skipping"
return 0
else
local response
response=$(cat "$response_file" 2> /dev/null || echo "")
error "Failed to post log snapshot (HTTP $http_code): $response"
return 1
fi
}
capture_task_log_snapshot() {
if [[ -z $TASK_ID ]]; then
log "No task ID, skipping log snapshot"
exit 0
fi
if [[ -z $CODER_AGENT_URL ]]; then
error "CODER_AGENT_URL not set, cannot capture log snapshot"
exit 1
fi
if [[ -z $CODER_AGENT_TOKEN ]]; then
error "CODER_AGENT_TOKEN not set, cannot capture log snapshot"
exit 1
fi
if ! command -v jq > /dev/null 2>&1; then
error "jq not found, cannot capture log snapshot"
exit 1
fi
if ! command -v curl > /dev/null 2>&1; then
error "curl not found, cannot capture log snapshot"
exit 1
fi
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
local payload_file="${tmpdir}/payload.json"
if ! fetch_and_build_messages_payload "$payload_file"; then
error "Cannot capture log snapshot without messages"
exit 1
fi
local message_count
message_count=$(jq '.messages | length' < "$payload_file")
if ((message_count == 0)); then
log "No messages for log snapshot"
exit 0
fi
log "Retrieved $message_count messages for log snapshot"
# Ensure payload fits within size limit.
if ! truncate_messages_payload_to_size "$payload_file" "$MAX_PAYLOAD_SIZE"; then
error "Failed to truncate payload to size limit"
exit 1
fi
local final_size final_count
final_size=$(wc -c < "$payload_file")
final_count=$(jq '.messages | length' < "$payload_file")
log "Log snapshot payload: $final_size bytes, $final_count messages"
if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then
error "Log snapshot capture failed"
exit 1
fi
}
main() {
log "Shutting down AgentAPI"
if [[ $TASK_LOG_SNAPSHOT == true ]]; then
capture_task_log_snapshot
else
log "Log snapshot disabled, skipping"
fi
log "Shutdown complete"
}
main "$@"
@@ -14,6 +14,8 @@ WAIT_FOR_START_SCRIPT="$ARG_WAIT_FOR_START_SCRIPT"
POST_INSTALL_SCRIPT="$ARG_POST_INSTALL_SCRIPT"
AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
TASK_ID="${ARG_TASK_ID:-}"
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
set +o nounset
command_exists() {
@@ -23,6 +25,13 @@ command_exists() {
module_path="$HOME/${MODULE_DIR_NAME}"
mkdir -p "$module_path/scripts"
# Check for jq dependency if task log snapshot is enabled.
if [[ $TASK_LOG_SNAPSHOT == true ]] && [[ -n $TASK_ID ]]; then
if ! command_exists jq; then
echo "Warning: jq is not installed. Task log snapshot requires jq to capture conversation history."
echo "Install jq to enable log snapshot functionality when the workspace stops."
fi
fi
if [ ! -d "${WORKDIR}" ]; then
echo "Warning: The specified folder '${WORKDIR}' does not exist."
echo "Creating the folder..."
@@ -0,0 +1,84 @@
#!/usr/bin/env node
// Mock AgentAPI server for shutdown script tests.
// Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port]
const http = require("http");
const port = process.argv[2] || 3284;
// Parse messages from environment or use default
let messages = [];
if (process.env.MESSAGES) {
try {
messages = JSON.parse(process.env.MESSAGES);
} catch (e) {
console.error("Failed to parse MESSAGES env var:", e.message);
process.exit(1);
}
}
// Presets for common test scenarios
if (process.env.PRESET === "normal") {
messages = [
{ id: 1, type: "input", content: "Hello", time: "2025-01-01T00:00:00Z" },
{
id: 2,
type: "output",
content: "Hi there",
time: "2025-01-01T00:00:01Z",
},
{
id: 3,
type: "input",
content: "How are you?",
time: "2025-01-01T00:00:02Z",
},
{
id: 4,
type: "output",
content: "Good!",
time: "2025-01-01T00:00:03Z",
},
{ id: 5, type: "input", content: "Great", time: "2025-01-01T00:00:04Z" },
];
} else if (process.env.PRESET === "many") {
messages = Array.from({ length: 15 }, (_, i) => ({
id: i + 1,
type: "input",
content: `Message ${i + 1}`,
time: "2025-01-01T00:00:00Z",
}));
} else if (process.env.PRESET === "huge") {
messages = [
{
id: 1,
type: "output",
content: "x".repeat(70000),
time: "2025-01-01T00:00:00Z",
},
];
}
const server = http.createServer((req, res) => {
if (req.url === "/messages") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ messages }));
} else if (req.url === "/status") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "stable" }));
} else {
res.writeHead(404);
res.end();
}
});
server.listen(port, () => {
console.error(`Mock AgentAPI listening on port ${port}`);
});
process.on("SIGTERM", () => {
server.close(() => process.exit(0));
});
process.on("SIGINT", () => {
server.close(() => process.exit(0));
});
@@ -0,0 +1,61 @@
#!/usr/bin/env node
// Mock Coder instance server for shutdown script tests.
// Captures POST requests to /log-snapshot endpoint.
const http = require("http");
const fs = require("fs");
const port = process.argv[2] || 8080;
const outputFile = process.env.OUTPUT_FILE || "/tmp/snapshot-posted.json";
const httpCode = parseInt(process.env.HTTP_CODE || "204", 10);
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://localhost:${port}`);
// Expected path: /api/v2/workspaceagents/me/tasks/{task_id}/log-snapshot
const pathMatch = url.pathname.match(/\/tasks\/([^\/]+)\/log-snapshot$/);
if (req.method === "POST" && pathMatch) {
const taskId = pathMatch[1];
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});
req.on("end", () => {
// Save captured snapshot with task ID for verification
const snapshotData = {
task_id: taskId,
payload: JSON.parse(body),
};
fs.writeFileSync(outputFile, JSON.stringify(snapshotData, null, 2));
console.error(
`Captured snapshot for task ${taskId} (${body.length} bytes) to ${outputFile}`,
);
// Return configured status code
res.writeHead(httpCode);
res.end();
});
req.on("error", (err) => {
console.error("Request error:", err);
res.writeHead(500);
res.end();
});
} else {
res.writeHead(404);
res.end();
}
});
server.listen(port, () => {
console.error(`Mock Coder instance listening on port ${port}`);
});
process.on("SIGTERM", () => {
server.close(() => process.exit(0));
});
process.on("SIGINT", () => {
server.close(() => process.exit(0));
});
+16 -17
View File
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.1"
version = "4.7.4"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
@@ -47,7 +47,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.1"
version = "4.7.4"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_boundary = true
@@ -59,7 +59,7 @@ module "claude-code" {
### Usage with AI Bridge
[AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) is a Premium Coder feature that provides centralized LLM proxy management. To use AI Bridge, set `enable_aibridge = true`.
[AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) is a Premium Coder feature that provides centralized LLM proxy management. To use AI Bridge, set `enable_aibridge = true`. Requires Coder version >= 2.29.0.
For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage with Tasks](#usage-with-tasks) example below.
@@ -68,7 +68,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.1"
version = "4.7.4"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_aibridge = true
@@ -96,12 +96,11 @@ resource "coder_ai_task" "task" {
data "coder_task" "me" {}
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
ai_prompt = data.coder_task.me.prompt
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.4"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
ai_prompt = data.coder_task.me.prompt
# Optional: route through AI Bridge (Premium feature)
# enable_aibridge = true
@@ -121,7 +120,7 @@ This example shows additional configuration options for version pinning, custom
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.1"
version = "4.7.4"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
@@ -177,7 +176,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.1"
version = "4.7.4"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
install_claude_code = true
@@ -199,7 +198,7 @@ variable "claude_code_oauth_token" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.1"
version = "4.7.4"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
@@ -210,7 +209,7 @@ module "claude-code" {
#### Prerequisites
AWS account with Bedrock access, Claude models enabled in Bedrock console, appropriate IAM permissions.
AWS account with Bedrock access, Claude models enabled in Bedrock console, and appropriate IAM permissions.
Configure Claude Code to use AWS Bedrock for accessing Claude models through your AWS infrastructure.
@@ -272,7 +271,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.1"
version = "4.7.4"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -286,7 +285,7 @@ module "claude-code" {
#### Prerequisites
GCP project with Vertex AI API enabled, Claude models enabled through Model Garden, service account with Vertex AI permissions, appropriate IAM permissions (Vertex AI User role).
GCP project with Vertex AI API enabled, Claude models enabled through Model Garden, service account with Vertex AI permissions, and appropriate IAM permissions (Vertex AI User role).
Configure Claude Code to use Google Vertex AI for accessing Claude models through Google Cloud Platform.
@@ -329,7 +328,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.1"
version = "4.7.4"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
+6 -3
View File
@@ -1,5 +1,5 @@
terraform {
required_version = ">= 1.0"
required_version = ">= 1.9"
required_providers {
coder = {
@@ -276,9 +276,11 @@ resource "coder_env" "claude_code_oauth_token" {
}
resource "coder_env" "claude_api_key" {
count = local.claude_api_key != "" ? 1 : 0
agent_id = var.agent_id
name = "CLAUDE_API_KEY"
value = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key
value = local.claude_api_key
}
resource "coder_env" "disable_autoupdater" {
@@ -324,7 +326,8 @@ locals {
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".claude-module"
# Extract hostname from access_url for boundary --allow flag
coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "")
coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "")
claude_api_key = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key
# Required prompts for the module to properly report task status to Coder
report_tasks_system_prompt = <<-EOT
@@ -42,7 +42,7 @@ run "test_claude_code_with_api_key" {
}
assert {
condition = coder_env.claude_api_key.value == "test-api-key-123"
condition = coder_env.claude_api_key[0].value == "test-api-key-123"
error_message = "Claude API key value should match the input"
}
}
@@ -298,6 +298,13 @@ run "test_aibridge_enabled" {
enable_aibridge = true
}
override_data {
target = data.coder_workspace_owner.me
values = {
session_token = "mock-session-token"
}
}
assert {
condition = var.enable_aibridge == true
error_message = "AI Bridge should be enabled"
@@ -314,12 +321,12 @@ run "test_aibridge_enabled" {
}
assert {
condition = coder_env.claude_api_key.name == "CLAUDE_API_KEY"
condition = coder_env.claude_api_key[0].name == "CLAUDE_API_KEY"
error_message = "CLAUDE_API_KEY environment variable should be set"
}
assert {
condition = coder_env.claude_api_key.value == data.coder_workspace_owner.me.session_token
condition = coder_env.claude_api_key[0].value == data.coder_workspace_owner.me.session_token
error_message = "CLAUDE_API_KEY should use workspace owner's session token when aibridge is enabled"
}
}
@@ -370,7 +377,7 @@ run "test_aibridge_disabled_with_api_key" {
}
assert {
condition = coder_env.claude_api_key.value == "test-api-key-xyz"
condition = coder_env.claude_api_key[0].value == "test-api-key-xyz"
error_message = "CLAUDE_API_KEY should use the provided API key when aibridge is disabled"
}
@@ -379,3 +386,18 @@ run "test_aibridge_disabled_with_api_key" {
error_message = "ANTHROPIC_BASE_URL should not be set when aibridge is disabled"
}
}
run "test_no_api_key_no_env" {
command = plan
variables {
agent_id = "test-agent-no-key"
workdir = "/home/coder/test"
enable_aibridge = false
}
assert {
condition = length(coder_env.claude_api_key) == 0
error_message = "CLAUDE_API_KEY should not be created when no API key is provided and aibridge is disabled"
}
}
+2 -2
View File
@@ -16,7 +16,7 @@ A module that adds JupyterLab in your Coder template.
module "jupyterlab" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jupyterlab/coder"
version = "1.2.1"
version = "1.2.2"
agent_id = coder_agent.main.id
}
```
@@ -29,7 +29,7 @@ JupyterLab is automatically configured to work with Coder's iframe embedding. Fo
module "jupyterlab" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jupyterlab/coder"
version = "1.2.1"
version = "1.2.2"
agent_id = coder_agent.main.id
config = {
ServerApp = {
@@ -77,7 +77,7 @@ describe("jupyterlab", async () => {
expect(output.exitCode).toBe(1);
expect(output.stdout).toEqual([
"Checking for a supported installer",
"No valid installer is not installed",
"No supported installer found.",
"Please install pipx or uv in your Dockerfile/VM image before running this script",
]);
});
+1 -1
View File
@@ -14,7 +14,7 @@ check_available_installer() {
INSTALLER="uv"
return
fi
echo "No valid installer is not installed"
echo "No supported installer found."
echo "Please install pipx or uv in your Dockerfile/VM image before running this script"
exit 1
}