mirror of
https://github.com/coder/registry.git
synced 2026-06-03 04:58:15 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 360b3cd3ce | |||
| fa30191394 |
@@ -3,7 +3,7 @@ display_name: Codex CLI
|
||||
icon: ../../../../.icons/openai.svg
|
||||
description: Run Codex CLI in your workspace with AgentAPI integration
|
||||
verified: true
|
||||
tags: [agent, codex, ai, openai, tasks]
|
||||
tags: [agent, codex, ai, openai, tasks, aibridge]
|
||||
---
|
||||
|
||||
# Codex CLI
|
||||
@@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.0.0"
|
||||
version = "4.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = var.openai_api_key
|
||||
workdir = "/home/coder/project"
|
||||
@@ -32,7 +32,7 @@ module "codex" {
|
||||
module "codex" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.0.0"
|
||||
version = "4.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
workdir = "/home/coder/project"
|
||||
@@ -40,7 +40,49 @@ module "codex" {
|
||||
}
|
||||
```
|
||||
|
||||
### 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. 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.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_aibridge = true
|
||||
}
|
||||
```
|
||||
|
||||
When `enable_aibridge = true`, the module:
|
||||
|
||||
- Configures Codex to use the AI Bridge profile with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token
|
||||
|
||||
```toml
|
||||
[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 then runs with `--profile aibridge`
|
||||
|
||||
This allows Codex to route API requests through Coder's AI Bridge instead of directly to OpenAI's API.
|
||||
Template build will fail if `openai_api_key` is provided alongside `enable_aibridge = true`.
|
||||
|
||||
### Usage with Tasks
|
||||
|
||||
This example shows how to configure Codex with Coder tasks.
|
||||
|
||||
```tf
|
||||
resource "coder_ai_task" "task" {
|
||||
@@ -52,17 +94,46 @@ data "coder_task" "me" {}
|
||||
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.0.0"
|
||||
version = "4.1.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
|
||||
# Optional: route through AI Bridge (Premium feature)
|
||||
# enable_aibridge = true
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
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.1.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
|
||||
sandbox_mode = "danger-full-access"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
EOT
|
||||
|
||||
# Add extra MCP servers
|
||||
additional_mcp_servers = <<-EOT
|
||||
[mcp_servers.GitHub]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
type = "stdio"
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
@@ -92,33 +163,6 @@ preferred_auth_method = "apikey"
|
||||
network_access = true
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
For custom Codex configuration, use `base_config_toml` and/or `additional_mcp_servers`:
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.0.0"
|
||||
# ... other variables ...
|
||||
|
||||
# Override default configuration
|
||||
base_config_toml = <<-EOT
|
||||
sandbox_mode = "danger-full-access"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
EOT
|
||||
|
||||
# Add extra MCP servers
|
||||
additional_mcp_servers = <<-EOT
|
||||
[mcp_servers.GitHub]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
type = "stdio"
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
> [!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).
|
||||
|
||||
@@ -137,3 +181,4 @@ module "codex" {
|
||||
- [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)
|
||||
|
||||
@@ -113,7 +113,7 @@ describe("codex", async () => {
|
||||
sandbox_mode = "danger-full-access"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
|
||||
|
||||
[custom_section]
|
||||
new_feature = true
|
||||
`.trim();
|
||||
@@ -189,7 +189,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 +215,7 @@ describe("codex", async () => {
|
||||
approval_policy = "untrusted"
|
||||
preferred_auth_method = "chatgpt"
|
||||
custom_setting = "test-value"
|
||||
|
||||
|
||||
[advanced_settings]
|
||||
timeout = 30000
|
||||
debug = true
|
||||
@@ -228,7 +228,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 +454,32 @@ 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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
required_version = ">= 1.9"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
@@ -71,6 +71,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."
|
||||
@@ -115,8 +136,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,12 +176,31 @@ 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, "/")
|
||||
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" {
|
||||
@@ -196,6 +236,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 +252,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}' \
|
||||
|
||||
@@ -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")"
|
||||
@@ -140,6 +152,11 @@ function populate_config_toml() {
|
||||
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 +202,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
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user