diff --git a/registry/coder/modules/aibridge-proxy/README.md b/registry/coder/modules/aibridge-proxy/README.md new file mode 100644 index 000000000..b1b4fd9ba --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/README.md @@ -0,0 +1,83 @@ +--- +display_name: AI Bridge Proxy +description: Configure a workspace to route AI tool traffic through AI Bridge via AI Bridge Proxy. +icon: ../../../../.icons/coder.svg +verified: true +tags: [helper, aibridge] +--- + +# AI Bridge Proxy + +This module configures a Coder workspace to use [AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy). +It downloads the proxy's CA certificate from the Coder deployment and exposes outputs that tool-specific modules can use to route their traffic through the proxy. + +```tf +module "aibridge-proxy" { + source = "registry.coder.com/coder/aibridge-proxy/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + proxy_url = "https://aiproxy.example.com" +} +``` + +> [!NOTE] +> AI Bridge Proxy is a Premium Coder feature that requires [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) to be enabled. +> See the [AI Bridge Proxy setup guide](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup) for details on configuring the proxy on your Coder deployment. + +## How it works + +AI Bridge Proxy is an HTTP proxy that intercepts traffic to AI providers and forwards it through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge), enabling centralized LLM management, governance, and cost tracking. +Any process with the proxy environment variables set will route **all** its traffic through the proxy. + +This module does **not** set proxy environment variables globally on the workspace. +Instead, it provides outputs (`proxy_auth_url` and `cert_path`) for use by tool-specific modules. +It is recommended that tool modules scope the proxy environment variables to their own process rather than setting them globally on the workspace, to avoid routing unnecessary traffic through the proxy. + +> [!WARNING] +> If the setup script fails (e.g. the proxy is unreachable), the workspace will still start but the agent will report a startup script error. +> Tools that depend on the proxy will not work until the issue is resolved. Check the workspace build logs for details. + +## Startup Coordination + +When used with tool-specific modules (e.g. [Copilot](https://registry.coder.com/modules/coder-labs/copilot)), +the setup script signals completion via [`coder exp sync`](https://coder.com/docs/admin/templates/startup-coordination) so dependent modules can wait for the `aibridge-proxy` module to complete before starting. + +To enable startup coordination, set `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment: + +```hcl +env = [ + "CODER_AGENT_TOKEN=${coder_agent.main.token}", + "CODER_AGENT_SOCKET_SERVER_ENABLED=true", +] +``` + +> [!NOTE] +> Startup coordination requires Coder >= v2.30. Without it, the sync calls are skipped gracefully but dependent modules may fail to start if the `aibridge-proxy` setup has not completed in time. + +## Examples + +### Custom certificate path + +```tf +module "aibridge-proxy" { + source = "registry.coder.com/coder/aibridge-proxy/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + proxy_url = "https://aiproxy.example.com" + cert_path = "/home/coder/.certs/aibridge-proxy-ca.pem" +} +``` + +### Proxy with custom port + +For deployments where the proxy is accessed directly on a configured port. +See [security considerations](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup#security-considerations) for network access guidelines. + +```tf +module "aibridge-proxy" { + source = "registry.coder.com/coder/aibridge-proxy/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + proxy_url = "http://internal-proxy:8888" +} +``` diff --git a/registry/coder/modules/aibridge-proxy/main.test.ts b/registry/coder/modules/aibridge-proxy/main.test.ts new file mode 100644 index 000000000..29274d3d6 --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/main.test.ts @@ -0,0 +1,254 @@ +import { serve } from "bun"; +import { + afterEach, + beforeAll, + describe, + expect, + it, + setDefaultTimeout, +} from "bun:test"; +import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +let cleanupFunctions: (() => Promise)[] = []; +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; +afterEach(async () => { + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +const FAKE_CERT = + "-----BEGIN CERTIFICATE-----\nMIIBfakecert\n-----END CERTIFICATE-----\n"; + +// Runs terraform apply to render the setup script, then starts a Docker +// container where we can execute it against a mock server. +const setupContainer = async (vars: Record = {}) => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + proxy_url: "https://aiproxy.example.com", + ...vars, + }); + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer("lorello/alpine-bash"); + + registerCleanup(async () => { + await removeContainer(id); + }); + + return { id, instance }; +}; + +// Starts a mock HTTP server that simulates the Coder API certificate endpoint. +// Returns the server and its base URL. +const setupServer = (handler: (req: Request) => Response) => { + const server = serve({ + fetch: handler, + port: 0, + }); + registerCleanup(async () => { + server.stop(); + }); + return { + server, + // Base URL without trailing slash + url: server.url.toString().slice(0, -1), + }; +}; + +setDefaultTimeout(30 * 1000); + +describe("aibridge-proxy", () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + // Verify that agent_id and proxy_url are required. + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + proxy_url: "https://aiproxy.example.com", + }); + + it("downloads the CA certificate successfully", async () => { + let receivedToken = ""; + const { url } = setupServer((req) => { + const reqUrl = new URL(req.url); + if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") { + receivedToken = req.headers.get("Coder-Session-Token") || ""; + return new Response(FAKE_CERT, { + status: 200, + headers: { "Content-Type": "application/x-pem-file" }, + }); + } + return new Response("not found", { status: 404 }); + }); + + const { id, instance } = await setupContainer(); + + // Override ACCESS_URL and SESSION_TOKEN at runtime to point at the mock server. + const exec = await execContainer(id, [ + "env", + `ACCESS_URL=${url}`, + "SESSION_TOKEN=test-session-token-123", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy CA certificate saved to /tmp/aibridge-proxy/ca-cert.pem", + ); + + // Verify the cert was written to the default path. + const certContent = await execContainer(id, [ + "cat", + "/tmp/aibridge-proxy/ca-cert.pem", + ]); + expect(certContent.stdout).toContain("BEGIN CERTIFICATE"); + + // Verify the session token was sent in the request header. + expect(receivedToken).toBe("test-session-token-123"); + }); + + it("fails when the server is unreachable", async () => { + const { id, instance } = await setupContainer(); + + // Port 9999 has nothing listening, so curl will fail to connect. + const exec = await execContainer(id, [ + "env", + "ACCESS_URL=http://localhost:9999", + "SESSION_TOKEN=mock-token", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).not.toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy setup failed: could not connect to", + ); + }); + + it("fails when the server returns a non-200 status", async () => { + const { url } = setupServer(() => { + return new Response("not found", { status: 404 }); + }); + + const { id, instance } = await setupContainer(); + + const exec = await execContainer(id, [ + "env", + `ACCESS_URL=${url}`, + "SESSION_TOKEN=mock-token", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).not.toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy setup failed: unexpected response", + ); + }); + + it("fails when the server returns an empty response", async () => { + const { url } = setupServer((req) => { + const reqUrl = new URL(req.url); + if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") { + return new Response("", { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const { id, instance } = await setupContainer(); + + const exec = await execContainer(id, [ + "env", + `ACCESS_URL=${url}`, + "SESSION_TOKEN=mock-token", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).not.toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy setup failed: downloaded certificate is empty.", + ); + }); + + it("saves the certificate to a custom path", async () => { + const { url } = setupServer((req) => { + const reqUrl = new URL(req.url); + if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") { + return new Response(FAKE_CERT, { + status: 200, + headers: { "Content-Type": "application/x-pem-file" }, + }); + } + return new Response("not found", { status: 404 }); + }); + + // Pass a custom cert_path to terraform apply so the script uses it. + const { id, instance } = await setupContainer({ + cert_path: "/tmp/custom/certs/proxy-ca.pem", + }); + + const exec = await execContainer(id, [ + "env", + `ACCESS_URL=${url}`, + "SESSION_TOKEN=mock-token", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy CA certificate saved to /tmp/custom/certs/proxy-ca.pem", + ); + + const certContent = await execContainer(id, [ + "cat", + "/tmp/custom/certs/proxy-ca.pem", + ]); + expect(certContent.stdout).toContain("BEGIN CERTIFICATE"); + }); + + it("does not create global proxy env vars via coder_env", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + proxy_url: "https://aiproxy.example.com", + }); + + // Proxy env vars should NOT be set globally via coder_env. + // They are intended to be scoped to specific tool processes. + const proxyEnvVarNames = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + ]; + const proxyEnvVars = state.resources.filter( + (r) => + r.type === "coder_env" && + r.instances.some((i) => + proxyEnvVarNames.includes(i.attributes.name as string), + ), + ); + expect(proxyEnvVars.length).toBe(0); + }); +}); diff --git a/registry/coder/modules/aibridge-proxy/main.tf b/registry/coder/modules/aibridge-proxy/main.tf new file mode 100644 index 000000000..62200a31e --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/main.tf @@ -0,0 +1,81 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "proxy_url" { + type = string + description = "The full URL of the AI Bridge Proxy. Include the port if not using standard ports (e.g. https://aiproxy.example.com or http://internal-proxy:8888)." + + validation { + condition = can(regex("^https?://", var.proxy_url)) + error_message = "proxy_url must start with http:// or https://." + } +} + +variable "cert_path" { + type = string + description = "Absolute path where the AI Bridge Proxy CA certificate will be saved." + default = "/tmp/aibridge-proxy/ca-cert.pem" + + validation { + condition = startswith(var.cert_path, "/") + error_message = "cert_path must be an absolute path." + } +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +locals { + # Build the proxy URL with Coder authentication embedded. + # AI Bridge Proxy expects the Coder session token as the password + # in basic auth: http://coder:@host:port + proxy_auth_url = replace( + var.proxy_url, + "://", + "://coder:${data.coder_workspace_owner.me.session_token}@" + ) +} + +# These outputs are intended to be consumed by tool-specific modules, +# to set proxy environment variables scoped to their process, rather than globally. +output "proxy_auth_url" { + description = "The AI Bridge Proxy URL with Coder authentication embedded (http://coder:@host:port)." + value = local.proxy_auth_url + sensitive = true +} + +output "cert_path" { + description = "Path to the downloaded AI Bridge Proxy CA certificate." + value = var.cert_path +} + +# Downloads the CA certificate from the Coder deployment. +# This runs on workspace start but does not block login, if the script +# fails, the workspace remains usable and the error is visible in the build logs. +# Tools that depend on the proxy will fail until the certificate is available. +resource "coder_script" "aibridge_proxy_setup" { + agent_id = var.agent_id + display_name = "AI Bridge Proxy Setup" + icon = "/icon/coder.svg" + run_on_start = true + start_blocks_login = false + script = templatefile("${path.module}/scripts/setup.sh", { + CERT_PATH = var.cert_path, + ACCESS_URL = data.coder_workspace.me.access_url, + SESSION_TOKEN = data.coder_workspace_owner.me.session_token, + }) +} diff --git a/registry/coder/modules/aibridge-proxy/main.tftest.hcl b/registry/coder/modules/aibridge-proxy/main.tftest.hcl new file mode 100644 index 000000000..08e329a53 --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/main.tftest.hcl @@ -0,0 +1,210 @@ +run "test_aibridge_proxy_basic" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + } + + assert { + condition = var.agent_id == "test-agent-id" + error_message = "Agent ID should match the input variable" + } + + assert { + condition = var.proxy_url == "https://aiproxy.example.com" + error_message = "Proxy URL should match the input variable" + } + + assert { + condition = var.cert_path == "/tmp/aibridge-proxy/ca-cert.pem" + error_message = "cert_path should default to /tmp/aibridge-proxy/ca-cert.pem" + } +} + +run "test_aibridge_proxy_empty_url_validation" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "" + } + + expect_failures = [ + var.proxy_url, + ] +} + +run "test_aibridge_proxy_invalid_url_validation" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "aiproxy.example.com" + } + + expect_failures = [ + var.proxy_url, + ] +} + +run "test_aibridge_proxy_url_formats" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + } + + assert { + condition = can(regex("^https?://", var.proxy_url)) + error_message = "Proxy URL should be a valid URL with scheme" + } +} + +run "test_aibridge_proxy_https_with_port" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com:8443" + } + + assert { + condition = can(regex("^https?://", var.proxy_url)) + error_message = "Proxy URL should support HTTPS with custom port" + } +} + +run "test_aibridge_proxy_http_with_port" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "http://internal-proxy:8888" + } + + assert { + condition = can(regex("^https?://", var.proxy_url)) + error_message = "Proxy URL should support HTTP with custom port" + } +} + +run "test_aibridge_proxy_empty_cert_path_validation" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + cert_path = "" + } + + expect_failures = [ + var.cert_path, + ] +} + +run "test_aibridge_proxy_relative_cert_path_validation" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + cert_path = "relative/path/ca-cert.pem" + } + + expect_failures = [ + var.cert_path, + ] +} + +run "test_aibridge_proxy_custom_cert_path" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + cert_path = "/home/coder/.certs/ca-cert.pem" + } + + assert { + condition = var.cert_path == "/home/coder/.certs/ca-cert.pem" + error_message = "cert_path should match the input variable" + } +} + +run "test_aibridge_proxy_script" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + } + + assert { + condition = coder_script.aibridge_proxy_setup.run_on_start == true + error_message = "Script should run on start" + } + + assert { + condition = coder_script.aibridge_proxy_setup.start_blocks_login == false + error_message = "Script should not block login" + } + + assert { + condition = coder_script.aibridge_proxy_setup.display_name == "AI Bridge Proxy Setup" + error_message = "Script display name should be 'AI Bridge Proxy Setup'" + } +} + +run "test_aibridge_proxy_auth_url_https" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + } + + override_data { + target = data.coder_workspace_owner.me + values = { + session_token = "mock-session-token" + } + } + + assert { + condition = output.proxy_auth_url == "https://coder:mock-session-token@aiproxy.example.com" + error_message = "proxy_auth_url should contain the mocked session token" + } + + assert { + condition = output.cert_path == "/tmp/aibridge-proxy/ca-cert.pem" + error_message = "cert_path output should match the default" + } +} + +run "test_aibridge_proxy_auth_url_http_with_port" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "http://internal-proxy:8888" + } + + override_data { + target = data.coder_workspace_owner.me + values = { + session_token = "mock-session-token" + } + } + + assert { + condition = output.proxy_auth_url == "http://coder:mock-session-token@internal-proxy:8888" + error_message = "proxy_auth_url should preserve the port" + } + + assert { + condition = output.cert_path == "/tmp/aibridge-proxy/ca-cert.pem" + error_message = "cert_path output should match the default" + } +} diff --git a/registry/coder/modules/aibridge-proxy/scripts/setup.sh b/registry/coder/modules/aibridge-proxy/scripts/setup.sh new file mode 100644 index 000000000..7b0238477 --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/scripts/setup.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +if [ -z "$CERT_PATH" ]; then + CERT_PATH="${CERT_PATH}" +fi + +if [ -z "$ACCESS_URL" ]; then + ACCESS_URL="${ACCESS_URL}" +fi + +if [ -z "$SESSION_TOKEN" ]; then + SESSION_TOKEN="${SESSION_TOKEN}" +fi + +set -euo pipefail + +# Signal startup coordination +if command -v coder > /dev/null 2>&1; then + coder exp sync start "aibridge-proxy-setup" > /dev/null 2>&1 || true +fi + +if [ -z "$ACCESS_URL" ]; then + echo "Error: Coder access URL is not set." + exit 1 +fi + +if [ -z "$SESSION_TOKEN" ]; then + echo "Error: Coder session token is not set." + exit 1 +fi + +if ! command -v curl > /dev/null; then + echo "Error: curl is not installed." + exit 1 +fi + +echo "--------------------------------" +echo "AI Bridge Proxy Setup" +printf "Certificate path: %s\n" "$CERT_PATH" +printf "Access URL: %s\n" "$ACCESS_URL" +echo "--------------------------------" + +CERT_DIR=$(dirname "$CERT_PATH") +mkdir -p "$CERT_DIR" + +CERT_URL="$ACCESS_URL/api/v2/aibridge/proxy/ca-cert.pem" +echo "Downloading AI Bridge Proxy CA certificate from $CERT_URL..." + +# Download the certificate with a 5s connection timeout and 10s total timeout +# to avoid the script hanging indefinitely. +if ! HTTP_STATUS=$(curl -s -o "$CERT_PATH" -w "%%{http_code}" \ + --connect-timeout 5 \ + --max-time 10 \ + -H "Coder-Session-Token: $SESSION_TOKEN" \ + "$CERT_URL"); then + echo "❌ AI Bridge Proxy setup failed: could not connect to $CERT_URL." + echo "Ensure AI Bridge Proxy is enabled and reachable from the workspace." + rm -f "$CERT_PATH" + exit 1 +fi + +if [ "$HTTP_STATUS" -ne 200 ]; then + echo "❌ AI Bridge Proxy setup failed: unexpected response (HTTP $HTTP_STATUS)." + echo "Ensure AI Bridge Proxy is enabled and reachable from the workspace." + rm -f "$CERT_PATH" + exit 1 +fi + +if [ ! -s "$CERT_PATH" ]; then + echo "❌ AI Bridge Proxy setup failed: downloaded certificate is empty." + rm -f "$CERT_PATH" + exit 1 +fi + +echo "AI Bridge Proxy CA certificate saved to $CERT_PATH" +echo "✅ AI Bridge Proxy setup complete." + +# Signal successful completion to unblock dependent scripts. +# Only called on success, if the script fails, dependents remain blocked +# until timeout, preventing them from starting without a valid certificate. +if command -v coder > /dev/null 2>&1; then + coder exp sync complete "aibridge-proxy-setup" > /dev/null 2>&1 || true +fi