Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,4 @@ gha-creds-*.json

# IDEs
.idea
.serena
15 changes: 14 additions & 1 deletion registry/coder/modules/agentapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.1.1"
version = "2.2.0"

agent_id = var.agent_id
web_app_slug = local.app_slug
Expand Down Expand Up @@ -62,6 +62,19 @@ module "agentapi" {
}
```

## Caching the AgentAPI binary

When `agentapi_cache_dir` is set, the AgentAPI binary is cached to that directory after the first download. On subsequent workspace starts, the cached binary is used instead of re-downloading from GitHub. This is particularly useful when workspaces use a persistent volume.

```tf
module "agentapi" {
# ... other config
agentapi_cache_dir = "/home/coder/.cache/agentapi"
}
```

The cached binary is stored with a name that includes the architecture and version (e.g., `agentapi-linux-amd64-v0.10.0`) so different versions can coexist in the same cache directory.

## 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).
65 changes: 65 additions & 0 deletions registry/coder/modules/agentapi/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,71 @@ describe("agentapi", async () => {
expect(respAgentAPI.exitCode).toBe(0);
});

test("cache-dir-uses-cached-binary", async () => {
// Verify that when a cached binary exists in the cache dir, it is used
// instead of downloading. Use a pinned version so the cache filename is
// deterministic (resolving "latest" requires a network call).
const cacheDir = "/home/coder/.agentapi-cache";
const pinnedVersion = "v0.10.0";
const { id } = await setup({
moduleVariables: {
install_agentapi: "true",
agentapi_cache_dir: cacheDir,
agentapi_version: pinnedVersion,
},
});

// Pre-populate the cache directory with a fake agentapi binary.
// The binary is named after the arch and pinned version.
const cachedBinary = `${cacheDir}/agentapi-linux-amd64-${pinnedVersion}`;
await execContainer(id, [
"bash",
"-c",
`mkdir -p ${cacheDir} && cp /usr/bin/agentapi ${cachedBinary}`,
]);
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test hardcodes the cached binary name as agentapi-linux-amd64-..., but the install script selects agentapi-linux-arm64 on aarch64. On ARM hosts (e.g., Apple Silicon / Colima), this will miss the cache and exercise the download path unexpectedly. Consider deriving binary_name by querying uname -m inside the container and building the expected cache filename accordingly.

Copilot uses AI. Check for mistakes.

const respModuleScript = await execModuleScript(id);
expect(respModuleScript.exitCode).toBe(0);
expect(respModuleScript.stdout).toContain(
`Using cached AgentAPI binary from ${cachedBinary}`,
);

await expectAgentAPIStarted(id);
});

test("cache-dir-saves-binary-after-download", async () => {
// Verify that after downloading agentapi, the binary is saved to the cache
// dir under the resolved version name. Use a pinned version so the cache
// filename is deterministic.
const cacheDir = "/home/coder/.agentapi-cache";
const pinnedVersion = "v0.10.0";
const { id } = await setup({
skipAgentAPIMock: true,
moduleVariables: {
agentapi_cache_dir: cacheDir,
agentapi_version: pinnedVersion,
},
});

const cachedBinary = `${cacheDir}/agentapi-linux-amd64-${pinnedVersion}`;
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test assumes the cached filename uses agentapi-linux-amd64-..., which will fail on ARM (aarch64) where the script caches agentapi-linux-arm64-.... Consider computing the expected filename from the container architecture so the test is portable across dev environments.

Suggested change
const cachedBinary = `${cacheDir}/agentapi-linux-amd64-${pinnedVersion}`;
// Determine the container architecture so the expected cached filename
// matches how the script names the AgentAPI binary.
const archResult = await execContainer(id, ["uname", "-m"]);
expect(archResult.exitCode).toBe(0);
const arch = archResult.stdout.trim();
const agentArch =
arch === "aarch64" || arch === "arm64" ? "linux-arm64" : "linux-amd64";
const cachedBinary = `${cacheDir}/agentapi-${agentArch}-${pinnedVersion}`;

Copilot uses AI. Check for mistakes.
const respModuleScript = await execModuleScript(id);
expect(respModuleScript.exitCode).toBe(0);
expect(respModuleScript.stdout).toContain(
`Caching AgentAPI binary to ${cachedBinary}`,
);

await expectAgentAPIStarted(id);

// Verify the binary was saved to the cache directory.
const respCacheCheck = await execContainer(id, [
"bash",
"-c",
`test -f ${cachedBinary} && echo "cached"`,
]);
expect(respCacheCheck.exitCode).toBe(0);
expect(respCacheCheck.stdout).toContain("cached");
});

test("no-subdomain-base-path", async () => {
const { id } = await setup({
moduleVariables: {
Expand Down
6 changes: 6 additions & 0 deletions registry/coder/modules/agentapi/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ variable "module_dir_name" {
description = "Name of the subdirectory in the home directory for module files."
}

variable "agentapi_cache_dir" {
type = string
description = "Path to a directory where the AgentAPI binary will be cached after download. On subsequent workspace starts, the cached binary is used instead of downloading again. Useful with persistent volumes to avoid repeated downloads."
default = ""
}

locals {
# we always trim the slash for consistency
Expand Down Expand Up @@ -209,6 +214,7 @@ resource "coder_script" "agentapi" {
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}' \
ARG_CACHE_DIR='${var.agentapi_cache_dir}' \
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agentapi_cache_dir is interpolated directly into a single-quoted shell assignment. If the path contains a single quote or newline, it can break the generated script. Consider passing this value the same way as ARG_WORKDIR (base64-encoded/decoded) or otherwise escaping it before embedding it into the heredoc.

Suggested change
ARG_CACHE_DIR='${var.agentapi_cache_dir}' \
ARG_CACHE_DIR="$(echo -n '${base64encode(var.agentapi_cache_dir)}' | base64 -d)" \

Copilot uses AI. Check for mistakes.
/tmp/main.sh
EOT
run_on_start = true
Expand Down
59 changes: 48 additions & 11 deletions registry/coder/modules/agentapi/scripts/main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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}"
CACHE_DIR="${ARG_CACHE_DIR:-}"
set +o nounset

command_exists() {
Expand Down Expand Up @@ -62,24 +63,60 @@ if [ "${INSTALL_AGENTAPI}" = "true" ]; then
echo "Error: Unsupported architecture: $arch"
exit 1
fi

if [ "${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/${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

cached_binary=""
if [ -n "${CACHE_DIR}" ]; then
resolved_version="${AGENTAPI_VERSION}"
if [ "${AGENTAPI_VERSION}" = "latest" ]; then
# Resolve the actual version tag so the cache key is stable (e.g. v0.10.0, not "latest").
# GitHub redirects /releases/latest to /releases/tag/vX.Y.Z; we extract the tag from that URL.
resolved_version=$(curl \
--retry 3 \
--retry-delay 3 \
--fail \
--retry-all-errors \
-Ls \
-o /dev/null \
-w '%{url_effective}' \
"https://github.com/coder/agentapi/releases/latest" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' || echo "latest")
echo "Resolved AgentAPI latest version to: ${resolved_version}"
fi
cached_binary="${CACHE_DIR}/${binary_name}-${resolved_version}"
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When resolving latest, the fallback || echo "latest" means a transient network/redirect failure will cache under the literal key "latest" and can pin the cache to a stale binary (contradicting the intended "never stale" behavior). Consider disabling caching for that run when the tag cannot be resolved (e.g., leave cached_binary empty if resolved_version is still "latest"), or treat resolution failure as a warning and proceed without writing/using a cache entry.

Suggested change
"https://github.com/coder/agentapi/releases/latest" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' || echo "latest")
echo "Resolved AgentAPI latest version to: ${resolved_version}"
fi
cached_binary="${CACHE_DIR}/${binary_name}-${resolved_version}"
"https://github.com/coder/agentapi/releases/latest" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' || true)
if [ -z "${resolved_version}" ]; then
echo "Warning: Failed to resolve latest AgentAPI version tag; proceeding without cache for this run."
else
echo "Resolved AgentAPI latest version to: ${resolved_version}"
fi
fi
if [ -n "${resolved_version}" ] && [ "${resolved_version}" != "latest" ]; then
cached_binary="${CACHE_DIR}/${binary_name}-${resolved_version}"
else
cached_binary=""
fi

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved_version is derived from AGENTAPI_VERSION and then interpolated into a filesystem path. Because agentapi_version can be an arbitrary string, this can introduce path traversal or invalid filenames (e.g., containing /, .., newlines) and cause writes outside CACHE_DIR when caching. Please sanitize the version component before building cached_binary (or restrict agentapi_version to a safe pattern when agentapi_cache_dir is set).

Suggested change
cached_binary="${CACHE_DIR}/${binary_name}-${resolved_version}"
# Sanitize the resolved version so it is safe to use as a filename component.
# Allow only alphanumerics, dots, underscores, and hyphens; replace others with '_'.
safe_resolved_version=$(printf '%s' "${resolved_version}" | tr -c 'A-Za-z0-9._-' '_')
if [ -z "${safe_resolved_version}" ]; then
echo "Warning: Resolved AgentAPI version '${resolved_version}' is empty after sanitization; skipping cache."
cached_binary=""
else
cached_binary="${CACHE_DIR}/${binary_name}-${safe_resolved_version}"
fi

Copilot uses AI. Check for mistakes.
fi

if [ -n "${cached_binary}" ] && [ -f "${cached_binary}" ]; then
echo "Using cached AgentAPI binary from ${cached_binary}"
cp "${cached_binary}" agentapi
chmod +x agentapi
sudo mv agentapi /usr/local/bin/agentapi
else
curl \
--retry 5 \
--retry-delay 5 \
--fail \
--retry-all-errors \
-L \
-C - \
-o agentapi \
"$download_url"
chmod +x agentapi

if [ -n "${cached_binary}" ]; then
echo "Caching AgentAPI binary to ${cached_binary}"
mkdir -p "${CACHE_DIR}"
cp agentapi "${cached_binary}"
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caching is an optimization, but with set -e a failure in mkdir -p "${CACHE_DIR}" or cp agentapi "${cached_binary}" will abort the whole install/start even though the binary was successfully downloaded. Make cache writes best-effort (emit a warning and continue) so a misconfigured or read-only cache dir doesn’t break workspace startup.

Suggested change
mkdir -p "${CACHE_DIR}"
cp agentapi "${cached_binary}"
if ! mkdir -p "${CACHE_DIR}"; then
echo "Warning: Failed to create cache directory ${CACHE_DIR}. Continuing without caching."
elif ! cp agentapi "${cached_binary}"; then
echo "Warning: Failed to cache AgentAPI binary to ${cached_binary}. Continuing without caching."
fi

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Writing the cache file via cp agentapi "${cached_binary}" is not atomic. If multiple workspaces share the same persistent cache volume, concurrent starts can race and one workspace may read a partially-written binary. Consider writing to a temp file in CACHE_DIR and then mv-ing into place (atomic rename), optionally with a lock to serialize writers.

Suggested change
cp agentapi "${cached_binary}"
tmp_cached_binary="${cached_binary}.$$"
cp agentapi "${tmp_cached_binary}"
mv -f "${tmp_cached_binary}" "${cached_binary}"

Copilot uses AI. Check for mistakes.
fi

sudo mv agentapi /usr/local/bin/agentapi
fi
fi
if ! command_exists agentapi; then
echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually."
Expand Down