From 4d0306703fffc3ec880ba6a0ca61906573166204 Mon Sep 17 00:00:00 2001 From: Riajul Islam Date: Fri, 27 Feb 2026 13:42:27 +0000 Subject: [PATCH 1/2] feat(agentapi): add agentapi_cache_dir for persistent binary caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in agentapi_cache_dir variable. When set, the AgentAPI binary is stored in that directory after the first download and reused on subsequent workspace starts — useful with a shared persistent volume to avoid repeated GitHub downloads. The binary is cached under a versioned filename (e.g. agentapi-linux-amd64-v0.10.0) so multiple versions can coexist. When agentapi_version is "latest", the actual release tag is resolved via GitHub's redirect before computing the cache key, preventing the cache from getting stuck on a stale binary. The resolution request is skipped entirely when agentapi_cache_dir is not set. Bumps version 2.1.1 → 2.2.0. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + registry/coder/modules/agentapi/README.md | 15 ++++- registry/coder/modules/agentapi/main.test.ts | 65 +++++++++++++++++++ registry/coder/modules/agentapi/main.tf | 6 ++ .../coder/modules/agentapi/scripts/main.sh | 59 +++++++++++++---- 5 files changed, 134 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 8d1b05c91..fa7b1f931 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,4 @@ gha-creds-*.json # IDEs .idea +.serena diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index e7a9869fb..3b8364bd9 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -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 @@ -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). diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index 20b47b1a0..d259bf1ab 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -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}`, + ]); + + 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}`; + 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: { diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 6914be779..b9454102e 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -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 @@ -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}' \ /tmp/main.sh EOT run_on_start = true diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 63e013eb9..8feaf7531 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -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() { @@ -62,6 +63,7 @@ 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 @@ -69,17 +71,52 @@ if [ "${INSTALL_AGENTAPI}" = "true" ]; then 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}" + 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}" + 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." From 512706a782e86fd60a591452c583a3d48c08d476 Mon Sep 17 00:00:00 2001 From: Riajul Islam Date: Sat, 28 Feb 2026 00:16:38 +0000 Subject: [PATCH 2/2] fix(agentapi): address Copilot PR review comments on cache logic - Pass agentapi_cache_dir base64-encoded to avoid single-quote injection in the heredoc (same pattern as ARG_WORKDIR) - Skip cache entirely if latest version resolution fails instead of falling back to the literal string "latest" as a cache key - Sanitize resolved_version before using it as a filename component to prevent path traversal (tr -c 'A-Za-z0-9._-' '_') - Make cache writes best-effort: warn and continue if mkdir or cp fails so a misconfigured/read-only cache dir doesn't abort workspace startup - Write cache atomically via cp + mv to avoid concurrent workspaces on a shared volume observing a partially-written binary - Derive container architecture in tests via uname -m so cache filename is correct on both amd64 and arm64 hosts Co-Authored-By: Claude Sonnet 4.6 --- registry/coder/modules/agentapi/main.test.ts | 19 +++++++++++-- registry/coder/modules/agentapi/main.tf | 2 +- .../coder/modules/agentapi/scripts/main.sh | 28 +++++++++++++++---- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index d259bf1ab..6bec30926 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -163,9 +163,15 @@ describe("agentapi", async () => { }, }); + // Derive the binary name from the container's architecture so the test is + // portable across amd64 and arm64 hosts. + const archResult = await execContainer(id, ["uname", "-m"]); + expect(archResult.exitCode).toBe(0); + const agentArch = + archResult.stdout.trim() === "aarch64" ? "linux-arm64" : "linux-amd64"; + // 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}`; + const cachedBinary = `${cacheDir}/agentapi-${agentArch}-${pinnedVersion}`; await execContainer(id, [ "bash", "-c", @@ -195,7 +201,14 @@ describe("agentapi", async () => { }, }); - const cachedBinary = `${cacheDir}/agentapi-linux-amd64-${pinnedVersion}`; + // Derive the binary name from the container's architecture so the test is + // portable across amd64 and arm64 hosts. + const archResult = await execContainer(id, ["uname", "-m"]); + expect(archResult.exitCode).toBe(0); + const agentArch = + archResult.stdout.trim() === "aarch64" ? "linux-arm64" : "linux-amd64"; + + const cachedBinary = `${cacheDir}/agentapi-${agentArch}-${pinnedVersion}`; const respModuleScript = await execModuleScript(id); expect(respModuleScript.exitCode).toBe(0); expect(respModuleScript.stdout).toContain( diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index b9454102e..ff6fca847 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -214,7 +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}' \ + ARG_CACHE_DIR="$(echo -n '${base64encode(var.agentapi_cache_dir)}' | base64 -d)" \ /tmp/main.sh EOT run_on_start = true diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 8feaf7531..0036901a3 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -86,10 +86,19 @@ if [ "${INSTALL_AGENTAPI}" = "true" ]; then -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}" + "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}" ]; then + # Sanitize the version so it is safe to use as a filename component. + # Allow only alphanumerics, dots, underscores, and hyphens; replace others with '_'. + safe_version=$(printf '%s' "${resolved_version}" | tr -c 'A-Za-z0-9._-' '_') + cached_binary="${CACHE_DIR}/${binary_name}-${safe_version}" fi - cached_binary="${CACHE_DIR}/${binary_name}-${resolved_version}" fi if [ -n "${cached_binary}" ] && [ -f "${cached_binary}" ]; then @@ -111,8 +120,17 @@ if [ "${INSTALL_AGENTAPI}" = "true" ]; then if [ -n "${cached_binary}" ]; then echo "Caching AgentAPI binary to ${cached_binary}" - mkdir -p "${CACHE_DIR}" - cp agentapi "${cached_binary}" + # Write atomically via a temp file so concurrent workspace starts on a shared + # volume never observe a partially-written binary. + if ! mkdir -p "${CACHE_DIR}"; then + echo "Warning: Failed to create cache directory ${CACHE_DIR}. Continuing without caching." + else + tmp_cached_binary="${cached_binary}.$$" + if ! cp agentapi "${tmp_cached_binary}" || ! mv -f "${tmp_cached_binary}" "${cached_binary}"; then + rm -f "${tmp_cached_binary}" + echo "Warning: Failed to cache AgentAPI binary to ${cached_binary}. Continuing without caching." + fi + fi fi sudo mv agentapi /usr/local/bin/agentapi