Compare commits

..

2 Commits

Author SHA1 Message Date
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
12 changed files with 747 additions and 44 deletions
+78 -33
View File
@@ -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)
+32 -4
View File
@@ -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"',
);
});
});
+46 -3
View File
@@ -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
+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));
});