diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md new file mode 100644 index 00000000..0dc97565 --- /dev/null +++ b/registry/coder/modules/agentapi/README.md @@ -0,0 +1,54 @@ +--- +display_name: AgentAPI +description: Building block for modules that need to run an agentapi server +icon: ../../../../.icons/coder.svg +maintainer_github: coder +verified: true +tags: [internal] +--- + +# AgentAPI + +The AgentAPI module is a building block for modules that need to run an agentapi server. It is intended primarily for internal use by Coder to create modules compatible with Tasks. + +We do not recommend using this module directly. Instead, please consider using one of our [Tasks-compatible AI agent modules](https://registry.coder.com/modules?search=tag%3Atasks). + +```tf +module "agentapi" { + source = "registry.coder.com/coder/agentapi/coder" + version = "1.0.0" + + agent_id = var.agent_id + web_app_slug = local.app_slug + web_app_order = var.order + web_app_group = var.group + web_app_icon = var.icon + web_app_display_name = "Goose" + cli_app_slug = "goose-cli" + cli_app_display_name = "Goose CLI" + module_dir_name = local.module_dir_name + install_agentapi = var.install_agentapi + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + start_script = local.start_script + install_script = <<-EOT + #!/bin/bash + set -o errexit + set -o pipefail + + echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh + chmod +x /tmp/install.sh + + ARG_PROVIDER='${var.goose_provider}' \ + ARG_MODEL='${var.goose_model}' \ + ARG_GOOSE_CONFIG="$(echo -n '${base64encode(local.combined_extensions)}' | base64 -d)" \ + ARG_INSTALL='${var.install_goose}' \ + ARG_GOOSE_VERSION='${var.goose_version}' \ + /tmp/install.sh + EOT +} +``` + +## 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). diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts new file mode 100644 index 00000000..fab16967 --- /dev/null +++ b/registry/coder/modules/agentapi/main.test.ts @@ -0,0 +1,151 @@ +import { + test, + afterEach, + expect, + describe, + setDefaultTimeout, + beforeAll, +} from "bun:test"; +import { execContainer, readFileContainer, runTerraformInit } from "~test"; +import { + loadTestFile, + writeExecutable, + setup as setupUtil, + execModuleScript, + expectAgentAPIStarted, +} from "./test-util"; + +let cleanupFunctions: (() => Promise)[] = []; + +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; + +// Cleanup logic depends on the fact that bun's built-in test runner +// runs tests sequentially. +// https://bun.sh/docs/test/discovery#execution-order +// Weird things would happen if tried to run tests in parallel. +// One test could clean up resources that another test was still using. +afterEach(async () => { + // reverse the cleanup functions so that they are run in the correct order + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +interface SetupProps { + skipAgentAPIMock?: boolean; + moduleVariables?: Record; +} + +const moduleDirName = ".agentapi-module"; + +const setup = async (props?: SetupProps): Promise<{ id: string }> => { + const projectDir = "/home/coder/project"; + const { id } = await setupUtil({ + moduleVariables: { + experiment_report_tasks: "true", + install_agentapi: props?.skipAgentAPIMock ? "true" : "false", + web_app_display_name: "AgentAPI Web", + web_app_slug: "agentapi-web", + web_app_icon: "/icon/coder.svg", + cli_app_display_name: "AgentAPI CLI", + cli_app_slug: "agentapi-cli", + agentapi_version: "latest", + module_dir_name: moduleDirName, + start_script: await loadTestFile(import.meta.dir, "agentapi-start.sh"), + folder: projectDir, + ...props?.moduleVariables, + }, + registerCleanup, + projectDir, + skipAgentAPIMock: props?.skipAgentAPIMock, + moduleDir: import.meta.dir, + }); + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/aiagent", + content: await loadTestFile(import.meta.dir, "ai-agent-mock.js"), + }); + return { id }; +}; + +// increase the default timeout to 60 seconds +setDefaultTimeout(60 * 1000); + +// we don't run these tests in CI because they take too long and make network +// calls. they are dedicated for local development. +describe("agentapi", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + test("happy-path", async () => { + const { id } = await setup(); + + await execModuleScript(id); + + await expectAgentAPIStarted(id); + }); + + test("custom-port", async () => { + const { id } = await setup({ + moduleVariables: { + agentapi_port: "3827", + }, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id, 3827); + }); + + test("pre-post-install-scripts", async () => { + const { id } = await setup({ + moduleVariables: { + pre_install_script: `#!/bin/bash\necho "pre-install"`, + install_script: `#!/bin/bash\necho "install"`, + post_install_script: `#!/bin/bash\necho "post-install"`, + }, + }); + + await execModuleScript(id); + await expectAgentAPIStarted(id); + + const preInstallLog = await readFileContainer( + id, + `/home/coder/${moduleDirName}/pre_install.log`, + ); + const installLog = await readFileContainer( + id, + `/home/coder/${moduleDirName}/install.log`, + ); + const postInstallLog = await readFileContainer( + id, + `/home/coder/${moduleDirName}/post_install.log`, + ); + + expect(preInstallLog).toContain("pre-install"); + expect(installLog).toContain("install"); + expect(postInstallLog).toContain("post-install"); + }); + + test("install-agentapi", async () => { + const { id } = await setup({ skipAgentAPIMock: true }); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + const respAgentAPI = await execContainer(id, [ + "bash", + "-c", + "agentapi --version", + ]); + expect(respAgentAPI.exitCode).toBe(0); + }); +}); diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf new file mode 100644 index 00000000..3d8ad744 --- /dev/null +++ b/registry/coder/modules/agentapi/main.tf @@ -0,0 +1,277 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.7" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "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)." + default = null +} + +variable "web_app_group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "web_app_icon" { + type = string + description = "The icon to use for the app." +} + +variable "web_app_display_name" { + type = string + description = "The display name of the web app." +} + +variable "web_app_slug" { + type = string + description = "The slug of the web app." +} + +variable "folder" { + type = string + description = "The folder to run AgentAPI in." + default = "/home/coder" +} + +variable "cli_app" { + type = bool + description = "Whether to create the CLI workspace app." + default = false +} + +variable "cli_app_order" { + type = number + description = "The order of the CLI workspace app." + default = null +} + +variable "cli_app_group" { + type = string + description = "The group of the CLI workspace app." + default = null +} + +variable "cli_app_icon" { + type = string + description = "The icon to use for the app." + default = "/icon/claude.svg" +} + +variable "cli_app_display_name" { + type = string + description = "The display name of the CLI workspace app." +} + +variable "cli_app_slug" { + type = string + description = "The slug of the CLI workspace app." +} + +variable "pre_install_script" { + type = string + description = "Custom script to run before installing the agent used by AgentAPI." + default = null +} + +variable "install_script" { + type = string + description = "Script to install the agent used by AgentAPI." + default = "" +} + +variable "post_install_script" { + type = string + description = "Custom script to run after installing the agent used by AgentAPI." + default = null +} + +variable "start_script" { + type = string + description = "Script that starts AgentAPI." +} + +variable "install_agentapi" { + type = bool + description = "Whether to install AgentAPI." + default = true +} + +variable "agentapi_version" { + type = string + description = "The version of AgentAPI to install." + default = "v0.2.3" +} + +variable "agentapi_port" { + type = number + description = "The port used by AgentAPI." + default = 3284 +} + +variable "module_dir_name" { + type = string + description = "Name of the subdirectory in the home directory for module files." +} + + +locals { + # we always trim the slash for consistency + workdir = trimsuffix(var.folder, "/") + encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : "" + encoded_install_script = var.install_script != null ? base64encode(var.install_script) : "" + encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : "" + agentapi_start_script_b64 = base64encode(var.start_script) + agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh")) +} + +resource "coder_script" "agentapi" { + agent_id = var.agent_id + display_name = "Install and start AgentAPI" + icon = var.web_app_icon + script = <<-EOT + #!/bin/bash + set -e + set -x + + command_exists() { + command -v "$1" >/dev/null 2>&1 + } + + module_path="$HOME/${var.module_dir_name}" + mkdir -p "$module_path/scripts" + + if [ ! -d "${local.workdir}" ]; then + echo "Warning: The specified folder '${local.workdir}' does not exist." + echo "Creating the folder..." + mkdir -p "${local.workdir}" + echo "Folder created successfully." + fi + if [ -n "${local.encoded_pre_install_script}" ]; then + echo "Running pre-install script..." + echo "${local.encoded_pre_install_script}" | base64 -d > "$module_path/pre_install.sh" + chmod +x "$module_path/pre_install.sh" + "$module_path/pre_install.sh" 2>&1 | tee "$module_path/pre_install.log" + fi + + echo "Running install script..." + echo "${local.encoded_install_script}" | base64 -d > "$module_path/install.sh" + chmod +x "$module_path/install.sh" + "$module_path/install.sh" 2>&1 | tee "$module_path/install.log" + + # Install AgentAPI if enabled + if [ "${var.install_agentapi}" = "true" ]; then + echo "Installing AgentAPI..." + arch=$(uname -m) + if [ "$arch" = "x86_64" ]; then + binary_name="agentapi-linux-amd64" + elif [ "$arch" = "aarch64" ]; then + binary_name="agentapi-linux-arm64" + else + echo "Error: Unsupported architecture: $arch" + exit 1 + fi + if [ "${var.agentapi_version}" = "latest" ]; then + # for the latest release the download URL pattern is different than for tagged releases + # https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases + download_url="https://github.com/coder/agentapi/releases/latest/download/$binary_name" + else + download_url="https://github.com/coder/agentapi/releases/download/${var.agentapi_version}/$binary_name" + fi + curl \ + --retry 5 \ + --retry-delay 5 \ + --fail \ + --retry-all-errors \ + -L \ + -C - \ + -o agentapi \ + "$download_url" + chmod +x agentapi + sudo mv agentapi /usr/local/bin/agentapi + fi + if ! command_exists agentapi; then + echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually." + exit 1 + fi + + echo -n "${local.agentapi_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-start.sh" + echo -n "${local.agentapi_wait_for_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-wait-for-start.sh" + chmod +x "$module_path/scripts/agentapi-start.sh" + chmod +x "$module_path/scripts/agentapi-wait-for-start.sh" + + if [ -n "${local.encoded_post_install_script}" ]; then + echo "Running post-install script..." + echo "${local.encoded_post_install_script}" | base64 -d > "$module_path/post_install.sh" + chmod +x "$module_path/post_install.sh" + "$module_path/post_install.sh" 2>&1 | tee "$module_path/post_install.log" + fi + + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + cd "${local.workdir}" + nohup "$module_path/scripts/agentapi-start.sh" true "${var.agentapi_port}" &> "$module_path/agentapi-start.log" & + "$module_path/scripts/agentapi-wait-for-start.sh" "${var.agentapi_port}" + EOT + run_on_start = true +} + +resource "coder_app" "agentapi_web" { + slug = var.web_app_slug + display_name = var.web_app_display_name + agent_id = var.agent_id + url = "http://localhost:${var.agentapi_port}/" + icon = var.web_app_icon + order = var.web_app_order + group = var.web_app_group + subdomain = true + healthcheck { + url = "http://localhost:${var.agentapi_port}/status" + interval = 3 + threshold = 20 + } +} + +resource "coder_app" "agentapi_cli" { + count = var.cli_app ? 1 : 0 + + slug = var.cli_app_slug + display_name = var.cli_app_display_name + agent_id = var.agent_id + command = <<-EOT + #!/bin/bash + set -e + + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + agentapi attach + EOT + icon = var.cli_app_icon + order = var.cli_app_order + group = var.cli_app_group +} + +resource "coder_ai_task" "agentapi" { + sidebar_app { + id = coder_app.agentapi_web.id + } +} diff --git a/registry/coder/modules/agentapi/scripts/agentapi-wait-for-start.sh b/registry/coder/modules/agentapi/scripts/agentapi-wait-for-start.sh new file mode 100644 index 00000000..7430e9ec --- /dev/null +++ b/registry/coder/modules/agentapi/scripts/agentapi-wait-for-start.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -o errexit +set -o pipefail + +port=${1:-3284} + +# This script waits for the agentapi server to start on port 3284. +# It considers the server started after 3 consecutive successful responses. + +agentapi_started=false + +echo "Waiting for agentapi server to start on port $port..." +for i in $(seq 1 150); do + for j in $(seq 1 3); do + sleep 0.1 + if curl -fs -o /dev/null "http://localhost:$port/status"; then + echo "agentapi response received ($j/3)" + else + echo "agentapi server not responding ($i/15)" + continue 2 + fi + done + agentapi_started=true + break +done + +if [ "$agentapi_started" != "true" ]; then + echo "Error: agentapi server did not start on port $port after 15 seconds." + exit 1 +fi + +echo "agentapi server started on port $port." diff --git a/registry/coder/modules/agentapi/test-util.ts b/registry/coder/modules/agentapi/test-util.ts new file mode 100644 index 00000000..66860def --- /dev/null +++ b/registry/coder/modules/agentapi/test-util.ts @@ -0,0 +1,130 @@ +import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, + runTerraformApply, + writeFileContainer, +} from "~test"; +import path from "path"; +import { expect } from "bun:test"; + +export const setupContainer = async ({ + moduleDir, + image, + vars, +}: { + moduleDir: string; + image?: string; + vars?: Record; +}) => { + const state = await runTerraformApply(moduleDir, { + agent_id: "foo", + ...vars, + }); + const coderScript = findResourceInstance(state, "coder_script"); + const id = await runContainer(image ?? "codercom/enterprise-node:latest"); + return { id, coderScript, cleanup: () => removeContainer(id) }; +}; + +export const loadTestFile = async ( + moduleDir: string, + ...relativePath: [string, ...string[]] +) => { + return await Bun.file( + path.join(moduleDir, "testdata", ...relativePath), + ).text(); +}; + +export const writeExecutable = async ({ + containerId, + filePath, + content, +}: { + containerId: string; + filePath: string; + content: string; +}) => { + await writeFileContainer(containerId, filePath, content, { + user: "root", + }); + await execContainer( + containerId, + ["bash", "-c", `chmod 755 ${filePath}`], + ["--user", "root"], + ); +}; + +interface SetupProps { + skipAgentAPIMock?: boolean; + moduleDir: string; + moduleVariables: Record; + projectDir?: string; + registerCleanup: (cleanup: () => Promise) => void; + agentapiMockScript?: string; +} + +export const setup = async (props: SetupProps): Promise<{ id: string }> => { + const projectDir = props.projectDir ?? "/home/coder/project"; + const { id, coderScript, cleanup } = await setupContainer({ + moduleDir: props.moduleDir, + vars: props.moduleVariables, + }); + props.registerCleanup(cleanup); + await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]); + if (!props?.skipAgentAPIMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/agentapi", + content: + props.agentapiMockScript ?? + (await loadTestFile(import.meta.dir, "agentapi-mock.js")), + }); + } + await writeExecutable({ + containerId: id, + filePath: "/home/coder/script.sh", + content: coderScript.script, + }); + return { id }; +}; + +export const expectAgentAPIStarted = async ( + id: string, + port: number = 3284, +) => { + const resp = await execContainer(id, [ + "bash", + "-c", + `curl -fs -o /dev/null "http://localhost:${port}/status"`, + ]); + if (resp.exitCode !== 0) { + console.log("agentapi not started"); + console.log(resp.stdout); + console.log(resp.stderr); + } + expect(resp.exitCode).toBe(0); +}; + +export const execModuleScript = async ( + id: string, + env?: Record, +) => { + const envArgs = Object.entries(env ?? {}) + .map(([key, value]) => ["--env", `${key}=${value}`]) + .flat(); + const resp = await execContainer( + id, + [ + "bash", + "-c", + `set -o errexit; set -o pipefail; cd /home/coder && ./script.sh 2>&1 | tee /home/coder/script.log`, + ], + envArgs, + ); + if (resp.exitCode !== 0) { + console.log(resp.stdout); + console.log(resp.stderr); + } + return resp; +}; diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock.js b/registry/coder/modules/agentapi/testdata/agentapi-mock.js new file mode 100644 index 00000000..4d2417ba --- /dev/null +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +const http = require("http"); +const args = process.argv.slice(2); +const portIdx = args.findIndex((arg) => arg === "--port") + 1; +const port = portIdx ? args[portIdx] : 3284; + +console.log(`starting server on port ${port}`); + +http + .createServer(function (_request, response) { + response.writeHead(200); + response.end( + JSON.stringify({ + status: "stable", + }), + ); + }) + .listen(port); diff --git a/registry/coder/modules/agentapi/testdata/agentapi-start.sh b/registry/coder/modules/agentapi/testdata/agentapi-start.sh new file mode 100644 index 00000000..1564fe03 --- /dev/null +++ b/registry/coder/modules/agentapi/testdata/agentapi-start.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -o errexit +set -o pipefail + +use_prompt=${1:-false} +port=${2:-3284} + +module_path="$HOME/.agentapi-module" +log_file_path="$module_path/agentapi.log" + +echo "using prompt: $use_prompt" >>/home/coder/test-agentapi-start.log +echo "using port: $port" >>/home/coder/test-agentapi-start.log + +agentapi server --port "$port" --term-width 67 --term-height 1190 -- \ + bash -c aiagent \ + >"$log_file_path" 2>&1 diff --git a/registry/coder/modules/agentapi/testdata/ai-agent-mock.js b/registry/coder/modules/agentapi/testdata/ai-agent-mock.js new file mode 100644 index 00000000..eb228a30 --- /dev/null +++ b/registry/coder/modules/agentapi/testdata/ai-agent-mock.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +const main = async () => { + console.log("mocking an ai agent"); + // sleep for 30 minutes + await new Promise((resolve) => setTimeout(resolve, 30 * 60 * 1000)); +}; + +main(); diff --git a/test/test.ts b/test/test.ts index 0de9fb04..bb09a410 100644 --- a/test/test.ts +++ b/test/test.ts @@ -324,3 +324,13 @@ export const writeFileContainer = async ( } expect(proc.exitCode).toBe(0); }; + +export const readFileContainer = async (id: string, path: string) => { + const proc = await execContainer(id, ["cat", path], ["--user", "root"]); + if (proc.exitCode !== 0) { + console.log(proc.stderr); + console.log(proc.stdout); + } + expect(proc.exitCode).toBe(0); + return proc.stdout; +};